Engineering reference: the shift swap broker 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 race condition, and why there is almost no model here.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 4 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
- 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 |
|---|---|---|---|
ss-offer | Function URL | Validates ownership and the cut-off; creates the swap | 10s / 512 MB |
ss-eligible | SQS swap queue | The four filters and the ordering; sends the first batch | 20s / 512 MB |
ss-accept | Function URL | Conditional write on the swap; closes the losing offers | 10s / 512 MB |
ss-approve | Function URL + EventBridge | Writes the rota and the counters; runs the batch timers | 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 |
|---|---|---|
ss-offer-role | dynamodb:PutItem, sqs:SendMessage | The swaps table; the swap queue |
ss-eligible-role | dynamodb:Query, sns:Publish, secretsmanager:GetSecretValue | Counters, read; staff numbers only; the rota credential |
ss-accept-role | dynamodb:UpdateItem | The swaps table only |
ss-approve-role | dynamodb:UpdateItem, secretsmanager:GetSecretValue, ses:SendEmail | Swaps and counters; the rota credential; one 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: swaps
PK swap_id S swp_2026_07_23_5e21
shift M {date, start, end, site, station}
giving_up S ash@example.com
taking S rae@example.com, or null
state S open | accepted | approved | expired | withdrawn
eligible L the ordered list, computed ONCE
batch N which batch is currently out
batch_expires S 2026-07-23T16:00:00Z
snapshot M the taker’s hours and swap count at approval
override M {rule, reason, by} or null
The accept:
UpdateExpression: SET taking = :who, #s = :accepted
ConditionExpression: #s = :open
Two people accepting in the same second cannot both satisfy that.
Table: counters
PK person S rae@example.com
swaps_taken L [ISO dates, last 90 days]
hours_by_week M {2026-W30: 31.0, ...}
`swaps_taken` is a list of dates rather than a count, so the rolling
30-day window is computed rather than maintained — which means it cannot
drift and does not need a nightly reset job.
Inbound and outbound
- The staff app is static files in S3 behind CloudFront, reached through a signed staff link. There is no login and no app store.
- Offer links are signed, scoped to one swap and one recipient, single-use, and expire with the batch window.
- The rota is read on every eligibility run and written only by
ss-approve. No other function has the credential. - SMS goes through SNS to numbers from the staff list only. A phone number in any request body is never a destination.
The model call
- There is no model in the request path. Eligibility is four filters over a rota and a skills matrix, which is set arithmetic.
- The one optional use is turning a free-text reason on an override into a category for the monthly report, and even that is better done with a short pick list.
- This is worth stating because it is the most obviously AI-shaped problem in the series — matching people to shifts — and it is completely solved by filtering and sorting.
- If you add one, add it to the ordering rather than the filtering, and be able to explain the order to somebody who got fewer hours than a colleague.
- The cost page assumes none, which is why the read band is nominal.
Things worth knowing before you build it
- Compute the eligible list once and store it. Recomputing per batch can silently drop somebody who was already offered the shift.
- Move both people’s hour totals on approval. Forgetting the giver’s makes every subsequent overtime check wrong in the safe-looking direction.
- Store swaps_taken as dates, not a count. A rolling window computed from dates cannot drift and needs no reset job.
- Tell the loser of a race what actually happened. “Rae got there first” is honest; “you were not selected” is not what occurred.
- Publish the ordering rule. Whatever it is, an unexplained order for who gets extra hours will be assumed to be favouritism.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts