DEV Community

John
John

Posted on Originally published at hexisteme.github.io

Your Tests Were Green Because the Gate Was Never Wired In

Originally published on hexisteme notes.

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
)
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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:

  • connect_ledger() sat outside the top-level try block, so a corrupted ledger or a permissions error skipped the entire except chain and fell through to Python's default exit code of 1 — the exact ambiguity the new convention existed to remove, reintroduced by the code that removed it.
  • Persisting a verdict happened inside except GateViolation:. If the write itself raised — a missing permission on the audit log, say — Python replaces the original exception with the new one. A BLOCK verdict and its reason disappeared from the output entirely and came back reported as [UNJUDGED]. This is the most dangerous shape in the whole set, because it doesn't just misreport a crash as a violation — it hides a real violation behind a crash.
  • The same code path swallowed AWAIT_HUMAN the same way.

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.

More notes at hexisteme.github.io/notes.

Top comments (0)