Silent defaults are the real diff in an agent patch. A green suite only proves that yesterday’s named tests still pass. It does not prove that a new timeout, an or [] fallback, or a widened except block is safe.
Record every invented default, widened handler, deleted guard, and encoding choice in an assumption ledger. Attach one falsifier per row. Refuse the merge if a row has no falsifier. Freeze flakes by exception signature after independent CI shards, not by test name.
The workflow below is a proposed method. Examples are labeled. No pass-rate, latency, or production metric is claimed.
The four assumption classes that survive green CI
Named tests follow named APIs. Agent patches often change the unnamed contract.
-
Default invention. A literal appears where the old code required a caller-supplied value:
timeout=30,retries=3,or [],or {},Noneon a previously required field. -
Error widening.
except Exception, a bareexcept:, or a catch-and-return-Nonepath that used to propagate. -
Guard deletion. A removed
if,assert, or bounds check. Empty or oversized input now flows through. - Encoding drift. Bytes versus text, naive versus aware datetime, slash versus backslash, JSON key order treated as meaning.
If you cannot point to a ledger row, you reviewed the commit message. You did not review the patch.
A seven-step merge gate
Work locally. Keep the ledger in the same change as the patch. Behavior falsifiers must fail on the parent revision and pass on the patch. Negative-space checks must pass on both.
1. Isolate the candidate
Generate or receive one patch at a time. Do not stack a refactor on a behavior change. One diff, one ledger.
If you need a cheap place to produce a candidate, MonkeyCode’s free model access and free server option can host that generation step. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those two availability claims are the only product facts used here. Do not treat the model as a reviewer. Tests it writes will usually encode the same defaults you are trying to catch.
2. Build the ledger from the unified diff
Parse the diff. Do not parse the cover letter. The extractor below is a heuristic. It will miss semantic assumptions. That is expected. Humans still own rows the regex cannot see.
# example heuristic extractor — not a semantic analyzer
from __future__ import annotations
import hashlib
import re
from pathlib import Path
DEFAULT_PAT = re.compile(
r"""(?P<lhs>\w+)\s*=\s*(?P<rhs>None|\[\]|\{\}|""|''|\d+)"""
)
WIDE_EXCEPT = re.compile(r"except(\s+Exception)?\s*:")
REMOVED_GUARD = re.compile(r"^-\s*(if |assert )", re.M)
def assumption_id(kind: str, file: str, snippet: str) -> str:
payload = f"{kind}|{file}|{snippet.strip()}"
return hashlib.sha256(payload.encode()).hexdigest()[:12]
def extract_ledger(diff_text: str) -> list[dict]:
rows: list[dict] = []
current = "unknown"
for line in diff_text.splitlines():
if line.startswith("+++ b/"):
current = line[6:]
continue
if line.startswith("+") and not line.startswith("+++"):
body = line[1:]
m = DEFAULT_PAT.search(body)
if m:
rows.append(
{
"id": assumption_id("default", current, body),
"class": "default_invention",
"file": current,
"claim": f"{m.group('lhs')} defaults to {m.group('rhs')}",
"falsifier": None,
}
)
if WIDE_EXCEPT.search(body):
rows.append(
{
"id": assumption_id("wide_except", current, body),
"class": "error_widening",
"file": current,
"claim": "exception path no longer propagates",
"falsifier": None,
}
)
if REMOVED_GUARD.match(line):
rows.append(
{
"id": assumption_id("guard", current, line),
"class": "guard_deletion",
"file": current,
"claim": "guard removed: " + line[1:].strip(),
"falsifier": None,
}
)
return rows
if __name__ == "__main__":
diff = Path("candidate.diff").read_text(encoding="utf-8")
for row in extract_ledger(diff):
print(row)
Save the output as assumptions.json next to the patch. Empty falsifier fields are merge blockers, not todos.
3. Require a falsifier per row
A falsifier is a check that would fail if the assumption is wrong. Properties beat one-off examples for defaults and encodings. Fixtures beat properties for error paths you can name.
# example falsifiers — proposed tests, not measured production cases
from datetime import datetime, timezone
def test_timeout_default_is_caller_visible():
"""default_invention: omit timeout; the API must not invent 30s."""
try:
from service import fetch
except ImportError:
return # example skip; do not ship a silent skip in real CI
raised = False
try:
fetch("https://example.invalid", timeout=None)
except TypeError:
raised = True
assert raised, "omitting timeout must remain a caller error"
def test_empty_payload_still_rejected():
"""guard_deletion: empty body must not become {}."""
from parser import parse_body
try:
parse_body(b"")
except ValueError:
return
raise AssertionError("empty payload must not parse")
def test_datetime_roundtrip_keeps_tz():
"""encoding_drift: UTC in, UTC out."""
from codec import dumps, loads
original = datetime(2026, 9, 8, 12, 0, tzinfo=timezone.utc)
assert loads(dumps(original)).tzinfo is not None
Map each test id back onto assumptions.json. A test that does not cite a row is noise. A row that does not cite a test is a veto.
4. Run parent, then patch
Behavior rows need a fail-then-pass witness. Negative-space rows need pass-then-pass.
# proposed local protocol
git rev-parse HEAD > /tmp/patch.sha
git stash push -u -m candidate
pytest tests/falsifiers -q --tb=line; echo parent_exit:$?
git stash pop
pytest tests/falsifiers tests/negspace -q --tb=line; echo patch_exit:$?
Record both exit codes in the ledger. A behavior falsifier that is already green on the parent is not a witness. It is an existing test wearing a new name.
5. Negative-space checksum
Agents edit nearby helpers. Pin the files that must not change.
# example: checksum files outside the allowed path set
import hashlib
from pathlib import Path
ALLOWED = {"src/service.py", "src/parser.py"}
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_unrelated_modules_unchanged():
baseline = Path("negspace.baseline").read_text(encoding="utf-8").splitlines()
for line in baseline:
rel, expected = line.split(" ", 1)
if rel in ALLOWED:
continue
actual = digest(Path(rel))
assert actual == expected, f"unexpected edit in {rel}"
Generate negspace.baseline on the parent. Commit it with the patch. If the agent needed a fifth file, the ledger must gain a fifth row. Silent extra files are not “cleanup.”
6. Freeze flakes by signature, not by name
Name-based skips hide renamed tests. Signature-based freezes stay attached to the failure shape.
# example signature key — proposed policy, not a shipped runner
import hashlib
import traceback
def normalize_frame(filename: str, func: str) -> str:
name = filename.replace("\\", "/").split("/")[-1]
return f"{name}::{func}"
def flake_signature(exc: BaseException, fixture_id: str) -> str:
frames = traceback.extract_tb(exc.__traceback__)[-3:]
parts = [type(exc).__name__]
parts.extend(normalize_frame(f.filename, f.name) for f in frames)
parts.append(fixture_id)
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]
Proposed freeze rule:
- A failure is not a flake until the same signature appears on three independent CI shards.
- The freeze key is the signature, never the pytest node id.
- If any ledger property for the same
assumption_idgoes red, delete the freeze. Properties outrank skips.
Do not freeze error-widening rows. A swallowed exception that “only fails sometimes” is still a contract change.
7. Apply the merge rule
Merge only when every ledger row has a falsifier, every behavior falsifier has a fail-then-pass witness, negative-space checksums match, and any freeze file cites signatures plus shard ids. Missing evidence is a no.
Decision table
| Assumption class | Required falsifier | Merge if missing | Name-freeze allowed |
|---|---|---|---|
| Default invention | Property over omitted, zero, and overflow values | No | No |
| Error widening | Fixture that must still raise | No | No |
| Guard deletion | Negative fixture: empty, null, oversize | No | No |
| Encoding drift | Round-trip property on bytes or tz | No | No |
| Timing / order flake | Not a ledger row; signature freeze only | n/a | After 3 shards |
The table is the review checklist. If a patch class is not in the table, add a row before you argue about style.
What the extractor will get wrong
Regex does not understand intent. A timeout=30 in a test helper is not the same claim as timeout=30 in a public client. Encoding drift often lives in a dependency bump with no literal in the diff. Guard deletion can be a move, not a drop.
That is why the ledger is a document, not a linter score. The script proposes rows. A reviewer accepts, edits, or rejects them. Shipping the extractor without the human step just automates confirmation bias.
Limitations
This method does not measure coverage. It does not rank tests. It does not prove absence of bugs. It only makes invented defaults expensive to ignore.
Heuristic extraction fails on generated code, macro-heavy repos, and patches that change behavior through data files. Parent-then-patch witnesses need a linear history; rebase mid-review and you must re-run both sides. Signature freezes still misfire if a helper is inlined and the last three frames change.
Free model output does not reduce the ledger. It increases the number of silent defaults you must name.
Who should not use this
Do not use this as a substitute for review on safety-critical, auth, or payment paths. Do not use it if nobody will reject a row. Do not use it to justify a permanent skip file. Do not use it when the “patch” is a comment-only or lockfile-only change; the classes above will fabricate work.
Teams that cannot run the parent revision locally should not claim fail-then-pass witnesses. Teams that cannot pin shard ids should not freeze anything.
Close
Copy the ledger schema even if you never run the extractor. The schema is the review. The green check is not.
Top comments (0)