Engineering reference: the data request handler 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 two timestamps, and the deletion of the package itself.
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
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 |
|---|---|---|---|
dr-recognise | S3 ObjectCreated (SES) | Cheap filters, one Bedrock call, records both timestamps | 20s / 512 MB |
dr-gather | SQS request queue | Fans out to every source; records nothing-held explicitly | 300s / 1024 MB |
dr-review | Function URL | Serves the review, records redactions, releases the package | 30s / 1024 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 |
|---|---|---|
dr-recognise-role | s3:GetObject, bedrock:InvokeModel, dynamodb:PutItem | The mail prefix; one model arn; the requests table |
dr-gather-role | secretsmanager:GetSecretValue, s3:PutObject | One secret per source system; the packages prefix |
dr-review-role | s3:GetObject/DeleteObject, ses:SendRawEmail | The packages prefix; 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: requests
PK request_id S req_2026_08_08_b4e1
received_at S 2026-08-08T09:12:00Z — the clock runs from HERE
recognised_at S 2026-08-08T09:47:00Z — how well the recogniser works
kind S access | deletion | correction | objection
subject S the person the data is about
requester S usually the same; different for third-party
verification S authenticated | address_on_file | asked | pending
clock_paused L [{from, to, reason}]
due_at S computed from received_at plus pauses
package_key S s3://packages/req_... — deleted after response
redactions L [{source, reason}]
withheld L [{item, basis}]
responded_at S when a person released it
Two timestamps, always. A schema with only one will eventually compute a
deadline from the wrong date.
Table: sources
PK request_id S req_2026_08_08_b4e1
SK system S helpdesk
method S api | manual
asked_at S 2026-08-08T09:48:00Z
answered_at S 2026-08-08T09:48:04Z
result S found | nothing_held | failed
item_count N 14
assigned_to S for manual sources
`nothing_held` is a first-class result. A row missing entirely means the
system was never asked, which is a different and worse thing.
Inbound and outbound
- SES receipt rules on the monitored addresses write to S3, and the S3 event fires recognition. More than one inbox is normal: sales, support and info all receive these.
- The manual entry form is a Function URL behind a signed staff link, and its date field defaults to today with an explicit prompt to change it if the request arrived earlier.
- Each source system has its own secret, read only by
dr-gather, and read-only scopes wherever the system offers them. - The package is deleted a configured period after the response is sent. It is a complete copy of everything you hold about one person and there is no reason to keep a second one.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, asked one narrow question about whether a message is a data request and which kind. - Cheap filters run first. Auto-replies, bulk mail and internal senders are excluded structurally, which removes most of an inbox before any model call.
- Output is a JSON schema with a kind and a confidence, both nullable. Low confidence produces the clarifying question rather than a decision.
- It never reads the gathered data. The model sees inbound messages only; the package is never sent to a model, which would be a strange thing to do with somebody’s complete personal data.
- Redaction is human. Identifying third-party data is a judgement with a breach on the other side of getting it wrong.
Things worth knowing before you build it
- Record both received_at and recognised_at. The clock runs from arrival, and a schema with one timestamp will eventually compute a deadline from the wrong date.
- Search every system by every identifier you hold for that person, not just the address the request came from.
- Record nothing_held explicitly. A missing source row means the system was never asked, which is completely different.
- Do not ask for identity documents by default. It is disproportionate, it collects sensitive data you then have to delete, and it reads as obstruction.
- Delete the assembled package after responding. It is the single most sensitive object the business will ever hold about one person.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts