Skip to content

fix(hydra-gates): a not-applicable gate is not a coverage gap — fix the accounting, then flip the flag - #164

Merged
rubenvdlinde merged 2 commits into
mainfrom
fix/hydra-gates-coverage-accounting
Aug 5, 2026
Merged

fix(hydra-gates): a not-applicable gate is not a coverage gap — fix the accounting, then flip the flag#164
rubenvdlinde merged 2 commits into
mainfrom
fix/hydra-gates-coverage-accounting

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

PO instruction, verbatim: "if a gate is legitimately not applicable the flag shouldnt fail the run"

What the current code actually does — measured, not inferred

_skip deliberately does not add to _EMITTED_GATES, so every skip counts against coverage. And most gates never even reach a _skip: they are wrapped in a bare if [ -d src ] with no else, so when the prerequisite is false they emit nothing at all — no line, no reason, no trace.

Measured on a Tier-0 fixture with nothing wrong with it:

[hydra-gates] COVERAGE: 33 of 63 declared gates reported a result.
[hydra-gates] GATES THAT DID NOT RUN: 4 5 6 7 10 12 13 14 15 19 22 24 25 26 31 32 33
                                      34 35 36 37 38 39 40 41 42 43 44 45 53
EXIT=98

30 of 63 gates did not report; 25 of them only because the repo has no src/. Of the 30, just 4 emitted a SKIPPED line at all — the other 26 were pure silence. So yes: category (a) currently fails the run, and the flag was unusable fleet-wide.

Three states, only two fail

verdict meaning counts?
NOT APPLICABLE subject matter absent from this repo/diff no
SKIPPED (structural) subject matter EXISTS, nothing produced the gate's input yes
SKIPPED (wiring) the gate's own helper/tool is missing yes

wiring is called out separately because it is the worst shape: a missing helper once let gate-7 report PASS over 11 real unguarded IDOR endpoints.

Gates 4, 24 and 33 — classified explicitly

gate-4 had three ways to emit nothing, all byte-identical to success:

  • no composer.jsonna (no PHP dependency tree exists)
  • composer.json/.lock not in a diff-scoped run → na (ADR-020 — the case the PO named)
  • composer not on PATH → wiring. A dependency tree that exists and was never audited. This one had no output whatsoever before.

gate-24 is split on the gate's own subject matter, not on the presence of its checker — the only test that cannot drift from what the gate correlates:

  • no new LeafDescriptor( in lib/ and no registerIntegration( in src/na (there is no pair to correlate)
  • either present, no parity script → structural

Deciding from [ -f scripts/check-integration-parity.sh ] alone is why every repo in the fleet reported the same skip regardless of whether it had any leaves.

gate-33's input is the one thing the runner cannot make for itself:

  • no src/na
  • src/, caller never declared enable-axena — that choice is a visible line in the caller's workflow, not a gap hidden in the runner
  • enable-axe declared, report absent → structural, and it fails

quality.yml now passes --axe-enabled from inputs.enable-axe so the runner can tell those apart instead of guessing "unverified" for both — which is what made the flag unusable everywhere.

Two things stop NOT APPLICABLE becoming a mute

  1. Silence still counts AGAINST coverage. A gate that emits nothing is a gap by default. To stop counting it must declare itself not-applicable, by name and with a reason.
  2. The category is validated. na|structural|wiring only; anything else is a hard gate failure, never a default. A gate cannot stop counting by misspelling its reason. Asserted in the suite — with a reverse control proving the unpatched runner does not emit that error.

The 25 silent gates are covered by applicability declarations restating each group's own if guard, negated. They cannot mask a live gate: _declare_na refuses to touch a gate that already reported, and each condition fires only when the prerequisite is absent — so a gate that stays silent while its subject exists remains a gap exactly as before. A positive control asserts that with src/ present, all 16 src-guarded gates really run, so table-vs-guard drift is detectable.

Both directions proven

Either alone is satisfiable by a broken flag — one that never fails passes the first, one that always fails passes the second.

run expected measured
only category-(a) skips, flag ON 0 EXIT=033 of 33 applicable gates ran
+ an integration leaf registered, no parity check (b) 98 EXIT=98[gate-24] SKIPPED (structural)
helper check_no_admin_idor.py removed (c) 98 EXIT=98[gate-7] SKIPPED (wiring)
--axe-enabled with no report (b) 98 EXIT=98[gate-33] SKIPPED (structural)
typo'd category nq fail FAIL — … not one of na|structural|wiring

Suite 25 → 31 tests, 0 failing.

bin/hydra-gates had to learn the new verdict

It recomputes coverage independently. A NOT APPLICABLE line contains no ': SKIPPED', so the old filter would have counted every not-applicable gate as having REPORTED A RESULT — miscounted silently, in the direction that always reads better than the truth. It also no longer prints ALL 63 GATES PASSED — and all 63 of them ran over a run where 30 did not.

Blast radius of the flip — measured on real repos, flag ON

repo not applicable DID NOT RUN
openregister 4, 33 none
opencatalogi 4, 7, 24, 33 none
docudesk 4, 6, 7, 24, 33 none
hermiq 4, 33 24
openconnector 4, 7, 33 24

Three of five have complete coverage. The two that don't fail on a genuine, named, actionable gap: both register integration leaves and neither ships a parity check. That is the flag working, not the flag misfiring. Only repos with enable-hydra-gates: true are affected at all.

Verification

🤖 Generated with Claude Code

Conduction Release Bot added 2 commits August 5, 2026 15:33
…he accounting, then flip the flag

--require-full-coverage could not be switched on anywhere, and the reason was
the accounting, not the fleet. "Did not run" collapsed three different facts
into one and the flag failed on all three.

Measured before the change, on a Tier-0 fixture with nothing wrong with it: 30
of 63 gates emitted NOTHING AT ALL — no line, no reason, no trace — and the flag
exited 98. Twenty-five of those thirty were silent only because they are wrapped
in a bare `if [ -d src ]` on a repo with no frontend. A control that red-flags a
repo for gates it has no surface for is a control that gets switched off.

The runner now distinguishes three states, and only two of them fail:

  NOT APPLICABLE       subject matter absent from this repo or this diff. Named
                       in the coverage block, excluded from it, does not fail.
  SKIPPED (structural) the subject matter EXISTS and nothing produced the gate's
                       input. Fails.
  SKIPPED (wiring)     the gate's own helper or tool is missing. Fails — this is
                       the shape that once let gate-7 report PASS over 11 real
                       unguarded IDOR endpoints.

The three gates named by the product owner, classified explicitly:

  gate-4  no composer.json -> na; composer.json unchanged in a diff-scoped run
          -> na (ADR-020); composer NOT ON PATH -> wiring. That third case had
          no output at all before: a dependency tree that exists and was never
          audited, indistinguishable from a clean audit.
  gate-24 split on the gate's OWN subject matter, not on the presence of its
          checker: no `new LeafDescriptor(` and no `registerIntegration(` -> na
          (there is no pair to correlate); either present without a parity
          script -> structural.
  gate-33 no src/ -> na; src/ but the caller never declared enable-axe -> na,
          because that choice is a visible line in the caller's workflow rather
          than a gap hidden here; enable-axe declared and no report -> structural.
          quality.yml now passes --axe-enabled so the runner can tell those apart
          instead of guessing "unverified" for both.

Two properties stop `na` becoming a mute. Silence still counts AGAINST coverage
by default — a gate stops counting only by declaring itself not-applicable out
loud. And the category is validated: na|structural|wiring are the only accepted
values and anything else is a hard gate failure, so a gate cannot stop counting
by misspelling its reason. Both are asserted in the suite.

The 25 silent gates are handled by applicability declarations that restate each
gate group's own `if` guard, negated. They cannot mask a live gate: _declare_na
refuses to touch a gate that already reported, and each condition fires only
when the prerequisite is absent — so a gate that stays silent while its subject
exists remains a coverage gap exactly as before. A positive control asserts that
with src/ present, all sixteen src-guarded gates really run.

bin/hydra-gates recomputes coverage independently and had to learn the new
verdict: a NOT APPLICABLE line contains no ': SKIPPED', so the old filter would
have counted every not-applicable gate as having REPORTED A RESULT. It also no
longer prints "ALL 63 GATES PASSED — and all 63 of them ran" over a run where 30
did not.

Proved both directions, since either alone is satisfiable by a broken flag — one
that never fails passes the first, one that always fails passes the second:
a run whose only gaps are not-applicable exits 0; the same fixture with an
integration leaf registered and no parity check exits 98. Suite 25 -> 31 tests,
0 failing.

Measured with the flag on across five fleet repos: openregister, opencatalogi
and docudesk have ZERO gates that did not run. hermiq and openconnector have
exactly one each — gate-24, and it is a genuine structural gap: both register
integration leaves and neither ships a parity check.
…— and the CI control was measuring a proxy

Two things the coverage split exposed.

1. gate-22 and gate-53 reported a missing `node` as a finding about the app.
   With node off PATH, `node check_manifest.js` exits 127, falls through to the
   catch-all branch, and gate-22 says "1 schema violation(s) in
   src/manifest.json"; gate-53's ajv probe is itself a `node -e`, so it says
   "ajv not resolvable" and sends a reader to install a library when the
   interpreter is what is missing. Both now report SKIPPED (wiring) and name
   the tool. Same shape as gate-4 reporting "CVEs or advisories" for a composer
   that could not run.

   Measured with an exhaustively-shadowed PATH — and the instrument was
   controlled first: the obvious approach of stripping node's directory from
   PATH left a SECOND node still resolvable, so that run would have proved
   nothing.

2. The package workflow's reverse-direction control asserted `grep -q "GATES
   THAT DID NOT RUN:"` on the reasoning that node is deliberately absent there.
   That reasoning was wrong and the assertion was a proxy: the only gates that
   need node are guarded by `[ -f src/manifest.json ]`, and that fixture has no
   manifest, so node's absence never reached them. What actually produced the
   line was the `git rm` above it emptying src/ — the a11y gates went silent for
   a reason unrelated to node, and the control passed on a coincidence.

   It stopped passing the moment those gates began declaring themselves NOT
   APPLICABLE, which is the correct verdict for a repo with no frontend. Rather
   than relax it, it now asserts what it was reaching for: ran + notApplicable +
   didNotRun == declared, so no gate can fall out of the tally silently
   whatever the reason, plus every counted gate must have said so on its own
   line. Verified to HOLD on real runs and to BREAK on doctored output.

Also fixes a pre-existing actionlint finding in the same file: `github.head_ref`
was interpolated directly into an inline script, where a fork-PR branch name is
attacker-controlled source text. Passed through the environment instead.
@rubenvdlinde
rubenvdlinde merged commit f7eaf2a into main Aug 5, 2026
7 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/hydra-gates-coverage-accounting branch August 5, 2026 13:52
rubenvdlinde added a commit to ConductionNL/app-versions that referenced this pull request Aug 5, 2026
…he shared quality.yml

The first commit here was measured against quality.yml as it stood this
morning. It has since changed underneath the pin, and that is the second
half of the same defect.

ConductionNL/.github#164 (merged 13:52Z) flipped
`hydra-gates-require-full-coverage` to default TRUE in the SHARED
quality.yml — which every repo references at `@main`. The accounting
that makes that flag survivable, NOT APPLICABLE as distinct from a
structural or a wiring gap, ships in the PINNED package. So a repo on
any pin older than that change fails the coverage assertion for gates it
has no subject matter for.

Not a prediction. Run 31012647428 on this branch, at v1.2.0, 26 jobs,
Hydra Gates FAILED:

  RESULT: ALL GATES PASSED — EXCEPT GATES 4 5 6 7 14 15 22 24 25 33 53,
  WHICH DID NOT RUN.
  --require-full-coverage was set: treating incomplete coverage as failure.

Re-measured on this branch, diff-scoped against origin/development
exactly as CI scopes it, each run in its own mount namespace with a
private tmpfs:

  v1.0.1  exit 98  FAIL
  v1.2.0  exit 98  FAIL   <- confirmed by the real CI run above
  v1.3.0  exit 0   PASS   52 applicable gates ran and passed;
                          11 named NOT APPLICABLE

v1.3.0 is `f7eaf2a` = .github@main, tagged so the ref is reproducible.

Refs ConductionNL/.github#159
rubenvdlinde added a commit to ConductionNL/app-versions that referenced this pull request Aug 5, 2026
…failing CI (#124)

* ci(quality): move hydra-gates-ref off v1.0.1, which predates 16 live dead gates

v1.0.1 is `f4d9756` (2026-08-03). Three gate fixes merged after it, so
every Hydra Gates run this repo has ever made executed a script in which
16 gates reported PASS when their helper never ran (.github#147),
gate-33 had no axe report to read and never said so (#148), and gates 6
and 7 reported PASS on an empty scope (#149).

The tick was identical either way, which is why nothing here shows it.

Measured before opening this PR — full tree, private mount namespace and
tmpfs so the runner's hardcoded /tmp/hydra-gate-*.log paths cannot be
contaminated by another run on the same host (.github#158 item 6):

  v1.0.1  4 gates fail: 38 skip-link, 40 form-label-association,
                        43 table-headers, 45 prefers-reduced-motion
  v1.2.0  the SAME 4 fail; no new failure, and no failure disappears

What does change is the accounting: 61 declared gates become 63, and
gates 24 and 33 now print SKIPPED with a named reason instead of being
absent from the summary.

Refs ConductionNL/.github#159

* ci(quality): correct the target ref to v1.3.0 — v1.2.0 is RED under the shared quality.yml

The first commit here was measured against quality.yml as it stood this
morning. It has since changed underneath the pin, and that is the second
half of the same defect.

ConductionNL/.github#164 (merged 13:52Z) flipped
`hydra-gates-require-full-coverage` to default TRUE in the SHARED
quality.yml — which every repo references at `@main`. The accounting
that makes that flag survivable, NOT APPLICABLE as distinct from a
structural or a wiring gap, ships in the PINNED package. So a repo on
any pin older than that change fails the coverage assertion for gates it
has no subject matter for.

Not a prediction. Run 31012647428 on this branch, at v1.2.0, 26 jobs,
Hydra Gates FAILED:

  RESULT: ALL GATES PASSED — EXCEPT GATES 4 5 6 7 14 15 22 24 25 33 53,
  WHICH DID NOT RUN.
  --require-full-coverage was set: treating incomplete coverage as failure.

Re-measured on this branch, diff-scoped against origin/development
exactly as CI scopes it, each run in its own mount namespace with a
private tmpfs:

  v1.0.1  exit 98  FAIL
  v1.2.0  exit 98  FAIL   <- confirmed by the real CI run above
  v1.3.0  exit 0   PASS   52 applicable gates ran and passed;
                          11 named NOT APPLICABLE

v1.3.0 is `f7eaf2a` = .github@main, tagged so the ref is reproducible.

Refs ConductionNL/.github#159
rubenvdlinde added a commit that referenced this pull request Aug 5, 2026
…ed runners — decidesk and doriath went red on gates 4, 24 and 33 (#173)

`hydra-gates-require-full-coverage` was flipped to default TRUE (#164) once the
runner could tell a not-applicable gate from a real gap. But this workflow is
consumed at @main while the gate package is consumed at the caller's own
`hydra-gates-ref`, so the new default was handed to runners that predate the
accounting entirely — and those count EVERY gate they did not run as a gap.

Measured, not inferred, both after the flip and both failing on nothing else:

  decidesk job 92432089644  ref v1.0.1  ALL 61 GATES GREEN
                            COVERAGE: 58 of 61 / DID NOT RUN: 4 24 33 -> exit 98
  doriath  job 92363547732  ref v1.0.1  same three gates, same verdict

Gates 4, 24 and 33 are the exact trio the product owner named as legitimately
not applicable. So the flip was producing the outcome it was made to prevent,
in every repo whose pin had not moved — and pins do not move on in-flight
branches.

The flag is now handed only to a package that can honour it. The probe reads the
package's own accounting (`_NA_GATES` in the runner, the `NOT APPLICABLE`
verdict in the wrapper) rather than comparing versions, because a pin may be a
tag, a branch, a SHA or a fork. Verified against every published tag:
v1.0.0/v1.0.1/v1.1.0/v1.2.0 -> withheld, v1.3.0/v1.4.0 -> applied. A checkout
missing either file is a broken checkout, not an old one, and fails.

Withholding is stated as a `::warning::` naming the pin and the fix. A control
that switches itself off quietly is a control that has stopped existing.

`--axe-enabled` is gated on the same probe. The old wrapper's parser ends in
`*) APP_DIR="$1"`, so an unrecognised flag is swallowed as a POSITIONAL and
clobbers the app dir set moments earlier: a v1.0.1 pin handed it aborts with
`FATAL: --app-dir '--axe-enabled' is not a directory. No gate ran.` — exit 99,
every gate lost, for a flag the caller never typed.

Same ref skew, second instance: axe-run.cjs landed in v1.4.0 and every caller is
pinned v1.3.0, so `enable-axe: true` would die on `Cannot find module` and then
present as "the axe report never arrived" — pointing at Playwright, the one
place the fault is not. Asserted at the checkout with a named error instead.

The two probe literals are a CONTRACT: renaming either would silently disable
coverage enforcement fleet-wide, with a warning blaming the caller's pin. The
package's own suite now asserts both, plus a reverse control proving the probe
does not match the pre-v1.3.0 shape of either file. Positive-controlled: with
both literals renamed in a copy, those assertions go red.

Four-way measurement on one fixture, one variable at a time:

  v1.0.1 + --require-full-coverage        exit 98  <- the observed failure
  v1.0.1, flag withheld (this change)     exit 0
  v1.4.0 + flag, only (a) gaps            exit 0   33 of 33 applicable gates ran
  v1.4.0 + flag + a structural gap        exit 98  [gate-24] SKIPPED (structural)

Also removes a stale comment left by the #161/#166/#168 cycle that instructed
future readers to embed the axe runner inline — the thing that made the whole
workflow unresolvable fleet-wide.

Suite 31 -> 34 tests, 0 failing. actionlint clean (positive-controlled: a
typo'd step id is reported). Largest `run:` step 8,533 B, far below the ~19 KB
that makes a workflow unresolvable.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
…-020 working (#182)

gate-28 turned RED on every PR in the fleet whose diff does not touch
lib/**/*.php, which is most of them. It reported the DIFF's shape as the
REPOSITORY's defect.

`_lt_files` empty had two causes, and the final `else` stated them as one:

  a) the repo has lib/**/*.php but this diff touches none of them — ADR-020
     diff-scoping working, the same state gate-4 reports NOT APPLICABLE for
     the same diff;
  b) lib/ exists but holds no tracked .php at all.

Neither is (c) "files WERE in scope and none carried a declaration", the
genuine structural gap that branch describes. All three printed (c), and for
(a) that message is FALSE: with zero files in scope, "0 in-scope file carried
a declaration" is true only vacuously.

Not cosmetic. hydra-gates-require-full-coverage defaults to TRUE (#164) and a
structural gap fails the run with exit 98, so this was a fleet-wide falsely-RED
gate — the mirror of the falsely-GREEN shape #172 built this taxonomy to kill.
It surfaced the moment a repo unpinned to main, because gate-28 does not exist
at v1.3.0.

Measured on nldesign against the real runner, three arms, before and after:

  diff = .github/workflows/code-quality.yml   before SKIPPED(structural) -> exit 98
                                              after  NOT APPLICABLE
  diff = 3 files under lib/                   before PASS   after PASS
  diff = one lib/*.php declaring AGPL while
         composer.json declares EUPL-1.2      after  FAIL — 1 file(s)

The third arm is the one that matters: turning a red into a not-applicable is
exactly how a gate gets muted, so the mismatch it exists to catch was injected
and confirmed still caught.

The (c) message now also states the in-scope count, so a reader can tell it
apart from the case it used to be conflated with.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde added a commit that referenced this pull request Aug 8, 2026
…s missing (#248)

#178 (gate-28 fails every PHP-free PR in a PHP repo) was already FIXED by
#182 on 2026-08-06, released in v1.5.2 and v1.6.0. The issue was simply
never closed. Measured, not assumed — all three states behave correctly on
main today. This commit adds no runtime change; it adds the acceptance
evidence and closes three real gaps in what the suites actually asserted.

WHAT WAS NOT BEING ASSERTED

1. The suite never ran --require-full-coverage, so it proved "gate-28 prints
   NOT APPLICABLE" and INFERRED "the run is not failed". Those are different
   claims and have come apart before (#164 defaulted the flag TRUE in the
   workflow while every local suite ran without it).

2. Nothing exercised the _fail 28 branch end to end. A licence MISMATCH is
   the gate's actual purpose; only the weaker "no file carried a tag"
   control existed. The helper->log->wc -l->_fail wiring was untested.

3. check_license_triangle.py's declared_file_count() had NO test in either
   direction, despite being the single number the runner's PASS-vs-structural
   decision rests on, and nothing pinned the stdout/stderr split it travels
   over.

A TRAP FOUND WHILE WRITING THIS

The coverage-failure branch lives inside `if [ "${_FAILED}" -eq 0 ]`. Any
other failing gate suppresses the sentence entirely, so "the coverage
sentence is absent" passes automatically on a fixture that trips an
unrelated gate — including on a fully reverted runner. The first draft wrote
its Vue file to src/App.vue, which gate-38 fails; both runs exited 1, not 98,
and both new assertions were measuring gate-38. Fixtures are now otherwise
green (src/components/Widget.vue), the pass case reads the POSITIVE "ALL N
APPLICABLE GATES GREEN" sentence, and the control uses a composer.json with
no license field rather than untagged PHP (which also fails gate-1, making
the coverage branch unreachable).

MUTATION-CHECKED — every new assertion shown capable of failing:
  M1 empty diff scope -> structural (pre-#182)  -> 2 new shell assertions RED, status 98
  M2 structural -> na (gate stops failing)      -> 2 new shell assertions RED
  M3 _fail 28 disabled                          -> 2 new shell assertions RED (drift became PASS)
  M4 declared_file_count -> len(files)          -> 3 new python tests RED
  M5 count printed to stdout                    -> 4 new python tests RED; clean repo turns FAIL
  M6 empty scope invents a finding + a count    -> 3 new python tests RED
  M7 declared_file_count -> 0                   -> 5 new python tests RED; PASS became structural

Counts: shell 5 -> 11 assertions, python 19 -> 29 tests. Both appended to,
never overwritten. Full helper suite: 27 passed, 0 failed, 2 quarantined.

No runtime file is touched: the diff is the two test files only.

Closes #178

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
@rubenvdlinde
rubenvdlinde restored the fix/hydra-gates-coverage-accounting branch August 8, 2026 16:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant