Engineering reference: the feedback follow-up router 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 routing timeout, and the single model call.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 4 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
- 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 |
|---|---|---|---|
fr-ask | EventBridge hourly | Finds eligible orders, applies both caps, sends tokenised surveys | 60s / 512 MB |
fr-receive | Function URL | Validates the token, records score and comment, enqueues | 10s / 512 MB |
fr-route | SQS response queue | One Bedrock call, context fan-out, routing with a 60-minute timeout | 30s / 1024 MB |
fr-report | EventBridge weekly + quarterly | Theme counts, response and ask rates, recovery measurement | 30s / 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 |
|---|---|---|
fr-ask-role | dynamodb:Query/PutItem, ses:SendEmail | The asks table; one verified identity |
fr-receive-role | dynamodb:PutItem, secretsmanager:GetSecretValue | Responses; the token signing key only |
fr-route-role | bedrock:InvokeModel, secretsmanager:GetSecretValue, sns:Publish | One model arn; commerce and helpdesk read credentials; the staff topic |
fr-report-role | dynamodb:Query, ses:SendEmail | Responses and asks, read; 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: responses
PK response_id S rsp_2026_08_09_d21f
order_id S from the token, not from the customer
customer S or ’anonymous’
score N 3
comment S verbatim
theme S delivery | quality | communication | price |
staff | website | unmatched
serious S safety | legal | data | leaving | none
routed_to S a named person
routed_at S 2026-08-09T10:14:00Z
acknowledged_at S or null; null at +60m re-routes
replied_at S set by the person, one tap
ordered_again_at S filled in later, for the recovery measure
`ordered_again_at` is written by a monthly backfill, and it is the only
field that measures whether any of this worked.
Table: asks
PK customer S the customer identifier
SK order_id S the order asked about
asked_at S 2026-08-07T09:00:00Z
token S hash of the signed token
responded BOOL false
Two caps enforced from one table: one row per order prevents asking twice
about the same thing, and a query by customer with a date bound enforces
the per-person frequency cap.
Inbound and outbound
- Survey links carry a signed order token, single-use for recording, so a response arrives already attached to a specific transaction rather than to a customer.
- The receive endpoint always returns success, including for an invalid or reused token, and records the anomaly. A customer who clicks twice should not see an error page.
- Routing uses SNS to a person’s own endpoint, never a shared inbox, with a sixty-minute acknowledgement timeout that re-routes to the next person on the rota.
- Context lookups are read-only against commerce and helpdesk, with separate credentials, and a failed lookup renders as an explicit note rather than a blank section.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, doing two things in one call: classifying into your fixed theme list and flagging serious content. - Called once per response with a comment. A score with no comment never reaches it.
- Grounded with your theme list, so it returns one of your labels or unmatched and never invents a theme.
- It never drafts a reply. A generated apology to somebody who is already annoyed is transparent and makes the situation worse, which is a rare case of the obvious feature being clearly wrong.
- The serious-content check errs upward. A false escalation costs somebody two minutes; a missed safety comment does not.
Things worth knowing before you build it
- Token the order, not the customer. It is the difference between a reply that asks what happened and one that already knows.
- Enforce both caps. One ask per order and a per-person frequency cap; either alone leaves a failure mode open.
- Check for serious content before looking at the score. The eight out of ten mentioning an injury is the response that matters most that week.
- Route to a named person with an acknowledgement timeout. A shared inbox is where responses go to wait for somebody else.
- Report the ask rate next to the score. It is the cheapest defence against the most common way a satisfaction number gets improved without anything improving.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts