Skip to content

Part 7 of 7 · Exit interview collector series ~7 min read

Engineering reference: the exit interview collector 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 deliberately unjoined tables, and the narrow model call.

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.
  • 3 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 exit interview collector drawn with AWS service namesThree boxes across the top outside the AWS account. The Question form, served as static files from S3 behind CloudFront. The Theme list, read through the Google Sheets API read-only. And SES outbound, carrying the asks and reminders. Inside the account, three groups. EventBridge carrying the six-week timer and the quarterly report schedule. Three Lambda functions named ask, classify and report. And three DynamoDB tables named leavers, responses and aggregates. A note gives the region as us-east-1, one account, and states that no query in this system joins leavers to responses.AWS ACCOUNTQuestion formCloudFront + S3Theme listSheets API, read-onlySES outboundasks, remindersEventBridgethe six-week timer,the quarterly reportLambda x3ask, classify, reportDynamoDB x3leavers, responses,aggregatesingroundsoutus-east-1. One account. No query in this system joins leavers to responses.
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
  • Management
  • 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
ei-askFunction URL + EventBridge dailyMints tokens, sends both asks and their reminders15s / 512 MB
ei-classifyFunction URLAccepts a response by token; one Bedrock call into themes20s / 512 MB
ei-reportEventBridge quarterlyApplies the threshold and sends the themes page20s / 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
ei-ask-roledynamodb:PutItem (leavers), ses:SendEmailThe leavers table; one verified identity
ei-classify-rolebedrock:InvokeModel, dynamodb:PutItemOne model arn; responses and aggregates — no read on leavers at all
ei-report-roledynamodb:Query, ses:SendEmailThe aggregates table only; 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: leavers

PK   leaver_id         S   lv_2026_07_21_7b3d
     name              S   for the ask only
     work_email        S   for ask one
     personal_email    S   for ask two, if given
     leaving_date      S   2026-07-10
     token_hash        S   sha256 of the response token
     ask2_due          S   2026-08-21
     ttl               N   epoch, +12 months

The token itself is NEVER stored. Only its hash, and only here — which
means the mapping cannot be recomputed from either side.

Table: responses

PK   token             S   the random token, from the link
SK   round             S   exit | followup
     answers           L   six free-text answers
     quarter           S   2026-Q3   — the coarsest useful time key
     ttl               N   epoch, +3 years

No name, no email, no role, no manager, no leaving date. The quarter is
the only temporal field and it is only reported once several responses
share it.

Table: aggregates

PK   quarter           S   2026-Q3
SK   theme             S   scheduling | progression | unmatched | ...
     exit_count        N   how many raised it at exit
     followup_count    N   how many raised it at six weeks
     people            N   distinct tokens, for the threshold

This is the only table the report function can read. It contains counts
and no text, so there is no code path from a report to a sentence.

Inbound and outbound

  • The question form is static files in S3 behind CloudFront. The token in the URL is the only credential and there is no login.
  • Tokens are random, 128 bits, minted once per leaver and used for both asks so the two responses are linkable to each other and to nothing else.
  • The classify function cannot read the leavers table. Its IAM role has no permission on it, which makes the separation an access-control fact rather than a convention.
  • The report function can read only the aggregates table, which holds no text. There is no permission by which a report could contain a quote.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, classifying six short answers into your fixed theme list.
  • Called once per response, at submission, never again. Themes are stored as counts and the report never re-runs classification.
  • Grounded with your theme list, so the output is one of your labels or none. It never names a theme of its own.
  • Output is a JSON schema of theme labels only. The model is not asked to summarise, quote or characterise anything.
  • Unmatched is a first-class output. A rising unmatched count is how the theme list gets updated, and forcing a match would hide that.

Things worth knowing before you build it

  • Mint the token randomly. A token derived from an email address makes the whole separation recomputable by anybody holding the leaver list.
  • Deny the classify role any access to the leavers table. A convention that the code does not join them is much weaker than a policy that says it cannot.
  • Keep the raw answers off the aggregate table. It is the only thing standing between the report and a quote.
  • Collect the personal address on the last day. It is the single point of failure for the more valuable second response.
  • Say on the form what you can and cannot promise. Over-promising anonymity buys one round of honest answers and poisons the well afterwards.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts