Skip to content

Part 7 of 7 · Certification expiry tracker series ~7 min read

Engineering reference: the certification expiry tracker architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the schedule, and the one model call.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 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 certification expiry tracker drawn with AWS service namesThree boxes across the top outside the AWS account. The Capture page, served as static files from S3 behind CloudFront. The Staff and certification lists, read through the Google Sheets API read-only. And SES outbound, carrying the chases and escalations. Inside the account, three groups. S3 holding card photographs and EventBridge carrying a daily sweep. Three Lambda functions named read, chase and view. And two DynamoDB tables named cards and chases. A note gives the region as us-east-1, one account, and states there is no integration with any awarding body's register.AWS ACCOUNTCapture pageCloudFront + S3Staff + cert listsSheets API, read-onlySES outboundchases, escalationsS3 + EventBridgecard photos,daily sweepLambda x3read, chase, viewDynamoDB x2cards, chasesingroundsoutus-east-1. One account. No integration with any awarding body's register.
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: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • 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
ce-readS3 ObjectCreatedTextract on both sides, then one Bedrock call; writes the card60s / 1024 MB
ce-chaseEventBridge dailyWalks the ladder for every card in its lead-time window60s / 512 MB
ce-viewFunction URLCrew-against-job checks and the point-in-time query15s / 512 MB

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
ce-read-roletextract:AnalyzeDocument, bedrock:InvokeModel, dynamodb:PutItemThe photos prefix; one model arn; the cards table
ce-chase-roledynamodb:Query/UpdateItem, ses:SendEmailCards and chases; one verified identity
ce-view-roledynamodb:Query, s3:GetObjectCards, read; the photos prefix, read

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: cards

PK   person            S   j.reed@example.com
SK   type_issued       S   first-aid-at-work#2023-09-12
     expires           S   2026-09-12   — null if it does not expire
     number            S   4471-882
     awarding_body     S   as printed
     photo_keys        L   [front, back]
     registered_by     S   j.reed@example.com
     superseded_by     S   another SK, for a correction

Append-only. Current state is computed from these rows and never stored,
so a status field cannot drift away from the evidence.

GSI  expiry-index        PK type, SK expires   — the daily chase sweep

Table: chases

PK   person_type       S   j.reed@example.com#first-aid-at-work
     expires           S   2026-09-12
     lead_days         N   90
     stage             S   holder | holder2 | manager | scheduling | lapsed
     last_sent         S   2026-06-14T08:00:00Z
     booked_for        S   2026-08-20, or null
     lapsed_since      S   set on expiry day, never cleared until replaced

A lapsed row keeps chasing weekly with no end condition other than a new
card being registered. It is the only alert in this series that never rests.

Inbound and outbound

  • The capture page is static files in S3 behind CloudFront with an origin access control, reached through a signed staff link minted when somebody joins the staff list.
  • Photos upload with a presigned PUT, front and back, so a phone on a site connection is not holding a Lambda open.
  • Booking confirmation links are signed, scoped to one chase, and expire after the course date plus a fortnight.
  • Nothing is fetched from an awarding body. Their registers are not open APIs and scraping them would create an assurance claim the system cannot honestly make.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, used to pick a certification type from your list and to identify which date on the card is the expiry.
  • Called once per card, keyed on the image digest so a retry is free.
  • Grounded with your certification types and their aliases, and with the staff list, so neither a type nor a person can be invented.
  • Output is a JSON schema with type, holder, number, issued and expires, all nullable. A null expiry with an explicit does-not-expire flag is different from a failed read, and the schema distinguishes them.
  • Dates are returned as read, with the ambiguity flagged rather than resolved. Resolving an ambiguous date silently is how somebody loses three months of validity.

Things worth knowing before you build it

  • Check validity against the job date, not today. It is the most common bug in this kind of system and it fails in the direction that costs a day of work.
  • Capture both sides. On a large minority of cards the expiry is on the back, and a system that accepts one photograph will have a register full of null dates.
  • Set lead times from how long renewals actually took, which the register knows once it has a year of gap statistics.
  • Never let a lapsed chase time out. It is the one alert here that should get louder rather than quieter.
  • Do not claim qualification. Record what card you were shown and when; anything stronger is a claim you cannot support from a photograph.

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

All posts