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.
- At 09:14 the operator added a freeze rule for migrations.
- At 09:21 the agent listed files and planned an index.
- At 09:40 the chat crossed the compaction threshold on the server.
- At 09:41 a summary stub replaced the long history block.
- At 09:44 the agent proposed a SQL edit for query speed.
- At 09:45 the apply tool wrote the migration file to disk.
- At 09:47 unit tests passed because they never execute SQL.
- 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.
- The freeze lived only in prompt text, not repository policy.
- Compaction had no allowlist for must-keep safety sentences.
- The apply tool trusted the model instead of a deny list.
- Unit tests did not open files under the schema directory.
- One long session spanned planning, compaction, and the write.
- 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
#!/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())
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
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.
- Source-only hunk against
src/queue.pymust exit0. - Edit under
schema/20260907_add_index.sqlmust exit2. - New file
migrations/late.sqlmust exit2. - Rename from
schema/old.sqlintosrc/copy.pymust exit2. - Deleted
tools/agent_policy.txtmust exit2. - Comment-only policy file with no globs must exit
2. - Empty stdin must exit
0and write nothing. - Windows-style
schema\late.sqlin the diff must exit2.
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
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}
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)