Skip to content

Part 7 of 7 · Pension enrolment assessor series ~7 min read

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

The pension enrolment assessor drawn with AWS service namesThree boxes across the top sit outside the AWS account. Payroll exports, weekly and monthly, with a row per worker. Staff messages, by email and the pension form. And Letters and payroll, meaning fixed templates and pay instructions. Each connects to the AWS account container below. Inside are three components. S3 with SES inbound, where exports and messages land. Five Lambda functions covering ingest, assess, read, duties and notify. And three DynamoDB tables holding assessments, duties and messages. A note says eu-west-1, one account, pay runs assessed on import, messages on arrival, and open duties walked daily.AWS ACCOUNTPayroll exportsweekly and monthly,a row per workerStaff messagesemail and thepension formLetters and payrollfixed templates,pay instructionsS3 + SES inboundexports andmessages landLambda x5ingest, assess, read,duties, notifyDynamoDB x3assessments, duties,messagesingroundsouteu-west-1. One account. Pay runs assessed on import, messages on arrival, open duties walked daily.
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
  • 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 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
ae-ingestS3 put, exports prefixParses a pay run into earnings paid per worker per pay reference period120s / 1024MB
ae-assessStep after ingestCategorises every worker against the dated thresholds and opens any duty300s / 2048MB
ae-readSES inbound, messages prefixClassifies a staff message and checks for a signature or statement60s / 1024MB
ae-dutiesEventBridge, dailyWalks open duties: joining windows, deferral dates, refunds, re-enrolment120s / 1024MB
ae-notifyStep after assess, read or dutiesSends fixed templates and raises payroll instructions; writes no free text60s / 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

RoleAllowedOn
ae-ingest-roles3:GetObject, dynamodb:PutItemThe exports prefix; assessments write, as pending rows only
ae-assess-roledynamodb:Query, dynamodb:UpdateItem, dynamodb:PutItemassessments read and write; duties write. No S3, no model
ae-read-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the messages prefix; messages write
ae-duties-roledynamodb:Query, dynamodb:UpdateItemduties read and write; assessments and messages read only
ae-notify-roleses:SendTemplatedEmail, s3:PutObject, dynamodb:UpdateItemNamed 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