Engineering reference: the background check chaser 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 one table, the relay path, and the deliberately small data model.
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.
- 1 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
- Outside AWS
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 |
|---|---|---|---|
bc-create | Function URL | Builds the check items for a starter from the role’s list | 10s / 512 MB |
bc-update | S3 ObjectCreated (SES) | Matches a reply to an item, relays the content, deletes the copy | 30s / 1024 MB |
bc-chase | EventBridge daily | Predicts, chases the owner, escalates sideways, sends the summary | 60s / 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 |
|---|---|---|
bc-create-role | dynamodb:PutItem, secretsmanager:GetSecretValue | The checks table; the Sheets credential only |
bc-update-role | s3:GetObject/DeleteObject, ses:SendRawEmail | The relay prefix; one verified identity |
bc-chase-role | dynamodb:Query, ses:SendEmail | The checks table; one verified identity |
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: checks
PK starter_id S st_2026_07_02_9a11
SK check_id S reference#1
type S reference | right_to_work | disclosure | medical
owner_kind S referee | candidate | agency | clinic
owner_contact S an address, not a name
status S held | outstanding | chased | arrived |
cleared | conversation | withdrawn
depends_on S another check_id, or null
requested_at S 2026-07-02
expected_days N 5
chased_at L [ISO timestamps]
arrived_at S set by the relay, not by a person
closed_by S set only by a person
ttl N epoch, +2 years
There is no field on this item for a check RESULT, and adding one changes
what the system is. Content is relayed to HR storage and deleted.
GSI status-index PK status, SK requested_at — the daily sweep
Inbound and outbound
- An SES receipt rule set on the monitored address writes the whole message to a short-lived S3 prefix with a one-day lifecycle rule as a backstop.
- The relay deletes its own copy after forwarding to HR storage. The lifecycle rule exists only in case the delete fails.
- Matching uses
In-Reply-Tofirst, then a token in the reference form link, and asks a human third. It never matches on candidate name alone. - Reference forms are signed, scoped to one check, single-use, and expire sixty days after the request.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, used only when thread headers and tokens both fail to match a reply to an item. - It is given the subject line and the first few lines of the body, never the attachment and never the full message.
- Output is a JSON schema with a candidate check id and a confidence. Below the floor it returns null and the message goes to a human.
- It never reads a result. The prompt is a matching task, and the content that would let it form a view on a reference is not in the prompt.
- Most updates never reach it because a threaded reply or a form token settles the match for free.
Things worth knowing before you build it
- Do not add a result field. It is the one change that turns this from a small tracker into a system with a legal basis, a retention schedule and a subject access path.
- Delete the relay copy in code, and keep the lifecycle rule as a backstop rather than as the mechanism.
- Chase at the owner’s cadence, not yours. An agency chased every two days against a fourteen-day turnaround will filter you.
- Escalate sideways. Replacing an unresponsive referee resets the clock honestly; pushing harder on a dead request does not.
- Compute medians from your own completed checks. Published turnarounds are consistently optimistic, and the difference is the whole planning error.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts