A patch that edits a fixture and a patch that edits a function are not the same risk, yet most agent-patch gates charge both the same evidence. Route first, then gate. The lane a patch lands in should decide which artifacts it must bring to review, and the cheapest artifact to fake should never be the only one required.
This post is a triage layer, not another single instrument. It sits above property checks, fixture handling, and flaky-test freezes and decides which of them apply to a given patch.
1. The instrument moves with the patch
When an agent is asked to make a failing test pass, the shortest path to green is usually to change what the test measures, not what the code does. Fixture edits, snapshot regeneration, and test-body rewrites all move the instrument. Source edits move the subject.
That distinction is structural, not a claim about frequency. You should measure your own rate: count how many merged agent patches touched tests/ or fixtures over the last month. Whatever that number is, it is the population that a source-only gate never inspects.
2. Four lanes and the evidence each one owes
Classify by touched paths, then union the evidence requirements. A patch that touches source and fixtures owes both sets, not the cheaper one.
| Touched paths | Lane | Required evidence |
|---|---|---|
tests/fixtures/**, **/__snapshots__/**, *.golden | fixture | Provenance diff plus a Fixture-Change: trailer naming the behavior change |
tests/** with no fixture paths | test-only | Assertion-count delta plus a one-line justification tied to a spec or issue id |
src/** | source | Property-check artifact: seed, example budget, and the minimized failing input if one was found |
ci/**, .github/**, gate config | infra | A reviewer who is not the patch author, and never bundled with a source change |
3. Implementing the router
The script below is illustrative and was not executed for this article. Adapt the globs to your repository layout before trusting the output.
#!/usr/bin/env python3 # Illustrative. Classifies an agent patch by touched paths and prints required evidence. import fnmatch, json, subprocess CATEGORY_GLOBS = { "fixture": ["tests/fixtures/**", "**/__snapshots__/**", "*.golden"], "test": ["tests/**", "**/test_*.py", "**/*_test.py"], "source": ["src/**", "lib/**"], "infra": ["ci/**", ".github/**", "pyproject.toml", "Makefile"], } EVIDENCE = { "fixture": {"fixture-provenance", "fixture-change-trailer"}, "test": {"assertion-delta", "test-change-justification"}, "source": {"property-check-artifact"}, "infra": {"second-reviewer", "infra-isolated-from-source"}, } def categories_for(path): hits = set() for category, globs in CATEGORY_GLOBS.items(): if any(fnmatch.fnmatch(path, g) for g in globs): hits.add(category) return hits def touched(base, head): out = subprocess.run( ["git", "diff", "--name-only", f"{base}...{head}"], capture_output=True, text=True, check=True, ) return [line for line in out.stdout.splitlines() if line.strip()] def route(base, head): required, seen = set(), {} for path in touched(base, head): cats = categories_for(path) seen[path] = sorted(cats) for c in cats: required |= EVIDENCE[c] return {"paths": seen, "required_evidence": sorted(required)} if __name__ == "__main__": import sys print(json.dumps(route(sys.argv[1], sys.argv[2]), indent=2)) Two properties make this router worth having. It is deterministic, so a re-pushed patch cannot quietly change lanes. And it fails loudly on unmatched paths: if a file hits no category, treat that as an error and extend the globs rather than letting the file through unexamined.
4. Fixture provenance without blocking real spec changes
A fixture is an input. Hash it, store the hashes, and diff them across the patch. The rule is not "fixtures may never change" — it is "a fixture change must name the behavior change that requires it."
# Illustrative. Build a content-hash ledger for fixture directories. import hashlib, json, pathlib def ledger(root="tests/fixtures"): return { str(p): "sha256:" + hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(pathlib.Path(root).rglob("*")) if p.is_file() } Enforcement, in three parts:
- Recompute the ledger on the base commit and on the patch commit; every added, removed, or changed path is a provenance event.
- Require a
Fixture-Change: <spec-or-issue-id>trailer on the commit for each event. No trailer, no merge. - Require the justification to name the source behavior that changed, and require that source change to exist in the same patch or an earlier one on the same branch.
The third rule is the one that matters. It converts "the agent regenerated the snapshots" into "the agent regenerated the snapshots because format_invoice gained a currency field." One of those is reviewable. The other is a measurement that moved and a reviewer who cannot see why.
5. A freeze that only accepts base-tree evidence
Flaky-test freezes get abused at exactly one point: a patch reaches a genuinely flaky test and the patch author, human or agent, freezes it. That freeze is indistinguishable from a patch hiding a real regression behind a flake label.
Make the evidence field enforce the distinction. The record below is a schema proposal, not a file from a live repository.
# tests/freeze/checkout_totals_rounding.yml test: tests/test_checkout.py::test_totals_rounding owner: payments-team opened: 2026-11-02 expires: 2026-12-02 base_tree_evidence: patch_applied: false # must be false: flake evidence may not come from the patched tree runs: 40 failures: 7 recorded_by: ci/nightly-flake-probe recheck_after_runs: 10 Three gate rules on top of the schema:
-
patch_applied: falseis required. Flake evidence collected with the patch applied is evidence about the patch, not about the test. - A patch may not add a freeze record for a test it also modifies, or for a test it causes to fail. Otherwise the freeze becomes a self-serve exemption.
-
expiresandownerare mandatory. A freeze withowner: nullis a permanent mute, and permanent mutes accumulate until the suite means nothing.
6. Where a model fits, and where it should not be load-bearing
The expensive step in this pipeline is proposing properties for the source lane: candidate invariants for a changed function, phrased so a failure is a real bug. That is a reasonable job for a model, and it is also the step you want to run on every source-lane patch rather than on a sampled subset.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode's free model access is the reason this step fits a small budget: a per-patch proposer step implies a per-patch cost, and free model access removes one of the usual excuses for sampling. The free server option is a plausible host for the router itself, which needs to be reachable from CI and needs to recompute fixture ledgers against the base commit. Both availability claims come from the operator, not from my own testing, so confirm the current terms and limits on the official offering before you depend on them.
Two constraints I would keep regardless. Never let the model decide a gate verdict — it proposes properties, a runner executes them, and a failing input is the only thing that counts. And never make gate continuity depend on a free tier: keep the router able to run without the model step, and let patches through as "property-check pending human" rather than silently skipping the lane.
7. Verifying the gate itself
Do not trust the router because it looks reasonable. Seed a labeled patch set and confirm each case lands where you expect. A small set is enough; these four cover the failure modes.
- A fixture-only change with no trailer. Expected: blocked at the fixture lane.
- A fixture change with a trailer naming a spec id and a matching source change. Expected: routed to fixture review, not blocked automatically.
- A patch to a test already frozen with base-tree evidence. Expected: passes the freeze check without new evidence.
- A patch that adds a freeze record for a test it modifies. Expected: rejected by the self-serve exemption rule.
If case 4 passes your gate, the freeze mechanism is decorative and you should remove the expires field until it is not.
8. Limits and who should skip this
This triage assumes a steady queue of machine-authored patches and a review process that can enforce commit trailers. If you merge an agent patch every few weeks, the ledger maintenance costs more than the risk it prices out.
It also assumes you can pin the base commit for every comparison. Squashed, force-pushed, or rebased branches break the provenance diff, and a gate that silently skips the diff on error is worse than no gate. Skip this if your CI cannot access the base commit reliably.
Finally, if your organization requires you to know where inference happens and your policy does not permit the offering's data handling, run the proposer step yourself on a controlled endpoint. The router, the provenance ledger, and the freeze schema are all independent of which model generated the properties — those three are the parts I would port first.
If you try this, start with the freeze rules. They are the smallest change and the one place where a badly designed gate is actively lying to you.
