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
- 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
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 |
|---|---|---|---|
ce-read | S3 ObjectCreated | Textract on both sides, then one Bedrock call; writes the card | 60s / 1024 MB |
ce-chase | EventBridge daily | Walks the ladder for every card in its lead-time window | 60s / 512 MB |
ce-view | Function URL | Crew-against-job checks and the point-in-time query | 15s / 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
| Role | Allowed | On |
|---|---|---|
ce-read-role | textract:AnalyzeDocument, bedrock:InvokeModel, dynamodb:PutItem | The photos prefix; one model arn; the cards table |
ce-chase-role | dynamodb:Query/UpdateItem, ses:SendEmail | Cards and chases; one verified identity |
ce-view-role | dynamodb:Query, s3:GetObject | Cards, 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:0on 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