Summary
Since c1f7deb (merge of #258, main at 2026-08-08T14:21:55Z), gates 19 / 25 / 62 / 63 classify an empty ADR-020 diff scope as structural. structural counts against --require-full-coverage, which is ON by default. The result: any PR whose diff happens not to touch a spec file and not to touch a manifest/menu-layout fails with exit 98, with zero gate findings and nothing wrong with the code.
This is not the same as #169 (gates 4/24/33, subject matter genuinely absent from the repo). Here the subject matter is present, clean, and passes when the gate is allowed to look at it.
Reproduction
Repo: ConductionNL/doriath, PR #191 — a 3-file change (one controller + two new unit test files). Every gate that ran passed.
##[error]hydra-gates passed every gate that ran, but a gate whose SUBJECT MATTER EXISTS
did not report, and hydra-gates-require-full-coverage is set.
GATES THAT DID NOT RUN: 19 62 63
Reproduced locally against hydra-gates @ 846baed:
$ bash .../run-hydra-gates.sh --require-full-coverage --scope-to-diff origin/development .
[gate-19] e2e-coverage: SKIPPED (structural) — the diff touched NO spec file …
[gate-62] store-plane: SKIPPED (structural) — the diff touched no manifest or menu-layout …
[gate-63] settings-surface: SKIPPED (structural) — the diff touched no manifest or menu-layout …
[hydra-gates] 57 GATE(S) GREEN — but 3 of 60 APPLICABLE gates DID NOT RUN
EXIT=98
The subject matter exists, is inspectable, and is clean
Run gate-63's own checker on the same tree with and without --base:
$ python3 scripts/lib/check_store_and_settings_surface.py . --gate settings
rc=0
checked 2 manifest(s): 0 failure(s), 0 warning(s).
$ python3 scripts/lib/check_store_and_settings_surface.py . --gate settings --base origin/development
rc=3
No changed manifest / menu-layout — EMPTY SCOPE (ADR-020 diff scoping against 'origin/development').
2 manifest(s) exist here and NONE were inspected; this gate's placement rules are UNVERIFIED by this run.
The gate did not fail to run. It ran, found the diff filter empty, and returned rc=3. Nothing is missing, broken or unmeasurable — ADR-020 scoping simply excluded it, which is the entire point of ADR-020.
Why structural is the wrong category here
The runner's own definitions (run-hydra-gates.sh, _skip):
structural The subject matter EXISTS and nothing produced the gate's input.
An app with src/ that ships no axe report has runtime
accessibility defects it has not looked for. A real gap.
…and the header it prints for na:
[hydra-gates] NOT APPLICABLE — subject matter absent from this repo or this diff.
"Absent from this diff" is exactly the rc=3 case, and the runner already advertises that as na. structural was written for the axe case — an input the repo should have produced and didn't. A PR that legitimately doesn't touch a manifest has not failed to produce anything.
The substance of #258 is right — gates 62/63 previously printed PASS over an empty scope, which is a false green (confirmed: at 68254af the same tree reports [gate-62] PASS / [gate-63] PASS with nothing inspected). The regression is only in the category chosen for rc=3, and its interaction with --require-full-coverage.
Control: development is red too — the green it is compared against is a stale measurement
doriath's development tip 4bfeab7c8 was gated success at 14:21:47Z. The classification change landed on main at 14:21:55Z — 8 seconds later. hydra-gates-ref defaults to main, so the two runs are different programs, not different code.
Same commit, zero changes, today's package, faithful push scope:
$ cd <worktree @ 4bfeab7c8>
$ HYDRA_GATE_PUSH_BEFORE=b11ee90be bash .../run-hydra-gates.sh --require-full-coverage --scope-to-diff .
[hydra-gates] SCOPE-FILE-COUNT: 2 # playwright.config.ts, tests/e2e/playwright.config.ts
[gate-19] e2e-coverage: SKIPPED (structural)
[gate-25] contract-coverage: SKIPPED (structural)
[gate-62] store-plane: SKIPPED (structural)
[gate-63] settings-surface: SKIPPED (structural)
EXIT=98
So the branch every PR is compared against does not itself pass. Any strict-subset merge rule reading "green on development, red on the PR" is reading a 30-second-old build of a different program.
Measured blast radius
Fleet PR runs created after 14:21:55Z whose Hydra Gates job failed, classified by annotation:
| repo |
run |
cause |
| doriath |
31266516836 |
coverage-98 |
| doriath |
31266161406 |
coverage-98 |
| larpingapp |
31265829882 |
coverage-98 |
| softwarecatalog |
31266322853 |
coverage-98 |
| opencatalogi |
31266489179 |
real finding |
| pipelinq |
31262984939 |
real finding |
| larpingapp |
31266504079 |
real finding |
| softwarecatalog |
31264780566 |
real finding |
| openconnector |
31262941378 |
real finding |
4 runs across 3 repos blocked on nothing. It is diff-dependent, not repo-dependent: a PR is blocked purely by whether it happened to touch a spec file and a manifest.
There is no honest workaround in the consuming repo
- gate-19 wants a changed spec scenario.
- gates 62/63 want a changed manifest or menu-layout.
A bugfix PR has no legitimate reason to edit src/manifest.json. Editing one to make a gate run is manufacturing the gate's input — the exact false-green this package exists to prevent. The only lever left in the repo is hydra-gates-require-full-coverage: false, which switches the control off fleet-wide. Both are worse than the bug.
Suggested fix (for the maintainer to weigh)
Map "empty ADR-020 diff scope" to na rather than structural, for every diff-scoped gate (19, 25, 62, 63 at minimum), while keeping #258's real fix — never print PASS over a scope that was never opened. A third category (e.g. scoped-out: reported, excluded from the coverage verdict, never rendered as PASS) would preserve both properties and keep the distinction visible in the output.
Whatever the shape, the invariant worth pinning with a test: a gate whose checker returns rc=0 when run unscoped must never fail a run merely because the diff did not reach it.
Summary
Since
c1f7deb(merge of #258,mainat 2026-08-08T14:21:55Z), gates 19 / 25 / 62 / 63 classify an empty ADR-020 diff scope asstructural.structuralcounts against--require-full-coverage, which is ON by default. The result: any PR whose diff happens not to touch a spec file and not to touch a manifest/menu-layout fails with exit 98, with zero gate findings and nothing wrong with the code.This is not the same as #169 (gates 4/24/33, subject matter genuinely absent from the repo). Here the subject matter is present, clean, and passes when the gate is allowed to look at it.
Reproduction
Repo:
ConductionNL/doriath, PR #191 — a 3-file change (one controller + two new unit test files). Every gate that ran passed.Reproduced locally against
hydra-gates@846baed:The subject matter exists, is inspectable, and is clean
Run gate-63's own checker on the same tree with and without
--base:The gate did not fail to run. It ran, found the diff filter empty, and returned rc=3. Nothing is missing, broken or unmeasurable — ADR-020 scoping simply excluded it, which is the entire point of ADR-020.
Why
structuralis the wrong category hereThe runner's own definitions (
run-hydra-gates.sh,_skip):…and the header it prints for
na:"Absent from this diff" is exactly the rc=3 case, and the runner already advertises that as
na.structuralwas written for the axe case — an input the repo should have produced and didn't. A PR that legitimately doesn't touch a manifest has not failed to produce anything.The substance of #258 is right — gates 62/63 previously printed
PASSover an empty scope, which is a false green (confirmed: at68254afthe same tree reports[gate-62] PASS/[gate-63] PASSwith nothing inspected). The regression is only in the category chosen for rc=3, and its interaction with--require-full-coverage.Control:
developmentis red too — the green it is compared against is a stale measurementdoriath's
developmenttip4bfeab7c8was gated success at 14:21:47Z. The classification change landed onmainat 14:21:55Z — 8 seconds later.hydra-gates-refdefaults tomain, so the two runs are different programs, not different code.Same commit, zero changes, today's package, faithful push scope:
So the branch every PR is compared against does not itself pass. Any strict-subset merge rule reading "green on development, red on the PR" is reading a 30-second-old build of a different program.
Measured blast radius
Fleet PR runs created after 14:21:55Z whose Hydra Gates job failed, classified by annotation:
4 runs across 3 repos blocked on nothing. It is diff-dependent, not repo-dependent: a PR is blocked purely by whether it happened to touch a spec file and a manifest.
There is no honest workaround in the consuming repo
A bugfix PR has no legitimate reason to edit
src/manifest.json. Editing one to make a gate run is manufacturing the gate's input — the exact false-green this package exists to prevent. The only lever left in the repo ishydra-gates-require-full-coverage: false, which switches the control off fleet-wide. Both are worse than the bug.Suggested fix (for the maintainer to weigh)
Map "empty ADR-020 diff scope" to
narather thanstructural, for every diff-scoped gate (19, 25, 62, 63 at minimum), while keeping #258's real fix — never printPASSover a scope that was never opened. A third category (e.g.scoped-out: reported, excluded from the coverage verdict, never rendered as PASS) would preserve both properties and keep the distinction visible in the output.Whatever the shape, the invariant worth pinning with a test: a gate whose checker returns rc=0 when run unscoped must never fail a run merely because the diff did not reach it.