Engineering reference: the tip pool splitter 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 tables, the decimal handling, and why there is no model anywhere in it.
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.
- 2 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
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 |
|---|---|---|---|
tp-collect | S3 ObjectCreated + Function URL | Card figure, cash declaration, hours; completeness check | 20s / 512 MB |
tp-split | EventBridge weekly | Weighted hours, rate, shares, remainder rotation | 20s / 512 MB |
tp-publish | SQS split queue | Personal messages, the period summary, and the payroll export | 30s / 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 |
|---|---|---|
tp-collect-role | s3:GetObject, dynamodb:UpdateItem | The exports prefix; the periods table |
tp-split-role | dynamodb:UpdateItem, secretsmanager:GetSecretValue | Periods and rotation; the Sheets credential only |
tp-publish-role | ses:SendEmail, s3:PutObject | One verified identity; the exports prefix |
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: periods
PK site S ashford
SK period_end S 2026-07-26
state S collecting | split | published | corrected
card N 642.30
cash N 188.00
cash_declared_by S ash@example.com
cash_declared_at S 2026-07-26T23:40:00Z
rule M the weights and basis, copied at split time
rate S “2.6077” — stored as a string, see below
shares L [{person, hours, weight, weighted, share}]
remainder N 0.02
remainder_to L [sam@, kit@]
supersedes S a previous version, for a correction
The rule is COPIED onto the period at split time. A later edit to the
sheet cannot restate a published period.
Table: rotation
PK site S ashford
order L a stable list of people
position N 4 — carried between periods, never reset
Resetting `position` each period is the bug that makes the rotation
pointless: whoever is first in `order` would get a penny every week.
Inbound and outbound
- Card and hours exports land in an S3 prefix from the point of sale and payroll. Both fire the same collect function.
- The cash declaration comes through a Function URL from a signed staff link, and records who declared it and when as first-class fields rather than metadata.
- Nothing is written back to payroll. The export is a file; paying people is payroll’s job and this system has no credential for it.
- Personal share links are signed, scoped to one person and one period, and expire after ninety days.
The model call
- There is no model in this system. Not in the request path, not in the reporting, not anywhere.
- That is worth stating explicitly in a series where most systems use one: this problem is arithmetic and a model would only add a way to be wrong about money.
- Use decimal arithmetic, not floats. Python’s
Decimalwith an explicit context; the rate is stored as a string so it round-trips exactly. - Round half up, not half even. Banker’s rounding is correct in many contexts and surprising here, and surprise is the thing this system exists to remove.
- Compute the remainder from the pool, not by summing rounding errors. The two differ, and only the first is definitionally right.
Things worth knowing before you build it
- Use Decimal, never floats. A rate of 2.6077 in binary floating point will eventually produce a share that is a penny out and nobody will be able to explain why.
- Carry the rotation position between periods. Resetting it hands the same person a penny every week and makes the whole mechanism worse than useless.
- Copy the rule onto the period at split time. A sheet edit must never be able to restate a published period.
- Use final hours, not draft ones. A share that changes after publication costs more trust than a split published two days later.
- Never round in the house’s favour, not even once, not even as a convention. It is four pounds a year and it is the loudest thing this system could say.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts