Engineering reference: the pension enrolment assessor architecture
The same system with the service names filled in: what parses a pay run, what assesses it, what reads a message, and where the dates that every deadline hangs off are kept.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 5 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
- Storage
- Database
- App integration
- Analytics
Region and account
- Region:
eu-west-1. Ireland rather than London, because staff messages arrive by email and SES inbound receipt rules are the binding constraint, as elsewhere in this series. The data is payroll and pension data about UK staff, so confirm the transfer position with whoever advises you on data protection before relying on this choice; every other step here is arithmetic and would run anywhere. - 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 |
|---|---|---|---|
ae-ingest | S3 put, exports prefix | Parses a pay run into earnings paid per worker per pay reference period | 120s / 1024MB |
ae-assess | Step after ingest | Categorises every worker against the dated thresholds and opens any duty | 300s / 2048MB |
ae-read | SES inbound, messages prefix | Classifies a staff message and checks for a signature or statement | 60s / 1024MB |
ae-duties | EventBridge, daily | Walks open duties: joining windows, deferral dates, refunds, re-enrolment | 120s / 1024MB |
ae-notify | Step after assess, read or duties | Sends fixed templates and raises payroll instructions; writes no free text | 60s / 512MB |
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 |
|---|---|---|
ae-ingest-role | s3:GetObject, dynamodb:PutItem | The exports prefix; assessments write, as pending rows only |
ae-assess-role | dynamodb:Query, dynamodb:UpdateItem, dynamodb:PutItem | assessments read and write; duties write. No S3, no model |
ae-read-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the messages prefix; messages write |
ae-duties-role | dynamodb:Query, dynamodb:UpdateItem | duties read and write; assessments and messages read only |
ae-notify-role | ses:SendTemplatedEmail, s3:PutObject, dynamodb:UpdateItem | Named templates only; the instructions prefix; duty evidence fields |
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: assessments
PK org_id#worker_id S
SK period_start#run_id S one row per worker per pay run
frequency S weekly | fortnightly | four_weekly | monthly
paid_pence N earnings payable in the period
age_on_assessment N from date of birth, on the day
thresholds S tax_year#frequency row that was applied
category S eligible_jobholder | non_eligible_jobholder |
entitled_worker | active_member | outside
duty_id S set where this assessment opened a duty
supersedes S the assessment a corrected pay run replaced
paid_pence is compared against the published figure for its frequency,
never against the annual trigger divided in code. The gap between 192.00
and 192.31 is a real worker in a real week, and comparing floats in
pounds finds new ways to be wrong about it.
Table: duties
PK org_id#worker_id S
SK kind#arose_on S enrol | opt_in | join | postpone | refund |
re_enrol
arose_on S the date the duty arose, written once
due_by S from arose_on and the dated rules row
source S the assessment or message that opened it
deferral_date S postpone only, at most three months on
decided_by S postponements and exceptions: a person
evidence M {provider_ack, letter_sent, payroll_run}
state S open | done | missed | superseded
arose_on is written when the duty opens and never recomputed. A re-issued
pay run can change the week a worker crossed the trigger, and a date
derived on demand would move a deadline behind a letter already sent. A
genuine change supersedes the duty, with a reason, instead.
Table: messages
PK org_id#worker_id S
SK received_at#message_id S
kinds SS leave | join | change | question
notice_form S signed | statement | none
confidence S high | low; low always routes to a person
leaving_type S opt_out | cease; set by code, from dates
routed_to S scheme_process | payroll | person
template_id S the reviewed reply that was sent
model_id S
leaving_type is deliberately not a model output. The model says a worker
wants to leave; the enrolment record says whether the opt-out period is
still open. Two fields written by two pieces of code is what stops a
confident classification from deciding a refund.
Inbound and outbound
- Payroll exports are parsed, not read by a model. A weekly export is a fixed-header file with a row per worker, and assessing it is a comparison against a threshold row. A model has nothing to add and a decimal point to lose.
- Messages arrive by email or the pension form and both land in the same function. A message sent to a site manager is forwarded to the same address, so the route does not depend on who a worker happened to ask.
- Everything outbound is a template. Enrolment letters, postponement notices and every reply to a message are SES templates, reviewed once. The notify role can send named templates and nothing else.
- Nobody is enrolled or opted out automatically. The system raises instructions for the payroll and the provider, and holds each duty open until confirmation of each part comes back.
The model call
- One call per message. Nothing per worker, per pay run or per assessment. Assessment is a comparison.
- A small, fast model. Four kinds and two presence checks from two or three sentences is classification, not reasoning.
- Multi-label, with leaving dominant. A message can be a question and a wish to leave at once, and routing treats any leaving signal as leaving.
- The model writes no text a worker sees. Its output is a JSON object of kinds, a notice-form field and a confidence. Replies are selected from templates by code.
- Absent means null. A message that cannot be matched to a worker, or whose intent is unclear, returns null and low confidence and goes to a person. A guessed worker id is worse than none.
Things worth knowing before you build it
- Assess on earnings paid in the pay reference period. Payable does not mean earned, and contracted hours are neither.
- Use the published threshold for each pay frequency, not the annual figure divided. The weekly trigger is £192, and £192.20 is over it.
- Ask whether the worker is already an active member before anything else. Reassessing members from scratch will one day stop somebody’s deductions because they had a quiet week.
- Work out state pension age from date of birth. It is not one number, and it is moving.
- Anchor the opt-out period on the later of membership and the enrolment letter. Letters sent in batches make the difference real.
- Keep the leaving record after an opt-out. Re-enrolment three years later, and its twelve-month exception, both depend on the date.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts