Engineering reference: the holiday accrual tracker architecture
The same system with the service names filled in: what reads a contract, what accrues a period, what moves a balance across a year end, and what settles a leaver.
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
- Management
Region and account
- Region:
eu-west-2. London, because this is employment data about UK workers and there is no reason for it to leave. Bedrock model availability is checked here rather than assumed; the contract read is the only step that would have to move if the model needed were not present, and it is the only step that is not arithmetic. - 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 |
|---|---|---|---|
hol-contract | S3 put, contracts prefix | Reads leave year, entitlement, rate and any recovery term | 60s / 1024MB |
hol-accrue | EventBridge, on pay period close | Turns hours worked into one append-only ledger line per worker | 300s / 1024MB |
hol-book | API Gateway, leave request events | Reserves, releases and deducts against the pot expiring first | 30s / 512MB |
hol-close | EventBridge, per leave year boundary | Applies carry-over with a reason and a computed expiry | 300s / 2048MB |
hol-leaver | Termination event | Closes the part-period, values the balance, writes the evidence | 300s / 2048MB |
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 |
|---|---|---|
hol-contract-role | bedrock:InvokeModel, s3:GetObject, dynamodb:UpdateItem | One model id; the contracts prefix; workers |
hol-accrue-role | s3:GetObject, dynamodb:Query, dynamodb:PutItem | The timesheets prefix; workers read; ledger write, no update |
hol-book-role | dynamodb:Query, dynamodb:PutItem, dynamodb:UpdateItem | ledger read and append; bookings read and write |
hol-close-role | dynamodb:Query, dynamodb:PutItem | ledger read; ledger append. No delete on anything, ever |
hol-leaver-role | dynamodb:Query, s3:PutObject, ses:SendEmail | All three tables read; the evidence prefix write-once; 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: workers
PK employer_id#worker_id S
SK ’terms#’ + effective S one item per contract version
leave_year_start S MM-DD, or the hire anniversary
leave_year_source S contract | policy | statutory_default
accrual_rate N 0.1207 unless the contract is better
pot_split M {four_week, remainder, contractual}
recovery_term BOOL a written term allowing deduction on leaving
evidence_key S the contract itself, not a summary
leave_year_source is stored because the statutory default is per-worker
and the other two are per-business. A system that only stores the date
cannot tell you which of its 46 year ends it invented.
Table: ledger
PK employer_id#worker_id S
SK period_end#seq S append-only; seq disambiguates corrections
kind S accrual | deduction | carry_in | carry_out |
adjustment | payout
hours N signed
pot S four_week | remainder | contractual
leave_year S the window this line belongs to
expires_on S set on carry_in lines only
reason S sickness | family_leave | employer_failure |
agreement | leave_year_moved
inputs M hours worked, rate, rounding, source file
Nothing in this table is ever updated or deleted. A correction is an
adjustment line carrying the same period_end and a later seq, so the
balance as at any past date is still recoverable.
Table: bookings
PK employer_id#worker_id S
SK booking_id S
from_date S
to_date S
hours N
state S requested | reserved | taken | cancelled
deducted_seq S the ledger line, set when state becomes taken
state and deducted_seq together are the invariant: a booking in state
taken must name a ledger line, and a ledger deduction must name a
booking. A nightly check that both directions hold is four lines of
code and catches every partial write this system can produce.
Inbound and outbound
- Timesheets are parsed, not read by a model. They are the largest files here and they are fixed-header exports from payroll or a rota tool. A model would be slower and would occasionally invent an hour.
- Contracts are read once per version. A variation is a new item under the same worker, never an edit, so a balance calculated last March still knows which terms applied then.
- Leave requests arrive over an API and the reservation is a conditional write. Two managers approving the same week is a condition failure rather than a lock.
- Balances go out monthly by SES, to every worker, with what expires and when. This is the operational feature that closes the employer-failure carry-over route.
The model call
- One call per contract version. Nothing per shift, per period, per booking or per balance query. All of those are arithmetic.
- A mid-tier model. The extraction is four fields and a quotation for each, out of a long document. Length is not the same as difficulty.
- Every extracted term comes back with the clause it came from, verbatim and with a page reference, because a disputed balance is settled by reading the clause rather than by trusting the field.
- Absent means null, never a default. A contract that says nothing about recovering overtaken leave must produce null, not false and certainly not true. Null is the answer that stops the leaver job making a deduction.
- No model touches accrual, deduction, carry-over or the payout. Every one of them has to be re-runnable years later and explainable to somebody who disagrees with the result.
Things worth knowing before you build it
- Accrue on hours paid, from the same source payroll used. It is the only external check this system has, and it stops being a check the moment the two read different files.
- Keep protected absence accruing. A worker on maternity leave or long-term sick works zero hours and accrues at the 52-week average, and omitting that is the largest single underpayment this design can produce.
- Round accrual up, in every period, per worker. A rounding rule that favours the employer is indefensible exactly because it is consistent.
- Store the pot each hour sits in. Four weeks are valued at normal remuneration and the rest at basic pay, and reconstructing the split at termination means reading two years of payslips under time pressure.
- Compute carry-over expiry at the time of carrying and store the date. Recomputing it later against whatever the rules say then is how a balance silently changes.
- Send the balance out monthly. It is a small feature that converts an open-ended liability into one that expires on schedule.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts