DEV Community

Finley Zhou
Finley Zhou

Posted on

Agents May Shrink the Freeze Ledger. They May Not Grow It.

A green CI job is not a score if the flaky freeze ledger grew. Score an agent patch on three read-only surfaces: locked property oracles, hashed fixtures, and a freeze budget that is only allowed to shrink.

That is the whole rule. The rest of this article is a harness you can run, plus the cases where you should not.

Agent patches fail in a boring way. They do not only break production code. They also edit the evidence. A freeze file that gained keys, a TTL that jumped from seven days to ninety, or a fixture that grew a wildcard path will still print green. The merge then records a false repair.

This workflow treats the freeze ledger as production code the agent does not own. Properties and fixtures stay locked. Freeze entries may be deleted. They may not be added or extended by the patch under score.

What this gate measures

Three lanes, three hashes, one budget.

  1. Property lane. Oracle modules are listed in SCORE_PROPERTIES. The agent cannot write those paths. A property that always returns true is not a score; it is a tautology and must be classified before merge.
  2. Fixture lane. Input blobs are hashed. The patch cannot retarget glob patterns or rewrite golden files that feed the oracles.
  3. Freeze lane. freeze.yaml is human-owned. The patch may remove an entry after the flake is gone. Growth of keys, bytes, or expiry is a merge failure.

The parent tree is the baseline. Diff the three lanes against HEAD^ (or the merge-base). Do not score the working copy of the tests the agent was allowed to edit.

Decision table

Use this table in review. Do not improvise in the PR thread.

Observation Freeze ledger Property / fixture Gate
New freeze id appears Grew Unchanged fail
Existing expires moved later Grew in time Unchanged fail
max_entries or max_bytes raised Grew in budget Unchanged fail
Freeze id deleted, tests still fail Shrank, but flake remains Unchanged fail
Freeze id deleted, matching test passes on parent and patch Shrank Unchanged pass freeze lane
Property file or fixture hash changed Any Mutated fail
Assertion hash in a freeze entry rewritten Mutated evidence Unchanged fail
No freeze diff, properties pass, fixtures match Flat Locked score the code diff

The last row is the only row that may proceed to a correctness score. Everything above it is evidence tampering or an incomplete repair.

Freeze file shape

Keep the ledger small and typed. Example:

# freeze.yaml — human-owned, CODEOWNERS required
version: 1
budget:
  max_entries: 12
  max_bytes: 4096
  max_ttl_days: 14
entries:
  - id: parser-timeout-large-input
    test: tests/test_parser.py::test_large_input
    assertion_hash: "e3b0c44298fc1c149afbf4c8996fb924"
    added_at: "2026-09-15"
    expires: "2026-09-29"
    owner: humans
Enter fullscreen mode Exit fullscreen mode

Rules that belong in code, not in a wiki:

  1. owner must be a human team, never agent.
  2. expires must be on or before added_at + max_ttl_days.
  3. assertion_hash is a digest of the failing assertion text, not the test function name.
  4. Entry count and file size must stay at or under budget.

Test names churn. Assertion hashes do not, unless the assertion itself changed. If the assertion changed, that is a property or fixture edit and belongs in a human PR.

Reference gate

The script below is a complete checker. Save it as tools/freeze_budget.py. Run it against the merge-base, not against the agent’s working tests.

#!/usr/bin/env python3
"""Fail if an agent patch grows the flaky freeze ledger."""
from __future__ import annotations

import hashlib
import json
import subprocess
import sys
from datetime import date, datetime
from pathlib import Path

try:
    import yaml
except ImportError:
    print("pip install pyyaml", file=sys.stderr)
    raise

FREEZE = Path("freeze.yaml")
PROP_LIST = Path("SCORE_PROPERTIES")  # one path per line
FIXTURE_ROOT = Path("fixtures")


def git_show(rev: str, path: Path) -> bytes:
    r = subprocess.run(
        ["git", "show", f"{rev}:{path.as_posix()}"],
        capture_output=True,
    )
    if r.returncode != 0:
        return b""
    return r.stdout


def load(raw: bytes) -> dict:
    if not raw.strip():
        return {"version": 1, "budget": {}, "entries": []}
    return yaml.safe_load(raw)


def entry_map(doc: dict) -> dict[str, dict]:
    return {e["id"]: e for e in doc.get("entries") or []}


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def tree_hash(rev: str, root: Path) -> str:
    r = subprocess.run(
        ["git", "ls-tree", "-r", rev, root.as_posix()],
        capture_output=True,
        check=True,
    )
    return sha256_bytes(r.stdout)


def parse_day(value: str) -> date:
    return datetime.strptime(value, "%Y-%m-%d").date()


def check_doc(doc: dict, today: date) -> list[str]:
    errors: list[str] = []
    budget = doc.get("budget") or {}
    entries = doc.get("entries") or []
    max_entries = int(budget.get("max_entries", 0))
    max_bytes = int(budget.get("max_bytes", 0))
    max_ttl = int(budget.get("max_ttl_days", 14))
    raw = yaml.safe_dump(doc, sort_keys=True).encode()
    if max_entries and len(entries) > max_entries:
        errors.append(f"entries {len(entries)} > max_entries {max_entries}")
    if max_bytes and len(raw) > max_bytes:
        errors.append(f"freeze bytes {len(raw)} > max_bytes {max_bytes}")
    for e in entries:
        if e.get("owner") == "agent":
            errors.append(f"{e.get('id')}: owner cannot be agent")
        added = parse_day(str(e["added_at"]))
        expires = parse_day(str(e["expires"]))
        if expires > today:
            pass
        if (expires - added).days > max_ttl:
            errors.append(f"{e['id']}: ttl exceeds {max_ttl} days")
        digest = str(e.get("assertion_hash", ""))
        if len(digest) < 32:
            errors.append(f"{e.get('id')}: assertion_hash too short")
    return errors


def main() -> int:
    if len(sys.argv) != 2:
        print("usage: freeze_budget.py <merge-base-sha>", file=sys.stderr)
        return 2
    base = sys.argv[1]
    today = date.today()
    parent = load(git_show(base, FREEZE))
    current = load(FREEZE.read_bytes() if FREEZE.exists() else b"")
    errors = check_doc(current, today)
    p, c = entry_map(parent), entry_map(current)
    added = sorted(set(c) - set(p))
    removed = sorted(set(p) - set(c))
    if added:
        errors.append(f"freeze ids added: {added}")
    for key in sorted(set(c) & set(p)):
        if parse_day(str(c[key]["expires"])) > parse_day(str(p[key]["expires"])):
            errors.append(f"{key}: expires extended")
        if c[key].get("assertion_hash") != p[key].get("assertion_hash"):
            errors.append(f"{key}: assertion_hash rewritten")
    pb = parent.get("budget") or {}
    cb = current.get("budget") or {}
    for field in ("max_entries", "max_bytes", "max_ttl_days"):
        if int(cb.get(field, 0) or 0) > int(pb.get(field, 0) or 0):
            errors.append(f"budget.{field} increased")
    prop_parent = git_show(base, PROP_LIST)
    prop_now = PROP_LIST.read_bytes() if PROP_LIST.exists() else b""
    if sha256_bytes(prop_parent) != sha256_bytes(prop_now):
        errors.append("SCORE_PROPERTIES mutated")
    if FIXTURE_ROOT.exists() or git_show(base, FIXTURE_ROOT):
        if tree_hash(base, FIXTURE_ROOT) != tree_hash("HEAD", FIXTURE_ROOT):
            errors.append("fixtures tree mutated")
    report = {
        "freeze_added": added,
        "freeze_removed": removed,
        "errors": errors,
        "ok": not errors,
    }
    print(json.dumps(report, indent=2))
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Wire it after checkout, before any “tests passed” signal:

BASE=$(git merge-base origin/main HEAD)
python tools/freeze_budget.py "$BASE" || exit 1
# then run only the locked property list
xargs -a SCORE_PROPERTIES -I{} pytest {} -q
Enter fullscreen mode Exit fullscreen mode

Protect the ledger in CODEOWNERS so a green agent job cannot self-approve growth:

/freeze.yaml            @your-org/maintainers
/SCORE_PROPERTIES       @your-org/maintainers
/fixtures/              @your-org/maintainers
/tools/freeze_budget.py @your-org/maintainers
Enter fullscreen mode Exit fullscreen mode

A deleted freeze id is not a pass by itself. Re-run the named test on the parent tree and on the patch. If parent still fails, the freeze was hiding a real bug and must stay, or the bug must be fixed in code. If parent passes and the patch passes, the freeze has earned deletion.

Property checks that target the ledger

Do not only property-test the product. Property-test the freeze file. The examples below are oracles. Keep them on the locked list.

# tests/properties/test_freeze_ledger.py
from datetime import datetime, timedelta
from pathlib import Path

import yaml

LEDGER = yaml.safe_load(Path("freeze.yaml").read_text())


def test_no_agent_owner():
    for e in LEDGER["entries"]:
        assert e["owner"] != "agent"


def test_ttl_bounded():
    max_ttl = LEDGER["budget"]["max_ttl_days"]
    for e in LEDGER["entries"]:
        added = datetime.strptime(e["added_at"], "%Y-%m-%d")
        expires = datetime.strptime(e["expires"], "%Y-%m-%d")
        assert expires - added <= timedelta(days=max_ttl)


def test_ids_unique():
    ids = [e["id"] for e in LEDGER["entries"]]
    assert len(ids) == len(set(ids))
Enter fullscreen mode Exit fullscreen mode

These tests are cheap. They catch the class of patch that “fixes” flakes by rewriting policy. Run them on every agent score, including patches that never touch freeze.yaml, so a silent regenerate cannot land.

Proposal lane versus scoring lane

Generating new properties is useful. Scoring with editable properties is not. Split the trees.

  1. Check out a throwaway worktree or a remote session that does not mount your scoring checkout.
  2. Ask a model to propose properties for the freeze ledger and for the product function under change.
  3. Copy candidates into a human review branch. Hash them. Append only the accepted paths to SCORE_PROPERTIES.
  4. Score the agent patch in CI against that locked list, hashed fixtures, and the freeze budget. The agent never sees a writable oracle.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. A free model session on a free server is one way to keep step 1 off the scoring tree. That split is the feature that matters here: proposal I/O stays away from freeze.yaml, fixtures/, and the property list. Do not treat a remote session as a substitute for the gate. The gate still has to refuse freeze growth after the patch returns.

If you already isolate proposal from scoring with a local worktree, keep doing that. A remote session is optional plumbing, not a correctness proof.

What a passing score looks like

Emit one JSON object and stop. Example shape after a clean patch:

{
  "freeze_added": [],
  "freeze_removed": ["parser-timeout-large-input"],
  "errors": [],
  "ok": true
}
Enter fullscreen mode Exit fullscreen mode

Then, and only then, publish the property results. A removed freeze id should appear next to the pytest node that now passes on parent and on patch. If you cannot name that node, the deletion is not evidenced.

Do not summarize with a thumbs-up. Store the freeze diff, the fixture tree hash, the property list hash, and the merge-base SHA in the job log. Those four values are the score. Pass/fail without them is a nightlight.

Limitations

This gate does not prove the product is correct. It proves the agent did not enlarge the excuse file and did not retarget the oracles.

It will not catch a freeze that was already too broad on main. If humans merged a 90-day TTL last month, the budget check only stops further growth unless you also tighten max_ttl_days in a human PR.

It will not catch a property that encodes the implementation. Locked tautologies stay tautologies. Classify those in a separate check: the property must reject at least one documented counterexample on the parent tree.

Hashing fixtures/ will fail noisy generated binaries. If your fixtures are rebuilt every run, they are not fixtures. Pin them or move them out of the scoring tree.

git show on a missing path returns empty. The script treats a missing parent freeze as an empty ledger. The first freeze file in a repo must land in a human commit, or the first agent patch will be allowed to create it. Seed an empty entries: [] file on main before you enable agent scoring.

Who should not use this

Skip this method if you do not run agent-authored patches, or if your suite has no flake ledger because every failure is already fail-closed. You do not need a freeze budget if you never freeze.

Skip it if CODEOWNERS is theater: the agent’s token can approve freeze.yaml. The budget script is then a log line, not a gate.

Skip it for exploratory branches where humans are allowed to add freezes. Run the growth check only on the scoring job that claims an agent repair. Mixing those modes in one workflow will block legitimate human quarantine.

The method is for teams that already distrust a green bar after an automated edit. If that is not your threat model, a normal pytest job is enough.

Keep the freeze ledger smaller tomorrow than it is today. That single monotonic rule, plus locked properties and hashed fixtures, is the scoring surface. Everything else is commentary.

Top comments (0)