If your change freeze lives in one wiki sentence, every pager turns into a negotiation, and the loudest voice in the incident channel decides what production is allowed to do at 3am. The fix is unglamorous: make the freeze window a value that runbook steps evaluate, make the unfreeze a recorded decision with named exit criteria, and generate the on-call card from the same file that holds your alerts, first commands, and escalation ladder. Everything below is the artifact I would hand a team on a Monday, including the parts that are deliberately boring.
The 3am question nobody can answer
Picture a CheckoutErrorRateHigh page landing in the middle of a holiday freeze, with a rollback, a config flag flip, and a schema migration all on the table. Three people have three opinions about which of those is allowed, the doc says "freeze", the channel says "just ship it", and the release manager who could arbitrate is asleep. So here is the blunt question: which of those three actions is exempt by written rule, and who can prove it after the fact?
Prose cannot answer that, because prose has no return value. What a runbook needs is something callable that returns ALLOW, HOLD, or EXEMPT plus a reason string you can paste straight into the incident channel. That is the whole design goal, and the next sections build it in roughly eighty lines of Python.
One file, four consumers
I keep the runbook as YAML because four readers need the same source: the human on call, the CI job, the alert-payload contract test, and whatever assistant I ask to review wording. Here is a trimmed runbook.yaml covering the four sections that matter most when the page goes off.
service: checkout-api owner: payments-platform freeze: - name: year-end tz: America/New_York starts: '2026-12-24T12:00:00' ends: '2027-01-02T09:00:00' always_exempt: [rollback, security-patch] exit_criteria: - 'error-budget burn under 2x for 30 minutes' - 'named approver from payments-platform in the incident doc' alerts: - name: CheckoutErrorRateHigh first_commands: - kubectl -n checkout rollout status deploy/checkout-api --timeout=30s - kubectl -n checkout get pods -l app=checkout-api -o wide - kubectl -n checkout logs deploy/checkout-api --since=10m --tail=200 escalation: - { after: 5m, to: on-call-secondary } - { after: 15m, to: incident-commander } Two rules keep this file honest. First, every first_commands entry must be read-only, because a panicking human pastes them blindly and one destructive flag poisons the whole card. Second, every freeze entry carries exit_criteria, because "unfreeze when it feels safe" is not a rule, it is a mood with a timestamp attached.
Rendering the card without a template engine
Thirty lines of Python turn that YAML into the card, so the terminal, the incident doc, and the pager notification all show identical text. No screenshot rot, no stale command that references a deployment renamed two quarters ago.
import sys, yaml def card(path, alert_name): doc = yaml.safe_load(open(path)) alert = next(a for a in doc['alerts'] if a['name'] == alert_name) nl = chr(10) out = ['# {0} ({1})'.format(alert_name, doc['service']), '', '## First commands', ''] out += ['{0}. `{1}`'.format(i, c) for i, c in enumerate(alert['first_commands'], 1)] out += ['', '## Escalation', ''] out += ['- after {0}: {1}'.format(e['after'], e['to']) for e in alert['escalation']] return nl.join(out) if __name__ == '__main__': print(card(sys.argv[1], sys.argv[2])) Running python card.py runbook.yaml CheckoutErrorRateHigh gives you the same card everywhere, and that is the point of owning the file. You are not maintaining a document any more, you are maintaining a value.
The freeze decision as a pure function
Time zones are where freeze logic quietly breaks, because a window that starts at noon in New York does not start at noon in the timestamp your alert pipeline emits. So the decision function refuses naive datetimes outright and lets zoneinfo do the conversion work.
from dataclasses import dataclass from datetime import datetime from enum import Enum from zoneinfo import ZoneInfo class Decision(str, Enum): ALLOW = 'allow' HOLD = 'hold' EXEMPT = 'exempt' @dataclass(frozen=True) class Freeze: name: str tz: str starts: datetime ends: datetime always_exempt: frozenset def decide(self, now, change_class, approver=None): for stamp in (now, self.starts, self.ends): if stamp.tzinfo is None: raise ValueError('naive datetime: refusing to guess a time zone') if not (self.starts <= now < self.ends): return Decision.ALLOW, 'outside ' + self.name if change_class in self.always_exempt: return Decision.EXEMPT, change_class + ' is always exempt' if change_class == 'emergency-fix' and approver: return Decision.EXEMPT, 'emergency-fix approved by ' + approver return Decision.HOLD, 'inside ' + self.name + '; unfreeze needs exit criteria' def load(raw): tz = ZoneInfo(raw['tz']) return Freeze( name=raw['name'], tz=raw['tz'], starts=datetime.fromisoformat(raw['starts']).replace(tzinfo=tz), ends=datetime.fromisoformat(raw['ends']).replace(tzinfo=tz), always_exempt=frozenset(raw.get('always_exempt', [])), ) The half-open interval starts <= now < ends matters more than it looks, because it makes the boundary behaviour something you can test instead of something you argue about. A rollback is exempt unconditionally, an emergency fix needs a named approver, and everything else holds until someone writes down which exit criteria were observed.
Tests that fail CI before the page fires
Boundary cases are cheap to encode, so encode them as a table and let the build tell you when someone edits the window. The New York window above starts at 17:00 UTC and ends at 14:00 UTC, which the assertions below pin explicitly.
import pytest from datetime import datetime, timezone from runbook.freeze import load, Decision RAW = { 'name': 'year-end', 'tz': 'America/New_York', 'starts': '2026-12-24T12:00:00', 'ends': '2027-01-02T09:00:00', 'always_exempt': ['rollback'], } F = load(RAW) @pytest.mark.parametrize('now,cls,approver,expected', [ (datetime(2026, 12, 24, 16, 59, tzinfo=timezone.utc), 'schema-migration', None, Decision.ALLOW), (datetime(2026, 12, 24, 17, 0, tzinfo=timezone.utc), 'schema-migration', None, Decision.HOLD), (datetime(2026, 12, 24, 17, 0, tzinfo=timezone.utc), 'rollback', None, Decision.EXEMPT), (datetime(2026, 12, 24, 17, 0, tzinfo=timezone.utc), 'emergency-fix', None, Decision.HOLD), (datetime(2026, 12, 24, 17, 0, tzinfo=timezone.utc), 'emergency-fix', 'emery', Decision.EXEMPT), (datetime(2027, 1, 2, 14, 0, tzinfo=timezone.utc), 'schema-migration', None, Decision.ALLOW), ]) def test_freeze_table(now, cls, approver, expected): assert F.decide(now, cls, approver)[0] is expected def test_naive_datetime_is_rejected(): with pytest.raises(ValueError): F.decide(datetime(2026, 12, 25, 12, 0), 'deploy') Add one more case for a window that spans a daylight-saving transition, such as a March change window from Friday evening to Sunday morning in the same zone. Do you want to discover an hour of unguarded production during a spring-forward weekend, or would you rather find it in a pull request on a Tuesday?
Where a free model endpoint actually helps
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I run this workflow alongside MonkeyCode, an open-source coding assistant project, because its free model access and free server option cover the two places where I want inference but do not want a bill: drafting YAML out of existing prose, and a second pair of eyes on first_commands for destructive flags. The operator describes the free tier as 10 million tokens plus a free server, and I would check the project page for the current terms before planning around them, since free tiers move.
The important discipline is deterministic-first. The model never decides freeze state, because a non-deterministic reviewer has no business returning ALLOW about production; it only reads text and proposes a diff that a human approves.
import os, json, urllib.request ENDPOINT = os.environ['MONKEYCODE_BASE_URL'] MODEL = os.environ['MONKEYCODE_MODEL'] KEY = os.environ.get('MONKEYCODE_API_KEY', '') def review_commands(commands): prompt = ('Classify each on-call first command as SAFE or DESTRUCTIVE. ' 'Giona one reason each. Do not invent commands.\n\n' + '\n'.join(commands)) body = json.dumps({'model': MODEL, 'messages': [{'role': 'user', 'content': prompt}]}).encode() req = urllib.request.Request( ENDPOINT, data=body, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + KEY}) payload = json.load(urllib.request.urlopen(req, timeout=60)) return payload['choices'][0]['message']['content'] Treat that snippet as illustrative rather than executed here, because the request shape depends on the compatible API the project exposes at the time you read this. Three guardrails keep it from becoming a liability: grep the file for tokens and hostnames before sending anything, verify every flag the model mentions against --help instead of trusting it, and require a human approval on the resulting diff. A model that says a command is safe has told you nothing about your cluster.
The unfreeze record is four fields
Unfreeze is not a feeling that spreads across a channel; it is an append-only record with a timestamp, a change class, a named approver, and the exit criteria actually observed. If you cannot fill all four, you are not unfrozen, you are just hoping loudly and documenting it afterwards. The freeze itself should end by clock, because an expired freeze nobody clears is indistinguishable from no freeze at all, and that ambiguity always resolves in the wrong direction at the worst time.
Who should not adopt this
- Teams paging less than once a month. A schema, a renderer, and a CI gate are real overhead; keep a one-page card and skip the state machine.
- Organizations where freeze is a negotiation, not a rule. Write the approver list first, because code cannot repair an unclear policy and will only make the ambiguity faster.
- Teams with legally auditable change approval. Keep your change-management system as the record of truth and let this YAML mirror the window only, knowing that mirrors drift.
- Anyone who cannot send text to a hosted model. Drop the review step entirely; it is optional and the rest of the workflow stands without it.
- Teams that never review the file after incidents. A runbook with no post-incident diff is a museum exhibit, and the missing commit is the bug to file.
Rollout in four steps
- Copy the YAML for one alert on your loudest service and keep the first commands strictly read-only.
- Add the parametrized freeze table for the window that scares you most, including one case that crosses a clock change.
- Wire both the render diff and the freeze tests into CI, then make a failing build block merges to the runbook directory.
- After the next incident, add whatever command you wished you had as a new test case, so the card learns from the page instead of the postmortem.
None of this is clever, and that is why it survives contact with a real rotation. If you try the reviewer step and want a starting point without a billing account, the free model access and free server option are what I used, and the project docs are the honest place to check what is still offered today.

