Why Gates Fail Closed All Tiers

Why a broken gate blocks you instead of passing silently, and how to bypass one responsibly when you need to.

Field Guide

This page explains the reasoning behind a behavior you may find surprising the first time you hit it: a gate that crashes blocks your task, rather than letting it through.

The problem this solves

A "gate" is a hook that runs before a task can complete — architecture checks, contract-break checks, budget checks, containment checks. The naive way to write one is: try to run the check, and if anything goes wrong (an exception, a timeout, a missing module), just let the task through. That sounds harmless, but it means a broken gate and a passing gate look identical from the outside. A gate that silently stops working is worse than no gate at all, because it keeps the appearance of protection without the substance.

PairCoder's gates do the opposite: any failure of the gate itself — not the check it's enforcing, but the gate's own execution — blocks the task. This is called fail-closed, and it applies uniformly. There is no fail-open gate category.

What counts as "the gate itself failing"

It's worth being precise about this, because it's not the same as "the check found a problem." A gate fails closed when:

  • The gate handler throws an exception
  • The gate exceeds its timeout (30 seconds by default, configurable per-gate)
  • The gate returns something malformed — not a dict, or a dict missing the expected result key
  • The hook name isn't recognized at all (a typo in config, or a module that failed to import)

In every one of these cases, the honest answer is "I don't know whether this would have passed" — and the system treats "don't know" as "blocked," not as "assume it's fine."

This is about execution, not policy

A gate can legitimately return "not blocked" as its actual decision — that's the check passing normally. Fail-closed only fires when the gate couldn't run the check at all. If a gate in observation/warn mode crashes while trying to read its data, that's still a fail-closed event, even though the gate's policy would have been advisory either way. The distinction is policy-level pass vs. execution-level failure.

What you'll see when it happens

A fail-closed gate tells you which gate broke, what went wrong, and that it blocked you for safety — not because your work violated a rule:

text
Gate "arch_check_modified" failed closed: ImportError: No module named '...'
  The gate could not execute and blocked this task for safety.
  To bypass: bpsai-pair task update <id> --status done --skip-gates
  To fix: ensure the gate module is installed and accessible.

No retry happens automatically — retrying a crashed gate rarely helps and just adds latency. You have two real options: fix the underlying issue (usually a missing dependency or a config typo) and re-run, or bypass deliberately.

Bypassing responsibly

Bypasses exist because sometimes you genuinely need to move past a broken gate — and PairCoder's philosophy is not "never let the operator override," it's "never let an override happen invisibly." Every bypass is audited, not silent:

  • task update <id> --status done --skip-gates or --force — both write an entry to bypass_log.jsonl
  • ttask done --no-strict skips acceptance-criteria verification, also logged
  • task update --local-only --reason "..." requires you to state why, also logged

This is the same idiom repeated everywhere in the tool: the consequence is enforced mechanically, and the escape hatch is audited rather than forbidden. The underlying principle is to enforce at the point where state actually changes and route everything else around it — the thing that actually changes state (a task completion, a commit) is where the hard check lives, while conversational shortcuts are only ever a convenience layer on top of it, never a way around it.

Rule of thumb

If you find yourself reaching for --force or --skip-gates as a habit rather than a rare exception, that's a signal the underlying gate or your workflow needs attention — not that the flag is doing something wrong. The audit log is there so a pattern of bypasses is visible later, to you or a teammate, rather than invisible forever.