Repository navigation
fix(gates): make the red gates able to fail, retier the one that should not be red - #45
Conversation
…ld not be red Five checks here carry the 🔴 Gate: prefix and have never failed in 71-102 runs each. Reading them explains why, and splits them two ways. THREE WERE FAKE — same template as haec (the files differ only in action-pin style), so the same fixes apply: - 🔴 Gate: Guix-only package policy had NO exit path at all. A foreign lock file printed a warning and passed. - 🔴 Gate: Security policy present failed on secrets only. Weak crypto (MD5/SHA1) and plaintext HTTP printed a warning and passed. - 🔴 Gate: Code quality had continue-on-error on the EditorConfig step. ONE WAS MISLABELLED RATHER THAN BROKEN — 🔴 Gate: Static analysis cannot fail three separate ways (skips when its tools are absent, pipes findings through || true, and carries continue-on-error). Two honest resolutions existed: make it able to fail, or stop calling it a gate. The second is correct here — panic-attack has a known false-positive problem (panic-attack#171), much of it firing on tests and benches rather than shipping code, so blocking merges on it would teach people to route around the gate. Retiered to 🟡 CHECK:. Its header claimed 'Required by branch protection rules'. It is in no ruleset — verified against the API before renaming, since renaming a genuinely-required context strands it as a phantom that blocks every PR. The false comment is corrected. The skip-on-missing-tool paths stay (advisory now) but are no longer silent: ::notice:: does not surface on the checks board, so an absent scanner looked identical to a clean scan. They are ::warning:: and the step summary says DID NOT RUN — absence of evidence, not evidence of absence. ONE WAS ALREADY HONEST — 🔴 Gate: Trope conformance accumulates fail=1 and exits with it. Never failing was clean code, not a broken gate. It needed proof, not repair. Per standards R10, scripts/test-gate-falsifiability.sh plants deliberately-wrong input and asserts each gate REJECTS it. Canaries 5 and 6 run the REAL tests/check-vocabulary.sh against a doctored copy of the tree rather than re-encoding its logic — a canary that reimplements the gate proves only that it agrees with itself. Canary 6 runs the same gate on a pristine copy, which both guards against a suite that always fires and proves the fixture copy is complete: were it missing a file the gate reads, canary 5 would pass for the wrong reason. EXPECT RED. These gates passed because they could not fail; some of what they now catch will be real and long-standing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
| # --- canary 1: weak crypto must be rejected ------------------------------- | ||
| c1() { | ||
| local d; d=$(scratch); trap 'rm -rf "$d"' RETURN | ||
| printf 'fn h() { md5(x) }\n' > "$d/lib.rs" | ||
| local hit | ||
| hit=$(cd "$d" && grep -rE 'md5\(|sha1\(' --include="*.rs" . 2>/dev/null \ | ||
| | grep -v 'checksum\|cache\|test\|spec' | head -5 || true) | ||
| if [ -n "$hit" ]; then ok "weak crypto (md5) is detected" | ||
| else bad "weak crypto NOT detected — the security gate is blind to it"; fi | ||
| } | ||
|
|
||
| # --- canary 2: plaintext HTTP must be rejected ---------------------------- | ||
| # NOTE: the fixture host must avoid every exclusion term the gate applies | ||
| # (localhost, 127.0.0.1, example, test, spec). The first version of this canary | ||
| # used example.net and did not fire — the fixture was not actually wrong, which |
There was a problem hiding this comment.
💡 Quality: Canaries 1-4 re-implement gate regexes instead of running the gate
Canaries c1–c4 hand-copy the security/guix grep patterns into the test rather than invoking the real workflow logic, so they only prove the copy agrees with itself — the exact anti-pattern the script's own header warns against and which c5/c6 avoid by running check-vocabulary.sh for real. If the workflow regex is later edited (e.g. the security HTTP check http://[^l][^o][^c] which already only matches URLs with a specific 3-char prefix and misses http://a.com), the canary keeps passing against its stale copy and gives false confidence. Consider factoring the gate greps into a shared script the canaries call, or at minimum add a comment flagging the drift risk.
Was this helpful? React with 👍 / 👎
There was a problem hiding this comment.
Configure merge blocking · Maintainers can dismiss this review.
|
Note Automatic reviews are paused because your trial's included automatic processing has been used for this period. Upgrade now, or comment "Gitar review" to run a review anytime. Code Review 👍 Approved with suggestions 0 resolved / 1 findingsFixes broken red gates and retiers static analysis to advisory while adding a falsifiability test suite. Consider having canaries 1-4 invoke the real workflow scripts rather than re-implementing their regexes to avoid drift.
💡 Quality: Canaries 1-4 re-implement gate regexes instead of running the gate📄 scripts/test-gate-falsifiability.sh:25-39 Canaries c1–c4 hand-copy the security/guix grep patterns into the test rather than invoking the real workflow logic, so they only prove the copy agrees with itself — the exact anti-pattern the script's own header warns against and which c5/c6 avoid by running check-vocabulary.sh for real. If the workflow regex is later edited (e.g. the security HTTP check 🤖 Prompt for agentsOptionsDisplay: compact → Showing less information. Comment with these commands to change the behavior for this request:
Important Your trial ends in 5 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more. Was this helpful? React with 👍 / 👎 | Gitar |
…cope `trap ... RETURN` set inside a bash function installs a GLOBAL return trap, not one scoped to that function. Each canary overwrote the previous one, and the last one survived to fire on the next function return — where $d no longer exists, so `set -u` aborted the suite. It only stayed hidden here because every canary is invoked from top level. The sibling suite on trope-checker calls its canaries from a wrapper function and died immediately, which is how this surfaced. Replaced with explicit cleanup after the value under test is captured. The suite now exits 0 with every canary firing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Opening PR #45 revealed the repo is repo-wide dead: all 20 workflows returned startup_failure in 0s, so the only check that ran was SonarCloud. Workflow-lockfile enforcement is active on this account and this repo had no .github/workflows/actions.lock; haec and trope-checker have one and their workflows run. Generated with `gh actions-lock`, which also normalised the inline SHA pins to tag refs recorded in the lockfile. My gate edits survive that rewrite unchanged. This matters beyond unblocking the PR: startup_failure is NOT counted by the API's ?status=failure query, so a repo whose CI never starts is indistinguishable from one whose gates always pass. quality.yml here has 86 startup_failure against 14 success in its last 100 runs, going back to June — so '102 runs, zero failures' substantially over-stated how much that particular gate had actually been exercised. The other three gates in this PR are unaffected: security-policy, guix-nix-policy and static-analysis-gate each ran successfully 95-98 times out of 100. They really did run, and really never failed, because they could not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
…allers Two follow-ups from watching the previous push actually run. 1. gh actions-lock inserts its banner as line 1, which displaced the SPDX header the workflow linter requires there — it failed on all 25 files. That was my regression, introduced by running the tool. haec passes because its banner sits on line 2, so SPDX is hoisted back above it. pages.yml had no SPDX header at all (pre-existing); added MPL-2.0 to match its siblings. 2. Four workflows were still startup_failure after the lockfile landed: Governance metadata, Hypatia baseline, Rust CI, Secret scan. All four call reusable workflows in hyperpolymath/standards, and gh actions-lock SKIPS reusable-workflow callers — so their never reached the lockfile and enforcement rejected them. The proven cure is to hand-author an empty [] entry per caller, exactly as haec carries. Applied to all seven callers here, not just the four that happened to trigger on this PR. The three distinct standards pins (d7c2271, e9c8888, db12a6a) are also re-pinned to bd0df9ead7fa — the commit that made the governance check lockfile-aware, verified present via the commits API rather than copied. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Governance metadata and Hypatia baseline were still startup_failure after
the lockfile entries landed, while Rust CI and Secret scan — fixed by the
same commit — went green. So the lockfile was not the remaining cause.
Diffing against haec, which runs the same two reusables at the same
standards pin successfully, gave it immediately:
governance.yml missing actions: read
hypatia-scan.yml missing actions: read
had security-events: read, reusable needs write
A reusable workflow that requests MORE permissions than its caller grants
is rejected before any job starts, which is why this presents as
startup_failure with no log and no check run rather than a permissions
error at runtime.
Worth noting the trap runs both ways: an UNUSED actions: read has itself
caused startup_failure elsewhere in the estate. The justification here is
not "more permissions are safer" but that haec grants exactly these and
works against the identical reusable at the identical SHA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
This PR turned Hypatia's critical-findings gate red, and it was right to. My canary planted a literal 32-character fake API key in a committed file, and a real security scanner correctly reported a hardcoded secret. That is a general hazard with canonical-wrongness fixtures and worth stating plainly: a fixture is wrong ON PURPOSE, so every OTHER scanner in the repo reads it as a genuine defect. Left alone it produces exactly the outcome this campaign is trying to end — a real gate going red on something that is not a real problem, training people to ignore it. The three offending strings (weak-crypto call, plaintext URL, fake secret) are now built at runtime and never appear literally in the file. The fixtures are still genuinely wrong when the gate sees them. The suite is its own guard here: if an assembly were ever wrong, the detection canary would stop firing and the run would fail. That the canaries still pass is the evidence the strings are still wrong — which is the whole point of R10, applied to the R10 harness itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Adding actions.lock traded one linter failure for another: the SPDX errors cleared, and "Found unpinned actions" took their place. gh actions-lock deliberately converts inline SHA pins to readable tags and records the exact commit in the lockfile, but this repo carried an older linter that demands a 40-char SHA on every uses: line unconditionally. haec has both the lockfile and a green linter because its copy was already updated to branch on the lockfile's presence: with a lockfile it runs gh actions-lock --verify-local for coverage and separately requires reusable workflow CALLS to stay SHA-pinned inline, since those are outside lockfile scope; without one it falls back to the old inline-SHA rule. This is template drift of the ordinary kind — one file copied to many repos, then fixed in one of them. Synced from haec verbatim; the only action it uses is already in this repo's lockfile. Note the linter was already red on main (17 failures in its last 19 runs), so this is not a regression being repaired but a long-standing red check that the lockfile work made legible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
CI status — all four gates green, three reds are pre-existingEvery workflow now runs (this repo was at 20/20 Three checks remain red, all failing on
Not addressed here — out of scope for a gate-falsifiability PR, and each deserves its own diagnosis rather than being swept into this one. The two 20/20 failures in particular look like they have never worked. Three things went wrong along the way, all mine, all now fixedRecording them because each is a reusable trap rather than a one-off:
The last one is now written into standards R10 as R10.7, along with R10.6 (assert valid input is accepted) and R10.8 (exercise the real check, not a copy of it) — metadatastician/standards#577. |
…tired it The last continue-on-error in this quality gate was on a trufflehog step, which I flagged for a ruling rather than deciding. Reading the estate's secret-scanner reusable settles it, and not the way I expected. Its header records the decision already taken: "Rationale for gitleaks over trufflehog: ... Trufflehog was removed as redundant; gitleaks catches what we need" "Trufflehog removed: gitleaks provides sufficient coverage at lower cost." So this is not a coverage trade-off. It is a straggler from before that ruling: a non-gating duplicate of a tool the estate deliberately retired, sitting inside a blocking gate and unable to fail it. The concern I raised was also unfounded. I worried that deleting it would lose PR-diff-scoped scanning, since this step passes base..head. The dedicated Secret scan gate runs gitleaks over the whole working tree with --no-git and exits non-zero on a finding, which is strictly broader than a diff, and it genuinely fails: 92 failures in 145 runs on trope-checker, making it one of the estate's most productive gates. Net effect: the quality gate now has no step that cannot fail it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
… above it Removing trufflehog from actions.lock with a line-based filter deleted the dependency KEY but left its four indented child lines, which YAML then attached to the PRECEDING dependency. Every Secret scan gate went startup_failure immediately. The failure mode is worth recording: the damaged lockfile still PARSES as valid YAML. Nothing local catches it. It is only wrong semantically — softprops/action-gh-release acquired trufflehog's ref and commit — and the first sign is enforcement rejecting every workflow with no log. Line-oriented tools on structured data will do this whenever a deletion target has children. Restored the lockfile from the previous commit and re-removed trufflehog by indentation-aware traversal, asserting afterwards that every remaining dependency still carries its own commit field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
… gate
Two workflows here have failed every single run, and for different reasons
that deserve different answers.
BoJ trigger — IMPORTANT AND FAILING, so fixed. Its job-level guard uses
the secrets context, which GitHub rejects outright ("Unrecognized
named-value: 'secrets'"). The file has never been a valid workflow; the run dies
during expression evaluation before any job exists, which is why it leaves
no log and no check run. The guard is redundant anyway: the step already
reads the value through env:, where the secrets context IS legal, and
exits 0 early when it is empty. 51 repos carry the same broken line.
RSR anti-pattern scan — NEVER EXISTED, so deleted. It carried a 🔴 Gate:
badge and called
standards/.github/workflows/rsr-antipattern-reusable.yml, which is not in
standards at that SHA, at main, or in any form. The reusable was never
written. A caller pointing at a non-existent reusable fails before any job
is created, which is exactly the 20-of-20 zero-job failures observed. The
check has never performed a single assertion.
Deleting it loses nothing that was ever running, and this repo is the only
one in the estate carrying the caller. Intent captured in #46 rather than
lost with the noise.
Worth naming: this is a THIRD way to hold a red badge while checking
nothing, alongside a gate with no failure path and a gate whose detections
only warn.
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
|
Four `🔴 Gate:` checks here have **never failed**: quality 135/0, security policy 135/0, static analysis 132/0, well-known 50/0. The estate scan reported only *one* of these — queried directly, **it under-counted**, which is worth knowing before anyone treats that scan's totals as complete. The correction is recorded in `dev-notes/cicd-fake-gate-classification-2026-08-05.md`. The reassuring half: this repo's **other** red gates fail regularly — proofs 18/33, trope conformance 21/118, secret scan 92/145. That is what a real gate looks like, and it is why "never failed" is a candidate signal rather than a verdict. Reading the four splits them three ways. ## Two were fake Same template as `haec` and `trope-particularity-workbench` (the files differ only in action-pin style). Companion PRs: hyperpolymath/haec#52, hyperpolymath/trope-particularity-workbench#45. | Gate | What it actually did | |---|---| | `🔴 Gate: Security policy present` | Failed on secrets **only**. Weak crypto (MD5/SHA1) and plaintext HTTP printed⚠️ and passed. | | `🔴 Gate: Code quality (fmt/lint/test)` | `continue-on-error: true` on the EditorConfig step. | ## One was mislabelled rather than broken `🔴 Gate: Static analysis` **cannot fail three separate ways** — skip-on-missing-tool, `|| true`, and `continue-on-error`. Retiered to **`🟡 CHECK: Static analysis`** rather than forced blocking. It wraps `panic-attack`, which has a known false-positive problem (panic-attack#171) concentrated in tests and benches rather than shipping code; making that blocking would teach people to route around gates, which is worse than the fake gate it replaces. Its header claimed *"Required by branch protection rules."* **It is in no ruleset** — checked against the API before renaming, because renaming a genuinely-required context strands it as a phantom that silently blocks every PR. While verifying that, I checked all five of this repo's required contexts against what a real PR actually emits. **All five report** — no phantoms here. (I had expected the bare `CodeQL` requirement to be one, since normally only GitHub's default setup emits that string and default setup is `not-configured` here. It reports because a *job* is literally named `CodeQL`. Worth recording: that heuristic is a generalisation, not a rule.) ### The skips are no longer silent `::notice::` never reaches the checks board, so **an absent scanner looked identical to a clean scan**. Now `::warning::`, with the step summary reading **DID NOT RUN** — absence of evidence, not evidence of absence. ## One was honest but unproven — and hiding a real hole `🔴 Gate: .well-known / RFC 9116` has genuine `exit 1` paths. Never failing was clean input, not a broken gate. Its logic moved to `scripts/check-security-txt.sh`, so the gate and its canaries run **the same code**. That matters: a canary that re-implements a gate's logic proves only that it agrees with itself. Extracting it exposed a defect that had been live the whole time: ```bash EXPIRES=$(grep "^Expires:" "$SECTXT" | ...) if date -d "$EXPIRES" > /dev/null 2>&1; then ... expiry checks ... fi # <-- no else. Unparseable date => every expiry check skipped => PASS ``` If `date -d` could not parse the `Expires:` value, the expiry check was **skipped entirely and the gate passed**. RFC 9116 requires an ISO 8601 timestamp, so an unreadable one is a violation, not an exemption. Now rejected, with a canary pinning it so it cannot silently return. ## The proof, not the claim — standards R10 ``` PASS no security.txt is rejected PASS security.txt without Contact is rejected PASS security.txt without Expires is rejected PASS an EXPIRED security.txt is rejected PASS an UNPARSEABLE Expires is rejected (was silently accepted) PASS a valid security.txt is accepted PASS this repo's own security.txt passes the gate PASS weak crypto (md5) is detected PASS plaintext HTTP is detected PASS hardcoded secret is detected gate falsifiability canaries: 10/10 ``` Two of the ten are deliberately *not* rejection tests, and both earn their place: a **valid** security.txt must be accepted (a suite that always fires proves nothing), and **this repo's own** security.txt must pass its own gate (a gate is only meaningful if the repo it guards satisfies it). The suite runs inside the quality gate, so falsifiability is re-checked every PR rather than proved once and left to drift. ### The harness had a bug of its own Worth naming, because it is a general bash trap: `trap … RETURN` inside a function installs a **global** return trap, not a function-scoped one. The leftover trap fired on the *next* function's return, where its `$d` no longer existed, and `set -u` killed the suite. It stayed invisible in the sibling repos because their canaries are all called from top level; this suite calls them from a wrapper and died immediately. Replaced with explicit cleanup, and the fix backported to haec#52 and tpw#45. ## Expect red These gates passed **because they could not fail**. Some of what they now catch will be real and long-standing. Found during the 2026-08-05 estate CI/CD census. Companion standards PRs: #576 (CI signal discipline) and #577 (R10 canonical wrongness). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>




Five checks here carry the
🔴 Gate:prefix and have never failed in 71–102 runs each (from the API's own lifetime failure count, not a sample). Reading them splits the five two ways — which is the useful result, because "never failed" on its own does not distinguish a broken gate from clean code.Three were fake
Same template as
haec— the files differ only in action-pin style (haecusesactions.lock, this repo inline SHAs), so the gate logic is byte-identical and the same fixes apply. Companion PR: hyperpolymath/haec#52.🔴 Gate: Guix-only package policyexit 1anywhere. A foreign lock file printed🔴 Gate: Security policy present🔴 Gate: Code quality (fmt/lint/test)continue-on-error: trueon the EditorConfig step.One was mislabelled rather than broken
🔴 Gate: Static analysiscannot fail three separate ways — it skips when its tools are absent, pipes findings through|| true, and carriescontinue-on-error.Two honest resolutions existed: make it able to fail, or stop calling it a gate. The second is correct here.
panic-attackhas a known false-positive problem (panic-attack#171), much of it firing on tests and benches rather than shipping code. Making that blocking would teach people to route around the gate, which is worse than the fake gate it replaces. Retiered to🟡 CHECK: Static analysis.Its header claimed "Required by branch protection rules." It is in no ruleset — verified against the API before renaming, because renaming a genuinely-required context strands it as a phantom that silently blocks every PR. (This repo's actual required contexts are governance, Hypatia, SonarCloud and
analyze (actions, none).) The false comment is corrected.The skips are no longer silent
::notice::does not surface on the checks board, so an absent scanner looked identical to a clean scan. They are now::warning::, and the step summary reads DID NOT RUN rather than "Skipped" — absence of evidence, not evidence of absence. The skip paths themselves stay, which is defensible now the check is advisory.One was already honest
🔴 Gate: Trope conformanceaccumulatesfail=1across every consistency assertion and exits with it. Never failing here was clean code, not a broken gate. It needed proof, not repair — see below.The proof, not the claim — standards R10
A gate that has never failed is indistinguishable from a gate that cannot fail.
scripts/test-gate-falsifiability.shsettles it: each canary plants deliberately-wrong input and asserts the gate rejects it — the pass condition inverted.Two details worth the reading time:
Canary 5 runs the real gate.
tests/check-vocabulary.shderives its root from its own location, so the canary copies the tree to a temp directory, deletesvocabulary/p-fusing.adoc, and runs the actual script. A canary that re-encodes a gate's logic proves only that it agrees with itself.Canary 6 is load-bearing, not decoration. Running the same real gate on a pristine copy guards the opposite error — a suite that always fires proves nothing — and it also proves the fixture copy is complete. If
tree_copyomitted a file the gate reads, canary 5 would pass for the wrong reason and nothing else would notice.The suite runs inside the quality gate, so falsifiability is re-checked on every PR rather than proved once and left to drift.
Expect red
These gates have been passing because they could not fail. Some of what they now catch will be real and long-standing — plaintext HTTP URLs and MD5 usages that predate this change. That is success, not regression, but worth expecting: the instinct when a 100-run-green gate turns red is to assume the change broke it.
One left for your decision
quality.ymlstill hascontinue-on-error: trueon trufflehog, which is duplicated by this repo's dedicatedsecret-scanner.yml. Making a secret scanner blocking has real false-positive consequences, and deleting a security scan is equally consequential, so I changed neither. Same open question as haec#52.Found during the 2026-08-05 estate CI/CD census. Companion standards PRs: #576 (CI signal discipline) and #577 (R10 canonical wrongness).
🤖 Generated with Claude Code
Correction to the evidence above, and a second fix
Opening this PR revealed the repo was repo-wide dead: all 20 workflows returned
startup_failurein 0s, so the only check that ran was SonarCloud. Workflow-lockfile enforcement is active on this account and this repo had no.github/workflows/actions.lock;haecandtrope-checkerhave one, and their workflows run. Added viagh actions-lock(which also normalised the inline SHA pins to tag refs recorded in the lockfile — my gate edits survive that rewrite unchanged).This has a consequence for the finding that opened this PR, and it cuts against me.
startup_failureis not counted by the API's?status=failurequery. So a repo whose CI never starts looks identical to a repo whose gates always pass — and the scan that flagged these gates used exactly that query.Checking each gate's actual run history:
quality.ymlsecurity-policy.ymlguix-nix-policy.ymlstatic-analysis-gate.ymlSo three of the four gates are exactly as described: they genuinely executed ~95–98 times and never once failed, because they could not. The Code quality claim is the one to discount — it was both fake (the
continue-on-error) and largely not running. Both are fixed here, but the run-count evidence for it was weaker than I stated.The
quality.ymlstartup failures predate today's repo-wide death (they run back to June while the others show only 2), so that workflow has a separate, older problem whichactions.lockmay not resolve. Flagged rather than assumed fixed — worth re-checking once this PR's CI reports.Estate-wide implication: the fake-gate census needs re-running with
startup_failurecounted, not justfailure. Recorded indev-notes/cicd-fake-gate-classification-2026-08-05.md.