Part 3 of 7 · Shift scheduler series ~5 min read

How a fair rota gets drafted

Once a week, by default Thursday at 2pm local time, an EventBridge Scheduler rule fires the drafter Lambda. The Lambda reads the week, sorts the shifts, and fills them one at a time, deciding for each shift who’s the fair, qualified, available pick — or flagging it if nobody fits. The whole decision is plain Python. No model. No vector search. Every setting lives in the rules doc, where the manager can edit it without a deploy.

Key takeaways

  • The drafter runs once a week via EventBridge Scheduler, by default Thursday at 2pm local time.
  • Settings live in the rules doc — max-hours caps, rest gaps, skill requirements, and how hard to balance hours.
  • Four outcomes per shift, every run: filled, fair-swap, short-staffed, held.
  • DynamoDB tracks a running hours total per person so the draft balances load as it goes.
  • The drafter itself never calls a model. The placement is entirely deterministic.

The decision flow, per shift

Decision flow per shift on every weekly draft A vertical decision flow diagram. At the top, an input box "Shift from the week" with the day, start and end time, role, and skills needed. Below that, a step "List qualified, available people" — everyone cleared for the skills and free for that day and time, minus anyone over their max-hours cap or inside a rest gap. Below that, a check "Anyone left?" — if no qualified, available person remains, route to "Short-staffed" and flag it for the manager. If yes, continue. The next step "Rank by hours below target" — orders the candidates by how far each is below their weekly hours target, so the person carrying the least load so far rises to the top. The next step "One clear best, or a tie?" — if one candidate is clearly furthest below target, place them and route to "Filled." If two are close, route to "Fair-swap" and give the shift to the one further below target to even the hours out. If the manager marked this shift to assign by hand, route to "Held" — the drafter leaves it open for the manager. Each terminal box — Filled, Fair-swap, Short-staffed, Held — updates the running hours total in DynamoDB and records the placement and the reason. A note at the bottom: the rules doc holds every setting; the drafter's code only enforces them — change a cap or a rest gap in the doc and the next run uses the new value. Shift from the week day · time · role · skills Step 1 List qualified people cleared, free, under cap Step 2 Anyone left? after cap and rest gap Step 3 Rank by hours below target least-loaded rises to top Step 4 One clear best? manager-held → held close tie → fair-swap Step 5 Place and update hours write to DDB ss-hours table Short-staffed nobody fits, flag it Filled one clear best Fair-swap close tie, even hours Held manager assigns if none held held clear close tie The rules doc holds every setting — change a cap and the next run uses the new value.
Fig 3. The drafter’s decision tree, per shift, per weekly run. Five steps decide which of four outcomes applies. The rules doc holds every setting; the drafter only enforces them.

Fairness: the hours target isn’t magic, it’s in the sheet

Each person has a weekly hours target in the staff sheet — the full-timer is set to 38, the student to 12, the weekend-only person to 16. As the drafter fills shifts, it keeps a running total of the hours it’s already placed on each person this week. For every open shift, it prefers whoever is furthest below their target. Over a week, that evens the load: nobody ends up with every closing shift while someone else gets nine hours, unless availability genuinely forces it.

The settings exist for a reason. The max-hours cap stops the draft from quietly handing one person a sixth shift. The rest gap (default eleven hours) stops the close-then-open double that burns people out. The skill requirements stop a kitchen shift going to someone who’s never run the kitchen. Different shifts have different needs; the rules reflect that.

Per-person overrides exist too. The staff sheet has an optional column called cap_override. Set a number there and the drafter uses it instead of the default cap for that one person. This is the right escape hatch for the manager who agreed to let a senior staffer pick up extra hours over the holidays.

Four outcomes, always

Every shift, every run, lands in exactly one of four buckets. The names are simple on purpose.

  • Filled. One qualified, available person was clearly the fair pick — furthest below their target — and got placed. Their running hours total in the ss-hours DynamoDB table is updated. Most shifts, most weeks, land here.
  • Fair-swap. Two people both fit and both sit close on hours. The shift goes to the one further below target to even things out, and a short note records why. This is the rule that keeps the rota feeling fair instead of arbitrary.
  • Short-staffed. A shift has fewer qualified, available people than it needs — everyone cleared is either off, at their cap, or inside a rest gap. The drafter doesn’t pretend it’s fine. It flags the shift for the manager with the reason (“only Priya is cleared and she’s at her cap”) so a human can decide.
  • Held. A shift the manager marked to assign by hand — the trial shift for the new hire, the sensitive Saturday the manager wants to place personally. The drafter leaves it open and notes it as held. The manager fills it during the approval step in Part 4.

State that makes the draft deterministic

The drafter reads and writes one DynamoDB table as it goes. ss-hours holds the running placed-hours total per person for the week being drafted: (person_id, week_id, hours_placed). With that table plus the week from the sheet and the settings from the rules doc, the placement logic is a few dozen lines of Python and zero magic. The same week, the same availability, the same settings always produce the same draft. Re-running the draft for a week produces the same rota (the run clears and rebuilds ss-hours for that week each time, so there’s no double-counting).

Approving a draft freezes it: the placements become the published schedule, and the ss-hours totals become the baseline that swaps in Part 5 adjust against. Re-drafting before approval just rebuilds from scratch.

Why the weekly draft uses no model

The drafter could call a model to “figure out a good rota.” It doesn’t. Two reasons. First, the draft should be the one part of the system the manager can fully reason about — if the rules say cap at five shifts and respect an eleven-hour rest gap, that’s exactly what happens, every time. A model in that loop would make “why did Sam get this shift?” impossible to answer cleanly. Second, model calls cost money and add latency, and a rule-based placement is both cheaper and more explainable for a problem this well-defined.

Bedrock fires elsewhere — on the time-off note lane in Part 2, and on the weekly fairness summary mentioned in Part 6. Not on the draft. The drafter itself is plain Python that reads a sheet and writes placements.

Next post: how an approved schedule reaches the team, how quiet hours are honored, and what each person actually sees.

All posts