The whole point of the gate was to say no. It sat in front of every deploy and checked whether a “consent hold” was active — a hold being a flag on a branch that says do not ship this, a human hasn’t signed off. Think of it like the parking brake in a car that’s on a hill: if anything is uncertain, it’s supposed to lock. The gate’s entire reason to exist was to fail closed — to BLOCK when in doubt.
It had 231 passing tests. Typecheck was clean. CodeRabbit, the automated reviewer that reads your pull requests, had nothing to say. Green across the board.
Then one night I ran two separate adversarial reviews over the gate code — two independent passes whose only job was to attack it. Between them they found three ways the gate would fail open. Three ways the parking brake released itself on the hill.
The first: an empty branch — an if with nothing in it — quietly skipped a hold that was scoped to main. The check ran, matched, and then did nothing. The second: if a hold was missing a required field, the gate didn’t complain. It dropped the malformed hold on the floor and moved on to ALLOW. The third was the sneakiest. Two holds with the same key, and the code kept the last one written. A stale, already-expired hold could overwrite an active freeze, and the freeze just… evaporated.
Every one of those resolved to ALLOW when the correct answer was BLOCK. And here’s the part that kept me up: my tests didn’t miss these. My tests asserted them as correct.
That’s the trap. I wrote the gate with a mental model in my head. Then I wrote tests from the same head. When my model was wrong, the tests faithfully encoded the wrong behavior and CI lit up green to congratulate me. The tests weren’t checking the gate. They were checking that the gate agreed with me. It did. We were both wrong.
Green tests, clean CI, quiet CodeRabbit — those are necessary. For code whose job is to block, they are never sufficient. They can only confirm you built what you meant to build. They cannot tell you what you meant was safe.
Fail closed on missing, ambiguous, or duplicate fields give me the detail
The fix is a posture, not a patch: on any input a security gate can’t fully validate, THROW — don’t drop-and-continue. Drop-and-continue is how “no valid hold found” silently becomes ALLOW.
function activeHold(holds: Hold[], branch: string): Hold {
const scoped = holds.filter(h => h.branch === branch);
for (const h of scoped) {
// missing required field must fail CLOSED, not skip
if (h.expiresAt == null) throw new GateError(`hold ${h.key} missing expiresAt`);
}
// duplicate keys are ambiguous — refuse rather than last-wins
const keys = scoped.map(h => h.key);
if (new Set(keys).size !== keys.length) throw new GateError('duplicate hold keys');
const active = scoped.filter(h => h.expiresAt > Date.now());
if (active.length === 0 && scoped.length > 0)
throw new GateError('holds present but none active — refusing to allow');
return active[0] ?? NO_HOLD;
}Then fold every adversarial case back into the suite — and where a test encoded the old behavior, invert its assertion. The empty-branch test that once expected ALLOW now expects a throw.
So if you write auth checks, deploy gates, or consent gates, add one rule to your process: before it ships, someone who did not write it has to attack it with a single question — can this ALLOW when it must BLOCK? Author-independent, because the author already proved the bug looks correct to the author.
Make missing, ambiguous, and duplicate required fields throw. A gate that drops what it can’t understand isn’t a gate. It’s a door that opens when it gets confused.