The core conclusion is simple, and I will not bury it under another agent prompt. Once an on-call bot has touched production, the next danger is a second mutation, not a missing explanation. You need a freeze flag in the runbook, and an unfreeze path that a human must sign. Why do we keep treating freeze as a Slack vibe when the bot only obeys structured state?
I am writing this as a runbook control, not as a story about a heroic war room. Agentic on-call tools keep assuming the next step is another restart, another scale event, or another config write. A freeze rule is the smallest honest answer I have found: halt mutations after the first accepted action, then require a human unfreeze with evidence. If your bot cannot freeze, it is not on-call help. It is an unsupervised change pipeline wearing a pager hat.
What freeze is, and what it is not
Freeze is not silence, and it is not a polite system prompt that says please stop. Freeze is a durable incident flag that rejects mutating commands until an unfreeze record exists. Unfreeze is not a vibe check in chat. Unfreeze is a signed transition with a reason, a scope, and an expiry.
I keep freeze separate from three other controls people mash together. Allowlists decide which commands can ever exist. Escalation decides which human gets the page. Completeness checks decide whether the alert is even usable. Freeze answers a later question: the incident is already open, something already ran, so what is forbidden now?
Ask yourself the ugly version. If the bot rolled a canary and latency got worse, should it roll again because the model feels confident? If a human is already in the cluster, should the bot still drain nodes because the runbook text still says consider draining? Freeze exists because those answers must be no without another model debate.
The runbook rule I actually encode
I write four fields into the runbook so the bot cannot invent policy from tone. They are boring on purpose, because boring serializes.
-
freeze_after— which accepted action arms the freeze, usually the first mutating command. -
frozen_verbs— the command classes that die during freeze, such as restart, scale, drain, rollback, apply. -
read_allow— diagnostics that remain legal, such as logs, metrics, describe, trace. -
unfreeze— who may clear the flag, with which evidence, and for how long.
Here is a proposed runbook fragment. Treat it as an example schema, not a production dump from my pager.
# proposed example: incident freeze policy
apiVersion: oncall.example/v1
kind: Runbook
metadata:
id: checkout-latency
spec:
alert_match:
service: checkout
severity: ["sev1", "sev2"]
freeze_policy:
arm_on:
- class: mutate
once: true
frozen_verbs: [restart, scale, drain, rollback, apply, patch]
read_allow: [logs, metrics, describe, trace, kubectl_get]
unfreeze:
actors: ["incident-commander", "service-owner"]
require:
- human_ack: true
- evidence: ["graph_link", "change_ticket"]
- scope: service # not cluster-wide
ttl_seconds: 1800
rearm_on_mutate: true
Notice the last line. If unfreeze lets one more mutation through, the freeze arms again. Why would you unfreeze forever after a single ack? Humans get interrupted. Models get chatty. The flag should be sticky by default.
A freeze gate you can run locally
The runbook is theater unless a process actually rejects the next command. I want a gate that sits in front of the executor, not inside the prompt. Prompts forget. Files and locks forget less often.
This is proposed Python, labeled because I am not claiming a measured production rollout here. It keeps incident freeze state on disk so two bot workers cannot disagree by accident.
# proposed: freeze_gate.py
from __future__ import annotations
import json
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Literal
VerbClass = Literal["read", "mutate"]
MUTATING = {"restart", "scale", "drain", "rollback", "apply", "patch"}
@dataclass
class FreezeState:
incident_id: str
frozen: bool
armed_at: float | None
unfreeze_until: float | None
last_reason: str
class FreezeGate:
def __init__(self, path: Path):
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
def _load(self, incident_id: str) -> FreezeState:
if not self.path.exists():
return FreezeState(incident_id, False, None, None, "init")
raw = json.loads(self.path.read_text())
data = raw.get(incident_id) or {}
return FreezeState(
incident_id=incident_id,
frozen=bool(data.get("frozen")),
armed_at=data.get("armed_at"),
unfreeze_until=data.get("unfreeze_until"),
last_reason=data.get("last_reason", ""),
)
def _save(self, state: FreezeState) -> None:
raw = json.loads(self.path.read_text()) if self.path.exists() else {}
raw[state.incident_id] = asdict(state)
self.path.write_text(json.dumps(raw, indent=2, sort_keys=True))
def classify(self, verb: str) -> VerbClass:
return "mutate" if verb in MUTATING else "read"
def allow(self, incident_id: str, verb: str) -> tuple[bool, str]:
state = self._load(incident_id)
now = time.time()
if state.unfreeze_until and now > state.unfreeze_until:
state.frozen = True
state.unfreeze_until = None
state.last_reason = "ttl_expired_rearm"
self._save(state)
kind = self.classify(verb)
if kind == "read":
return True, "read_allow"
if state.frozen:
return False, f"frozen:{state.last_reason}"
return True, "mutate_open"
def note_success(self, incident_id: str, verb: str) -> None:
if self.classify(verb) != "mutate":
return
state = self._load(incident_id)
state.frozen = True
state.armed_at = time.time()
state.unfreeze_until = None
state.last_reason = f"armed_after:{verb}"
self._save(state)
def unfreeze(
self,
incident_id: str,
actor: str,
evidence: list[str],
ttl_seconds: int,
allowed_actors: set[str],
) -> tuple[bool, str]:
if actor not in allowed_actors:
return False, "actor_rejected"
if not {"graph_link", "change_ticket"}.issubset(set(evidence)):
return False, "evidence_incomplete"
state = self._load(incident_id)
now = time.time()
state.frozen = False
state.unfreeze_until = now + ttl_seconds
state.last_reason = f"unfrozen_by:{actor}"
self._save(state)
return True, "unfrozen"
Wire it in front of whatever executes shell. The bot may still narrate. Narration is cheap. apply is not.
# proposed: executor.py
FORBIDDEN_EXIT = 78 # EX_CONFIG, useful for pagers
def run_command(gate: FreezeGate, incident_id: str, verb: str, argv: list[str]) -> int:
ok, reason = gate.allow(incident_id, verb)
print(f"gate allow={ok} reason={reason} verb={verb}")
if not ok:
return FORBIDDEN_EXIT
# subprocess.run(argv, check=False) # hook your real executor here
gate.note_success(incident_id, verb)
return 0
Human unfreeze is a command, not a model reply. I want that on the timeline.
# proposed operator commands
python -m oncall_freeze unfreeze \
--incident INC-2026-0917 \
--actor incident-commander \
--evidence graph_link --evidence change_ticket \
--ttl 1800
python -m oncall_freeze status --incident INC-2026-0917
If your chat tool cannot emit that command, the chat tool is not the control plane. Stop asking it to be one.
Decision table for the next five minutes
I use a table during review because tables survive copy-paste better than paragraphs. This is the proposed matrix I want in the runbook footer.
| Situation | Verb | Freeze state | Result |
|---|---|---|---|
| Fresh incident, no mutation yet | describe |
open | allow |
| Fresh incident, no mutation yet | restart |
open | allow, then arm freeze |
| After first restart | logs |
frozen | allow |
| After first restart | scale |
frozen | reject |
| Human unfreeze with ticket + graph | rollback |
unfrozen, TTL live | allow once, then rearm |
| Unfreeze TTL expired | apply |
frozen again | reject |
| Random engineer, no role | unfreeze |
any | reject |
| Model proposes unfreeze in prose | n/a | any | ignore, not a transition |
Would you let the model tick that last row? I would not, because prose is not a signature. If the freeze file did not change, nothing unfroze.
A test plan you can execute without production
I do not want a demo that only works while the author watches the terminal. This is a proposed local test plan. Run it against a temp state file.
- Start with an empty freeze store and incident
INC-TEST. - Send
describe; expect allow and no freeze arm. - Send
restart; expect allow, thenfrozen=true. - Send
scale; expect reject withfrozen:armed_after:restart. - Send
logs; expect allow while still frozen. - Call
unfreezeasinternwith no evidence; expectactor_rejectedorevidence_incomplete. - Call
unfreezeasincident-commanderwith both evidence keys andttl_seconds=2. - Send
rollbackimmediately; expect allow, then freeze rearm. - Sleep past TTL if you unfroze without a mutate; expect the next mutate to reject.
- Crash the process and reload the JSON file; expect the same freeze bit.
# proposed: test_freeze_gate.py
from pathlib import Path
from freeze_gate import FreezeGate
def test_restart_arms_and_blocks_scale(tmp_path: Path):
gate = FreezeGate(tmp_path / "freeze.json")
ok, _ = gate.allow("INC-TEST", "restart")
assert ok
gate.note_success("INC-TEST", "restart")
ok, reason = gate.allow("INC-TEST", "scale")
assert not ok
assert reason.startswith("frozen:")
If step ten fails, you do not have a freeze rule. You have a variable in RAM, and RAM is not an incident record.
Where a free coding workspace actually helps
I draft freeze predicates and the reject reasons as code first, then I ask a model to nitpick missing verbs. That is a writing aid, not an executor. Disclosure: This article was prepared as part of MonkeyCode's product outreach. When I need a scratch place to iterate on the gate and the YAML without standing up extra infra, MonkeyCode's free model access and free server option are enough to run the checker against sample alerts. The freeze file still lives in my repo. The pager still belongs to a human.
Keep the model on the read path while freeze is armed. Let it summarize logs. Let it propose an unfreeze packet. Do not let it sign the packet. If that split feels inconvenient, good. Inconvenience is the point of a change freeze.
Limitations, and who should not use this
This approach fails in several honest ways, and I want those listed beside the code. Multiple bot replicas need a real lock or a single writer, because a JSON file is a teaching store, not a cluster database. Clock skew can expire unfreeze windows early or late, so NTP is part of the runbook. Freeze does not stop a human with kubectl on a laptop. If your real risk is shadow access, encode IAM, not YAML.
Do not use this if you have no mutating on-call bot. A human-only rotation already has change freezes through tickets, and extra flags just add theater. Do not use this as a substitute for blast-radius limits. A freeze after the first bad restart does not rewind the restart. Do not use this in regulated change windows that already require two-person review unless you wire unfreeze to that same review system. A homemade actor string is not an audit trail.
There is also a product limit I will not paper over. Free model access and a free server do not make the freeze correct. They only make the draft loop cheaper. Incorrect verbs in frozen_verbs will fail open for anything you forgot to name. Review the verb set like you review IAM actions.
Put the flag where the executor can see it
I want the freeze bit next to the incident id, not inside a paragraph the model can paraphrase away. Alerts still fire. First commands still need an allowlist. Escalation still needs an owner. After the first mutation, though, the only kind question is whether the next write is even legal.
If you already run an on-call bot, add the freeze file this week and prove the reject path with the test plan above. If the bot cannot fail that scale call on purpose, it will eventually succeed at the worst time. Do you want that success on your timeline, or do you want a boring exit 78?
Top comments (0)