Engineering reference: the training reminder bot 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 nightly recompute, and the one place a model appears.
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
- Database
- App integration
- Management
- 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 |
|---|---|---|---|
tb-derive | EventBridge nightly | Recomputes every person’s list; assigns the difference | 120s / 1024 MB |
tb-chase | EventBridge daily + weekly | Nudges, escalates, and builds the weekly gap report | 60s / 512 MB |
tb-confirm | Function URL | Records a completion; refuses self-confirmation | 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 |
|---|---|---|
tb-derive-role | dynamodb:Query/PutItem, secretsmanager:GetSecretValue | Assignments and completions; the Sheets credential only |
tb-chase-role | dynamodb:Query, ses:SendEmail | Assignments, read; one verified identity |
tb-confirm-role | dynamodb:PutItem, bedrock:InvokeModel | Completions; one model arn |
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: assignments
PK person S j.reed@example.com
SK requirement S abrasive-wheels
state S open | done | not_required
cause S joined | role_change | expiry
assigned_at S 2026-07-22
due_at S 2026-08-21 — assigned_at + the requirement’s grace
chased_at L [ISO timestamps]
closed_by S the completion id, or the not_required reason
One row per person per requirement. The derive function computes the set
each night and only writes the DIFFERENCE, which is what prevents
duplicate assignments on an unchanged list.
GSI requirement-index PK requirement, SK due_at — the gap report, by course
Table: completions
PK person S j.reed@example.com
SK completed_at S 2026-05-14#cmp_9a2f
requirement S manual-handling
delivery S in_house | external_course | external_cert | toolbox
valid_until S 2029-05-14 — from the requirement, or the cert’s own date
confirmed_by S supervisor@example.com — never the subject
evidence_url S a link, not a stored document
supersedes S another SK, for a correction
Append-only. `valid_until` is what the derive function subtracts against,
NOT completed_at — using the wrong one is the duplicate-assignment bug.
Inbound and outbound
- The confirm page is static files in S3 behind CloudFront, reached through a signed link in the assignment message.
- Self-confirmation is refused by comparing the confirming identity with the subject on the first line of the handler, before any write.
- Assignment links are signed, scoped to one person and one requirement, and expire ninety days after the due date.
- Nothing is written back to the staff or requirements sheets. They are read and never modified.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, used only to match an externally worded qualification name against your requirement list. - Called a handful of times a year, when somebody records an external certificate whose title does not exactly match a requirement.
- Grounded with your requirement names and aliases, so it picks one of yours or none.
- Output is a JSON schema with a requirement id and a confidence, both nullable. A null goes to a person with a pick list.
- Nothing else touches a model. Deriving a required-training list is set arithmetic, and set arithmetic should be code.
Things worth knowing before you build it
- Subtract on `valid_until`, not on `completed_at`. Getting this wrong asks people to redo training they did last month and destroys trust in the system immediately.
- Write only the difference on the nightly recompute. A full rewrite will re-send assignment messages for everything, every night.
- Put the grace period on the requirement. One global due window is wrong in both directions at once.
- Record an equivalent external qualification as a completion with its own expiry, never as a person-level exception. An exception has no end date.
- Report the gap by requirement, not by person. Six people waiting on one course date is a procurement task, and chasing six individuals will not move it.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts