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
- 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
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 |
|---|---|---|---|
ei-ask | Function URL + EventBridge daily | Mints tokens, sends both asks and their reminders | 15s / 512 MB |
ei-classify | Function URL | Accepts a response by token; one Bedrock call into themes | 20s / 512 MB |
ei-report | EventBridge quarterly | Applies the threshold and sends the themes page | 20s / 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 |
|---|---|---|
ei-ask-role | dynamodb:PutItem (leavers), ses:SendEmail | The leavers table; one verified identity |
ei-classify-role | bedrock:InvokeModel, dynamodb:PutItem | One model arn; responses and aggregates — no read on leavers at all |
ei-report-role | dynamodb:Query, ses:SendEmail | The 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:0on 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