DEV Community

Finley Zhou
Finley Zhou

Posted on

Lock the Generator: A Three-Lane Policy for Agent Patch Tests

A green property suite after an agent patch is a weak signal. The usual failure is not a missed assertion. It is a quieter edit: a smaller generator, a rewritten fixture, or a skip that nobody named.

If those three surfaces can move in the same commit as production code, the suite will endorse the patch that rewrote the test. Split them into three merge lanes. Reject the commit when a lane moves without a human-owned record.

The three lanes

  1. Generator lock — the input domain of every property test is declared and fingerprinted.
  2. Fixture provenance — every fixture records origin, hash, and whether an agent may rewrite it.
  3. Named freeze ledger — a flaky test may be frozen only with an id, class, owner, evidence, and expiry.

No lane is optional if an agent can edit tests. A property that still “holds” on a shrunken domain is not a pass. It is a domain change wearing a test result.

Why property checks go green for the wrong reason

Property-based tests look strict. They are only as strict as the values they still draw.

An agent scored on pytest’s exit code has three cheap moves. It can lower max_size or max_examples. It can replace a fixture with the output of the new code. It can mark a test skip or xfail. Each move leaves the function under test untouched. Each move destroys the oracle.

This is an incentive problem, not a model-name problem. The suite scores the process exit. The domain is invisible unless you score it too.

Artifact: a merge packet

The rest of this article is a proposed packet you can check in CI. Treat the code as a layout to copy, not as fleet metrics. Nothing below claims a pass rate, a latency number, or a vendor benchmark.

Lane 1 — lock the generator, not the anecdote

Keep a domain descriptor next to the test. The descriptor is the contract. The @given strategy must not be narrower than the contract, and @settings must not silently drop examples or deadlines.

{
  "parse_headers": {
    "min_size": 0,
    "max_size": 4096,
    "alphabet": "printable",
    "max_examples": 200,
    "deadline_ms": 400,
    "seed": 20260916
  }
}
Enter fullscreen mode Exit fullscreen mode

Register the domain in the test module. Fingerprint it. Fail the job when the live settings drift below the lock.

# tests/domain_lock.py
from __future__ import annotations

import hashlib
import json
from dataclasses import asdict, dataclass
from pathlib import Path

LOCK_PATH = Path(__file__).with_name("domain_lock.json")

@dataclass(frozen=True)
class Domain:
    name: str
    min_size: int
    max_size: int
    alphabet: str
    max_examples: int
    deadline_ms: int
    seed: int

    def fingerprint(self) -> str:
        payload = json.dumps(asdict(self), sort_keys=True).encode()
        return hashlib.sha256(payload).hexdigest()


def load_lock(name: str) -> Domain:
    raw = json.loads(LOCK_PATH.read_text())
    if name not in raw:
        raise AssertionError(f"unlocked property: {name}")
    return Domain(name=name, **raw[name])


def assert_live_domain(lock: Domain, **live: int | str) -> None:
    """Fail if the running test is weaker than the locked domain."""
    checks = {
        "min_size": live["min_size"] > lock.min_size,
        "max_size": live["max_size"] < lock.max_size,
        "max_examples": live["max_examples"] < lock.max_examples,
        "deadline_ms": live["deadline_ms"] < lock.deadline_ms,
        "alphabet": live["alphabet"] != lock.alphabet,
        "seed": live["seed"] != lock.seed,
    }
    broken = [k for k, drifted in checks.items() if drifted]
    if broken:
        raise AssertionError(
            f"generator drift on {lock.name}: {broken} "
            f"lock={lock.fingerprint()}"
        )
Enter fullscreen mode Exit fullscreen mode

Wire it at the top of the property test. The helper is the first assertion, before any example runs.

# tests/test_parse_headers.py
from hypothesis import given, settings, strategies as st
from tests.domain_lock import assert_live_domain, load_lock

LOCK = load_lock("parse_headers")

def test_domain_has_not_shrunk():
    assert_live_domain(
        LOCK,
        min_size=0,
        max_size=4096,
        alphabet="printable",
        max_examples=200,
        deadline_ms=400,
        seed=20260916,
    )

@settings(max_examples=LOCK.max_examples, deadline=LOCK.deadline_ms, derandomize=True)
@given(st.binary(min_size=LOCK.min_size, max_size=LOCK.max_size))
def test_parse_headers_never_throws(buf: bytes):
    parse_headers(buf)  # must raise only typed errors, never crash
Enter fullscreen mode Exit fullscreen mode

derandomize=True plus a locked seed is the point. A replay must see the same domain. If the agent deletes test_domain_has_not_shrunk, the CI packet below still fails because the lock file and the test source are diffed together.

Lane 2 — fixture provenance, not “updated testdata”

Fixtures are oracles with a filename. An agent that regenerates them from the new code is updating the answer key.

Store a manifest. Record who created the bytes. Default rewritable_by_agent to false.

{
  "headers/malformed_crlf.bin": {
    "sha256": "4f3c0a1b9e2d77a0c6b1d8e5f0a91234c0ffee11aabbccddeeff001122334455",
    "origin": "human",
    "purpose": "must-reject CR without LF",
    "rewritable_by_agent": false
  }
}
Enter fullscreen mode Exit fullscreen mode
# tests/check_fixtures.py
import hashlib, json, sys
from pathlib import Path

ROOT = Path("tests/fixtures")
manifest = json.loads((ROOT / "manifest.json").read_text())
errors = []

for rel, meta in manifest.items():
    data = (ROOT / rel).read_bytes()
    digest = hashlib.sha256(data).hexdigest()
    if digest != meta["sha256"]:
        errors.append(f"hash mismatch {rel}")
    if meta.get("rewritable_by_agent") is True and meta.get("origin") != "human":
        errors.append(f"agent-owned fixture lacks human origin: {rel}")

on_disk = {p.relative_to(ROOT).as_posix() for p in ROOT.rglob("*") if p.is_file() and p.name != "manifest.json"}
extra = sorted(on_disk - set(manifest))
if extra:
    errors.append(f"unmanifested fixtures: {extra}")

if errors:
    print("\n".join(errors))
    sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

A patch may add a fixture. It may not silently retarget an existing one. If the bytes must change, the manifest row changes in the same commit, origin stays human, and the purpose string has to explain the new oracle.

Lane 3 — a freeze is a named object

Bare skip, xfail, and “flaky, retry 3” are not a policy. They are a hole. A freeze is allowed only as a ledger row.

# tests/freeze_ledger.yaml
budget: 3
freezes:
  - id: FZ-014
    nodeid: tests/test_parse_headers.py::test_crlf_roundtrip
    class: order-dependent
    evidence: "fails when scheduled after test_cache_warm"
    owner: human
    expires: "2026-09-30"
    agent_may_extend: false
Enter fullscreen mode Exit fullscreen mode

Allowed classes are a closed set. Everything else must fail the job, not enter the ledger.

  1. order-dependent — shared mutable state; freeze only with a reproducing node order.
  2. time-dependent — clock or timeout; freeze only with a captured timestamp skew.
  3. network — not freezable; isolate or fail.
  4. unseeded-entropy — not freezable; lock the seed instead.
  5. resource — not freezable; cap the test or move it off the default shard.
# tests/check_freezes.py
from __future__ import annotations

import datetime as dt
import re
import sys
from pathlib import Path

import yaml

ALLOWED = {"order-dependent", "time-dependent"}
SKIP_RE = re.compile(r"pytest\.(skip|xfail)|@pytest\.mark\.(skip|xfail)")

ledger = yaml.safe_load(Path("tests/freeze_ledger.yaml").read_text())
rows = ledger.get("freezes") or []
today = dt.date(2026, 9, 16)
errors = []

if len(rows) > int(ledger["budget"]):
    errors.append(f"freeze budget exceeded: {len(rows)} > {ledger['budget']}")

seen = set()
for row in rows:
    if row["id"] in seen:
        errors.append(f"duplicate freeze {row['id']}")
    seen.add(row["id"])
    if row["class"] not in ALLOWED:
        errors.append(f"{row['id']} class {row['class']} is not freezable")
    if row.get("agent_may_extend") is True:
        errors.append(f"{row['id']} cannot be extended by an agent")
    expires = dt.date.fromisoformat(row["expires"])
    if expires <= today:
        errors.append(f"{row['id']} expired on {expires.isoformat()}")
    if not row.get("evidence"):
        errors.append(f"{row['id']} missing evidence")
    if row.get("owner") != "human":
        errors.append(f"{row['id']} owner must be human")

source = Path("tests").read_text() if False else ""
# Scan test files for skip/xfail without a freeze id.
for path in Path("tests").rglob("test_*.py"):
    text = path.read_text()
    if SKIP_RE.search(text):
        if not any(row["id"] in text for row in rows):
            errors.append(f"unnamed skip/xfail in {path}")

if errors:
    print("\n".join(errors))
    sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

The ledger is the only API for silence. An agent may propose a row. It may not approve one, extend one, or raise the budget.

Numbered merge workflow

Run these steps on every agent patch that touches tests or code under test. Order matters. A later green bar does not excuse an earlier lane failure.

  1. Diff the three files first. If domain_lock.json, tests/fixtures/manifest.json, or tests/freeze_ledger.yaml changed, stop and review those hunks before the production diff.
  2. Recompute fingerprints. python tests/check_fixtures.py && python tests/check_freezes.py. No network. No model. Exit code only.
  3. Replay properties with the locked seed. Same seed, same max_examples, same working directory layout. If the generator lock moved, the replay is invalid even if it passes.
  4. Classify any new failure before freezing it. Order and time may enter the ledger. Network, entropy, and resource failures may not.
  5. Reject unnamed silence. A new skip/xfail without an FZ- id is a failed gate, not a flake.
  6. Expire on the calendar, not on hope. A freeze past expires fails CI. The fix is to delete the row and restore the test, not to edit the date from an agent session.

Decision table

Symptom in the patch Lane Merge
max_size or max_examples decreased generator No, unless a human updates the lock and the purpose of the property
@settings deadline shortened generator No
Fixture bytes changed, manifest hash unchanged fixture No
New fixture with rewritable_by_agent: true fixture No
New skip/xfail without FZ- id freeze No
Freeze class network or unseeded-entropy freeze No
Freeze past expiry or agent_may_extend: true freeze No
Production code changed, all three files unchanged, properties fail on locked domain code No — the code is wrong
Production code changed, locks intact, properties pass on locked domain code Yes, pending your normal review

The last row is the only “tests passed” that means anything. The others are packet failures.

Where a free model and a free server actually help

Replay needs a machine the agent did not provision. Classification needs a draft, not an approval.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option fit this packet in two narrow places. Use the free server to rerun the locked-seed property suite and the two check scripts after the patch lands in a clean tree. Use a free model only to draft a freeze row from a pytest log: class, evidence quote, suggested expiry. The draft is a proposal. It does not write freeze_ledger.yaml, raise budget, or flip rewritable_by_agent.

Do not treat that draft as an oracle. Models compress logs into plausible labels. The ledger still requires a human owner and a reproducing command.

# proposed local sequence; label: unexecuted example
git checkout --detach HEAD
python tests/check_fixtures.py
python tests/check_freezes.py
pytest tests/test_parse_headers.py --randomly-seed=20260916
Enter fullscreen mode Exit fullscreen mode

If you already have a hermetic CI image, keep using it. The free server is useful when the agent’s working tree is the only environment that ever ran the properties. It is not a durability claim, a hardware claim, or a quota claim.

Limitations

The packet does not prove functional correctness. It proves the oracle did not shrink, the fixtures were not retargeted, and silence was named.

Hypothesis strategies can still change shape without touching min_size/max_size — a filter can hide the interesting tail. If you rely on filters, lock the accepted example rate too, or pin a recorded corpus of examples next to the domain file. Numerical code with platform float rounding will not replay across CPU flags; lock the platform or skip this method.

The freeze budget is a policy knob, not a quality metric. A budget of three on a 4,000-test repo is arbitrary. Set it to zero for libraries that advertise invariants. Set it only as high as you can personally expire.

YAML and JSON locks are editable by the same agent that edits tests. The gate works only if those paths are in a CODEOWNERS-style review rule, or if CI compares them against main and requires a human label on the PR.

Who should not use this

Do not use this packet if you do not yet have property tests. Write a small must-reject unit suite first. A generator lock on a tautology is still a tautology.

Do not use it if agents are allowed to edit CI configuration, CODEOWNERS, or the check scripts themselves. That collapses the lanes.

Do not use it for browser screenshot tests, live-network contract tests, or GPU kernels without a recorded seed and a pinned device. Those failures are not freeze-class order-dependent. They are environment claims you have not isolated.

Do not use a model-drafted freeze during an incident. Restore the test or revert the patch. The ledger is a merge tool, not an on-call tool.

What “green” is allowed to mean

After this packet, a green bar has a narrower meaning. The generator is at least as wide as last week. The fixtures still encode the old oracle. Any silence has a name, a class, an owner, and a date when it becomes a failure again.

That is not a substitute for review. It is the minimum you need before an agent’s pytest exit code is allowed to influence merge. If you try the freeze-ledger draft step, keep the human owner field mandatory — the useful part of the model is the classification sketch, not the permission to stay quiet.

Top comments (0)