Skip to content
FIELD NOTE · · · ARCHITECTURE

Phased TDD With Skeptic Gatekeeping

Running a gated, test-driven build with formal architectural review gates that catch the bugs your test suite cannot, and the three blockers a real review caught on a pipeline platform before any of them shipped.

The Problem

Test-driven development gives you confidence that the thing you built does what you said it does. It tells you nothing about whether the thing you said you would build is the right thing, whether the boundaries you drew are the right boundaries, or whether the deployment story you assumed is the deployment story you actually have. The bugs that survive a TDD suite are the bugs that the suite was structurally unable to ask about.

On the pipeline platform I built, three classes of bug fit that description exactly. A hbm2ddl.auto: update setting in the workers’ base config that would have raced multiple pods against each other trying to alter shared schema. A terminal-evaluation path in the DAG engine that worked for every scenario the unit tests covered and was structurally wrong for the ones they did not. RabbitMQ consumer wrappers that the design assumed existed and the implementation had not gotten around to writing yet. Every one of those would have passed a thorough TDD suite and broken in production.

The Approach

I added a gate between every phase. Not a meeting, not a checklist, not a retrospective: a formal architectural review by an explicit “Skeptic” role whose only job was to find what the TDD suite could not. The agent or human in that seat reads the actual code, runs the actual build, and produces a written verdict with three categories: blockers (must fix before the phase ships), warnings (must fix before the next phase ships), and recommendations (worth knowing about for the phase after that).

FIG. ESSAY-01 / PHASED TDD · SKEPTIC GATING & ARCHITECTURAL VERIFICATION
4 GATED STAGES · 3 BLOCKERS CAUGHT · SHEET ESSAY-TDD
GATE GATE GATE GATE FOUNDATIONS ENTITIES · FLYWAY FIXTURES ORCHESTRATOR DAG ENGINE · DISPATCH RETRY · TERMINAL EVAL SUB-PROCESSORS 6 OF 10 STAGES + FIRST E2E RUN AUTH + PATTERNS CREDENTIAL AUTH + HARDER DAG PATTERNS DRY-RUN PROOF NOT A PRODUCTION PUSH LOCAL & DEV ENVIRONMENT

The four gated stages on this project were Foundations (entities, Flyway, fixtures), Orchestrator (DAG engine, dispatch, retry, terminal evaluation), Sub-Processors (the first six of the ten pipeline stages plus the first end-to-end test run), and Auth + Patterns (credential authentication plus the harder DAG patterns). Each stage had its own merge request, its own test suite, and its own Skeptic verdict before anyone touched the next one; what came after the last gate was not a production push but a dry-run proof loop on local and the dev environment.

The gate caught three blockers that the test suites would not have caught on their own.

Foundations blocker: hbm2ddl.auto: update in the deployed base config. Hibernate’s update mode silently no-ops in some failure cases and silently races itself in others when multiple pods start at the same time. The unit tests passed because TestResources spun up a fresh container per run. The fix was validate in every deployed environment plus a separate migration job for schema changes, which is unrelated to anything any test could have asserted.

Orchestrator blocker: terminal evaluation half-logic. The orchestrator evaluated DAG termination in one place and dispatched eligible steps in another. The check-before-dispatch order was wrong in a way that produced phantom in-flight steps for any DAG that halted early (duplicate detected, record restricted). The unit tests all passed because none of them set up the exact pre-condition where a halt and a dispatch would race. The fix was state-set logic and a halt guard in front of the dispatch loop. The same gate also sent back a RabbitMQ publish inside the @Transactional boundary, a catch covered in its own essay.

Sub-Processors blocker: missing RabbitMQ consumer wrappers. The sub-processors were implemented as pure utility classes. The design assumed @RabbitListener wrappers around them. Nobody had written the wrappers. The unit tests passed because they called the utilities directly. The fix was a commitment, carried into the next stage, to deliver eight consumer wrappers before any workers pod shipped.

In addition to the blockers, each phase produced a handful of warnings and recommendations that fed forward. HikariCP pool size too high for a shared database. A shared AMQP channel that was not thread-safe. A timeoutSeconds field that was parsed but never enforced. None of those would block a phase, but they would absolutely have blocked production, and they were all written down before they could be forgotten.

Results

Four gated stages, roughly 580 tests at the last gate by the gate records (a suite since grown to roughly 1,200 by a static count across the four repositories), three architectural blockers caught and resolved before any of them could ship. The pattern that emerged was that the Skeptic role finds a different class of bug than the TDD suite by design. The suite asks “does this code do what its tests say it does?” The Skeptic asks “is this the right code at all?” Both are necessary; neither is sufficient. The two parity defects the platform’s first live dry run surfaced months later, a package-hash delimiter and a record-identifier keying rule, belong to the same class: no unit suite could have asked about them, because the answer lived in the real upstream system’s behavior, not in the code.

The gate is cheaper than it looks. The Foundations blocker took twenty minutes to fix once it was named. The Orchestrator blocker took an afternoon. The Sub-Processors blocker reshaped the next stage’s scope but did not delay the stage itself. The alternative was an outage somewhere downstream. The math favors the gate every single time.

The role rotates well. On this project the Skeptic was a separate agent for each phase, with a fresh context, no investment in the implementation choices, and a written checklist of resolved-and-do-not-re-raise items from the prior phase. On a human team the same pattern works with a senior engineer who is not in the implementation rotation for that phase. The point is not who reads the code; the point is that someone whose only job is to disagree with it gets the last word before the merge.