Skip to content

Part 7 of 7 · Shift swap broker series ~7 min read

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

The shift swap broker drawn with AWS service namesThree boxes across the top outside the AWS account. Rota and rules, read through the Google Sheets API or a rota application. The Staff app, served as static files from S3 behind CloudFront. And SNS with SES, carrying offers and approval requests. Inside the account, three groups. EventBridge carrying batch timers and an expiry sweep. Four Lambda functions named offer, eligible, accept and approve. And two DynamoDB tables named swaps and counters. A note gives the region as us-east-1, one account, and states that the rota is the source of truth and is written only on approval.AWS ACCOUNTRota + rulesSheets API or rota appStaff appCloudFront + S3SNS + SESoffers, approvalsEventBridgebatch timers,expiry sweepLambda x4offer, eligible,accept, approveDynamoDB x2swaps, countersingroundsoutus-east-1. One account. The rota is the source of truth and is written only on approval.
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
  • 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 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
ss-offerFunction URLValidates ownership and the cut-off; creates the swap10s / 512 MB
ss-eligibleSQS swap queueThe four filters and the ordering; sends the first batch20s / 512 MB
ss-acceptFunction URLConditional write on the swap; closes the losing offers10s / 512 MB
ss-approveFunction URL + EventBridgeWrites the rota and the counters; runs the batch timers20s / 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

RoleAllowedOn
ss-offer-roledynamodb:PutItem, sqs:SendMessageThe swaps table; the swap queue
ss-eligible-roledynamodb:Query, sns:Publish, secretsmanager:GetSecretValueCounters, read; staff numbers only; the rota credential
ss-accept-roledynamodb:UpdateItemThe swaps table only
ss-approve-roledynamodb:UpdateItem, secretsmanager:GetSecretValue, ses:SendEmailSwaps 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