Your Tests Were Green Because the Gate Was Never Wired In

← hexisteme · notes · 2026-08-24

A publish-blocking gate had full unit test coverage and stayed green through review after review. It had also never once been called by the real pipeline. Four rounds of adversarial verification turned up 19 blocking defects, three of them sitting directly under tests that had never gone red — because green tests prove behavior, not reachability, and reachability needs its own evidence.

I built a publish-blocking gate for a monetization agent. A draft gets registered, then judged, and the verdict comes back one of three ways: PASS, BLOCK, or AWAIT_HUMAN. Five machine gates run in sequence before a human ever sees the draft — check_warrant_gate, check_numeral_gate, check_expression_gate, check_disclosure_gate, check_revenue_axis_gate — living in agent_blog/gates/, dispatched from a tuple in verdict.py. Every gate had a unit test. Every unit test was green.

Then I told a verification agent to stop reading the tests and go break the thing for real. Four rounds later: 19 blocking defects found. Three of them had been sitting directly underneath tests that had never once gone red.

That gap is the subject of this post, because it isn't specific to this codebase. Green unit tests prove behavior — what a function does when you call it. They don't prove reachability — whether production ever calls it, whether the value survives the trip between call sites, or whether a failure gets reported as the thing it actually was. Those are three separate claims, and each one needs its own kind of evidence.

The measurement that makes the point concrete

Here's the cleanest demonstration I have. Take the fifth gate, check_revenue_axis_gate, and delete it from the dispatch tuple in verdict.py:

_MACHINE_GATE_CHECKS: Tuple = (
    check_warrant_gate,
    check_numeral_gate,
    check_expression_gate,
    check_disclosure_gate,
    check_revenue_axis_gate,   # <- delete this line
)

Run the gate's own unit tests, tests/test_gates.py: 18 passed. Fully green. It calls the gate function directly, so it has no way of knowing the pipeline stopped calling it too.

Run a second file, tests/test_gate_wiring.py, against the same commit, and it fails immediately:

AssertionError: ... ['check_revenue_axis_gate']

Same commit, same defect, two completely different verdicts depending on which file you ran. That contrast is the whole essay in one command.

It also isn't hypothetical — it's what actually happened before the wiring test existed. check_revenue_axis_gate was implemented and unit-tested, but nobody had added it to _MACHINE_GATE_CHECKS. The real pipeline never called it. A draft with revenue_axis="affiliate" and a subscription call-to-action mixed into the body — exactly what the gate exists to catch — sailed straight through to AWAIT_HUMAN.

Three blind spots, three different kinds of test

Wiring. A function can be fully implemented and fully unit-tested and still never execute in production, if nothing on the real call path invokes it. Unit tests can't see this, because they invoke the function themselves — that's the definition of a unit test. The fix isn't a better unit test. It's a structurally different one: a test that discovers the handlers independently of the dispatch list and diffs the two sets against each other.

Here's the one this project ended up with:

def _discover_gate_checks() -> dict[str, object]:
    """Collect every `check_*_gate` function by walking the gates package's
    modules. Building this list by reading the pipeline's own dispatch table
    would make the comparison meaningless — it has to come from an
    independent source, or the check verifies nothing."""
    discovered: dict[str, object] = {}
    for module_info in pkgutil.iter_modules(gates_package.__path__):
        if not module_info.name.endswith("_gate"):
            continue
        module = importlib.import_module(f"{gates_package.__name__}.{module_info.name}")
        for attr in dir(module):
            if attr.startswith("check_") and attr.endswith("_gate"):
                discovered[attr] = getattr(module, attr)
    return discovered


def test_every_defined_gate_is_registered_in_the_pipeline():
    discovered = _discover_gate_checks()
    registered = {check.__name__ for check in _MACHINE_GATE_CHECKS}
    unwired = sorted(set(discovered) - registered)
    assert not unwired, f"defined but never called by the pipeline: {unwired}"

The detail that matters is in the docstring: discovery walks the filesystem, not the dispatch tuple. If it imported _MACHINE_GATE_CHECKS and compared that list to itself, the test would pass by construction and prove nothing at all. It has to find the gates a second, independent way, or the "diff" is just the same list looking at its own reflection.

Round-trip. A design-stage decision had already moved a draft's derived values to a declaration-based representation — the operator declares the expression and its inputs, instead of the gate auto-searching for any arithmetic combination that happens to land on the number. That fix had no slot in the ledger's serialization format. It vanished silently on the trip from register to judge. Unit tests never caught this, because they hand objects across in memory — build a dossier, pass it straight into the gate function, skip the database entirely. The round trip through the real persistence layer is exactly the part a unit test is built to avoid, for speed. The effect here cut both ways: the fix was unusable on the actual CLI path, and legitimate drafts that depended on it got falsely blocked instead of passing. Catching this needs a save-then-load through the real persistence layer, or an end-to-end run on the real path — not a shortcut through memory.

Failure taxonomy. The CLI's top-level exception handler caught everything and exited 1. So "the code crashed and never reached a verdict" was indistinguishable, from the outside, from "the gate correctly blocked this draft." An AttributeError from a type mismatch looked exactly like policy enforcement. A test suite made entirely of green/red assertions has nothing to say about this kind of confusion — it needs an exit code that specifically means "I could not judge this," plus a test asserting each code maps to what it claims to.

The verb in the instruction changed what got found

The first rounds of verification were told to "review this" and "check the tests." They found none of the three blind spots above. The round that found all three was instructed differently: don't read the tests, actually attack the system and try to break it. The wording is the most visible thing that changed between them, but I didn't hold the code fixed across rounds, so this isn't a controlled comparison and I won't claim it as one. What I will say is that I don't read the earlier rounds as a weaker verifier — they were answering a different question. "Does this look right" and "can you make it do the wrong thing" are not the same test, even run by the same agent.

One more line in the brief mattered as much as "attack it": it also had to say, explicitly, that if the verifier couldn't break something, it should say so. Without that instruction, a verification report reads as coverage it never achieved — every attack vector nobody tried just looks like a pass. With it, the report for this project includes an honest list of what held: forging a verdict via dataclasses.replace, reusing or forging tokens, inserting duplicate PASS rows through raw SQL (blocked by a partial unique index), ten separate attempts to launder a value through the derivation mechanism, and provoking a false block on a legitimate draft. None of it worked. That negative result is worth as much as the nineteen defects that did land — it's the difference between "we found no more bugs" and "we tried these specific things and none of them worked."

The round that broke its own rule

The sharpest example came in the fourth round, and it generalizes past testing entirely. The recurring pattern by then was "a crash disguises itself as a policy verdict," so a fourth exit code got added: EXIT_UNJUDGED (4), for "reached no verdict at all." The convention became: 0 pass, 1 gate violations only, 2 awaiting human sign-off, 4 could not reach a verdict, 3 reserved.

The commit that introduced this convention violated it in three places, and two independent verification lenses caught all three:

The fix keeps the original verdict and its exit code intact, and reports the write failure separately as [PERSIST_FAILED] — one exception no longer gets to erase the other.

There was a fourth issue in the same round, caught by the same two lenses, that I find the most instructive of the nineteen: argparse's usage errors call sys.exit(2). In this CLI, 2 means "every gate passed, awaiting a human signature." A missing --id flag or a misspelled subcommand exited with the identical code as a draft that had cleared every gate and was waiting on a signature. Both lenses flagged it independently — and neither fixed it nor wrote it down. It sat there until it got closed by hand after reading the report. Alongside the fix, the codes got pinned to named constants — EXIT_PASS, EXIT_GATE_VIOLATION, EXIT_AWAIT_HUMAN, EXIT_UNJUDGED — instead of bare integers. An unnamed number can silently collide with a different meaning, and that's exactly the shape this defect had.

What generalizes

Green tests prove behavior, not reachability. Reachability has at least three separate parts — is it wired in, does the value survive the trip between systems, is a failure reported as what it actually was — and each one needs a test built to see that specific thing, not a better version of the tests you already have.

This isn't a call to add wiring tests, round-trip tests, and a failure-taxonomy exit code to every project by default. If the codebase is small enough to have one call site, no dispatch table, no serialization boundary between components, and no top-level handler collapsing exceptions into a single code, none of these three blind spots has anywhere to hide, and building tests for them costs more than they'd ever catch.

Where they do apply, the round-four defects point at something wider than testing. The change that establishes a convention is the change least likely to get checked against it — there's no history of violations to test for yet, and code that just wrote the rule feels, to whoever wrote it, like it obviously follows it. The argparse issue is a separate lesson standing next to that one: a verifier's finding can still get lost if nothing closes the loop afterward. Finding a defect and fixing a defect are two different steps, and this round only had one of them running on autopilot.

FAQ

Q. What's the difference between a green test suite and a gate that actually runs?
A. A unit test calls a function directly, so it proves the function behaves correctly when invoked. It says nothing about whether the real pipeline ever invokes it. In this project, check_revenue_axis_gate had a fully green unit test file (18 passed) while being absent from the pipeline's dispatch tuple, so the live system never called it at all.

Q. How do you test that a function is wired into the pipeline, not just that it works when called directly?
A. Write a test that discovers the handlers independently of the dispatch list, then diffs the two sets. The discovery step has to walk the source (for example, scanning a package's modules for functions matching a naming pattern) rather than reading the pipeline's own registration list — comparing a list to itself proves nothing.

Q. Why did asking the verifier to "review the code" miss all three blind spots?
A. Reviewing and attacking are different tasks. "Review this" and "check the tests" produced no findings across the wiring, round-trip, and failure-taxonomy defects. Rewording the brief to "don't read the tests, actually attack it and try to break it" found all three in the next round. The code wasn't held fixed across those rounds, so read that as a strong hint about how you word a verification brief, not as a controlled result.

Q. What's exit code 4 for, and why did adding it introduce three new bugs?
A. Exit code 4 (EXIT_UNJUDGED) was added to mean "reached no verdict at all," separating crashes from gate violations (1) and human sign-off (2). The commit that introduced this convention violated it in three places — a ledger-connection call outside the top-level try block, verdict persistence happening inside the exception handler so a write failure could erase the original verdict, and the same code path swallowing AWAIT_HUMAN. Two independent verification lenses caught all three.

Q. What did the adversarial verifier try that failed to break the system?
A. Forging a verdict via dataclasses.replace, reusing or forging tokens, inserting duplicate PASS rows through raw SQL (blocked by a partial unique index), ten separate attempts to launder a value through the derivation-declaration mechanism, and provoking a false block on a legitimate draft. None of it worked — and the brief required the verifier to report that explicitly rather than stay silent on untried attacks.

Related notes

← hexisteme · notes · CC-BY 4.0