Skip to content

Part 7 of 7 · Shelf price checker series ~7 min read

Engineering reference: the shelf price checker architecture

The same system with the service names filled in: what the phone uploads to, what reads a photo, where the dated price versions live, and what turns a walk into a morning’s list.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 5 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 3 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The shelf price checker drawn with AWS service namesThree boxes across the top sit outside the AWS account. The Walk page, a phone browser used bay by bay. Till exports, carrying price versions and promotions nightly. And Store managers, who receive the morning list by email. Each connects to the AWS account container below. Inside are three components. API Gateway with S3, handling uploads and the nightly exports. Five Lambda functions covering upload, read, prices, match and report. And three DynamoDB tables holding labels, prices and exceptions. A note says eu-west-2, one account, photos go straight to S3, the read is queued, price versions are never overwritten, and the list is ready before opening.AWS ACCOUNTWalk pagea phone browser,bay by bayTill exportsprice versions andpromotions, nightlyStore managersthe morning list,by emailAPI Gateway + S3uploads and thenightly exportsLambda x5upload, read, prices,match, reportDynamoDB x3labels, prices,exceptionsingroundsouteu-west-2. One account. Photos go straight to S3, the read is queued, price versions are never overwritten, and the list is ready before opening.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Storage
  • Database
  • App integration
  • Front-end & mobile

Region and account

  • Region: eu-west-2. London, because the stores are in Great Britain and photographs taken inside them have no reason to leave the country. The read needs a vision-capable model, and its availability here is checked rather than assumed; it is the only step that would move if it had to, and it would move alone.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
spc-uploadAPI Gateway, from the walk pageIssues a presigned S3 URL per photo, stamped with store, bay and time10s / 256MB
spc-readSQS, from S3 put on the photos prefixOne vision read per photo; writes label records and the retake verdict60s / 1024MB
spc-pricesS3 put, nightly till exportParses price and promotion versions and writes them with effective times300s / 1024MB
spc-matchSQS, after each readMatches labels to products and to the price in force at the photo’s time120s / 1024MB
spc-reportEventBridge, 05:30 daily and on walk closeRanks exceptions, orders them by bay, measures lag, emails the list300s / 1024MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
spc-upload-roles3:PutObject (presign only)The photos prefix for the caller’s store. Nothing readable at all
spc-read-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItem, sqs:SendMessageOne model id; the photos prefix; labels; the match queue. No access to prices
spc-prices-roles3:GetObject, dynamodb:PutItemThe exports prefix; prices, with a condition that refuses overwrites
spc-match-roledynamodb:Query, dynamodb:PutItemlabels and prices read; exceptions write
spc-report-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmaillabels, prices and exceptions; one verified identity

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: labels

PK   store_id#walk_id     S
SK   bay#photo_id#seq     S   the bay’s place in the walking order first
     taken_at             S   from the upload, not the read
     price_text           S   as printed: ’£1.25’, ’65p’, ’2 for £3’
     unit_price_text      S   as printed, with its unit: ’44.6p/100g’
     promo_text           S   mechanic wording, where printed
     promo_ends_text      S   end date as printed
     barcode              S   digits, where legible
     shelf_code           S   the store’s own item number, where legible
     description          S
     box                  M   {x, y, w, h} in the frame
     null_fields          SS  which fields could not be read

taken_at comes from the upload because it is the moment a customer could
have read that label. The read can run minutes later after a retry, and
a price change landing in between would turn a correct label into a
false overcharge.

Table: prices

PK   store_id#product_id  S
SK   effective_from       S   append-only; a version is never overwritten
     prices               L   [{pence, condition}] base first, then member
     unit_basis           S   kg | l | m | m2 | m3 | each
     pack_quantity        N   in the unit_basis
     promo_id             S   set while a promotion is live
     promo_ends_at        S   a moment, not a date
     cycle                S   scheduled | out_of_cycle
     listed_at            S   first appeared on the store’s swap list
     confirmed_at         S   the photo that first read this price

effective_from as the sort key is what makes ’the price in force when the
photo was taken’ a single query: the latest version at or before
taken_at. Overwrite versions instead and every walk that crossed a price
change reports mismatches that never existed on the shelf.

Table: exceptions

PK   store_id             S
SK   raised_at#product_id S
     kind                 S   overcharge | undercharge | unit_price |
                              no_label | unreadable | not_ranged
     cause                S   promotion_ended | price_changed |
                              wrong_position | absent | per_100g | arithmetic
     diff_pence           N   till minus label, sign kept
     weekly_units         N   what the difference applies to
     rank                 N   kind first, amount second
     route_seq            N   the bay’s place in the walking order
     fix                  S   relabel | till_matched | price_file_corrected
     fixed_by             S   who chose the fix
     closed_by_label      S   the label record that showed agreement

closed_by_label points at a label record rather than holding a flag,
because an exception is closed by a photo of the shelf agreeing with the
till. A tick records intent; a label record records the shelf.

Inbound and outbound

  • Photos go straight to S3. The walk page asks for a presigned URL per photo and uploads directly, so a hundred photos on a weak signal at the back of a store never pass through a function, and a dropped upload retries from the phone.
  • The till exports are parsed, not read by a model. Price versions and the promotions calendar arrive nightly as files with fixed headers. They are the largest input here and they need no judgement.
  • The retake verdict goes back to the phone. The walk page polls for each photo’s verdict and marks rejects within seconds, while the person is still standing in the bay.
  • Nothing is changed at the till. The output is a list and an email. Setting a till price to match a label is a pricing decision, and a person makes it in the till system.

The model call

  • One call per photo. Every label in the frame comes back from the same call as a list, each with a position. Nothing per label, per product or per store.
  • A small vision-capable model. Reading printed prices off a photograph is transcription, and the smallest model that does it reliably is the right one.
  • The read is blind, and IAM enforces it. spc-read-role has no access to the prices table. A model cannot be steered toward the expected price if the function calling it has no way to fetch one.
  • Characters, never conversions. Prices and unit prices come back exactly as printed. Pence and pounds, per 100 grams and per kilogram, are reconciled in code.
  • Absent means null. A price the model cannot make out comes back null and the label goes on the list as unreadable. A plausible guess is indistinguishable from a real reading once it is in the table.

Things worth knowing before you build it

  • Compare against the price version in force at the photo’s timestamp. Comparing with today’s price file turns every change since the walk into a false mismatch.
  • Rank by direction before amount. Sorting by the size of the difference puts margin at the top and the customers who were overcharged somewhere underneath it.
  • Hold floor area as store data. Unit pricing obligations differ between a shop above 280 square metres and one below it, even inside the same chain.
  • Store promotion ends as moments, taken from what the till actually does. An end date means something slightly different in every calendar.
  • Photos catch staff and customers. Have the walk page frame the shelf strip, and expire images once their exceptions close; the label record is the lasting evidence.
  • Close exceptions with a photo, not a tick. A swap list is only measured if the shelf itself confirms it.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts