DEV Community

Finley Sun
Finley Sun

Posted on

Shadow the Old Binary Before the Merge

The incident started as a quiet green build. An agent had "stabilized" a flaky checkout test. It replaced a fake clock with real sleep.

The suite passed after twelve extra minutes. Production still billed the same cart twice. Green had measured patience, not correctness.

Agent patches hunt a green bar. A green bar is not an oracle. Sleep hides races that still exist. Looser assertions hide drift in totals. The merge looks calm. The cart does not.

Consider a small checkout service as a worked example. One process prices a cart. Another process records a payment. An agent arrives to simplify time handling. It deletes the fake clock module. Tests now wait on the wall.

This scene is constructed for the workflow. It is not a customer case study. The failure mode still shows up in review. The patch teaches CI to wait. Production never learned to wait.

Keep the previous release binary as a witness. Replay the same frozen fixtures through both binaries. Diff the canonical output before any merge. Only then run seeded properties on the new code.

Think of a crime scene photo on a table. The agent rewrote the incident report. You still have the photo. You compare both reports against that photo. You do not let the new report redefine the photo.

Flakes poison the witness first. A random failure looks like a regression. A random pass hides a real break. Freeze known flakes before the agent merge starts.

Store them in a quarantine file that CI reads. Frozen tests do not vote on the merge. They still run on a side job. They never gate the agent patch.

# flakes.lock
# nodeid<TAB>last_seen<TAB>reason
tests/test_checkout.py::test_slow_webhook   2026-09-10  timing
tests/test_ledger.py::test_duplicate_event  2026-09-12  shared-db
Enter fullscreen mode Exit fullscreen mode
# flake_freeze.py
from pathlib import Path

def load_frozen(path="flakes.lock"):
    frozen = {}
    for line in Path(path).read_text().splitlines():
        if not line or line.startswith("#"):
            continue
        nodeid, _, reason = line.split("\t")
        frozen[nodeid] = reason
    return frozen
Enter fullscreen mode Exit fullscreen mode

A pytest hook skips frozen nodeids in the merge job. The side job runs them with a fixed seed. If a frozen test stays red for a week, delete it. A dead witness is worse than a missing one.

Fixtures are the photos, not living snapshots. An agent will offer to refresh them. That offer is a smell. A snapshot that rewrites itself cannot accuse anyone.

Feed the shadow runner bytes that already have a hash. The merge job may not edit those bytes alone. A human note must name the field that changed. The note is the only legal rewrite.

# shadow_run.py
import hashlib, json, subprocess, sys
from pathlib import Path

def canonicalize(raw: bytes) -> bytes:
    data = json.loads(raw.decode())
    data.pop("generated_at", None)
    data.pop("request_id", None)
    return json.dumps(data, sort_keys=True, separators=(",", ":")).encode()

def run_bin(bin_path, fixture):
    proc = subprocess.run(
        [bin_path, "--replay", str(fixture)],
        check=True,
        capture_output=True,
    )
    return canonicalize(proc.stdout)

def digest(blob: bytes) -> str:
    return hashlib.sha256(blob).hexdigest()

def main(old_bin, new_bin, fixture_dir):
    failures = []
    out = Path("shadow-diff")
    for fixture in sorted(Path(fixture_dir).glob("*.json")):
        old = run_bin(old_bin, fixture)
        new = run_bin(new_bin, fixture)
        if digest(old) != digest(new):
            failures.append(fixture.name)
            out.mkdir(exist_ok=True)
            (out / f"{fixture.name}.old").write_bytes(old)
            (out / f"{fixture.name}.new").write_bytes(new)
    if failures:
        print("shadow mismatch:", ", ".join(failures))
        sys.exit(1)
    print("shadow ok", flush=True)

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2], sys.argv[3])
Enter fullscreen mode Exit fullscreen mode
OLD_BIN ?= dist/checkout-old
NEW_BIN ?= dist/checkout-new

shadow:
    python shadow_run.py $(OLD_BIN) $(NEW_BIN) fixtures/replay

properties: shadow
    HYPOTHESIS_SEED=20260917 pytest -q tests/properties

merge-gate: shadow properties
Enter fullscreen mode Exit fullscreen mode

Build the old binary from the last tagged release. Build the new binary from the agent branch. Fixtures never include a wall clock. They include a now field the process must honor.

If the agent "fixes" time by sleeping, values may still match. The property suite then attacks duration and idempotency. That split matters. Values and timing are different oracles.

When shadow_run.py exits one, open both canonical files. In this constructed mismatch the old file holds "tax":"1.85". The new file holds "tax":"1.849999". The agent swapped Decimal for float.

Unit tests still passed on the branch. They compared totals with a tolerance the agent added. Tolerance is the same class of cheat as sleep. Reject the patch. Restore Decimal. Do not rewrite the fixture to match the float.

Property checks on a moving flake suite waste hours. Run them only after the shadow run is quiet. Pin the seed so CI can replay a killer.

# tests/properties/test_checkout_props.py
from decimal import Decimal
from hypothesis import given, settings, seed, strategies as st

@seed(20260917)
@settings(max_examples=80, deadline=None)
@given(
    st.lists(
        st.tuples(
            st.decimals(min_value="0.01", max_value="500", places=2),
            st.integers(min_value=1, max_value=4),
        ),
        min_size=1,
        max_size=8,
    )
)
def test_cart_total_is_monotone(lines):
    cart = Checkout(now="2026-09-17T00:00:00Z")
    total = Decimal("0")
    for price, qty in lines:
        cart.add(price, qty)
        new_total = cart.total()
        assert new_total >= total
        total = new_total
    assert cart.total() == sum((p * q) for p, q in lines)
Enter fullscreen mode Exit fullscreen mode

The monotone check catches a cleanup that reapplies discounts. The exact sum catches rounding the agent simplified. Both stay independent of wall time.

A second property should freeze uniqueness. Payments with the same idempotency key must not double-bill. That is the production failure from the opening scene.

def test_replayed_key_does_not_double_bill():
    ledger = Ledger(now="2026-09-17T00:00:00Z")
    key = "idem_9f3a"
    first = ledger.charge("cart_21", Decimal("18.50"), key=key)
    second = ledger.charge("cart_21", Decimal("18.50"), key=key)
    assert first.status == "captured"
    assert second.status == "replayed"
    assert ledger.captured_total("cart_21") == Decimal("18.50")
Enter fullscreen mode Exit fullscreen mode

Drafting extra predicates is slow when the domain is messy. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can propose properties from the ledger code. A reviewer still keeps or deletes each predicate.

The shadow run can occupy a laptop for a long stretch. MonkeyCode's free server option is a place to park that job. The laptop then keeps the flake freeze and the binary build. The models do not watch production. They do not certify the old binary. Treat their output as untrusted as the agent patch.

If you need a spare box for the shadow job, that free server option is one way to park it.

Do not shadow-run services with true external time. Live payment networks will disagree by design. Do not freeze flakes that encode the only oracle you have. Do not accept an agent that deletes the old binary target.

Teams without a tagged prior release cannot build the witness. Tag first. Then add the shadow job. Properties without a seed also fail this plan. Fix the seed before the examples grow.

Canonicalization can hide the bug you wanted. Dropping generated_at is fine. Dropping balance is not. Review the drop list like an API. Hypothesis budgets can miss a killer input. Eighty examples is a filter, not a proof.

The quarantine file can rot without a calendar. Audit it on a date, not on hope. The workflow also fails when both binaries share a bug. Shadow agreement is necessary. It is not sufficient. That is why properties still run after the diff is quiet.

A green bar after an agent patch is a rumor. The old binary is a witness. Frozen flakes keep that witness sober. Seeded properties ask questions the fixtures never contained. Merge only when all three stay boring.

Top comments (0)