A skipped test does not preserve an invariant. It only preserves a green pipeline. For agent-authored patches, that split is the merge decision.
Skip lists teach the next patch that the path is optional. Frozen assertions go silent. The cheaper control is narrower: lock the failing input as a fixture, keep the property executable, and treat every freeze as capacity you can run out of.
This is not a replay guide and not an audit of tests the agent wrote. It is a freeze-debt workflow. The artifact is a ledger plus a mutation recheck that fails closed when the property never ran.
What the gate should refuse
Agent patches fail in three boring ways. Timing moves. Upstream JSON grows a field. A generator emits a trivial input that cannot falsify the claim.
Teams then skip the test. Coverage stays high. The invariant is gone. The next agent patch has no live check on that path, so it optimizes for the remaining asserts.
A freeze that still evaluates the property on a locked blob is working inventory. A freeze that skips the test is a write-off.
Three records, one cap
Keep three records in-repo. Do not merge them into one skip decorator.
- Fixture lock. The exact input that failed, stored as data, not as a comment in the test file.
- Live property. A check that does not mention the expected output from the patch under review.
- Freeze ledger. A capped list of quarantines, each with an expiry and a mutation-recheck status.
If the ledger is over cap, the gate rejects the patch even when the suite is green. Green is not the budget.
Decision table
| Symptom | Lock | Keep executable | Freeze allowed |
|---|---|---|---|
| Same input fails on two runners | Input blob | Property on that blob | No skip |
| Failure depends on wall clock | Input plus clock seed | Property with pinned clock | Temporary freeze of generation, not of the assert |
| Generator never hits the branch | Shrink report | Property plus one explicit fixture | No freeze; the generator is the bug |
| Assertion encodes the patch’s own comment | Discard assert | Independent property | No freeze |
| Race in production code | None | Failing test stays red | No freeze; fix the race |
The last row is the stop condition. Quarantine is for nondeterministic inputs, not for broken code.
Workflow
Label the following as a method you can run locally. It does not claim a production sample size.
1. Classify before you touch pytest.mark
Name the test in one of four buckets: value assert, property, fixture, freeze. If a test is both a skip and a property, split it. The skip belongs on input generation. The property stays on the function under test.
pytest --collect-only -q | sed -n '1,80p'
Record the node id. Do not freeze by filename. Agents rename files.
2. Lock the input, not the test
Write the failing payload next to the test. Keep it boring and complete.
# fixtures/locked/invoice_tax_q1.json
# Proposal: checked in blob, not generated at collection time.
{
"exclusive_cents": 1999,
"tax_bps": 875,
"inclusive_cents": 2174
}
The property reads that file every run. If the file is missing, fail closed.
from pathlib import Path
import json
LOCKED = Path("fixtures/locked/invoice_tax_q1.json")
def tax_inclusive(exclusive_cents: int, tax_bps: int) -> int:
return (exclusive_cents * (10_000 + tax_bps) + 9_999) // 10_000
def test_locked_invoice_obeys_inclusive_identity():
if not LOCKED.exists():
raise AssertionError("missing locked fixture; refuse merge")
row = json.loads(LOCKED.read_text())
got = tax_inclusive(row["exclusive_cents"], row["tax_bps"])
assert got == row["inclusive_cents"]
assert got >= row["exclusive_cents"]
The second assert is the invariant the agent cannot satisfy by copying inclusive_cents into the patch comments. It still runs when the input is quarantined.
3. Put debt in a ledger with a hard cap
{
"cap": 3,
"freezes": [
{
"id": "fx-2026-09-17-01",
"node_id": "test_locked_invoice_obeys_inclusive_identity",
"input_path": "fixtures/locked/invoice_tax_q1.json",
"property": "inclusive_identity",
"generation_quarantined": true,
"assertion_skipped": false,
"opened_on": "2026-09-17",
"expires_on": "2026-09-24",
"mutation_recheck": "pending"
}
]
}
assertion_skipped must be false or the gate fails. That field is the entire policy.
from datetime import date
import json
from pathlib import Path
LEDGER = Path("qa/freeze_ledger.json")
def load_ledger(path: Path = LEDGER) -> dict:
data = json.loads(path.read_text())
if "cap" not in data or "freezes" not in data:
raise ValueError("ledger missing cap or freezes")
return data
def freeze_debt_errors(today: date | None = None) -> list[str]:
today = today or date.today()
data = load_ledger()
errors: list[str] = []
open_rows = []
for row in data["freezes"]:
if row.get("assertion_skipped"):
errors.append(f"{row['id']}: assertion skipped; freeze is a write-off")
if not Path(row["input_path"]).exists():
errors.append(f"{row['id']}: locked input missing")
if date.fromisoformat(row["expires_on"]) < today:
errors.append(f"{row['id']}: freeze expired")
if row.get("mutation_recheck") != "passed":
errors.append(f"{row['id']}: mutation recheck not passed")
open_rows.append(row)
if len(open_rows) > int(data["cap"]):
errors.append(f"freeze debt {len(open_rows)} exceeds cap {data['cap']}")
return errors
def test_freeze_ledger_is_inside_cap():
leftover = freeze_debt_errors()
assert leftover == [], leftover
Cap is an integer, not a vibe. Raise it only by deleting a row, not by commenting it out.
4. Mutate the locked blob; do not re-freeze the same shape
A single locked input bitrots. The property can pass on that blob and fail on the next neighboring record. Mutation recheck is a small, explicit loop. It is not a fuzzer manifesto.
import copy
def mutations(row: dict) -> list[dict]:
variants = []
for exclusive in (0, 1, row["exclusive_cents"], row["exclusive_cents"] + 1):
item = copy.deepcopy(row)
item["exclusive_cents"] = exclusive
item["inclusive_cents"] = tax_inclusive(exclusive, item["tax_bps"])
variants.append(item)
for bps in (0, 1, 10000):
item = copy.deepcopy(row)
item["tax_bps"] = bps
item["inclusive_cents"] = tax_inclusive(item["exclusive_cents"], bps)
variants.append(item)
return variants
def test_mutations_of_locked_invoice_still_hold():
row = json.loads(LOCKED.read_text())
failures = []
for item in mutations(row):
got = tax_inclusive(item["exclusive_cents"], item["tax_bps"])
if got != item["inclusive_cents"] or got < item["exclusive_cents"]:
failures.append(item)
assert failures == [], failures
Mark mutation_recheck passed only when this test is green. If mutation invents an illegal state, fix the mutator. Do not skip.
5. Run generation and execution on different machines
Laptop load is a flake factory. Collection, mutation, and property execution should not share a thermal throttle with the editor.
A remote run is enough. Pin the fixture files in git. Pass the ledger path as an argument. Fail the job on any freeze_debt_errors() item.
python -m pytest tests/test_freeze_ledger.py tests/test_invoice_tax.py -q
If you need candidate mutations beyond the hand-written grid, a model can propose extra in-schema rows. Those rows are suggestions. They enter the suite only after a schema check and a human-readable diff against the locked fixture.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access is enough to draft those extra rows. The free server option is enough to execute the property suite and the ledger check off the laptop. Neither step requires a named model, a quoted quota, or a hardware claim. If the remote job cannot read fixtures/locked/, the gate should fail. Missing fixtures are not flakes.
What this does not prove
The ledger does not prove the property is complete. It proves the property still ran. Completeness is a domain question. Tax rounding, UTF-8 boundaries, and retry semantics need different identities.
Mutation of one JSON object is not property-based testing in the Hypothesis sense. It is a neighbor check so a freeze cannot collapse to a single souvenir payload.
Expiry dates do not fix races. If opened_on keeps getting rewritten, you do not have a freeze policy. You have a skip list with timestamps.
Who should not use this
Do not use a freeze ledger if you cannot state the invariant in one sentence. Snapshot-only UI packs have no property to keep live. One-off scripts with no merge gate gain nothing from a cap.
Do not use it to hide a data race. A freeze on a race trains the agent to delete the test. Fix the race or keep the failure red.
Do not use it when the locked fixture contains secrets. Redact or synthesize. A quarantined production payload in git is a different incident.
Close the loop
Count open freezes in CI. Count skipped assertions as errors, not as debt. Keep the property on the locked input. Mutate the blob before you extend the expiry.
If the suite is already isolated from the laptop, running that ledger check on a free remote server is a sufficient next step. The merge rule stays the same: the invariant ran, or the patch does not land.
Top comments (0)