Skip to content

Part 7 of 7 · Feedback follow-up router series ~7 min read

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

The feedback follow-up router drawn with AWS service namesThree boxes across the top outside the AWS account. Survey responses, arriving by webhook or Function URL. Commerce and helpdesk systems, used for context lookups. And SNS with SES, carrying routing messages and digests. Inside the account, three groups. SQS carrying a response queue and EventBridge running the ask scheduler. Four Lambda functions named ask, receive, route and report. And two DynamoDB tables named responses and asks. A note gives the region as us-east-1, one account, and states that no reply to a customer is ever generated or sent by this system.AWS ACCOUNTSurvey responseswebhook or Function URLCommerce + helpdeskcontext lookupsSNS + SESrouting, digestsSQS + EventBridgeresponse queue,ask schedulerLambda x4ask, receive,route, reportDynamoDB x2responses, asksingroundsoutus-east-1. One account. No reply to a customer is ever generated or sent by this system.
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
  • 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 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
fr-askEventBridge hourlyFinds eligible orders, applies both caps, sends tokenised surveys60s / 512 MB
fr-receiveFunction URLValidates the token, records score and comment, enqueues10s / 512 MB
fr-routeSQS response queueOne Bedrock call, context fan-out, routing with a 60-minute timeout30s / 1024 MB
fr-reportEventBridge weekly + quarterlyTheme counts, response and ask rates, recovery measurement30s / 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
fr-ask-roledynamodb:Query/PutItem, ses:SendEmailThe asks table; one verified identity
fr-receive-roledynamodb:PutItem, secretsmanager:GetSecretValueResponses; the token signing key only
fr-route-rolebedrock:InvokeModel, secretsmanager:GetSecretValue, sns:PublishOne model arn; commerce and helpdesk read credentials; the staff topic
fr-report-roledynamodb:Query, ses:SendEmailResponses 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:0 on 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