An agent patch is not green because a runner printed green. It is green only when every passing test it cites already has a class in a ledger: locked fixture, property check, or quarantine. Unclassified tests are not evidence. They are unlabeled noise the model can reshape until the suite agrees.
That rule is the whole merge policy. The rest of this article is a concrete ledger, a gate script, and a decision table you can run before git merge.
Agent patches fail this policy in a narrow way. They do not need to delete assertions. They only need to move a test from "unknown" to "passed" by rewriting a golden file, catching a broader exception, or skipping a flake that used to fail the shard. If the suite has no class field, CI cannot tell a real invariant from a rewritten snapshot.
What the ledger is for
The ledger is not a test runner. It is an evidence filter. Production diffs may land only when the JUnit report's passing cases are already classified, and when quarantine rows stay out of the pass column.
Three classes are enough. More classes create review theater. Fewer classes let fixture rewrites masquerade as properties.
-
locked_fixture— byte-stable golden input or output. The digest is the contract. -
property— an invariant plus a seed corpus path. The seed may grow. The invariant name may not silently change. -
quarantine— known flake or order-dependent case. It cannot be cited as a pass. It cannot be deleted by the agent to "clean CI."
A fourth row type exists only as a staging area: draft. Agent-authored tests start there. Humans promote them. Drafts never green a production diff.
Ledger schema
Keep the file next to the suite. YAML is enough. One row per pytest nodeid.
# tests/ledger.yaml
version: 1
rows:
- id: tests/test_parse.py::test_header_roundtrip
class: locked_fixture
digest: sha256:9f3c1a0b7d2e44aa88c1f0c0a1b2c3d4e5f60718293a4b5c6d7e8f9012345678
artifact: tests/goldens/header.bin
- id: tests/test_parse.py::test_length_never_negative
class: property
invariant: non_negative_length
seeds: tests/properties/seeds/length/
- id: tests/test_parse.py::test_concurrent_reload
class: quarantine
opened: 2026-09-10
reason: order-dependent on module import cache
owner: human
- id: tests/test_parse.py::test_agent_added_prefix_strip
class: draft
source: agent
proposed_class: property
The digest is a sha256 of the fixture bytes, not of the test file. Hashing the test file lets the agent rewrite both the assertion and the golden in one commit and keep the hash "consistent." Hash the artifact the test reads.
Gate script
Label the following as a proposed local gate. It does not claim production metrics. It fails closed when the report and the ledger disagree.
#!/usr/bin/env python3
"""ledger_gate.py — refuse pass evidence from unclassified or quarantined tests."""
from __future__ import annotations
import hashlib
import json
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
import yaml
ALLOWED_PASS = {"locked_fixture", "property"}
BLOCK_PASS = {"quarantine", "draft"}
def load_ledger(path: Path) -> dict[str, dict]:
data = yaml.safe_load(path.read_text())
rows = {}
for row in data["rows"]:
if row["id"] in rows:
raise SystemExit(f"duplicate ledger id: {row['id']}")
rows[row["id"]] = row
return rows
def parse_junit(path: Path) -> list[tuple[str, str]]:
root = ET.parse(path).getroot()
cases = []
for case in root.iter("testcase"):
nodeid = f"{case.attrib.get('file', case.attrib.get('classname'))}::{case.attrib['name']}"
if case.find("failure") is not None or case.find("error") is not None:
status = "fail"
elif case.find("skipped") is not None:
status = "skip"
else:
status = "pass"
cases.append((nodeid, status))
return cases
def fixture_digest(path: Path) -> str:
h = hashlib.sha256(path.read_bytes()).hexdigest()
return f"sha256:{h}"
def main(argv: list[str]) -> int:
ledger = load_ledger(Path(argv[1]))
report = parse_junit(Path(argv[2]))
errors: list[str] = []
for nodeid, status in report:
row = ledger.get(nodeid)
if row is None:
errors.append(f"UNCLASSIFIED {status}: {nodeid}")
continue
klass = row["class"]
if status == "pass" and klass in BLOCK_PASS:
errors.append(f"{klass.upper()} cited as pass: {nodeid}")
if status == "pass" and klass not in ALLOWED_PASS and klass not in BLOCK_PASS:
errors.append(f"unknown class {klass}: {nodeid}")
if klass == "locked_fixture":
art = Path(row["artifact"])
if not art.is_file():
errors.append(f"missing fixture: {art}")
elif fixture_digest(art) != row["digest"]:
errors.append(f"digest mismatch: {nodeid}")
if klass == "property":
seeds = Path(row["seeds"])
if not seeds.is_dir() or not any(seeds.iterdir()):
errors.append(f"empty property seeds: {nodeid}")
if klass == "quarantine" and status == "fail":
# Failures in quarantine are expected. Do not promote them.
pass
if errors:
print("ledger gate failed:")
for line in errors:
print(f" - {line}")
return 1
print(f"ledger gate ok: {len(report)} cases, {len(ledger)} classified")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
Run it after pytest, not instead of pytest. The suite still executes. The ledger only decides whether a pass is admissible.
python -m pytest tests --junitxml=build/junit.xml
python ledger_gate.py tests/ledger.yaml build/junit.xml
If you need nodeids that match pytest exactly, collect them first and store those strings in the ledger. Guessing from JUnit classname is a common source of false UNCLASSIFIED rows.
python -m pytest --collect-only -q tests > build/collected.txt
Numbered workflow
- Freeze the current suite into the ledger. Every existing nodeid gets a class. Anything you cannot defend as a property or a locked fixture goes to quarantine, not draft.
- Compute fixture digests with a single command and paste them into the YAML. Do not let the agent write the digest field on a file it also edited.
python - <<'PY'
from pathlib import Path
import hashlib
for p in Path("tests/goldens").glob("*"):
print(p, "sha256:" + hashlib.sha256(p.read_bytes()).hexdigest())
PY
- Give the coding agent a write path only under
tests/agent_drafts/plus permission to appendclass: draftrows. Deny edits totests/ledger.yamlclasses other than appending drafts. - Re-run pytest and the gate. Production files plus a draft-only report must fail the gate. That is the point.
- A human promotes a draft. Promotion is a class change, not a comment. Property promotion requires an invariant name and a non-empty seed directory. Fixture promotion requires a digest of bytes the human inspected.
- Quarantine rows get an
openeddate and an owner. The agent may not remove them, rename them, or mark them skipped to recover a green shard.
Where a free coding agent belongs
Draft generation is the only step that benefits from a disposable model endpoint. The ledger, the digests, and the quarantine list stay human-owned files in git.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that draft step: propose new property tests, suggest a proposed_class, and emit YAML rows. They do not get to flip quarantine to property, and they do not get to refresh a fixture digest. If the free server is unavailable, the gate still runs. The workflow degrades to human-authored drafts. It does not degrade to "trust the pass column."
Keep the prompt boring and checkable.
Append draft rows only. Do not edit class, digest, invariant, or seeds
on existing rows. Each draft must include a pytest nodeid, a proposed_class
of property or locked_fixture, and a one-line invariant or artifact path.
Do not add skips. Do not touch tests/ledger.yaml rows with class quarantine.
Decision table
| Signal in the patch or report | Ledger class | Gate result | Human action |
|---|---|---|---|
| Pass on existing unit test, digest unchanged | locked_fixture |
admit | none |
| Pass, golden bytes changed, digest not updated | locked_fixture |
reject | inspect bytes or revert |
| Pass, golden bytes changed, digest updated in same PR | locked_fixture |
admit only after review | treat as a contract change |
| Pass on invariant with seeds present | property |
admit | optional seed growth review |
| Pass on invariant, seed directory empty | property |
reject | restore seeds |
| Pass on a quarantined nodeid | quarantine |
reject | keep failing or unfreeze manually |
| Skip or delete of a quarantined nodeid by the agent | quarantine |
reject | restore the row |
| Pass on agent-added test | draft |
reject | promote or drop |
| Pass on a nodeid missing from YAML | none | reject | classify before merge |
The table is the review script. If a reviewer cannot point at a row, the evidence does not exist.
Failure analysis the gate is meant to catch
Unclassified suites leak three merge bugs. First, snapshot tests become writable oracles: the agent updates header.bin and the assertion still compares equal. Second, flakes become negative space: the agent deletes test_concurrent_reload and the shard goes green. Third, tautological drafts enter as if they were properties: assert parse(x) is not None with no seed and no invariant name.
The ledger does not prove the production patch is correct. It only stops those three evidence tricks. Pair it with a hold-out suite or a mutation pass if you need a stronger claim. This article does not reuse those gates. It only filters which passing tests may be mentioned in the merge note.
A cheap extra check belongs in CI around fixture paths:
# Fail if production diff and golden diff land together without a ledger digest bump.
git diff --name-only origin/main...HEAD > build/changed.txt
python - <<'PY'
from pathlib import Path
changed = Path("build/changed.txt").read_text().splitlines()
prod = [p for p in changed if p.startswith("src/")]
gold = [p for p in changed if p.startswith("tests/goldens/")]
ledger = Path("tests/ledger.yaml").read_text()
if prod and gold and "digest:" not in Path("tests/ledger.yaml").read_text():
raise SystemExit("production+golden diff without ledger context")
print("changed production", len(prod), "goldens", len(gold))
PY
Tighten that stub against your real digest-diff. The idea is mechanical: two-sided edits need a classified contract change, not a silent golden refresh.
Limitations
This workflow assumes deterministic nodeids, file-backed fixtures, and a human who will refuse promotion. It will not classify Playwright timing flakes into a digest. It will not replace contract tests that need a real network. It will not stop an agent that is allowed to edit ledger.yaml class fields.
Do not use it as the only control on medical, avionics, or payments code. Do not use it when the suite is 90% end-to-end UI. Do not use it to justify skipping quarantine forever; an opened date without an owner is just a parked failure. Do not publish the ledger as proof of coverage. Coverage is a different number, and unclassified high coverage is how weak patches get a chart.
If your team cannot block agent writes to golden files and to the ledger class column, stop at step 1. A YAML file the model can rewrite is not a gate.
What to merge instead of a slogan
Ship the YAML, the gate, and the collect-only list. Require the gate on production diffs. Leave draft generation optional. If you already run a free coding agent on a throwaway server, point it at draft rows rather than at the pass column. The merge token is the classified report, not the model's summary of why the suite looks green.
Top comments (0)