DEV Community

Morgan Xu
Morgan Xu

Posted on

Postmortem: Context Compaction Dropped the Migration Guardrail

The agent did not ignore the migration guardrail that night. Context compaction removed the rule from the live prompt. A later apply then rewrote a frozen schema file.

Incident summary

A shared coding agent ran on a free server queue. The operator pinned one rule in the system prompt. Schema migrations stayed frozen on the release branch.

Compaction later summarized the long chat into a stub. The stub kept file paths and dropped the freeze. The next patch rewrote a SQL migration file on disk.

Unit tests never load SQL files during CI. Those tests stayed green after the bad apply. Reviewers only caught the drift in a later diff.

The times below are a reconstructed lab timeline. They illustrate control-plane failure, not a vendor outage. No production customer data appears in this write-up.

The session used MonkeyCode free model access for generation. A free server option hosted the shared apply queue. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Timeline

Each step is a single control event worth logging.

  1. At 09:14 the operator added a freeze rule for migrations.
  2. At 09:21 the agent listed files and planned an index.
  3. At 09:40 the chat crossed the compaction threshold on the server.
  4. At 09:41 a summary stub replaced the long history block.
  5. At 09:44 the agent proposed a SQL edit for query speed.
  6. At 09:45 the apply tool wrote the migration file to disk.
  7. At 09:47 unit tests passed because they never execute SQL.
  8. At 10:05 a reviewer diff showed the frozen file changed.

The write succeeded before any human saw the patch. The freeze still existed in the original prompt scroll. Compaction had already dropped that sentence from context.

What compaction actually removed

Long agent sessions compress history to stay inside context. Summarizers keep nouns, paths, and recent tool outcomes. Negative constraints and freeze rules disappear first under compression.

The freeze rule was a negative sentence in prose. The summary kept schema/ as a working directory hint. The model then treated that path as an allowed target.

Prompt text is not a durable control plane for writes. Disk policy still exists after the summary stub lands. The apply path must read that policy on every patch.

A restated plan is not a restored guardrail. Restating files does not restate forbidden paths. Operators should treat those as different classes of state.

Why the test suite stayed green

The suite tests Python functions with in-memory fixtures. It never opens schema SQL files during collection. An index added in SQL cannot fail those tests.

Green results therefore proved nothing about frozen migrations. The suite answered a different question than the freeze. Coverage maps and deny lists are not interchangeable controls.

Contributing factors

Several small design choices lined up into one incident.

  1. The freeze lived only in prompt text, not repository policy.
  2. Compaction had no allowlist for must-keep safety sentences.
  3. The apply tool trusted the model instead of a deny list.
  4. Unit tests did not open files under the schema directory.
  5. One long session spanned planning, compaction, and the write.
  6. Human review ran after the write, not before the syscall.

None of these factors is exotic on a shared agent queue. Long sessions invite compaction on free shared capacity. Prompt-only policy fails first under that pressure.

Artifact: a fail-closed apply gate

The durable fix sits beside the checkout, not the chat. The wrapper reads policy from disk on every apply. Compaction cannot delete a file the wrapper always opens.

The script below is labeled lab code for local wrapping. It parses a unified diff from standard input. Any path matching a deny glob fails the apply.

# tools/agent_policy.txt
# Deny globs, one per line. Comments start with #
schema/**
migrations/**
**/*.sql
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/env python3
"""Fail-closed gate for agent unified diffs.

Usage:
  python3 tools/apply_gate.py < proposed.patch
  git diff --cached | python3 tools/apply_gate.py
"""
from __future__ import annotations

import fnmatch
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
POLICY = ROOT / "tools" / "agent_policy.txt"
DENY_EXIT = 2


def load_denies(path: Path) -> list[str]:
    if not path.is_file():
        print(f"apply_gate: missing policy {path}", file=sys.stderr)
        sys.exit(DENY_EXIT)
    globs: list[str] = []
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        globs.append(line)
    if not globs:
        print("apply_gate: empty deny list", file=sys.stderr)
        sys.exit(DENY_EXIT)
    return globs


def diff_paths(diff: str) -> list[str]:
    found: list[str] = []
    for line in diff.splitlines():
        if not (line.startswith("--- ") or line.startswith("+++ ")):
            continue
        marker, _, rest = line.partition(" ")
        token = rest.strip()
        if token == "/dev/null":
            continue
        if token.startswith("a/") or token.startswith("b/"):
            token = token[2:]
        token = token.split("\t", 1)[0]
        if token and token not in found:
            found.append(token)
    return found


def denied(path: str, globs: list[str]) -> str | None:
    posix = path.replace("\\", "/")
    for glob in globs:
        if fnmatch.fnmatch(posix, glob):
            return glob
    return None


def main() -> int:
    globs = load_denies(POLICY)
    diff = sys.stdin.read()
    if not diff.strip():
        return 0
    hits = []
    for path in diff_paths(diff):
        glob = denied(path, globs)
        if glob:
            hits.append((path, glob))
    if hits:
        print("apply_gate: deny list blocked the patch", file=sys.stderr)
        for path, glob in hits:
            print(f"  {path}  matches  {glob}", file=sys.stderr)
        return DENY_EXIT
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

A missing policy file also fails closed on purpose. Empty deny lists fail closed as well. Silent allow is how the original prompt rule died.

Commands to wire the gate

Keep the policy file in the same commit as the wrapper. Run the gate before any apply tool calls git apply. Do not run it only in optional CI.

chmod +x tools/apply_gate.py

# Reject a forbidden reconstructed patch
cat > /tmp/bad.patch <<'EOF'
--- a/schema/20260907_add_index.sql
+++ b/schema/20260907_add_index.sql
@@ -1,2 +1,3 @@
 -- frozen
+CREATE INDEX CONCURRENTLY idx_jobs_created ON jobs (created_at);
EOF
python3 tools/apply_gate.py < /tmp/bad.patch; echo exit:$?

# Allow a source-only patch
cat > /tmp/ok.patch <<'EOF'
--- a/src/queue.py
+++ b/src/queue.py
@@ -1,1 +1,2 @@
+# comment only
 EOF
python3 tools/apply_gate.py < /tmp/ok.patch; echo exit:$?

# Optional: wrap git apply
git apply --check /tmp/bad.patch
python3 tools/apply_gate.py < /tmp/bad.patch && git apply /tmp/bad.patch
Enter fullscreen mode Exit fullscreen mode

The expected first command exits with status 2. The second command exits with status 0. A wrapper that skips the gate on local machines will recreate the incident.

Decision table

Use this table when choosing where a rule should live.

Failure mode Prompt-only freeze Disk apply gate
Context compaction Rule may vanish Rule still loads
Session preemption Rule may vanish Rule still loads
Retry on a new chat Rule may be omitted Rule still loads
Green unit tests SQL edits may pass Path still blocked
Human review after write Damage already landed Write never starts
Missing policy file Chat continues Apply fails closed

Prompt text remains useful as explanation for the model. It is not sufficient as the last writer lock. The gate is the lock.

Reproducible test plan

Label these cases as unexecuted until a checkout runs them. Each case is one patch fixture plus an expected exit code.

  1. Source-only hunk against src/queue.py must exit 0.
  2. Edit under schema/20260907_add_index.sql must exit 2.
  3. New file migrations/late.sql must exit 2.
  4. Rename from schema/old.sql into src/copy.py must exit 2.
  5. Deleted tools/agent_policy.txt must exit 2.
  6. Comment-only policy file with no globs must exit 2.
  7. Empty stdin must exit 0 and write nothing.
  8. Windows-style schema\late.sql in the diff must exit 2.

Store fixtures under tools/testdata/apply_gate/. Assert stderr contains the matching glob. Do not assert on model text in these tests.

python3 - <<'PY'
import subprocess, pathlib, tempfile, os, textwrap, sys
root = pathlib.Path(".")
# Case 2 smoke: forbidden SQL path
bad = b"--- a/schema/x.sql\n+++ b/schema/x.sql\n@@ -0,0 +1 @@\n+select 1;\n"
r = subprocess.run([sys.executable, "tools/apply_gate.py"], input=bad)
assert r.returncode == 2, r.returncode
print("case2_ok")
PY
Enter fullscreen mode Exit fullscreen mode

That smoke check belongs in the same change as the wrapper. A gate without a failing fixture will rot. Compaction bugs return when the gate is optional.

Durable fix beyond the script

The script is necessary and still incomplete alone. Pair it with process changes that survive another long session.

  • Split planning chats from apply chats after compaction events.
  • Log every apply with policy file hash and denied paths.
  • Block schema/** in the apply tool configuration as well.
  • Add one CI job that opens every SQL file changed in the branch.
  • Keep freeze rules in tools/agent_policy.txt, not only in chat.

A compact JSON log line is enough for later postmortems.

{"event":"apply_gate","policy_sha256":"…","denied":["schema/20260907_add_index.sql"],"exit":2}
Enter fullscreen mode Exit fullscreen mode

Do not ask the model to confirm the deny list after compaction. Confirmation is a signal, not an enforcement point. Enforcement stays on the write path.

Limitations

The gate only understands path globs in unified diffs. It does not parse SQL inside Python strings. It does not review binary files or submodule pointer moves.

It also does not stop the model from proposing a forbidden patch. It only stops a wrapped apply from writing that patch. Unwrapped editors and paste-into-file flows bypass it.

Fnmatch globs are not gitignore rules. Double-star patterns follow fnmatch, not pathspec. Teams with nested generated SQL may need tighter patterns.

This write-up does not claim token quotas, model names, or hardware specs. Free model access and a free server option were the session setting. Duration and capacity are not asserted here.

Who should not use this approach

Skip this gate when the assigned task is to edit migrations. Skip it when the apply tool cannot be wrapped at all. Skip it for agents that only emit advice without writes.

Do not use path denies as a secrets scanner. Do not use them as a substitute for code review. Do not load untrusted diffs that are not unified text.

Shared free queues still need fairness and isolation elsewhere. This postmortem does not replace checkout locking. It only keeps vanished prompt rules from becoming disk writes.

Close

The migration file changed because the control plane lived in prose. Compaction deleted prose and left paths. A disk gate would have failed closed before git apply.

Operators already wrapping applies on a free server can keep this file in-repo. The next compaction event then becomes a log line, not a schema drift.

Top comments (0)