Engineering reference: the age check logger architecture
The same system with the service names filled in: what parses the till export, what reads a photographed page, what joins the two, and where the rules, the rates and the evidence packs live.
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
- Analytics
- Front-end & mobile
Region and account
- Region:
eu-west-2. London, because the licensing regime this is built around is England and Wales and the register holds descriptions of members of the public, many of them children, which the business will want kept in the UK. Nothing here receives email, so the SES inbound constraint does not apply. Bedrock model availability for the page read is checked in this region rather than assumed; it is the only step that would have to move. - 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 |
|---|---|---|---|
agc-till | S3 put, till exports prefix | Parses the nightly export into prompt records with their rule version | 300s / 1024MB |
agc-page | S3 put, page photos prefix | Transcribes a register page into lines, with nulls and confidences | 60s / 1024MB |
agc-join | EventBridge, daily 06:00 | Joins register lines to till refusals in both directions | 120s / 1024MB |
agc-rates | EventBridge, weekly | Rates per login and shift kind, shared-login checks, flags, the email | 300s / 2048MB |
agc-pack | Invoked with a store, date and time | Builds the evidence pack and writes it once | 300s / 2048MB |
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 |
|---|---|---|
agc-till-role | s3:GetObject, dynamodb:Query, dynamodb:BatchWriteItem | The till exports prefix; rules read; prompts write |
agc-page-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the photos prefix; register write. No access to prompts |
agc-join-role | dynamodb:Query, dynamodb:PutItem, dynamodb:UpdateItem | Prompts read; register read and write |
agc-rates-role | dynamodb:Query, dynamodb:PutItem, ses:SendEmail | Prompts and register read; flag items write; one verified identity |
agc-pack-role | dynamodb:Query, s3:PutObject | All three tables read; the packs prefix, write-once under Object Lock |
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: prompts
PK store_id#date S e.g. station-road#2026-08-21
SK till_id#shown_at S one item per restricted line scanned
category S alcohol | tobacco | vapes | knives | lottery | ...
rule_version S the rules row in force on the date of sale
answer S id_seen | refused | over_policy_age
id_kind S physical | digital, when answer is id_seen
answered_at S where the till records it
login S the till login, not necessarily the person
shift_kind S weekday_day | weekday_late | weekend_day |
weekend_late
answer has three values and never two. A till configured with a single
approve button collapses id_seen and over_policy_age into one value, and
every rate in part 4 depends on the difference between them.
Table: register
PK store_id#date S
SK kind#id S line#page-line | till_only#prompt |
review#at | flag#login
written M {time, item, reason, description} as written
confidence M per field; null where unreadable
join S joined | book_only | till_only | unexplained
prompt_key S the prompts item it joined, if any
photo_key S the page photograph it came from
reviewed_by S set from a review item, never by the model
closed_at S on flag items: when, and by whom
join is written in both directions. A till refusal with no line becomes a
till_only item on the same partition, so one query for a store and a day
shows both kinds of gap, rather than two queries that each look complete.
Table: rules
PK category S alcohol | tobacco | lottery | ...
SK effective_from S append-only; the latest row on or before the
date of sale applies
min_age N null where the rule is a birth date
born_on_or_after S set where sale is barred by date of birth
policy_age N the age the policy challenges below
accepted_id L photo_dob_hologram_or_uv | certified_digital
source S the instrument, or the licence condition
born_on_or_after exists because from 1 January 2027 the tobacco rule is
not an age. A schema with only min_age cannot express it at all: two
nineteen-year-olds can sit on opposite sides of the rule, and any number
stored in its place would give prompts the wrong rule_version.
Inbound and outbound
- Till exports are parsed, not read by a model. Each store’s export is fixed-format rows with a header, landed nightly into S3 by the till’s own scheduled export.
- Page photographs come from the shop phone through a pre-signed upload URL scoped to that store’s prefix. A retake request comes back on the same upload screen before the person closing has left.
- Flags go out weekly by SES to area managers, never to the staff concerned. A flag is a reason for a conversation, and a conversation is a manager’s job.
- Nothing is sent to the licensing authority automatically. A pack is written and a link goes to the licence holder, who decides what to send and to whom.
The model call
- One call per page photograph. Nothing per prompt, per login, per week or per pack. A rate is a count divided by a count.
- A vision-capable mid-tier model. A dozen handwritten lines in fixed columns is transcription; the hard part is the handwriting, not the reasoning.
- As written, never normalised. twenty to ten comes back as written. Turning it into 21:40 happens in code, where the page’s other lines and the shift can settle whether it meant morning or evening.
- Absent or illegible means null. A guessed time joins a line to the wrong till refusal, and a wrong join looks exactly like a right one three months later.
- Signatures are not read. The model reports that a signature is present. Who wrote the line comes from the rota and the joined till login, never from a reading of a scrawl.
Things worth knowing before you build it
- Treat a refusal rate near zero at a busy store as a finding. The count looks like good news and the rate is usually the first sign somebody has stopped asking.
- Configure the till with three answers. A single approve button makes ID seen and waved through the same value, and nothing downstream can separate them again.
- Compare a login with the same kind of shift across the group. Late shifts refuse more than day shifts everywhere, and a store average hides exactly the shift that matters.
- Hold ages, policy ages and accepted ID as rows with effective dates. The lottery age moved in 2021, digital proof of age is being added now, and tobacco becomes a birth-date rule on 1 January 2027.
- Photograph the page, never tear it out. Where the licence asks for a book, the book is the record and the photograph is a copy.
- Open a three-month window on a store after any underage sale. The second sale inside it is the licence holder’s offence, and the end date belongs in a field rather than in somebody’s memory.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts