Skip to content

Part 7 of 7 · Background check chaser series ~7 min read

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

The background check chaser drawn with AWS service namesThree boxes across the top outside the AWS account. SES inbound, receiving mail at a monitored address. The Check list, read through the Google Sheets API read-only. And HR storage, which is where any content is forwarded. Inside the account, three groups. S3 used only as a transient relay, and EventBridge carrying a daily sweep. Three Lambda functions named create, update and chase. And a single DynamoDB table named checks. A note gives the region as us-east-1, one account, one table holding statuses only, and states that no check result is ever stored.AWS ACCOUNTSES inbounda monitored addressCheck listSheets API, read-onlyHR storagewhere content goesS3 + EventBridgetransient relay,daily sweepLambda x3create, update, chaseDynamoDB x1checksingroundsoutus-east-1. One account. One table, statuses only. No check result is ever stored.
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
  • 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 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
bc-createFunction URLBuilds the check items for a starter from the role’s list10s / 512 MB
bc-updateS3 ObjectCreated (SES)Matches a reply to an item, relays the content, deletes the copy30s / 1024 MB
bc-chaseEventBridge dailyPredicts, chases the owner, escalates sideways, sends the summary60s / 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
bc-create-roledynamodb:PutItem, secretsmanager:GetSecretValueThe checks table; the Sheets credential only
bc-update-roles3:GetObject/DeleteObject, ses:SendRawEmailThe relay prefix; one verified identity
bc-chase-roledynamodb:Query, ses:SendEmailThe 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-To first, 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:0 on 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