The gate discipline
A gate is a check whose exit code decides whether work is done. Everything else in this process — the session ritual, the seeding of new repos, continuous deployment — rests on a gate script whose verdict can be trusted. This document is the set of rules that keeps that verdict honest. They were each earned by a specific failure, and the failure is recorded next to the rule so the rule is not mistaken for a preference.
1. One script is the Definition of Done
Section titled “1. One script is the Definition of Done”Every repo has one gate script (scripts/check.sh by convention) that runs every gate,
in order, and exits non-zero at the first failure naming the gate that fired. CI invokes
that exact script, so “green locally” equals “green in CI” byte for byte.
- To change a gate, change the script, not the workflow. The only thing a workflow may add is an input a gate needs (a toolchain, a checkout, a credential), never gate logic.
- A prose list of the gates goes stale the moment a gate is added, and nothing fails when it does. The script is the list. Documentation says “read the gate names off the script”, not “there are seven gates”.
2. The gate order is a process decision; the language realizes it
Section titled “2. The gate order is a process decision; the language realizes it”Gates run cheapest and most localising first, so a failure names the smallest thing that could be wrong and a broken tree is reported in seconds, not after an image build. The order below is the process’s; each language overlay maps its own tools onto it.
| # | Gate | What it decides |
|---|---|---|
| 0 | gate inputs | every input the script needs is declared, and every caller supplies it (§4) |
| 1 | dependencies | the lock file is verified and is exactly what the resolver would write |
| 2 | format | the formatter reports nothing |
| 3 | compile / static analysis | the code builds and the compiler-grade analyzers pass |
| 4 | lint | the linter passes, at a pinned version |
| 5 | test | the test suite passes, with the race detector on where the language has one, collecting coverage |
| 6 | coverage floors | per-package floors hold (§7) |
| 7 | vulnerability audit | no known-vulnerable dependency |
| 8 | image build | the container image builds |
| 9 | spec drift | the vendored process spec matches the version it is pinned to |
A gate that does not apply to a repo (a documentation repo has no compiler) is omitted, not stubbed: a gate that always passes is worse than no gate, because it reads as coverage.
3. Skippable locally, mandatory in CI
Section titled “3. Skippable locally, mandatory in CI”A gate whose input is heavy or ambient (a container daemon, a sibling checkout, a linter
binary) may skip locally when the input is absent — printed loudly, never silently —
and is mandatory under CI=true, where it fails instead. A developer without the tool
still gets a useful local run; CI gets no such courtesy.
4. Inputs are declared data, and a meta-gate checks every caller
Section titled “4. Inputs are declared data, and a meta-gate checks every caller”A gate script cannot see its own callers. Twice in four days, a gate was added that needed something not in the repo — a sibling checkout, a CLI — and one workflow that invoked the script was not updated to supply it. The first time it blocked production deploys for hours while CI on the same commit stayed green; the second time, a guardrail saying “grep every caller before pushing” was already written, and it failed anyway, because a guardrail is a memory aid attached to nothing. The author’s machine satisfied the input ambiently; CI had to satisfy it explicitly; and the environment that created the obligation was structurally unable to reveal it.
So inputs are declared as data in .xal/gate-inputs, one record per line:
id | detect | required-in-ci | caller-pattern | expires | descriptionand .xal/check-gate-inputs.sh runs as gate 0. It derives what the gate script actually
requires (from command -v NAME and VAR="${VAR:-default}" idioms), fails on anything
undeclared, discovers every workflow that invokes the gate script, and asserts each one
matches every required input’s caller-pattern. A pattern may be scoped to one workflow
(deploy.yml:secrets.MY_TOKEN) for inputs that other workflows consume.
- Gate 0 requires no inputs of its own. It reads only files already in the repo. A check that catches missing inputs must not itself be able to have one missing.
- Zero callers is a failure, never a pass. A renamed gate script would otherwise silently empty the check while it kept reporting green.
- A credential declares its expiry. A caller-pattern naming
secrets.is a credential by construction, soexpiresmust be a real date there; a past date fails, a date within thirty days warns. A secret has an expiry whether or not it is written down; putting it in the manifest is the difference between a rotation you schedule and one you discover from a red checkout.
5. Every gate ships with committed failing fixtures
Section titled “5. Every gate ships with committed failing fixtures”A gate that has never been seen to fail has not been tested. A rule matching nothing
and a rule finding nothing emit byte-identical green, and return 0 passes just as
convincingly as real logic. This has bitten in four distinct ways: a POSIX awk reading
\b as backspace so a leak pattern matched nothing; comm on numerically sorted input
mis-pairing line numbers; a file glob for a language the repo did not contain; and a rule
that was dead on arrival and green for weeks.
So, for every gate:
- one committed fixture per rule, proving that rule fails — the clean tree broken in exactly one way;
- assert which gate fired, by matching the gate’s own output, not only the exit code — a gate can fail for the wrong reason and look right from its exit code alone;
- a clean fixture too, proving the chain can still say yes, so “always fails” is caught as surely as “never fails”;
- where the rules are several, assert each fixture fires exactly one rule, so the fixtures discriminate between rules rather than merely tripping something.
The harness is a separate CI step, not a gate inside the gate script. Proving the chain
means running the chain, and a gate inside check.sh that ran the fixtures would recurse
forever. A reduced chain proves something other than what CI runs; an environment-variable
recursion guard makes the fixture run structurally different from the real run, which is
exactly the difference a fixture exists to rule out. The shape that works: the harness
copies the repo to a temporary directory, overlays one fixture, runs that copy’s gate
script — the real script, the whole chain, in a tree that differs by one file — and asserts
the exit code and the gate name. It runs after the real gate so a genuine failure is
reported before a fixture failure.
“The build ignores it” and “every tool ignores it” are different claims. A fixture tree is exactly where they come apart, because its purpose is to be broken. Hide fixtures from the build by whatever convention the language offers, then check that convention against every tool in the gate chain, one at a time.
6. Exit codes, matching, and the tree
Section titled “6. Exit codes, matching, and the tree”- Exit 0 is pass, 1 is “found a problem”, 2 is “could not run.” They are never collapsed. A missing manifest, a bad argument or an absent fixture directory is an infrastructure fault, not a pass, and it must not read as the repo having a problem either.
- Zero matches is never a pass. A check that iterates an empty list — no language overlays, no source files, no callers — refuses to report green over nothing.
- A gate never edits the tree it judges. A gate that tidies, formats or regenerates in place cannot be run twice and mean the same thing. It reports the diff and leaves the tree as committed.
- Never pipe a deliberately failing command into a test’s condition. With
pipefailon,check.sh | grep -qreturns the check’s status, not grep’s. Capture first, match second. - Never reuse a regex across two engines without confirming both support its
metacharacters.
grep -Ehas\b; POSIXawkdoes not, and it fails silently.
7. Coverage floors are a ratchet, and the floors file is a closed world
Section titled “7. Coverage floors are a ratchet, and the floors file is a closed world”Per-package coverage floors live in a committed coverage-floors file. A floor is set from
the measured percentage of the first green build, never from an aspiration, and it only
ever goes up. Guessing produces one of two useless states: so low it gates nothing, or so
high that the first honest commit is red and someone lowers it, which teaches everyone the
number is negotiable.
A floor of 0 is collect-only: measured and printed, not gated — the honest state for a package with no tests yet, visible rather than absent. A package with measured statements and no record fails the gate, because “new code arrived ungated” and “coverage held” must not emit the same green. A package listed with a non-zero floor and no measured statements fails, because “the package vanished” and “the package is fully covered” must not either.
8. Nothing enters without a caller
Section titled “8. Nothing enters without a caller”A script nothing invokes, a fixture tree no harness names, a field no session fills, a convention nobody follows: each is worse than absent, because every signal a reader checks — the file is present, the rule is written down — says the work is done. Before adding anything the question is not “is this good?” but “what calls it, today?” If the answer is “nothing yet”, it is not ready to land. Wire it to a caller or delete it; carrying it is not an option.
The test to apply to a rule that has never been exercised is not “has this been used?” but “could it be, by the people and mechanisms that actually exist?” A rule nothing can satisfy is not a discipline that slipped; deleting it is the honest fix.
9. A repo that runs a pipeline gates the pipeline too
Section titled “9. A repo that runs a pipeline gates the pipeline too”A repo whose sessions are built by an agent (the implementation stage in
lifecycle.md) carries four more obligations, because the agent is the one
contributor who will take any path the gate leaves open.
- The pipeline’s scripts have fixtures, and they run as a gate. The driver, the chain, the merge decision and the reader’s verdict parser are scripts like any other. Each rule they enforce ships a committed failing fixture, and a gate in the repo’s own gate script runs them, so a change that quietly weakens a refusal is red.
- Gate paths are protected. A session may not change the gate script, the fixtures, the
workflows, the declared inputs or the overlay’s tool configuration unless its plan lists
that path among its artifacts. The protected set is declared as data in
.xal/protected-paths, beside the inputs, and the merge decision reads it. CI runs the head’s own gate, so a session that weakened its gate would otherwise be judged by the gate it weakened. - The gate is run, never reported. The workflow runs the gate after the agent has finished and records the result. An agent’s claim that the gate passed is not evidence.
- The driver and CI resolve the same toolchain. The driver’s gate and CI’s gate judge the same commit, so they install tools through one shared step. Two copies of a toolchain setup drift, and a session that is green in one and red in the other is a disagreement nobody can act on.
A scaffold that seeds such repos carries the whole pipeline in its common tree, and its own seed-set check fails when a piece is missing, so no new repo arrives with a driver that has no fixtures or a merge workflow that has no decision script.
Checklist (a gate script is trustworthy when…)
Section titled “Checklist (a gate script is trustworthy when…)”- It is one script, in the gate order above, and CI invokes it verbatim.
- Gate 0 runs first, needs no inputs, and
.xal/gate-inputsdeclares every input with anexpiresvalue. - Every gate that can skip locally is mandatory under
CI=trueand prints its skip. - A fixture harness, run as its own CI step, proves each gate can fail naming itself, and a clean fixture proves the chain can pass.
- Exit 2 is distinguishable from exit 1; an empty input set is a failure.
- No gate modifies the working tree.
- Coverage floors are recorded from measurement and the floors file is a closed world.
- Every script, fixture and field in the repo has a caller you can name.
- Where an agent builds sessions: the pipeline’s fixtures run as a gate, gate paths are
declared in
.xal/protected-paths, and the driver and CI share one toolchain step.