diff --git a/CHANGELOG.md b/CHANGELOG.md index e638c8d..9f6c95f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,8 @@ repository still gets a decision, never by following the link; no release carrie - :memo: docs(e11): delete the false cost claim; reason 1 carries Shape D alone (F3-01) - :memo: docs(ci): D-159 recorded a residual that does not exist (ORPH-01..04) - :memo: docs(release): correct the residual the gate documents (R3-01) +- :memo: docs(ci): withdraw three falsifiable claims from the PR-reach reader (D-157) +- :memo: docs(ci): withdraw the third copy of the retracted claim (D-157) ### Features - :sparkles: feat(audit): measure REL-03/REL-07 by real mutation run, not by name @@ -109,6 +111,10 @@ repository still gets a decision, never by following the link; no release carrie - :bug: fix(ci): run the -count=1 wiring pin in the PR-visible verify job - :bug: fix(ci): pin COUNT1-F01 and refuse a merged gate step - :bug: fix(ci): bump golangci-lint to v2.13.1 for Go 1.27 (D-158) +- :bug: fix(ci): close two PR-reach fail-opens the review found end-to-end (D-157) +- :bug: fix(ci): read flush block sequences and whole branch names (D-157) +- :bug: fix(ci): strip CR, and close the one fail-open direction by construction (D-157) +- :bug: fix(ci): refuse globs on branches-ignore, grade disarm keys by name (D-157) ### Refactoring - :recycle: refactor(audit): pin REL-03 and REL-07 by test name, demote source shape @@ -133,6 +139,8 @@ repository still gets a decision, never by following the link; no release carrie - :white_check_mark: test(hack): the bash-3.2-clean assertion could not fail (BASH32-F01) - :white_check_mark: test(hack): close two gate fail-opens found by review (BASH32-F01) - :white_check_mark: test(ci): wire release-verify-test into task check (D-159) +- :white_check_mark: test(ci): share PR-reach and cross-pin the three PR-visible gates (D-157) +- :white_check_mark: test(ci): grade the shared PR-reach helper itself, on fixtures (D-157) ## [0.3.0] - 2026-08-18 ### Chores diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md index 8d6de3b..979371d 100644 --- a/docs/decisions/decisions.md +++ b/docs/decisions/decisions.md @@ -160,6 +160,7 @@ project/process decisions. | D-153 | 2026-08-19 | **The cosign signer-identity pin published in `SECURITY.md` was WRONG, not merely missing from `hack/install.sh`: the identity regexp is widened to `^https://github\.com/PlatformRelay/[Aa]ssent/` in both files (AUD2-S03 / SEC-03).** Substance first: `SECURITY.md`'s "Verify a tagged release" instructions pinned `--certificate-identity-regexp '^https://github.com/PlatformRelay/assent/'`, and an adopter who followed them on **v0.2.0 or v0.3.0 got a verification FAILURE on a genuine, correctly signed artifact** — the published recipe has been broken for every release since v0.2.0, and the natural reading of that failure is "this release was tampered with". Cause: the repository was renamed `PlatformRelay/assent` → `PlatformRelay/Assent` between v0.1.0 and v0.2.0; the keyless signing certificate's Subject Alternative Name carries GitHub's canonical casing; cosign compiles `--certificate-identity-regexp` as a Go RE2 pattern and matches it **case-sensitively**. Decoded from the published bundles: v0.3.0 and v0.2.0 sign as `https://github.com/PlatformRelay/Assent/.github/workflows/release.yaml@refs/tags/vX.Y.Z`, v0.1.0 as `https://github.com/PlatformRelay/assent/.github/workflows/release.yaml@refs/heads/main`. All three verify under the new value; independently re-confirmed with real cosign against the real v0.3.0 artifact (`Verified OK` under the new pin, exit 1 "expected SAN value to match regex" under the old). Options considered: **(a)** ship the pin byte-identical to the published-but-broken value and file the breakage as a follow-up (rejected — it would land a green gate asserting a guarantee that fails closed on this project's own releases, D-124's defect one level up); **(b)** case-insensitive `(?i)` (rejected — wider than the defect and it silently accepts casings GitHub never issues); **(c)** an explicit `[Aa]` class in both files, dots escaped (chosen). Not widened otherwise: the `^` anchor and the owner/repo scope stand, so another owner, an `assent-mirror` typosquat, another forge and an unescaped-dot host all still fail. Enforcement, per D-128: **one** published truth — `hack/release/install_cosign_pin_test.sh` extracts the pair from `hack/install.sh` and `SECURITY.md`, requires exactly one distinct value per file, reddens on drift, and (this is the assertion that would have caught the defect) matches the pin against the three **real** SANs above, committed as offline fixtures, plus six negatives. Extends D-110 (bundles beside archives) and D-109; the maintainer-path twin at `hack/release/verify-artifacts.sh:124` is still unpinned and is tracked in the backlog, not fixed here. Revert: restore `'^https://github.com/PlatformRelay/assent/'` in both files — which re-breaks verification of v0.2.0 and every later release, so revert only together with a repo rename back to lowercase. | | D-154 | 2026-08-23 | **Every `hack/**` script that uses a bash 4+ feature declares a PER-SCRIPT version floor via the shared `hack/lib/require-bash.sh`, because one of them was a silent local FAIL-OPEN under stock macOS bash 3.2 (BASH32-F01).** Substance first: `/bin/bash hack/docs/truthlag_pins_test.sh` on macOS printed 20 `PASS` lines, died at its `declare -A ex_s09_prefix_hint=(…)`, never printed its final `OK: all truth-lag pins green` banner — and **exited 0**. Its caller `task docs-gates`, and therefore `task check`, read that as a green gate, and AGENTS.md rule 4 makes a green local `task check` a per-commit precondition; CI is ubuntu/bash 5, so nothing merges through this path, which is precisely why it survived. Mechanism, measured on bash 3.2.57: 3.2 has no associative arrays, so under `set -u` it re-reads `declare -A x=([k]=v)` as an INDEXED array assignment and evaluates the subscript `k` **arithmetically**, hitting "unbound variable"; the shell terminates the script and the exit status is 0. Two theories were disproved en route and are recorded so they are not re-explored: the `trap … EXIT` status-swallow (3.2 propagates 1 through an EXIT trap correctly) and `set -e` (the script runs `set -uo pipefail`). The other two affected scripts fail CLOSED, but only incidentally: `hack/release/verify-artifacts.sh:202`'s literal is EMPTY, so there is no subscript to evaluate and it degrades to `declare: -A: invalid option`, which its `set -e` turns into exit 2 (measured with a `--dist` that reaches line 202 — an unreachable dist exits 1 much earlier); `hack/validate-schemas-stock.sh`'s `mapfile` degrades to "command not found" and it exits 1. Populating an empty literal or dropping `-e` silently converts a closed failure into the open one, so "how this construct happens to degrade" is not a guarantee worth depending on. Options considered: **(a)** one blanket repo-wide floor (rejected — the binding minimum differs per feature: `declare -A` 4.0, `local -n` 4.3, `mapfile -d ''` 4.4; a blanket floor both refuses shells that could run the 4.0 gates and hides which feature actually binds); **(b)** rewrite the three scripts to be 3.2-compatible (rejected — associative arrays and `mapfile` are the right tools here, a rewrite is a large risky change to scripts whose correctness IS the product, and it does nothing about the next gate author); **(c)** copy the existing inline `BASH_VERSINFO` guard from `hack/audit/aud2_exitgate_test.sh:71` into each script (rejected as the *only* measure — it duplicates the explanation three more times and still leaves the next author to rediscover the hazard); **(d) chosen** — a shared `require_bash [.] ` helper, called with each script's own correct floor (4.0 / 4.0 / 4.4), plus a meta-gate. Enforcement, per D-128, is one place: `hack/lint/bash_version_guard_test.sh`, wired as `task check` stage **20** and pinned in `hack/audit/exitgate_test.sh`'s `CHECK_STAGES` in the same commit. It scans every `hack/**/*.sh` for command-position bash 4+ constructs, requires a guard on each, requires the declared floor to be at least the feature's own minimum, and — the assertion that makes the rest non-vacuous — requires the scan to still find all four known feature-using files, so a typo in a detection pattern reds instead of quietly matching nothing. Its own negative control mutates real copies of the three scripts with the guard lines stripped and asserts they are flagged. `hack/audit/aud2_exitgate_test.sh` keeps its inline guard and is accepted by the gate as guarded; adopting the helper there is a follow-up, not this lane. Stated limits: the gate sees only command-position constructs in `hack/**/*.sh` (not `eval`, dynamically built command names, case-modification expansions, or scripts outside `hack/`), and the exit-0 control needs a real bash 3.2 — present on macOS, absent on CI ubuntu, where it SKIPs **loudly** rather than passing silently. Accepted behaviour change: under 3.2, `hack/release/verify-artifacts.sh --help` now refuses instead of printing usage. Revert: delete the guard calls — `task check` then silently stops certifying the docs truth-lag pins for every contributor whose `bash` is 3.2. | | D-156 | 2026-08-23 | **The tier-1 (CEL) ceiling is recorded (`docs/planning/rego-tier-ceiling.md`, E11-S01 / REQ-E11-S01-01+02) and it NARROWS E11: two of the four named shapes are struck outright and a third in part.** Substance first: of multi-pass / cross-manifest / set-difference / graph-relationship (`openspec/specs/later-phases.md:284`), **cross-manifest and set-difference are struck, the "named intermediate" half of multi-pass is struck, and only fold/aggregate-over-a-collection and in-input graph reasoning survive as justification for a second backend.** Measured against the surface this repo actually binds — `internal/core/aggregate/evaluate.go` `newEvalEnv`, eleven frozen predicate-scope variables, **zero extension libraries** — reproduced in a nested throwaway module pinned to the repo's `cel-go v0.31.0` (root `go.mod`/`go.sum` byte-unchanged; nothing added to the dependency graph, which judgment call (d) has not authorised). **What exceeds tier 1: (1) folds.** `sum`, `reduce`, `math.*` and `lists.*` are all `undeclared reference`; `size()` is the only aggregate in the surface, so a rule counting matched changes is expressible and a rule summing a numeric delta across them is not. **(2) Graph reasoning** — the iteration count of a CEL expression cannot be made data-dependent, so depth is syntactic and **unbounded** reachability has no spelling; a bounded `k`-hop form *is* writable and answers a fixed-depth question, not the rule (detailed below, with the refutations of two sharper claims this row previously made). **What is struck, with the CEL that does the job:** registry membership — `string(new) in facts.registry.topics.value` over a `cardinality: set` fact, already shipped green at `examples/packs/service-catalog/.assent/packs/catalog/rules/ownership.yaml:21`; keyed attribute lookup — either a purpose-built provider (`builtin/resource-owner`, exercised in both polarities by `examples/archetypes/referenced-resource-ownership/`) or `facts.

..value[key].field`, which compiles and is lint-clean because `internal/lint/facts_ref.go`'s D-051 shape check permits arbitrary navigation past `.value` and only forbids indexing `facts` itself; set difference — `oldEntry.acls.filter(a, !(a in entry.acls)).size() == 0`; named intermediates — no `cel.bind`, but re-deriving the sub-expression inside each leaf is semantically identical and compiles. **The sharpest finding is a distinction, not a rule:** same-changeset **cross-file** reasoning fails for **input availability**, not expressiveness — the evaluation unit is one file (`change.ChangeSet` is documented "for one file", `internal/change/diff.go:129`; `assent run` strips one `--subject file:`, `cmd/assent/run.go:266`, and diffs it alone, `:293`) and **REQ-E11-S05-01 pins the Rego module to the identical `EvaluationInput`** with the tier fenced to "declared data, no I/O". **A Rego module therefore fails that rule identically, so it is struck from E11's justification too** — the fix is an input decision, which E11's non-goals fence, not a backend decision. Corroborating: the one committed illustration of the escape hatch, `examples/policies/rego/bounded_change.rego`, is **entirely tier-1 expressible** — both `violations` rules are per-change predicates and `examples/policies/declarative/bounded-change.yaml` is the same rule already in the envelope. **The strongest single piece of evidence, and the one that sizes the epic, is the graph shape.** On the `assent run` path document-mode `walkNode` emits a Change **only where two scalars differ** — a sequence on either side makes the whole ChangeSet **opaque → REVIEW** before any rule runs (`internal/change/diff.go`) — and collection-mode `DiffEntries` is reached only from `internal/adoptertest`, so `old`/`new`/`entry`/`oldEntry` and every `changes[i].old`/`.new` bind **scalars**. That does **not** starve a graph rule, because a flat `set` fact still carries structure: `edges: {type: string, cardinality: set}` returning `["orders|billing","billing|ledger"]` is a **fully declarable, in-contract adjacency inside `EvaluationInput` today** — the declaration is legal (`schemas/provider/v1alpha1/response.schema.json`), `fact.value` carries no JSON-Schema type constraint and `ResolveFactsChecked` cross-checks the *declaration* not the value (`internal/provider/resolve.go`), outputs are operator-authored (`internal/provider/config.go`), the `http` transport is live on the plain run path with **no `--checkout`** (`cmd/assent/provider_host.go` `providerCallFor`), and a set fact binds as a CEL list. Tier-1 CEL nonetheless cannot answer the actual question over it: **CEL has no recursion, fixpoint, fold or user-defined function, so unbounded reachability has no spelling** (`transitiveClosure` → `undeclared reference`; the four comprehension macros iterate one level and cannot call themselves). **Stated precisely, because two drafts of this record overreached and both were caught by independent review:** a *bounded* `k`-hop check **is** expressible and does **not** need the string decomposition CEL lacks — over a finite in-input candidate set (a `nodes` set fact, the same construction as `edges`), decode is replaceable by **encode-and-compare**: `nodes.value.exists(m, nodes.value.exists(n, (string(new)+"|"+m) in edges.value && (m+"|"+n) in edges.value && (n+"|"+string(new)) in edges.value))` compiles **and evaluates** under the real `cel.CostLimit(1_000_000)` to `true/true/true/false` over the fixture above, and `nodes.value.filter(m, (string(new)+"|"+m) in edges.value)` recovers an edge's far end as `[billing]`. Both primitives (`+` on strings, `in`) were already in this record's own function census, so the claim that decoding was a second independent barrier was internally inconsistent as well as false. **The real ceiling is `k`, and a third draft got its cost wrong too** — it claimed `O(|N|^(k-1)·|E|)` "exhausts the cost budget on any real graph". Wrong complexity class and wrong conclusion, because the census had omitted the surface's only value binder, `[expr].all(v, …)`: binding each BFS frontier once per level makes cost additive across levels, and measured under the real `cel.CostLimit(1_000_000)` a ring `|N|=50, deg 5` costs 89,551 at `k=10`, 235,297 at `k=20` and 686,317 at `k=50` — roughly linear in `k`, and at `k=50 ≥ |N|` CEL is **not approximating at all, it decides reachability exactly**, in 69% of budget. The practical `k` collapses around `|N|≈200` (`k=10` at 46% of budget, `k=20` exceeds). Recorded because a reader sizing this epic is entitled to ask "would CEL suffice for *our* graph?" and the previous sentence foreclosed that question with an assertion measurement refutes; it is **not** offered as a second reason. **What carries the verdict is one property, now measured rather than recalled: the iteration count of a tier-1 CEL expression cannot be made data-dependent.** `reduce`, `transformList`, `transformMap`, two-var `all(i,x,…)`, `range`/`lists.range` and `cel.bind` are all `undeclared reference`; `for` is a `reserved identifier`; there is no fold, self-reference or generator; so depth is a **syntactic** property, hard-capped by cel-go's parser recursion limit of 250 (`expression recursion limit exceeded: 250` at depth 260, depth 200 still compiles). That a fixed `k` really is an approximation when the data outruns it is measured too: on a 4-cycle the `k=3` form evaluates `false` and the `k=4` form `true`. **Rego answers the unbounded question with `graph.reachable` — pure and deterministic — and that single gap is the whole justification.** **An input that is in contract and available today, over which CEL can only approximate and Rego decides, is exactly the per-rule evidence D-017's gate demanded — so E11 has TWO unconditional justifications (folds and graphs), neither waiting on any open question.** Deliverability caveat, stated because this row makes the same distinction for sub-shape B1: **no provider in the corpus ships an encoded adjacency today** — that one *can* follows from the provider contract, the same inference used to strike B1, not from a shipped example. Options considered: **(a)** record all four shapes as exceeding, the reading D-141's unlock invites (rejected — three are demonstrably expressible and an unfalsifiable ceiling claim is the defect this story exists to prevent); **(b)** strike set-difference **conditionally**, so that a narrowing answer to OQ-35 would return it to the ceiling (rejected on the second pass — both resolutions strike it: extending the entry binding makes CEL express it, narrowing makes it an input-availability failure that REQ-E11-S05-01 hands to Rego unchanged, so a conditional would have left a downstream story waiting on an answer that cannot change the verdict); **(c)** strike cross-manifest and set-difference **unconditionally**, strike half of multi-pass, keep folds and in-input graphs **both unconditionally** **(chosen)**. **Two rejected drafts are recorded because the same failure produced both, with the sign flipped, and only independent review caught either.** Draft 1 made the graph shape *contingent* on OQ-35/OQ-36 on the premise that "an adjacency needs a mapping-valued fact" — it does not; a set of encoded strings carries one inside the frozen declaration — which came from conflating a *provider-supplied catalog* (sub-shapes B1/B2, struck here as working and shipped) with **B3, which is specifically same-changeset cross-file *diffs***. It would have advised the operator to defer a 14-story epic on a premise that does not hold. Draft 2 replaced that with a second "independent reason" — that CEL cannot decode an edge, killing even a bounded two-hop check — **refuted by execution** (the encode-and-compare leaf above). Draft 3 replaced *that* with the exponential-cost claim above — **refuted by measurement**. The common cause across all three is one habit: **reaching for a second independent reason when one sufficient reason was already in hand**, and then applying an **asymmetric evidentiary standard** to protect it — accepting awkward-but-working spellings when they *struck* a shape (A2's re-derivation, B2's dynamic index) and rejecting them when they would have *narrowed* one this record wanted to keep. The verdict was right in all four drafts; the supporting argument was wrong in three. It now rests on one measured property and no supporting argument at all, which is the form it should have taken first. The correction to A2 runs the same way: the value binder `[expr].all(v, …)` removes the evaluation-cost half of A2's residual, so **A2 is struck more firmly than the first draft struck it**. Binds later stories: **S05** must not be widened to carry cross-manifest data; **S07**'s violation shape must support a fold result and a path/cycle witness, not a cross-manifest reference; **S12** must correct ADR-0002's `rego` bullet, which calls the tier an "escape hatch for **cross-entry checks**" — inaccurate for the shipped input contract; **S11** must label the unquarantined example a shape illustration, never evidence of need. **Binds E11-S04, whose denylist is not yet written: `graph.reachable` MUST be allowed**, with `split` alongside it — both pure and deterministic. `graph.reachable` is the one that carries the justification (it closes the graph at any depth); `split` is a convenience for rebuilding the adjacency. A capability set drafted from "deny anything unfamiliar" would strike out the epic's own strongest justification. Recorded in the S04 section of the epic spec, not only S01's. **That floor is held by REVIEW, not by a gate, and this row says so rather than inventing an enforcement path:** REQ-E11-S04-02's committed golden detects a *change* to the allowed set (so the sandbox cannot silently widen) and cannot detect an *omission* — a golden written without `graph.reachable` stays green forever. It says nothing about judgment call (d), since *which* builtins are callable is orthogonal to *where* the evaluator lives. Two residuals raised, neither decided, **neither gating any verdict here**, and neither the escalated rule-7 (d1)/(d2) question: **OQ-35** (the `entry`/`oldEntry` binding asymmetry — a silent `assent test` / `assent run` divergence; set-difference is struck either way and the graph shape needs no entry tree) and **OQ-36** (the frozen provider declaration has no object/map type, yet the authoring surface and `builtin/repo-file` together permit a mapping-valued fact). Also flagged, not edited (out of lane): `REQ-DEM-S05-04` calls the provider-visible tree the "merged-result checkout"; the code reads `/head`, documented at `cmd/assent/checkout.go:44` as "the MERGE-REQUEST HEAD". This lane writes no Go, adds no dependency and touches no schema. Revert: delete `docs/planning/rego-tier-ceiling.md` and the D-156 narrowing text from the E11 spec — which restores an epic justified by four shapes, three of which this record shows are not real. | +| D-157 | 2026-08-23 | **"Reaches pull requests" is now a SHARED, structural check (`hack/lib/pr_reach.sh`), the three PR-visible text gates CROSS-PIN each other's steps, and the fixed-string step check is replaced by an anchored `run:`-command match (GATES3).** Three residuals, each *demonstrated* by an independent reviewer on an earlier lane and each deliberately left unfixed there, all living in the same three files — `hack/lint/workflow_pins_test.sh`, `hack/audit/aud2_exitgate_test.sh`, `hack/examples/dogfood_wiring_test.sh`. **(1) The substantive one.** Two of the three answered "am I reachable on a PR?" with `grep -qE '^[[:space:]]+pull_request:'` over the workflow's `on:` block; a reviewer added `paths: ['internal/**']` under the trigger and measured **rc=0** — the trigger is present, the grep is satisfied, and the gate is disarmed for every PR that touches only `Taskfile.yml` or `.github/workflows/**`, which is exactly the class of PR these gates exist to grade. `types: [closed]` is the same defect by another route, and the grep's `[[:space:]]+` is indent-blind, so a `pull_request:` nested under `workflow_call.inputs` satisfied it too. **The third gate did not answer the question at all** — `workflow_pins_test.sh` asserted its step was present and undisarmed and never that the workflow carrying it runs on a PR; this is new coverage there, not a strengthening, and the brief that commissioned this lane assumed otherwise. **Decision on meaning:** a workflow "reaches PRs" when it has a top-level `pull_request:` trigger — a key of `on:` itself, at 2-space indent — whose sub-block carries **only keys from GitHub's five** (`types`, `branches`, `branches-ignore`, `paths`, `paths-ignore`; a sixth is **refused**, not guessed at), with **no** `paths:`/`paths-ignore:`, a `types:` list that is absent or a **superset** of GitHub's defaults (`opened`, `synchronize`, `reopened`), and no branch filter excluding `main` — **each read at whatever indent it is written at, and with its block sequence read whether the items are indented under the key or written FLUSH with it**, because the sub-block extraction is already scoped to the trigger and an indent pin bought no isolation the scoping did not already provide; branch and type names are compared as WHOLE tokens split on YAML's own separators, so `main-next` and `release/main` are not `main` — and when the target job carries neither a job-level `if:` **nor a job-level `needs:`** (a dependency that skips on PRs skips its dependents, defeating all three gates at once while every other assertion stays green), and the gate's own step carries no `if:`/`continue-on-error:` **in either the mapping-key or the sequence-item position** (` - if:` is the step's first key and is a sequence item). **Deliberately still accepted**, stated so a reviewer does not read the narrowing as wider than it is: `on: pull_request` and `on: [push, pull_request]` (the inline forms cannot carry a filter, so they are strictly safer than the block form), the quoted `"on":` key, a `types:` superset, `branches:` listing the literal `main`, `branches-ignore:` sparing `main`, block-scalar `run: |` invocations, arbitrarily long comment blocks inside a gate step, and step-level `if:` on *other* steps (`verify.yaml`'s changelog gate is legitimately PR-excluded). **Deliberately narrower than GitHub:** a `branches:` glob covering main without the literal token (`['ma*']`) reds, because a bash gate cannot honestly evaluate GitHub's glob semantics, and a flow mapping or anchor in the `on:` block is **refused** (distinct code) rather than guessed at. **(2)** Each gate pinned only its OWN step, so a PR deleting one of the other two was invisible to the gate that still ran — caught locally by `task check` and on push by `release-exitgate`, hence a gap and not a hole. Each gate now asserts all three steps; the mutation matrix is the full cross-product (3 deleted steps × the 2 observers that still run) rather than a diagonal. **(3)** `aud2_exitgate_test.sh`'s `check_pr_wiring` located its step with `grep -nF -- "$SELF"` — a search for the script *name*, which the step's own comments satisfy. Measured by the reviewer: with the `run:` line commented out it still failed, but reported **"WITH ARGUMENTS"** instead of "does not invoke", sending an operator to look for an argument that does not exist. The anchored form from `dogfood_wiring_test.sh` is ported back and shared. **Why one lane and one helper, not three fixes:** the INBOX residual explicitly asked for a single shared helper so the next gate author inherits the strong check rather than copying the weak one; fixing one of three identical checks would have been misleading. **Judgment calls.** (a) The helper is **codes-only and prints nothing** — every caller owns its wording, because roughly twenty existing `expect_red` controls pin stderr fragments. (b) The 1..6 **line cap** `workflow_pins_test.sh` used to bound step isolation is replaced by a structural invariant — exactly one `run:` key and no `uses:` key, both of which actionlint itself flags — because cross-pinning made the helper isolate the aud2 and dogfood steps, whose comment blocks are a dozen lines long, and no cap admits an honest step of theirs while rejecting a merged one. (c) The helper is held to `workflow_pins_test.sh`'s preamble rules (**bash 3.2**, portable ERE, no `git`, no `grep -q` on a pipe) rather than aud2's ≥4.3 floor: that gate runs first in CI before any toolchain, and the other two callers do not refuse bash 3.2, so a bash-4 construct here would silently weaken them on a maintainer's machine. (d) It is a **line-oriented reader with a structural bias, not a YAML parser** — it says so in its own header and fails closed on anchors, aliases and flow mappings, because an honest narrow gate beats an overreaching one. (e) No new `check:` stage and `CHECK_STAGES` untouched: all three gates were already wired. **Evidence:** 44 helper-level probes, 20 host-independent fixture self-checks carried inside two of the gates, and end-to-end mutation of a scratch copy of the REAL `verify.yaml` driven through all three real gate scripts (the layer that found (ii) and (iii); fixtures alone did not) plus, in the gates themselves, 13 new red controls in aud2 (`CONTROL_FLOOR` 38→51, text-only 32→45, both floors met exactly), 15 in `workflow_pins_test.sh` and 12 in `dogfood_wiring_test.sh` — each pinned to its own finding text or rc — and, in every gate, **green** controls for the legitimate shapes above, so the new checks are provably not always-red. **Eight fail-opens were found by building mutants rather than by reasoning — six of them in this lane's own code, across five review rounds — and the record names every one, because the lane's value is the mutants, not the prose.** (i) *This lane's own widening:* accepting a block-scalar `run: |` invocation naively means a bare `bash