Skip to content

Part 7 of 7 · Age check logger series ~7 min read

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

The age check logger drawn with AWS service namesThree boxes across the top sit outside the AWS account. Till exports carrying age prompts and answers, nightly. Page photos of the refusals book, taken at each shift end. And Area managers, who receive weekly flags and evidence packs. Each connects to the AWS account container below. Inside are three components. S3 with EventBridge handling exports, photos, packs and schedules. Five Lambda functions covering till, page, join, rates and pack. And three DynamoDB tables holding prompts, the register and the rules. A note says eu-west-2, one account, till exports land nightly, photos land at each shift end, rates run weekly, and an evidence pack is written once and never edited.AWS ACCOUNTTill exportsage prompts andanswers, nightlyPage photosthe refusals book,each shift endArea managersweekly flags andevidence packsS3 + EventBridgeexports, photos,packs, schedulesLambda x5till, page, join,rates, packDynamoDB x3prompts, register,rulesingroundsouteu-west-2. One account. Till exports land nightly, photos land at each shift end, rates run weekly, and an evidence pack is written once and never edited.
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
  • 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 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
agc-tillS3 put, till exports prefixParses the nightly export into prompt records with their rule version300s / 1024MB
agc-pageS3 put, page photos prefixTranscribes a register page into lines, with nulls and confidences60s / 1024MB
agc-joinEventBridge, daily 06:00Joins register lines to till refusals in both directions120s / 1024MB
agc-ratesEventBridge, weeklyRates per login and shift kind, shared-login checks, flags, the email300s / 2048MB
agc-packInvoked with a store, date and timeBuilds the evidence pack and writes it once300s / 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

RoleAllowedOn
agc-till-roles3:GetObject, dynamodb:Query, dynamodb:BatchWriteItemThe till exports prefix; rules read; prompts write
agc-page-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the photos prefix; register write. No access to prompts
agc-join-roledynamodb:Query, dynamodb:PutItem, dynamodb:UpdateItemPrompts read; register read and write
agc-rates-roledynamodb:Query, dynamodb:PutItem, ses:SendEmailPrompts and register read; flag items write; one verified identity
agc-pack-roledynamodb:Query, s3:PutObjectAll 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