DEV Community

Emery Huang
Emery Huang

Posted on

The Bot Already Acted: Freeze Mutating Commands Until a Human Unfreezes

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.

  1. freeze_after — which accepted action arms the freeze, usually the first mutating command.
  2. frozen_verbs — the command classes that die during freeze, such as restart, scale, drain, rollback, apply.
  3. read_allow — diagnostics that remain legal, such as logs, metrics, describe, trace.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Start with an empty freeze store and incident INC-TEST.
  2. Send describe; expect allow and no freeze arm.
  3. Send restart; expect allow, then frozen=true.
  4. Send scale; expect reject with frozen:armed_after:restart.
  5. Send logs; expect allow while still frozen.
  6. Call unfreeze as intern with no evidence; expect actor_rejected or evidence_incomplete.
  7. Call unfreeze as incident-commander with both evidence keys and ttl_seconds=2.
  8. Send rollback immediately; expect allow, then freeze rearm.
  9. Sleep past TTL if you unfroze without a mutate; expect the next mutate to reject.
  10. 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:")
Enter fullscreen mode Exit fullscreen mode

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)