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
- 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
devand aprodstack 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
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
spc-upload | API Gateway, from the walk page | Issues a presigned S3 URL per photo, stamped with store, bay and time | 10s / 256MB |
spc-read | SQS, from S3 put on the photos prefix | One vision read per photo; writes label records and the retake verdict | 60s / 1024MB |
spc-prices | S3 put, nightly till export | Parses price and promotion versions and writes them with effective times | 300s / 1024MB |
spc-match | SQS, after each read | Matches labels to products and to the price in force at the photo’s time | 120s / 1024MB |
spc-report | EventBridge, 05:30 daily and on walk close | Ranks exceptions, orders them by bay, measures lag, emails the list | 300s / 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
| Role | Allowed | On |
|---|---|---|
spc-upload-role | s3:PutObject (presign only) | The photos prefix for the caller’s store. Nothing readable at all |
spc-read-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem, sqs:SendMessage | One model id; the photos prefix; labels; the match queue. No access to prices |
spc-prices-role | s3:GetObject, dynamodb:PutItem | The exports prefix; prices, with a condition that refuses overwrites |
spc-match-role | dynamodb:Query, dynamodb:PutItem | labels and prices read; exceptions write |
spc-report-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | labels, 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-rolehas 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