Module 5: The Incident Lifecycle

7. Hands-on: a deterministic on-call rotation

Description

This lesson builds oncall/schedule.py: a weekly on-call rotation generator for Andes Cargo, with no SaaS, no external dependency, and — unlike almost any rotation example you'd find in a generic tutorial — no randomness at all. The rotation is a pure function of a fixed list of people and a fixed anchor date: running the script today, or running it a year from now, produces exactly the same result for the same week. It's the direct application of lesson 6: a rotation simple and portable enough that exporting it to any format or tool is trivial.

Connection to the module

This is the first and only piece in this module with real code infrastructure behind it — pure Python, actually executed. This lesson's output, the first few weeks of rotation, gets copied unchanged into INCIDENT-RESPONSE-PLAN.md's "On-call rotation" section in lesson 8, exactly the same pattern this ecosystem already used with scripts/error_budget_calculator.py (Module 2) and scripts/burn_rate_evaluator.py (Module 4).


Step 1 — Why never random, never datetime.now()

Two design decisions, neither accidental. A rotation generated with random.choice() isn't verifiable — nobody can confirm, by looking at the code, who's on call in week 12 without running the script and trusting that the random number generator's behavior hasn't changed between runs. A rotation anchored to datetime.now() is even worse: running the same script today and a week from now would produce different results for what should be the same fixed calendar week, because "today" changes every time it runs. Neither property is acceptable for an on-call document a real team needs to be able to audit, line by line, months after it was written — the same hard rule that has governed every script in this guide since Module 2.

The alternative this script uses: a fixed list of people (ROSTER), and a fixed anchor date (START_MONDAY), both written directly in the code, never calculated from the system clock.


Step 2 — The complete script

Create oncall/schedule.py at the root of andes-cargo-infra/:

# schedule.py
# Deterministic weekly on-call rotation for Andes Cargo. No SaaS, no randomness: a fixed
# roster, rotated by index, over a fixed set of example weeks -- reproducible, auditable,
# and portable to any tool (Module 5, lesson 6 on switching costs).

from datetime import date, timedelta

# Fixed roster, alphabetical order -- the starting order is a decision, not an accident
# (this lesson's Step 4 explains why the order matters and how it stays fair over time).
ROSTER = ["Ana", "Bruno", "Carla", "Diego"]

# Fixed anchor Monday. Never datetime.now() -- a schedule generated from "today" is not
# reproducible: running this script twice on two different days would silently produce
# two different schedules for the exact same week.
START_MONDAY = date(2026, 3, 2)
WEEKS_TO_GENERATE = 8


def primary_for_week(week_index):
    """Primary on-call, rotating through ROSTER once per week."""
    return ROSTER[week_index % len(ROSTER)]


def secondary_for_week(week_index):
    """Secondary/backup on-call: always the NEXT person in ROSTER after primary --
    guarantees the backup is never the same person as primary, deterministically."""
    return ROSTER[(week_index + 1) % len(ROSTER)]


def week_start(week_index):
    return START_MONDAY + timedelta(weeks=week_index)


def build_schedule(weeks=WEEKS_TO_GENERATE):
    schedule = []
    for week_index in range(weeks):
        schedule.append(
            {
                "week": week_index + 1,
                "starts": week_start(week_index).isoformat(),
                "primary": primary_for_week(week_index),
                "secondary": secondary_for_week(week_index),
            }
        )
    return schedule


def print_schedule(schedule):
    print(f"{'Week':<6}{'Starts':<12}{'Primary':<10}{'Secondary':<10}")
    for row in schedule:
        print(f"{row['week']:<6}{row['starts']:<12}{row['primary']:<10}{row['secondary']:<10}")


if __name__ == "__main__":
    print_schedule(build_schedule())

Each function does exactly one thing. primary_for_week() uses the modulo operator (%) over the week index and ROSTER's size — the same "wrap around the list" pattern that produces a cyclic rotation without needing any structure more complex than a list and an index. secondary_for_week() reuses the same logic, shifted one position, guaranteeing by construction — never by an extra check — that the backup never matches the primary. week_start() calculates any week's start date by adding whole weeks (timedelta(weeks=...)) to the fixed anchor date, with no dependency on the system clock at all.


Step 3 — Running the generator

python3 oncall/schedule.py

What to expect (literal — run twice, it produces, line by line, the same result, verified for this lesson):

Week  Starts      Primary   Secondary
1     2026-03-02  Ana       Bruno
2     2026-03-09  Bruno     Carla
3     2026-03-16  Carla     Diego
4     2026-03-23  Diego     Ana
5     2026-03-30  Ana       Bruno
6     2026-04-06  Bruno     Carla
7     2026-04-13  Carla     Diego
8     2026-04-20  Diego     Ana

Eight weeks, ROSTER's full cycle (four people) repeated exactly twice. Week 5 is once again identical, in primary and secondary, to week 1 — the visual proof that the pattern is cyclic, with a period equal to the list's size.


Step 4 — Why the initial alphabetical order is a decision, not an accident

ROSTER starts in alphabetical order (Ana, Bruno, Carla, Diego) for a concrete reason: it's the easiest criterion to audit with zero ambiguity — anyone new to the team can verify, without asking anyone, why the order is that one and not another. The alternative — ordering by seniority, by personal preference, or "however it occurred to us" — introduces an extra subjective decision that, over time, someone is going to question ("why is Diego always last in every cycle?"). Alphabetical order doesn't solve the underlying problem — someone always has to be first and someone always last within each cycle — but it does eliminate any suspicion of favoritism in how that order was decided.


Step 5 — One more step: why this format is already portable

This module's lesson 6 quoted the recommendation to treat on-call routing as a portable layer, with schedules in a simple format like YAML. build_schedule() already produces exactly that — a list of simple dictionaries, with no dependency on any API or proprietary interface — so exporting it to a real interchange format is a single line:

import json
print(json.dumps(build_schedule()[0], indent=2))

What to expect (literal, verified for this lesson):

{
  "week": 1,
  "starts": "2026-03-02",
  "primary": "Ana",
  "secondary": "Bruno"
}

This is precisely the property lesson 6's Hacker News quote recommends: the rotation's real data — who, when — never got trapped inside a specific vendor's internal logic. Migrating this same information to PagerDuty, Opsgenie, or any other tool tomorrow is an import-format problem, not a problem of rebuilding the rotation logic from scratch.


Common mistakes

Replacing week_index % len(ROSTER) with random.choice(ROSTER) "so it feels less predictable" (deliberately breaking determinism). What happens: someone, uncomfortable that the rotation is so easy to predict ahead of time, decides to introduce randomness "so it's not obvious who's next." How to spot it: if your version of schedule.py imports the random module anywhere. How to fix it: the rotation being predictable is the feature, not a flaw — a real team needs to be able to look at the calendar and know, weeks in advance, when it's each person's turn, exactly the same auditability requirement the rest of this guide has demanded of every script since Module 2. "Predictable" and "fair" aren't opposites; in fact, predictability is what makes it possible to verify the fairness of the distribution.

Adding a new person to ROSTER mid-way through an already-in-progress cycle, without considering what happens to already-generated weeks (breaking the rotation's continuity). What happens: someone adds a fifth name to the list after the first four weeks of on-call have already been communicated, without realizing that this retroactively changes who's on call starting from the week the change was made (week_index % 5 produces a completely different pattern than week_index % 4 for almost every week). How to spot it: if you compare the script's output before and after adding a person, and the weeks that had already been communicated no longer match. How to fix it: this is a real, honest limitation of this script, not a hidden bug — schedule.py, as it currently stands, assumes a stable ROSTER across the whole generated range of weeks. Changing the roster mid-way requires, at minimum, explicitly fixing from which week the change applies, instead of simply editing the list and re-running the script over the full range.

Assuming secondary_for_week() guarantees a perfectly fair distribution of who backs up whom (over-trusting the design without verifying it). What happens: someone concludes that, because the backup never matches the primary, the load of "being backup" is distributed perfectly evenly among the four people. How to spot it: if you never checked which specific (primary, secondary) pair repeats every full cycle. How to fix it: with this design, each person always backs up the same person ahead of them in ROSTER (Bruno always backs up Ana, Carla always backs up Bruno, and so on) — the pairing never changes. It's a real, honest limitation of the simplest possible design: it guarantees primary and secondary never match, but it doesn't guarantee variety in the pairings. A more sophisticated version could also rotate the backup's offset, at the cost of a pattern harder to predict at a glance — the same kind of explicit trade-off the rest of this guide has already declared in every representative case.


Exercises

Exercise 1 — Without running the script, calculate by hand who would be primary and secondary in week 12, using week_index % len(ROSTER). Remember week_index starts at 0 for week 1.

See solution

Week 12 corresponds to week_index = 11 (week 1 = index 0, so week 12 = index 11). primary_for_week(11) = ROSTER[11 % 4] = ROSTER[3] = "Diego". secondary_for_week(11) = ROSTER[(11 + 1) % 4] = ROSTER[12 % 4] = ROSTER[0] = "Ana". Primary: Diego. Secondary: Ana — exactly the same pair as week 4 and week 8 in the already-generated table (11 % 4 == 3, the same remainder as 3 % 4 and 7 % 4), confirming the cyclic pattern with a period of 4.

Exercise 2 — Explain why START_MONDAY is written as a fixed date (date(2026, 3, 2)) instead of calculated as "the next Monday from today." What concrete problem does this decision avoid?

See solution

Calculating "the next Monday from today" would require using date.today() or datetime.now(), which would break the script's full determinism: running it on a Tuesday would produce a different anchor date than running it on a Thursday of the same week, and the rotation's "week 1" would change every time someone ran the script on a different day. With a fixed date written directly in the code, week 1 always starts on March 2, 2026, no matter what "today" is for whoever runs the script — the same hard rule that has governed every fixed dataset in this guide since Module 2 (TRAFFIC_30_DAYS, BAD_WEEK): never calculate from the system clock something that needs to be reproducible.

Exercise 3 — Describe, in prose (without running anything), what line of schedule.py you would change to add a fifth person, "Elena," to the end of the roster, and predict how week 5's primary would change.

See solution

It would be enough to change the line ROSTER = ["Ana", "Bruno", "Carla", "Diego"] to ROSTER = ["Ana", "Bruno", "Carla", "Diego", "Elena"] — no other function would need changes, because primary_for_week() and secondary_for_week() already use len(ROSTER) generically, never the number 4 written directly. With five people, week 5 (week_index = 4) would go from ROSTER[4 % 4] = ROSTER[0] = "Ana" (with four people) to ROSTER[4 % 5] = ROSTER[4] = "Elena" (with five) — the full cycle now takes five weeks to repeat, not four, and each person goes from being on primary on-call one week out of every four (25%, Google SRE's exact limit) to one week out of every five (20%, below the limit, with more margin).


Summary and next step

This lesson built and ran oncall/schedule.py: a deterministic weekly rotation, with no SaaS, no randomness, and no dependency on the system clock — a fixed ROSTER of four people and a fixed anchor date (2026-03-02) produce eight weeks of primary and secondary on-call, verified identical across two independent runs. You confirmed, with the script's real output, that the pattern repeats exactly every four weeks, and saw how to export the first week to a portable format (JSON) in a single line — the direct application of lesson 6's point about why on-call needs to be designed portable from day one.

Before moving on you should be able to: run the script and get exactly this lesson's same numbers; calculate by hand the primary and secondary for any future week, using the modulo operator; and explain why START_MONDAY is never calculated from datetime.now().

Lesson 8, this module's final project, brings together the lifecycle (lesson 2), the severity matrix (lesson 5), the roles (lesson 4), and this same rotation into the complete document: INCIDENT-RESPONSE-PLAN.md.

Resources

  1. This same repository, Module 5, lesson 6 (06-on-call-with-honesty-about-its-cost.md) — the Hacker News quote that motivates this script's portable design.
  2. This same repository, Module 1, lesson 7 (07-the-vocabulary-youll-use-all-guide.md) — the 25% on-call time limit, quoted again in this lesson's Step 4.
  3. This same repository, Module 2, lessons 4 and 7 — the same pattern of fixed, deterministic data, never calculated from datetime.now(), that this lesson applies for the first time to a rotation of people instead of system traffic.
  4. Python — official datetime module documentation — the reference for date and timedelta used in this script.