Skip to content

Part 7 of 7 · Data request handler series ~7 min read

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

The data request handler drawn with AWS service namesThree boxes across the top outside the AWS account. SES inbound, receiving mail from the monitored inboxes. Your systems, listed in the register. And SES outbound, carrying the response. Inside the account, three groups. S3 holding assembled packages and SQS carrying one request queue. Three Lambda functions named recognise, gather and review. And two DynamoDB tables named requests and sources. A note gives the region as us-east-1, one account, and states that the package is deleted a set period after the response is sent.AWS ACCOUNTSES inboundthe monitored inboxesYour systemsfrom the registerSES outboundthe responseS3 + SQSpackages,one request queueLambda x3recognise, gather,reviewDynamoDB x2requests, sourcesingroundsoutus-east-1. One account. The package is deleted a set period after the response is sent.
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

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
dr-recogniseS3 ObjectCreated (SES)Cheap filters, one Bedrock call, records both timestamps20s / 512 MB
dr-gatherSQS request queueFans out to every source; records nothing-held explicitly300s / 1024 MB
dr-reviewFunction URLServes the review, records redactions, releases the package30s / 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

RoleAllowedOn
dr-recognise-roles3:GetObject, bedrock:InvokeModel, dynamodb:PutItemThe mail prefix; one model arn; the requests table
dr-gather-rolesecretsmanager:GetSecretValue, s3:PutObjectOne secret per source system; the packages prefix
dr-review-roles3:GetObject/DeleteObject, ses:SendRawEmailThe 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:0 on 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