A zero exit after an agent patch is a process result. It is not a statement about the test suite. If skip marks, xfail reasons, fixture bytes, or property names moved, the suite changed shape. Merge on the inventory diff, not on the color of the last job.
Agent patches fail in a narrow set of ways that a green bar hides. They delete a frozen flake. They rewrite a skip reason into a pass. They swap a property for three examples that encode the new code. None of those show up as a red pytest line if the command still returns 0. The gate below treats those edits as first-class regressions.
This is a proposed workflow, not a production study. No pass rates or latency numbers are claimed. The scripts are labeled examples you can run against a throwaway repo.
What the inventory must contain
Track three sets, and only three, on every agent-authored diff.
- Property registry. Names of tests that assert an invariant across inputs, not a single fixture. Count and identity both matter. A rename that preserves behavior is still a registry edit and needs a human row.
- Fixture hashes. Canonical bytes of golden files, recorded responses, and seed lists. Content, not path-only, because agents rename to dodge path filters.
-
Freeze contract. Every
skip,xfail,flaky, and quarantine entry, with owner, expiry, and a reason hash. The reason text stays out of the generator workspace.
Example tests may move. Properties, fixture bytes, and freeze rows may not move without an amendment file. That split is the whole protocol.
Why a green bar is the wrong signal
Pytest reports the tests it ran. It does not report the tests the patch retired. An agent that wants a green session has three cheap moves: mark skip, shrink the parametrize list, or replace hypothesis with a literal. All three can keep the job green. All three reduce the suite's power.
A second failure mode is quieter. The patch keeps the test name and weakens the body. Inventory identity is not enough. Pair the registry with a body hash for property files, and fail closed when the hash changes without an amendment.
Freeze contract format
Keep the contract in-repo, in a file the generator is not given. Proposed schema:
# tests/freeze_contract.yaml
version: 1
entries:
- id: pay-timeout-xfail
nodeid: tests/test_payments.py::test_timeout_retries
mark: xfail
owner: payments
expires: "2026-10-01"
reason_sha256: "9f2c1a0b7d4e88c1a6f0b3d9e5c4a2178b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e"
allowed_patch_touch: false
- id: report-flake-skip
nodeid: tests/test_report.py::test_large_csv_stream
mark: skip
owner: data-platform
expires: "2026-09-24"
reason_sha256: "0a11b22c33d44e55f66778899aabbccddeeff00112233445566778899aabbccdd"
allowed_patch_touch: false
Store the reason text in a secrets-unrelated, human-only path such as tests/freeze_reasons/ that is never copied into the generator tree. The agent sees a failing nodeid. It does not see "race in the CSV iterator under PyPy". If it can read the rationale, it can unfreeze with a plausible comment.
Proposed CI check (runnable sketch)
Label: unexecuted example. Save as tools/check_test_inventory.py and point it at HEAD versus the merge base.
#!/usr/bin/env python3
"""Fail if an agent diff mutates properties, fixtures, or the freeze contract."""
from __future__ import annotations
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
PROPERTY_ROOTS = ("tests/properties",)
FIXTURE_ROOTS = ("tests/fixtures",)
CONTRACT = Path("tests/freeze_contract.yaml")
AMEND = Path("tests/freeze_amendment.yaml")
PROPERTY_DECORATORS = ("@given(", "@hypothesis.given", "def test_property_")
def git_diff_names(base: str) -> list[str]:
out = subprocess.check_output(
["git", "diff", "--name-only", base, "HEAD"], text=True
)
return [line.strip() for line in out.splitlines() if line.strip()]
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def property_index(root: Path) -> dict[str, str]:
index: dict[str, str] = {}
if not root.exists():
return index
for path in root.rglob("test_*.py"):
text = path.read_text(encoding="utf-8")
if not any(token in text for token in PROPERTY_DECORATORS):
continue
index[str(path)] = hashlib.sha256(text.encode()).hexdigest()
return index
def fixture_index(root: Path) -> dict[str, str]:
index: dict[str, str] = {}
if not root.exists():
return index
for path in root.rglob("*"):
if path.is_file():
index[str(path)] = sha256_file(path)
return index
def load_contract_ids() -> set[str]:
if not CONTRACT.exists():
return set()
text = CONTRACT.read_text(encoding="utf-8")
return set(re.findall(r"^\s*- id: (\S+)", text, flags=re.M))
def load_amendment_ids() -> set[str]:
if not AMEND.exists():
return set()
text = AMEND.read_text(encoding="utf-8")
return set(re.findall(r"^\s*- id: (\S+)", text, flags=re.M))
def main() -> int:
base = sys.argv[1] if len(sys.argv) > 1 else "origin/main"
changed = git_diff_names(base)
report = {"base": base, "changed": changed, "violations": []}
before_props = {}
subprocess.check_call(["git", "stash", "push", "-u", "-m", "inventory-gate"])
try:
subprocess.check_call(["git", "checkout", base, "--quiet"])
for root in PROPERTY_ROOTS:
before_props.update(property_index(Path(root)))
before_fix = {}
for root in FIXTURE_ROOTS:
before_fix.update(fixture_index(Path(root)))
before_ids = load_contract_ids()
finally:
subprocess.check_call(["git", "checkout", "-", "--quiet"])
subprocess.call(["git", "stash", "pop"])
after_props: dict[str, str] = {}
for root in PROPERTY_ROOTS:
after_props.update(property_index(Path(root)))
after_fix: dict[str, str] = {}
for root in FIXTURE_ROOTS:
after_fix.update(fixture_index(Path(root)))
after_ids = load_contract_ids()
amended = load_amendment_ids()
if before_props != after_props:
report["violations"].append(
{"kind": "property_registry", "before": before_props, "after": after_props}
)
if before_fix != after_fix:
report["violations"].append(
{"kind": "fixture_hash", "before": before_fix, "after": after_fix}
)
dropped = before_ids - after_ids
if dropped - amended:
report["violations"].append(
{"kind": "freeze_drop_without_amendment", "ids": sorted(dropped - amended)}
)
added = after_ids - before_ids
if added - amended:
report["violations"].append(
{"kind": "freeze_add_without_amendment", "ids": sorted(added - amended)}
)
skip_touch = [
p for p in changed
if p.endswith((".py", ".yaml", ".yml"))
and _file_has_skip_edit(p, base)
]
if skip_touch:
report["violations"].append({"kind": "skip_mark_edit", "files": skip_touch})
Path("inventory-report.json").write_text(json.dumps(report, indent=2))
if report["violations"]:
print("inventory gate failed", file=sys.stderr)
print(json.dumps(report["violations"], indent=2))
return 1
print("inventory gate passed")
return 0
def _file_has_skip_edit(path: str, base: str) -> bool:
try:
diff = subprocess.check_output(
["git", "diff", "-U0", base, "HEAD", "--", path], text=True
)
except subprocess.CalledProcessError:
return False
needles = ("pytest.mark.skip", "pytest.mark.xfail", "@pytest.mark.flaky", "pytest.skip(")
return any(n in diff for n in needles)
if __name__ == "__main__":
raise SystemExit(main())
The stash/checkout dance is crude. Prefer generating both trees in a worktree if your CI image allows it. The point is the comparison, not the git choreography.
Amendment file, required when a human really does intend to retire a freeze:
# tests/freeze_amendment.yaml
version: 1
entries:
- id: report-flake-skip
action: expire
owner: data-platform
signed_by: human
note: "iterator race fixed in csv_stream.py; freeze not renewed"
No amendment, no freeze mutation. An agent-authored signed_by: human row is still a process failure. Put that field behind CODEOWNERS so only the owning team can land it.
Numbered merge protocol
-
Characterize HEAD. Record property hashes, fixture hashes, and freeze ids into
inventory-report.jsononmainbefore the agent session starts. Keep that file as a build artifact, not as prompt filler. -
Strip the generator tree. Copy the failing tests and application code. Omit
tests/freeze_reasons/, omit property sources if the failure is in an example test, omit the contract file. The generator gets symptoms, not the scoring rubric. - Generate off the oracle host. A local checkout that already holds the freeze reasons is the wrong place to sample patches. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can hold that stripped generator session so the proposal is produced away from the inventory tree. The gate does not run there. It runs on the merge host.
- Reapply the candidate onto a clean worktree that still has properties, fixtures, and the freeze contract. Do not reuse the generator directory as CI.
- Run the inventory script against the merge base. Fail on property hash drift, fixture hash drift, freeze add/drop without amendment, or skip-mark edits in the diff.
- Run the actual tests only if the inventory gate passed. A green pytest after a failed inventory is discarded. The two jobs are ordered, not parallel.
-
Expire freeze rows on a calendar, not on agent confidence. A job that lists rows with
expires < todayshould failmaineven when no agent patch is open.
Sample job fragment:
# .github/workflows/inventory-gate.yml
name: inventory-gate
on:
pull_request:
jobs:
inventory:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: inventory diff
run: python tools/check_test_inventory.py origin/${{ github.base_ref }}
- name: pytest after inventory
run: pytest -q
Order is load-bearing. Invert the two run steps and the protocol collapses back to "green means merge".
Decision table
| Diff observation | Inventory result | Merge |
|---|---|---|
| Example test added, properties unchanged, fixtures unchanged, freeze unchanged | pass | allowed |
| Property file hash changed, no amendment | fail property_registry
|
block |
| Fixture bytes changed, path unchanged | fail fixture_hash
|
block |
| Fixture path renamed, bytes identical | pass if hash set equal | allowed |
pytest.mark.skip added in application test |
fail skip_mark_edit
|
block |
Freeze id dropped, matching amendment signed_by: human
|
pass | allowed after CODEOWNERS |
| Freeze id dropped, no amendment | fail freeze_drop_without_amendment
|
block |
| Freeze id added by the agent to hide a new failure | fail freeze_add_without_amendment
|
block |
| pytest 0, inventory 1 | discard pytest | block |
| pytest 1, inventory 0 | normal test failure | block |
The table is the review checklist. If a reviewer cannot point to a row, the patch is not reviewed.
Property bodies, not just names
Name stability is cheap to fake. Require a body hash for files under tests/properties/. A one-line change from:
@given(st.integers())
def test_property_balance_nonnegative(n):
assert ledger_apply(n).balance >= 0
to:
@given(st.integers())
def test_property_balance_nonnegative(n):
assert True
is an inventory failure under this protocol. It would not be a pytest failure. That is the gap the gate exists to close.
For teams that cannot hash entire files, hash per function with ast and compare function-level digests. The sketch above stays file-level to keep the example short.
Limitations
The protocol assumes you already have a property directory, a fixture directory, and a freeze file. If the suite is only example tests, the property registry is empty and the gate cannot see weakening inside those examples. Do not adopt the script as a substitute for reading the diff.
Git stash around checkout is unsafe on dirty CI workspaces and is the wrong isolation primitive. Use git worktree add in real pipelines. The example does not handle binary fixtures larger than memory, generated snapshots that are supposed to update, or multilingual skip comments.
Reason hashes do not encrypt reasons. They only detect silent edits. Anyone with repo read access can still open tests/freeze_reasons/. The split is for the generator session, not for threat models that include the full clone.
MonkeyCode is used here only as an off-host generator surface. The article does not claim model identity, quota, hardware, duration, or score quality. If generation and gating share a filesystem, the product mention is irrelevant and you should ignore it.
Who should not use this
Do not use this gate on a repository with fewer than a handful of invariants and no freeze file. You will encode empty indexes and ship a check that always passes. Do not use it to ban all skip marks forever. Legitimate skips exist. They belong in the contract, with expiry.
Do not use it as a reason to hide tests from developers. Humans who own the freeze list must read reasons. The generator must not. That is the only information split this workflow needs.
If your agent is only allowed to edit src/ via path filters, keep the path filter. The inventory gate is extra. It catches the session that was granted test-tree write access because "the agent should add coverage" and then used that access to retire coverage.
A green job is an exit code. An inventory diff is a statement about what the suite still forbids. Keep them in that order.
Top comments (0)