Skip to content

AUD-S09 + AUD-S14: pin task, lockfile the ajv validator, scrub checkout credentials, unstrip the release header - #25

Merged
konih merged 14 commits into
mainfrom
lane/aud-s09-s14-workflow-pins
Aug 8, 2026
Merged

AUD-S09 + AUD-S14: pin task, lockfile the ajv validator, scrub checkout credentials, unstrip the release header#25
konih merged 14 commits into
mainfrom
lane/aud-s09-s14-workflow-pins

Conversation

@konih

@konih konih commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes the two remaining supply-chain pin findings from the 2026-08-06 audit, plus the release-body defect flagged 🔴 READ BEFORE TAGGING in the session INBOX.

Four commits, three logical changes plus the mandatory changelog regeneration. No Go code is touched — the diff is CI configuration, two shell gates, and a committed npm lockfile.


1. bfd6606 — AUD-S09 / SEC-04: pin the Task version in verify.yaml

Both verify: and release-exitgate: installed Task with a floating upstream tag, so a mutable release could change gate behaviour — or supply-chain posture — under CI with no diff in this repo. The version is now defined once, at workflow scope (env: TASK_VERSION: v3.52.0), and both install sites interpolate it, so the two jobs cannot skew either.

Spec conflict, reconciled explicitly rather than silently. AUD-S09's acceptance criterion says "every hit carries the pinned version", while its Goal mandates single-sourcing through a workflow-level env. These pull in opposite directions: with task@"${TASK_VERSION}" the install line carries no literal semver. The Goal wins, and the gate asserts the AC's intent instead:

  • zero floating tags anywhere under .github/workflows/**,
  • every cmd/task@ site interpolating ${TASK_VERSION},
  • exactly one workflow-level definition of it, as a literal vX.Y.Z.

v3.52.0 was verified as a real pin, not just a plausible string: go install …@v3.52.0 succeeds and hack/compare/exitgate_test.sh passes under that binary.

2. b9b5dfe — AUD-S14 / SEC-01 + SEC-03

SEC-01 — the schemas job installed its stock Draft 2020-12 validator with npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1. Only the two roots were pinned; every transitive dependency was resolved from a semver range at run time, so what CI validated the frozen schemas with could change without a diff here. Now hack/schemas-validator/{package.json,package-lock.json} are committed and CI runs npm ci --ignore-scripts27 packages, every one hash-pinned.

Two consequences worth spelling out:

  • hack/validate-schemas-stock.sh now prefers the lockfile-pinned binary over any globally installed ajv, warning when it must fall back. Without this, CI would install the pinned tree and then validate with whatever ajv happened to be on PATH.
  • schemas.yml's two paths: filters gain hack/schemas-validator/**, and Dependabot gains an npm ecosystem for that directory. A lockfile that never triggers the job consuming it, and that nothing updates, is a pin that rots into a stale-CVE pin.

SEC-03persist-credentials: false on the three checkouts that lacked it (verify.yaml both jobs, schemas.yml), matching the seven that already had it. None of those jobs pushes; the exit-gate scripts were grepped for git push/gh first and are clean.

3. df679ee — release-body fix (INBOX 🔴 pre-tag blocker)

release.yaml ran git-cliff with --strip header, and cliff.toml's changelog header is exactly where the long-lived Compatibility notes live. The D-120 warning — pins.toolDigest changes value after v0.1.0, so a mismatch across that boundary means "derived differently", not "different build" — reached CHANGELOG.md and never reached the GitHub Release body. Consumers reading only the Release page got no warning at all.

The header is the only possible home for such notes: CHANGELOG.md is regenerated in full from commit history, so a hand-edit there is wiped by the next task changelog-write. The fix is therefore to stop stripping it — the INBOX item's own first option — not to duplicate the note.

Verified by rendering the body locally with the pinned git-cliff v2.13.1; hack/release/README.md is updated to match.

4. 24a77f0CHANGELOG.md regeneration


Gate anti-fragility evidence

This lane's deliverable is largely negative grep assertions in a shell gate — the shape that most often ships green while testing nothing. Every countermeasure below is enforced on every run, not just once at authoring time.

Mutation testing — 31 controls, built into the gate

hack/lint/workflow_pins_test.sh is built as check functions over a workflows directory. Each runs twice: against the real tree (must be GREEN) and against a temp copy carrying the very violation it exists to catch (must be RED). Output of a normal run:

OK: mutation control — check_no_latest goes red on: a Task install reverted to @latest
OK: mutation control — check_task_pinned goes red on: an install site stopped interpolating ${TASK_VERSION}
OK: mutation control — check_task_pinned goes red on: an install site hard-codes its own version (the two jobs can skew)
OK: mutation control — check_task_pinned goes red on: TASK_VERSION is defined but is not a literal vX.Y.Z
OK: mutation control — check_task_pinned goes red on: the workflow-level TASK_VERSION definition was deleted
OK: mutation control — check_persist_credentials goes red on: verify.yaml checkout(s) stopped scrubbing credentials
OK: mutation control — check_persist_credentials goes red on: the schemas.yml checkout stopped scrubbing credentials
OK: mutation control — check_persist_credentials goes red on: release.yaml checkouts stopped scrubbing credentials (the highest-privilege job)
OK: mutation control — check_npm_ci_in_workflow goes red on: the job reverted to a run-time 'npm install' (unpinned transitives)
OK: mutation control — check_npm_ci_in_workflow goes red on: the validator directory dropped out of the paths: filters
OK: mutation control — check_validator_lockfile goes red on: package-lock.json was deleted
OK: mutation control — check_validator_lockfile goes red on: package.json loosened an exact pin into a caret range
OK: mutation control — check_validator_lockfile goes red on: a resolved package lost its integrity hash

Every mutation is asserted to have landed before its red is believed — a no-op sed would otherwise make the mutant identical to the clean tree and "prove" a check fires when nothing changed. This is not theoretical: it caught a defect in this very PR. The $-anchored sed used to delete a persist-credentials line silently matched nothing once that line grew a trailing comment:

FAIL: mutation harness: sed program '/^          persist-credentials: false$/d' did not change
  …/mutant-persist-schemas/schemas.yml — the mutant is identical to the clean tree, so any
  'the check fires' conclusion is false

TDD red-first, on the real findings

The gate was written before the fixes and reded naming exactly the sites the audit identified:

    verify.yaml:67: run: go install github.com/go-task/task/v3/cmd/task@latest
    verify.yaml:98: run: go install github.com/go-task/task/v3/cmd/task@latest
    schemas.yml:48: actions/checkout without `persist-credentials: false`
    verify.yaml:25: actions/checkout without `persist-credentials: false`
    verify.yaml:99: actions/checkout without `persist-credentials: false`

It also immediately reded on prose I had just written that contained the banned token — fixed by rewording the comment and by making the Task-site check skip comment lines, never by weakening the ban.

Release-body fix — behavioural check, mutation-proven

hack/release/changelog_gate_test.sh §6 is not a text assertion about release.yaml. It extracts the git-cliff step's args: from the workflow (step-scoped — args: also appears in both goreleaser steps), renders the release body with those exact arguments using the pinned git-cliff, and greps the result. Positive controls guard the render (non-empty, carries a real ## [X.Y.Z] section); a polarity control re-renders with --strip header and requires the note to disappear, so the check cannot pass for an unrelated reason.

Mutation — restoring --strip header in release.yaml:

== 6. the rendered GitHub Release body carries the compatibility notes ==
OK: release.yaml renders the body with: git-cliff --latest --strip header
FAIL: the rendered GitHub Release body carries no pins.toolDigest compatibility note — the
  D-120 warning reaches CHANGELOG.md but NOT the Release page (drop '--strip header' from the
  git-cliff step in release.yaml)

Linux / GNU grep + mawk

macOS BSD grep honours \t in an ERE; GNU grep does not, so grep -qE '\t' silently never matches — and as a negative assertion it then passes OPEN. Run under Debian:

$ docker run --rm -v "$PWD:/w" -w /w debian:stable-slim bash -c '…'
grep (GNU grep) 3.11
mawk 1.3.4 20250131
…
workflow_pins_test.sh: PASS
EXIT=0

All 31 mutation controls go red under GNU grep too — that is the part that matters, since it proves the patterns actually match there. The container has no git and the repo is a worktree (.git is a file), which also exercises the ${BASH_SOURCE} root resolution.

Constructs deliberately avoided, each a real divergence or flake source:

  • no \t, \s, \b, no grep -P — portable ERE and [[:space:]] only;
  • ${…} matched with grep -F, since braces are interval syntax in an ERE where GNU and BSD disagree;
  • no grep -q on the read end of a pipe (SIGPIPE → exit 141 under pipefail, which flaked 10/20 on Linux in a previous lane) — every match goes to a file first;
  • no sed -i (BSD requires an argument, GNU must not have one) and no git invocation;
  • checkout parsing is step-scoped, not file-scoped — a file-wide grep passes as soon as one checkout in the file sets the flag — and an ambiguous parse splits the step early and reports a violation, i.e. it fails closed;
  • every extraction carries a positive control (≥8 workflow files with all 8 names present, ≥10 checkout steps, ≥2 Task install sites, ≥20 hash-pinned lockfile entries), so a pattern that stops matching fails loudly instead of sweeping an empty set.

SEC-01 verified against the local install, not a global leak

The machine has a global ajv, which would have made "the lockfile works" indistinguishable from "the global one served". With PATH carrying no ajv at all:

$ PATH="/usr/bin:/bin:/usr/sbin:/sbin:<node>" bash hack/validate-schemas-stock.sh
EXIT=0   (warnings: 0)   stock-validator check passed (P3-P1-3)

Zero fallback warnings ⇒ the lockfile-pinned binary was used. Hiding node_modules flips it to the warning branch, confirming the A/B is real and not a constant.

The lockfile was also proven to reproduce from scratch, which the generation step alone does not show — --package-lock-only resolves metadata without ever fetching a tarball, whereas npm ci verifies every integrity hash against real downloads:

$ rm -rf hack/schemas-validator/node_modules
$ npm ci --ignore-scripts --prefix hack/schemas-validator
added 27 packages, and audited 28 packages in 802ms        # exit 0, no EINTEGRITY
$ PATH=<no ajv anywhere> bash hack/validate-schemas-stock.sh
EXIT=0   (warnings: 0)   stock-validator check passed (P3-P1-3)

Wiring — proven non-self-referentially

A gate that checks whether task check invokes it is unreachable precisely when the answer is "no". So lint-workflow-pins-test was added to hack/release/changelog_gate_test.sh's WIRED_TASKS, reached through a different check: entry, which already re-runs each wiring assertion against a Taskfile copy with the line deleted:

OK: check runs lint-workflow-pins-test (and lint-workflow-pins-test is defined)
OK: deleting '- task: lint-workflow-pins-test' from check: turns the assertion red

And the end-to-end proof, with the wiring line actually deleted from check::

$ task check
FAIL: 'task check' does not run 'lint-workflow-pins-test' — the gate is defined but invoked by nothing
TASK CHECK EXIT=201

Restored, and task check is green again. The gate is additionally wired into the verify: job as its first toolchain-free step, so an unpinned workflow reds a PR in seconds; the gate asserts that step's presence itself, and task check runs locally either way.


Gate matrix

Gate Result
task check (full, final tree) ✅ exit 0
actionlint (all 8 workflows) ✅ exit 0
bash hack/lint/workflow_pins_test.sh ✅ PASS (31 mutation controls)
…same, under debian:stable-slim (GNU grep 3.11 / mawk) ✅ PASS
bash hack/validate-schemas-stock.sh (global ajv shadowed) ✅ exit 0
bash hack/release/changelog_gate_test.sh ✅ PASS incl. new §6
bash hack/release/verify_tag_gate_test.sh ✅ PASS (step order intact)
gitleaks v8.30.1 over HEAD ✅ no leaks (lockfile integrity hashes are not false positives)
go install …/task@v3.52.0 + hack/compare/exitgate_test.sh ✅ the pin is installable and the gates pass under it

Confirmed on real CI (not just locally)

All checks green: verify, schema-validation, Lint workflow files (actionlint), CodeQL (go + actions), MkDocs, goreleaser snapshot. The three steps this PR is actually about:

verify: step Result
3 · workflow supply-chain pins (AUD-S09 — SEC-04) ✅ success — the new gate runs on ubuntu-latest/GNU grep before setup-go, needing no toolchain
17 · install Task (exit gate scripts) ✅ success — task@"${TASK_VERSION}" resolves and installs
schema-validation job ✅ success in 32s — npm ci --ignore-scripts --prefix hack/schemas-validator behaves as expected under Actions' Node 22

Note on one flake, unrelated to this branch: the first full task check hit TestExecDigestPin/match_allows_exec"signal: killed" in internal/provider. It passes in isolation and on re-run, and this branch touches zero Go files. Pre-existing flake, worth a separate look.


Residual risks (reported, not silently accepted)

  1. fast-json-patch@2.2.1 / GHSA-8gh8-hqwg-xf34withdrawn and fixed in 99661a2. The original text claimed the advisory was "not overridable in-range"; that premise was wrong (npm overrides exists to bypass a transitive's declared range). npm audit now reports 0 vulnerabilities, so no acceptance decision row is needed and no Dependabot alert will land against the tagged release.
  2. cliff.toml's footer (## [0.0.0] — pre-release development history) was already being appended to every Release body and still is. Out of scope for the INBOX finding; worth a separate decision about whether a per-release body should carry it.
  3. TASK_VERSION must now be bumped by hand. Dependabot does not update a go install pin inside a run: step. The gate enforces that it is a literal version, not that it is current.

Review round 1 — REQUEST CHANGES addressed (f2ee0a5, 99661a2, 8e4baa7)

Rebased onto 277b0ce via gh pr update-branch --rebase (server-side, no force-push). All findings fixed; both P1s were real.

F1 (P1) — comment-blind check_persist_credentials failed OPEN

Reproduced before fixing, under GNU grep 3.11 + mawk:

          # persist-credentials: false  # TEMPORARILY DISABLED
$ bash hack/lint/workflow_pins_test.sh
OK: all actions/checkout steps scrub the workflow token
EXIT=0          # token left in .git/config, gate green

After: EXIT=1, actions/checkout without an ACTIVE persist-credentials: false (a commented-out flag is not a flag).

The reviewer's framing is the important part — check_task_pinned already skipped comments, so this was a failure mode understood in one function and not carried to the others. The fix is therefore structural, not local: a shared code_view helper plus an audit of every check in the file, classified by polarity:

Check Assertion type Comment-blind? Direction Action
check_persist_credentials presence yes — F1 fail OPEN fixed, + control
check_npm_ci_in_workflow presence yes — F5 fail OPEN fixed, + control
check_ci_wiring (§5) presence yes — F6 fail OPEN fixed, + 2 controls
check_task_pinned (sites) presence no (already guarded) moved onto code_view
check_task_pinned (defs) presence no see F3
check_no_latest absence yes, deliberately fail CLOSED kept blunt, reasoning now written down
check_validator_lockfile presence n/a — strict JSON has no comments stated so it isn't re-derived

Each of F1/F5/F6 was reproduced as a live fail-open first and re-verified red after.

F2 (P1) — the CVE was fixable; my premise was wrong

I claimed fast-json-patch ^2.0.0 was "not overridable in-range." npm overrides exists to bypass a transitive's declared range, so the constraint I treated as binding never was. Verified myself rather than trusting the review transcript:

$ npm install --ignore-scripts --package-lock-only     exit 0   lockfile 304 → 292 lines
$ npm ci --ignore-scripts                              exit 0   added 26 packages, no EINTEGRITY
$ npm audit                                            exit 0   found 0 vulnerabilities      (was 2 high)
$ bash hack/validate-schemas-stock.sh   (no ajv on PATH at all)  exit 0, 0 fallback warnings
$ bash hack/lint/workflow_pins_test.sh                 PASS

The lockfile shrinks because 3.x drops fast-deep-equal. Guarded against silent reversal in both halves — the gate asserts package.json still declares the override and that the locked tree resolves 3.x — because dropping the override alone sends the next regeneration straight back to 2.2.1. 20th mutation control added for it.

The acceptance rationale is withdrawn, not reworded. No decision row is needed for a CVE that can simply be fixed, and no Dependabot alert will land against the tagged release.

F7 (P3) — my reachability claim was also wrong: fast-json-patch is required at module load by dist/commands/validate.js, the exact subcommand this repo runs, not only by diff/test. The conclusion survived for a different reason (ajv-cli only calls jsonPatch.compare(), never applyPatch/applyOperation, where the advisory lives). Moot now, but recorded: a wrong rationale that reaches the right answer is still wrong.

F3, F4, F5, F6 (P2/P3)

  • F3 — only 2-space TASK_VERSION: definitions were counted, so a job-level env: at 6-space indent silently overrode the workflow-level pin — precisely the skew the story exists to prevent. Now: definitions counted at any indent, exactly one required, and that one must be workflow-scoped. Reproduced as a fail-open, now red.
  • F4 — the headline "step-scoped, not file-scoped" claim had zero coverage: all three persist mutants deleted every occurrence, which a file-scoped implementation reds on identically. Added the discriminating control — delete only the second occurrence in release.yaml.
  • F5 — besides comment-blindness, npm[[:space:]]+install never matched npm --global install (nor npm i, nor npm add). Enumerating bad forms is a losing game, so the polarity is inverted: every npm invocation must be an npm ci, anything else is a finding.
  • F6 — §5's wiring check was a straight-line grep -Fqs with no control, the one shape this file's own preamble forbids. Now a check function with two controls (deleted, commented out).

expect_red now pins the reason, permanently

The reviewer verified each control reds for its own stated reason by patching expect_red locally. That property is now enforced on every run: each control passes a required fragment of the finding text, and a control that reds because an extraction broke or a file went missing is no longer accepted as proof the mutation was detected.

13 → 20 controls, all green locally and under GNU grep.

On grep flavours — the reviewer's ugrep lesson applied to my own machine

Checking rather than assuming turned up that this machine's interactive shell shims grep to ugrep 7.5.0 via a shell function. It is not exported, so a bash script.sh child does not inherit it — verified:

$ bash -c 'type -t grep; grep --version | head -1'
file
grep (BSD grep, GNU compatible) 2.6.0-FreeBSD

So the gate runs were genuinely BSD grep 2.6.0-FreeBSD + awk 20200816 locally, and GNU grep 3.11 + mawk 1.3.4 under debian:stable-slim. Two real, named targets — ad-hoc greps I typed at the prompt did go through ugrep, which is worth knowing but does not touch the gate.

Gates re-run after the fixes

task check exit 0 (fully green, changelog-verify included) · actionlint exit 0 · workflow_pins_test.sh PASS with 20 controls, locally and under GNU grep · validate-schemas-stock.sh exit 0 with ajv fully off PATH · npm audit 0 vulnerabilities · gitleaks no leaks · tree is changelog-clean after task changelog-write.

TestExecDigestPin flake not observed this round; it is being fixed in its own lane.

One item referred back rather than actioned

Reviewer F8 (P3) suggests indexing the AUD-S09 AC-vs-Goal reconciliation as a decision row. Not done here: docs/decisions/decisions.md is being edited concurrently by another session with D-126/D-127 in flight, so this lane does not touch it. Referred to the coordinator — it would need D-128 or higher.


Review round 2 — N1/N2/N3/N4 addressed (68ec454, a59539c, e4483fb)

N1 (P2) — code_view did not close the class it was credited with closing

You were right, and the previous commit message overclaimed. code_view drops a line only when its first non-blank character is #, so the searched-for text hiding in a live line's comment tail still satisfied every presence check. All three reproduced at exit 0, actionlint clean, before fixing:

Bypass Before After
fetch-depth: 1 # was: persist-credentials: false exit 0, OK: all actions/checkout steps scrub the workflow token exit 1, checkout step(s) leaving the workflow token in .git/config
run: npm --global install ajv-cli # was: run: npm ci --ignore-scripts … exit 0, OK: schemas.yml installs the validator with 'npm ci --ignore-scripts'SEC-01 fully inert exit 1, invokes npm in a form other than 'npm ci'
run: echo skipped # was: bash hack/lint/workflow_pins_test.sh OK: verify.yaml runs … printed exit 1, does not RUN … as an actual command

Extending the class rather than patching the three named sites found a fourth, unreported instance in check_task_pinned: cmd/task@v3.0.0 # cmd/task@"${TASK_VERSION}" satisfies the interpolation check and carries no floating tag, so it passed both checks and re-opened precisely the job-to-job skew SEC-04 exists to prevent. Fixed and controlled alongside the others.

The fix is structural rather than three anchors. A new command_view yields what actually executes — inline comment tail removed (YAML and shell agree: whitespace then #), then the list dash and run: key stripped — and presence checks anchor on that, so a comment tail is structurally unable to satisfy them. persist-credentials keeps a line-based parse with the anchored form you suggested. Confirmed beforehand that no workflow line carries # inside a quoted value, the only case where tail-stripping could lose real content.

N2 (P2) — present ≠ enabled

continue-on-error: true on the gate step left this gate and actionlint green while the CI half was neutered. Now the step's own block must carry neither continue-on-error nor if:; both variants have controls. (Confirmed your observation: if: false is caught by actionlint, continue-on-error is the silent one.)

N4 (P3) — lockfile half of the F2 guard now controlled

21st control rewrites the lock to fast-json-patch-2.2.1.tgz with the override still declared — what a careless regeneration actually produces — and requires does not resolve fast-json-patch to a 3.x release.

N3 (mandatory) — up to date with a2a2b17

gh pr update-branch --rebase failed with a conflict exactly as you predicted. Merged origin/main locally instead (no force-push); only CHANGELOG.md conflicted. The merge commit is named :wrench: chore(release): merge …, matching the convention main's own lane merges already use — cliff.toml's first commit_parser skips that prefix, so the merge subject does not leak into CHANGELOG.md (verified: 0 occurrences). task changelog-write is the last commit, and task changelog-verify is green on e4483fb, which is 0 commits behind origin/main.

One more anti-vacuity save, recorded because it is this file's whole purpose

While writing the N1 fix, the first version of the npm extraction anchored on :[0-9]+: — a prefix the single-file view does not emit (only the directory view does). It matched nothing, so "every npm invocation must be npm ci" swept an empty set and passed, over a tree containing a live global install. Only a sibling assertion failing exposed it. The extraction now carries its own positive control (an npm command must be found in schemas.yml), so that shape fails loudly. Same bug class as N1, found in my own fix for N1.

20 → 27 mutation controls.

Gates re-run

task check exit 0 to completion · actionlint exit 0 · workflow_pins_test.sh PASS with 27 controls under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU grep 3.11 + mawk 1.3.4 · validate-schemas-stock.sh exit 0 with ajv fully off PATH, 0 warnings · npm audit 0 vulnerabilities · gitleaks no leaks · task changelog-verify exit 0 · tree clean.

On your correction about the flake: it fired on the first task check of this round, which aborted the run before coverage, build, dogfood, compare-exitgate and changelog-verify. Re-ran the whole matrix to completion — the exit 0 above is from a run where all of those actually executed (27 controls and the coverage table both present in the log). Not fixed here; it is its own lane.


Re-based onto 9e50e17 and re-verified (53dc69a, 2b65620)

main advanced 15 commits (AUD-S10..S12 forge-hardening, PR #26) while this lane sat. gh pr update-branch --rebase failed on conflict again, so origin/main was merged in locally — no force-push. Only CHANGELOG.md conflicted; the merge commit uses the :wrench: chore(release): merge … subject that cliff.toml's first parser skips, so it does not leak into the changelog (verified 0 occurrences). task changelog-write is the last commit. Branch is 0 commits behind origin/main.

All 14 task check stages confirmed executed — not inferred from the exit code

The TestExecDigestPin flake fired on the first run and aborted at stage 4, so stages 5-14 never ran. Re-ran to completion and verified each stage by its own output banner:

RAN  1 fmt          RAN  6 build                     RAN 11 release-verify-tag-gate-test
RAN  2 vet          RAN  7 dogfood-comparison        RAN 12 docs-gates
RAN  3 lint         RAN  8 compare-exitgate-test     RAN 13 lint-depguard-test
RAN  4 test         RAN  9 changelog-verify          RAN 14 lint-workflow-pins-test
RAN  5 coverage     RAN 10 release-changelog-gate-test
---
ALL 14 STAGES EXECUTED

N1 / N2 bypasses re-verified against the merged tree

Bypass Result
fetch-depth: 1 # was: persist-credentials: false exit 1 — checkout step(s) leaving the workflow token in .git/config (SEC-03)
run: npm --global install ajv-cli # was: run: npm ci … exit 1 — invokes npm in a form other than 'npm ci'
run: echo skipped # was: bash hack/lint/workflow_pins_test.sh exit 1 — does not RUN … as an actual command
continue-on-error: true on the gate step (N2) exit 1 — present but DISARMED, while actionlint exits 0 on the same tree

That last row is the point of N2: actionlint is silent, so this gate is the only thing standing between "unblock CI" and a permanently inert pin.

Every control still reds for its own pinned reason — and that enforcement is itself proven

expect_red requires each control to produce a specific fragment of its finding text. To show that requirement is load-bearing rather than decorative, one fragment was replaced with a string that can never appear:

FAIL: mutation control: check_persist_credentials went red on 'verify.yaml checkout(s) stopped
scrubbing credentials' but NOT for its stated reason — the finding was expected to mention:
THIS FRAGMENT WILL NEVER APPEAR

27 controls, all green under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU grep 3.11 + mawk 1.3.4.

Gates on the merged head

task check exit 0 with all 14 stages executed · actionlint exit 0 · workflow_pins_test.sh PASS ×27 under both greps · validate-schemas-stock.sh exit 0 with ajv fully off PATH, 0 fallback warnings · npm audit 0 vulnerabilities · task changelog-verify exit 0 · gitleaks no leaks · tree clean.


Review round 3 — N5 (P1) and N6 fixed (630c04d, 62ef5a9)

N5 — the N2 guard was not fixed, and its controls proved the wrong thing

You were right on both halves, and the second is the worse one.

The extraction. inblock switched on at the marker line — the run: line — having already reset at the preceding - name:. The isolated "step" was therefore one line, and everything between - name: and run: was invisible. That is the conventional position for if: and continue-on-error:, the ordering GitHub's own docs use. Reproduced before fixing:

      - name: workflow supply-chain pins (AUD-S09 — SEC-04)
        continue-on-error: true
        run: bash hack/lint/workflow_pins_test.sh

gate:       exit 0
actionlint: exit 0        # completely silent

The controls. Both inserted the key after run:, so the matrix certified the one position that happened to work and never exercised the one that didn't. That is this lane's own recurring failure — an assertion that cannot observe what it claims to — moved up one level, into the control itself.

Secondary defect, also as described. verify.yaml:15 names this script in the file-header comment and nothing reset inblock until the first ^ - at line 25, so the region was 11 lines of which exactly one came from the step:

 1  # hack/lint/workflow_pins_test.sh asserts exactly one such definition exists,
 …
 5  env:
 7  jobs:
10      steps:
11          run: bash hack/lint/workflow_pins_test.sh

The [[ -s ]] control passed on that happily.

Fixed with the two-pass extraction you suggested — from the line the command was actually found on, walk back to the nearest preceding ^ - , then forward to the next. It now yields exactly:

 1        - name: workflow supply-chain pins (AUD-S09 — SEC-04)
 2          run: bash hack/lint/workflow_pins_test.sh

Positive controls upgraded to match: the block must be 1..6 lines, must start with a step marker, and must contain the invocation. Four controls now — continue-on-error and if: in both positions — plus one for the extraction losing its step start. Both before-run: bypasses verified red:

continue-on-error BEFORE run: → exit 1, "present but DISARMED", 2: continue-on-error: true
if: ${{ false }} BEFORE run:  → exit 1, "present but DISARMED", 2: if: ${{ false }}

N6 — precondition turned into a gate

You confirmed the plain-scalar case is genuinely fine, so this is only about block and quoted scalars. Both carriers verified caught by the new predicate:

schemas.yml:69:          echo "stage # 2" && npm --global install ajv-cli
schemas.yml:69:          echo 'stage # 2' && npm --global install ajv-cli

No workflow line may carry # preceded by an unbalanced quote. Zero hits across all 8 workflows, so it landed without touching a workflow file:

schemas.yml 0 · actionlint.yaml 0 · codeql.yaml 0 · docs.yaml 0
release.yaml 0 · scorecard.yaml 0 · verify.yaml 0 · vulncheck.yaml 0     TOTAL 0

Worth recording: the block-scalar carrier was already caught before section 6 even ran, by the npm extraction's own non-empty positive control — the one added after I hit that exact bug during the N1 fix. Defence in depth working by construction rather than luck.

27 → 31 controls, green under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU grep 3.11 + mawk 1.3.4.

Gates — graded on stage banners, not the exit code

The flake fired on two consecutive runs, and a third aborted at changelog-verify (my own new commit's drift). Only the fourth completed. Banner grading is what made that visible rather than guessable:

RAN  1 fmt        RAN  6 build                  RAN 11 release-verify-tag-gate-test
RAN  2 vet        RAN  7 dogfood-comparison     RAN 12 docs-gates
RAN  3 lint       RAN  8 compare-exitgate-test  RAN 13 lint-depguard-test
RAN  4 test       RAN  9 changelog-verify       RAN 14 lint-workflow-pins-test
RAN  5 coverage   RAN 10 release-changelog-gate-test
---
ALL 14 STAGES EXECUTED

I also tightened one banner mid-round: coverage: could match per-package go test output, so it now keys on (required: 90%), which only the coverage task emits. A stage-banner grader that can false-positive is the same bug as everything else in this lane.

task check exit 0 with all 14 stages · actionlint 0 · 31 controls under both greps · validate-schemas-stock.sh 0 with ajv fully off PATH, 0 warnings · npm audit 0 vulnerabilities · task changelog-verify 0 · gitleaks no leaks · tree clean · 0 commits behind origin/main (9e50e17) · task changelog-write is the last commit.

konih added 4 commits August 8, 2026 13:18
…flow env (AUD-S09 / SEC-04)

Both `verify:` and `release-exitgate:` installed Task with a floating upstream
tag, so a mutable release could change gate behaviour — or supply-chain posture
— under CI with no diff in this repo. The version is now defined ONCE, at
workflow scope (`env: TASK_VERSION: v3.52.0`), and both install sites
interpolate it, so the two jobs cannot skew either.

Spec reconciliation (recorded so review does not read it as a silent
deviation): AUD-S09's acceptance criterion says "every hit carries the pinned
version", while its Goal mandates single-sourcing through a workflow-level
`env`. Those conflict — with `task@"${TASK_VERSION}"` the install line carries
no literal semver. The Goal wins, and the new gate asserts the AC's intent
instead: zero floating tags anywhere under `.github/workflows/**`, every
`cmd/task@` site interpolating `${TASK_VERSION}`, and exactly ONE
workflow-level definition of it, as a literal vX.Y.Z.

The gate (`hack/lint/workflow_pins_test.sh`, REQ-AUD-S09-01) is almost entirely
negative assertions, the shape that most often ships green while testing
nothing. It is therefore built as check FUNCTIONS run twice — against the real
tree (must be green) and against temp copies carrying the very violation each
one exists to catch (must be red) — with the mutation asserted to have landed
before its red is believed. Five such controls run on every invocation. Only
portable ERE is used (no \t, \s, \b, no -P: GNU grep ignores \t in an ERE where
BSD honours it, and as a negative assertion that divergence passes OPEN on
Linux while looking green on macOS); no `grep -q` on the read end of a pipe
(SIGPIPE 141 under pipefail); no `git` and no `sed -i`, so it runs in a bare
container and inside a worktree.

Wiring, which is the deliverable and not an afterthought: `task check` runs
`lint-workflow-pins-test`, and the `verify:` job runs the script directly as its
first toolchain-free step. The wiring itself is proven by
`hack/release/changelog_gate_test.sh` — reached through a different `check:`
entry, so it stays red when the wiring line is deleted, which a self-assertion
inside the gate could never be.
…dentials (AUD-S14 / SEC-01 + SEC-03)

SEC-01 — the schemas job installed its stock Draft 2020-12 validator with
`npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1`. Only the two
ROOTS were pinned: every transitive dependency was resolved from a semver range
at run time, so what CI validated the frozen schemas with could change without a
diff in this repo. Now `hack/schemas-validator/{package.json,package-lock.json}`
are committed and CI runs `npm ci --ignore-scripts` — 27 packages, every one
hash-pinned.

Two consequences worth spelling out:
  * `hack/validate-schemas-stock.sh` now PREFERS the lockfile-pinned binary in
    hack/schemas-validator/node_modules over any globally installed ajv, warning
    when it has to fall back. Without that, CI would install the pinned tree and
    then validate with whatever `ajv` happened to be on PATH.
  * `schemas.yml`'s two `paths:` filters gain `hack/schemas-validator/**`, and
    dependabot gains an npm ecosystem for that directory. A lockfile that never
    triggers the job consuming it, and that nothing updates, is a pin that rots
    into a stale-CVE pin.

SEC-03 — `persist-credentials: false` on the three checkouts that lacked it
(verify.yaml both jobs, schemas.yml), matching the seven that already had it.
None of those jobs pushes, so the workflow token has no business surviving in
.git/config where any later step — or anything a dependency's build executes —
could use it.

Gate (REQ-AUD-S14-01/02), extending hack/lint/workflow_pins_test.sh: the
checkout check is STEP-scoped, not file-scoped, because a file-wide grep passes
as soon as one checkout in the file sets the flag; an ambiguous parse splits the
step early and reports a violation, i.e. it fails closed. Twelve mutation
controls now run on every invocation — one of them caught a defect in this very
commit, where the `$`-anchored sed used to delete a persist-credentials line
silently matched nothing once the line grew a trailing comment, which would have
"proven" the check fires while mutating nothing.

Verified with the global ajv fully off PATH, so a leaked global install cannot
be mistaken for a working local one: the pinned validator runs the stock gate
green and prints no fallback warning; hiding node_modules flips it to the
warning branch.

Residual, reported not silently accepted: ajv-cli@5.0.0 requires
fast-json-patch ^2.0.0, and 2.2.1 carries GHSA-8gh8-hqwg-xf34 (prototype
pollution). It is not overridable inside the range, it was already being
installed before this commit, and it is reachable only from ajv-cli's
`diff`/`test` subcommands, which this repo never invokes (only `compile` and
`validate`) over repo-committed inputs. Committing the lockfile makes the
exposure visible to Dependabot rather than creating it.
…elease body

Flagged in the session INBOX as a 🔴 READ BEFORE TAGGING item: release.yaml ran
git-cliff with `--strip header`, and cliff.toml's changelog header is exactly
where the long-lived "Compatibility notes" live. The result was that the D-120
warning — `pins.toolDigest` changes value after v0.1.0, so a mismatch across
that boundary means "derived differently", not "different build" — reached
CHANGELOG.md and never reached the GitHub Release body. Consumers reading only
the Release page, which is most of them, got no warning at all.

The header is the ONLY possible home for such notes: CHANGELOG.md is
regenerated in full from commit history, so a hand-edit there is wiped by the
next `task changelog-write`. So the fix is to stop stripping it, per the INBOX
item's own first option, rather than to duplicate the note somewhere.

Regression cover (hack/release/changelog_gate_test.sh §6, already wired into
`task check` via release-changelog-gate-test) is behavioural, not a text
assertion about the workflow: it EXTRACTS the git-cliff step's `args:` from
release.yaml, renders the release body with those exact arguments using the
pinned git-cliff, and fails if the note is absent. Positive controls guard the
render (non-empty, carries a real `## [X.Y.Z]` section), and a polarity control
re-renders with `--strip header` and requires the note to disappear — so the
check cannot pass for an unrelated reason. Verified by mutation: restoring
`--strip header` in release.yaml turns the gate red naming both the finding and
the fix.

Residual, unchanged by this commit and NOT part of the finding: the cliff.toml
footer ("## [0.0.0] — pre-release development history") was already being
appended to every Release body and still is. Worth a separate decision about
whether a per-release body should carry it.
@konih
konih force-pushed the lane/aud-s09-s14-workflow-pins branch from 24a77f0 to 7cbd124 Compare August 8, 2026 11:18
konih added 10 commits August 8, 2026 13:27
…ate (review F1/F3/F4/F5/F6)

F1 (P1, fail-OPEN) — `check_persist_credentials` matched
`persist-credentials:[[:space:]]*false` on ANY line, comments included. So

    # persist-credentials: false  # TEMPORARILY DISABLED

left the workflow token in .git/config while the gate printed
"OK: all actions/checkout steps scrub the workflow token" and exited 0.
Reproduced here before the fix (exit 0 with the token exposed) and after
(exit 1). What makes this worth more than a one-line patch: `check_task_pinned`
ALREADY skipped comment lines. The failure mode was understood in one function
and not carried to the others, so the fix is structural rather than local —
a shared `code_view` helper, and an audit of every check in the file:

  * PRESENCE assertions ("the flag is set", "the gate is invoked") fail OPEN
    when comment-blind. Three were: persist-credentials (F1), the npm install
    check (F5), the CI-wiring check (F6). All now read through `code_view`.
  * ABSENCE assertions ("no floating tag anywhere") fail CLOSED when
    comment-blind — a mention in prose merely reds the gate. `check_no_latest`
    keeps its blunt total ban deliberately, now with that reasoning written down
    instead of implied.
  * `check_validator_lockfile` reads strict JSON, which has no comments. Not
    affected, and now stated so the next reader does not re-derive it.

F3 (P2) — the "two jobs cannot skew" invariant was not the one being checked.
Only TWO-space `TASK_VERSION:` definitions were counted, so a JOB-level
`env: TASK_VERSION: v3.0.0` at six-space indent silently overrode the
workflow-level pin and the gate still passed. Definitions are now counted at ANY
indent, exactly one is required, and that one must be workflow-scoped.

F5 (P2) — besides the comment blindness, the negative pattern
`npm[[:space:]]+install` never matched `npm --global install` (nor `npm i`, nor
`npm add`). Enumerating bad forms is a losing game, so the polarity is inverted:
every npm invocation must be an `npm ci`, and anything else is a finding.

F4 (P2) — the headline "step-scoped, not file-scoped" claim had ZERO coverage.
All three persist mutants deleted every occurrence in their file, which a
file-scoped implementation would red on identically. Added the control that
discriminates: delete only the SECOND occurrence in release.yaml.

F6 (P3) — the CI-wiring assertion was a straight-line `grep -Fqs` with no
mutation control, the one shape this file's own preamble forbids. It is now a
check function with two controls (deleted, and commented out).

Six new controls, 13 -> 19, and `expect_red` is strengthened for all of them:
each control must now red for its OWN stated reason, pinned by a required
fragment of the finding text. A control that reds because an extraction broke,
or a file went missing, is no longer accepted as proof the mutation was
detected. The review reached this method by patching `expect_red` locally;
making it permanent means it is enforced on every run, not during one review.

Portability, stated precisely because a vague claim here is worthless: the
script sees BSD grep 2.6.0-FreeBSD + awk 20200816 locally, and GNU grep 3.11 +
mawk 1.3.4 under debian:stable-slim. All 19 controls red under both. (The
interactive shell on this machine shims `grep` to ugrep 7.5.0 via a
non-exported function, which a `bash script.sh` child does NOT inherit — the
same trap the review hit, verified rather than assumed.) The two new mutations
that sed cannot express portably — inserting lines, and changing only the Nth
occurrence — go through a new awk-based `mutate_awk` with the same
did-the-mutation-land assertion.
…gh8-hqwg-xf34 (review F2)

The previous commit accepted this advisory as unfixable on a false premise: I
wrote that `fast-json-patch ^2.0.0` was "not overridable in-range". npm
`overrides` exists precisely to bypass a transitive dependency's declared
range, so the constraint I treated as binding was never binding. Corrected, and
the acceptance rationale withdrawn rather than reworded — no D-row is needed
for a CVE that can simply be fixed.

    "overrides": { "fast-json-patch": "^3.1.1" }

Verified end to end on this machine rather than taken from the review:
  * `npm install --ignore-scripts --package-lock-only` → exit 0; the lockfile
    SHRINKS 304 → 292 lines (3.x drops the fast-deep-equal dependency).
  * `npm ci --ignore-scripts` → exit 0, 26 packages added, no EINTEGRITY.
  * `npm audit` → exit 0, **found 0 vulnerabilities** (was 2 high).
  * `hack/validate-schemas-stock.sh` with NO ajv anywhere on PATH → exit 0,
    zero fallback warnings, every schema compiled and every contract fixture
    validated. ajv-cli works unchanged against fast-json-patch 3.x.
  * `hack/lint/workflow_pins_test.sh` → PASS, and under GNU grep 3.11 + mawk.

Correcting the record on reachability too, since the earlier commit stated it
wrongly (review F7): `fast-json-patch` is `require`d at module load by
ajv-cli's `dist/commands/validate.js` — the very subcommand this repo runs —
not only by `diff`/`test`. The earlier conclusion happened to survive for a
different reason (ajv-cli only ever calls `jsonPatch.compare()`, never
`applyPatch`/`applyOperation`, which is where the advisory lives). That
reasoning is now moot, and it is recorded here only because a wrong rationale
that reached the right answer is still a wrong rationale.

Guarded against silent reversal, both halves, because either alone is
reversible: the gate asserts package.json still declares the override AND that
the locked tree actually resolves fast-json-patch 3.x — drop the override and
the next regeneration walks straight back to 2.2.1. New mutation control (20th)
deletes the override and requires the gate to red naming it.

Consequence worth stating: no Dependabot alert will land against the tagged
release, which was the reason the previous commit flagged this as a pre-tag
residual at all.
…N1/N2/N4)

N1 (P2) — `code_view` did not close the class the previous commit credited it
with closing. It drops a line only when the FIRST non-blank character is `#`,
so the searched-for text hiding in a live line's COMMENT TAIL still satisfied
every presence check. All three reproduced here with the gate at exit 0 and
actionlint clean, before fixing:

    fetch-depth: 1  # was: persist-credentials: false
    run: npm --global install ajv-cli  # was: run: npm ci --ignore-scripts …
    run: echo skipped  # was: bash hack/lint/workflow_pins_test.sh

The second is the one that matters: a plausible "just unblock it" edit that
leaves the ENTIRE SEC-01 supply-chain pin inert while the gate reports OK.

Extending the class rather than patching the three reported sites turned up a
FOURTH, unreported instance in `check_task_pinned`:
`cmd/task@v3.0.0  # cmd/task@"${TASK_VERSION}"` passed the interpolation check
and carries no floating tag, so it slipped both checks and re-opened exactly
the job-to-job skew SEC-04 exists to prevent. Fixed and controlled alongside.

The fix is structural, not three anchors: presence checks no longer read raw
lines. A new `command_view` yields what actually EXECUTES — inline comment tail
removed (YAML and shell agree: whitespace then `#`), then the list dash and the
`run:` key stripped — and the checks anchor on that, so a comment tail is
structurally unable to satisfy them. `persist-credentials` keeps a line-based
parse but its match is now anchored with only an optional tail permitted.
Confirmed first that no workflow line carries ` #` inside a quoted value, the
one case where tail-stripping could lose real content.

N2 (P2) — present is not enabled. `continue-on-error: true` on the gate step
leaves both this gate and actionlint green while the CI half is neutered, which
is the most realistic "unblock CI" edit there is and the silent one (`if: false`
disarms it too, but actionlint flags that). The step's own block must now carry
neither. Both variants controlled.

N4 (P3) — only the package.json half of the F2 override guard had a control.
Added the other half: the lockfile rewritten to fast-json-patch-2.2.1.tgz with
the override still declared — what a careless regeneration actually produces.

One more anti-vacuity save worth recording, because it is the exact failure this
file exists to prevent and it happened during this commit: the first version of
the npm extraction anchored on `:[0-9]+:`, a prefix the single-file view does
not emit. It matched nothing, so the "every npm invocation must be npm ci" check
swept an empty set and passed — over a tree with a live global install. Only a
sibling assertion failing exposed it. The extraction now carries its own
positive control (an npm command MUST be found in schemas.yml), so that shape
fails loudly instead of silently.

20 -> 27 mutation controls. Green under BSD grep 2.6.0-FreeBSD + awk 20200816
and GNU grep 3.11 + mawk 1.3.4; actionlint clean.
…lar precondition (review N5/N6)

N5 (P1) — the N2 guard was not fixed, and its controls proved the wrong thing.

The extraction switched `inblock` on at the MARKER line, which is the `run:`
line, having already reset it at the preceding `- name:`. So the "step" was one
line, and everything between `- name:` and `run:` was invisible — which is the
CONVENTIONAL position for `if:` and `continue-on-error:`, the ordering GitHub's
own docs use. Reproduced before fixing: `continue-on-error: true` placed before
`run:` gave gate exit 0 AND actionlint exit 0. Completely silent.

The worse half is the controls. Both inserted the key AFTER `run:`, so the
matrix certified the one position that happened to work and never exercised the
one that did not. That is this lane's own recurring failure — an assertion that
cannot observe what it claims to — moved up a level into the control itself.
Given the subject matter, it had to be right.

Secondary defect in the same extraction: verify.yaml:15 mentions this script in
the file-header comment, and nothing reset `inblock` until the first
`^      - ` at line 25, so the region also swallowed the header block plus
`env:`, `jobs:`, `verify:`, `runs-on:` and `steps:` — 11 lines, exactly one of
them from the step. The `[[ -s ]]` control passed happily on that.

Fixed with a two-pass extraction: start from the line the command was actually
found on, walk BACK to the nearest preceding step start, then forward to the
next one. It now yields exactly the two real lines. Positive controls upgraded
accordingly — the block must be 1..6 lines, must START with a step marker, and
must contain the invocation, so a slab of file header can no longer pass as a
step. Four controls now, both keys in both positions, plus one for the
extraction losing its step start.

N6 (P2) — `command_view` strips from the leftmost ` #`, which is exactly YAML's
rule for a PLAIN scalar (that case is genuinely fine: `run: echo "step # 1" &&
…` is truncated by YAML too, and actionlint reds it with SC1072). It is NOT the
rule inside a BLOCK or QUOTED scalar, where ` #` is ordinary content — there,
stripping discards live code, and `echo "stage # 2" && npm --global install
ajv-cli` reduces to `echo "stage` with SEC-01 inert.

The previous commit documented this as a precondition of the current tree.
Documentation is not enforcement: the next workflow edit reopens it silently.
It is now a gate — no workflow line may carry ` #` preceded by an unbalanced
quote. Both carrier shapes (double- and single-quoted) verified caught. Zero
hits across all 8 workflows, so it lands without touching a workflow file.

Worth noting what already held: the block-scalar carrier was ALSO caught, before
section 6 even ran, by the npm extraction's own non-empty positive control —
the one added after hitting that exact bug during the N1 fix. Defence in depth
behaving as intended rather than by luck.

27 -> 31 controls. Green under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU
grep 3.11 + mawk 1.3.4; actionlint clean.
@konih
konih merged commit 669d805 into main Aug 8, 2026
11 checks passed
@konih
konih deleted the lane/aud-s09-s14-workflow-pins branch August 8, 2026 12:37
konih added a commit that referenced this pull request Aug 8, 2026
…nership line

Two corrections to the row landed one commit ago, both material to how a
future reader acts on it.

1. The margin was stated as '0.01pp, about half a statement'. That reads the
   raw ratio, but the gate compares the ONE-DECIMAL total that
   `go tool cover -func` prints -- pct is the string '91.0', not '91.010'.
   Anything rounding to 91.0 passes, so the effective floor is ~90.95% and the
   true minimum is 4513 of 4961 statements. Actual slack is 2 statements, not
   half of one. Understating it invites someone to conclude the 91% floor is
   unsustainable and walk it back.

2. The row assigned the verify.yaml raise to 'Lane B'. PR #25 (AUD-S09/S14),
   the workflow-owning lane, merged 2026-08-08 -- before this lane landed. No
   open lane owns .github/workflows/**, so the split-gate residual is UNOWNED,
   and a reader who trusts the old wording would assume it is covered while CI
   quietly keeps enforcing 90%.

Row text only; no gate behavior change.
konih added a commit that referenced this pull request Aug 8, 2026
The aggregate ./internal/... total sat at 90.28% against a 90% floor, so the
gate had stopped MEASURING and started STEERING: any lane adding a branch
without a test reddened task check for a reason unrelated to that lane, and
the cheapest green was a filler test. AUD-S13's three behavior families lift
it to 91.010% (4479 -> 4515 of 4961 statements), so the floor moves with them.

Taskfile.yml coverage: desc, echo, awk threshold and FAIL message all move
together -- a half-updated gate prints a message that lies. GUIDELINES.md's
'>=90% coverage gate' claim moves with it.

Logged as D-128, which also records the two things a future reader needs:
the margin is 0.01pp (about half a statement), and .github/workflows/
verify.yaml still enforces 90% because that path belongs to Lane B / PR #25 --
so CI enforces the OLD floor until Lane B raises it.

D-128 supersedes the AUD-S13 spec's 'Not in scope: raising the D-010 floor'
line; the operator ruled otherwise for this change.
konih added a commit that referenced this pull request Aug 8, 2026
…nership line

Two corrections to the row landed one commit ago, both material to how a
future reader acts on it.

1. The margin was stated as '0.01pp, about half a statement'. That reads the
   raw ratio, but the gate compares the ONE-DECIMAL total that
   `go tool cover -func` prints -- pct is the string '91.0', not '91.010'.
   Anything rounding to 91.0 passes, so the effective floor is ~90.95% and the
   true minimum is 4513 of 4961 statements. Actual slack is 2 statements, not
   half of one. Understating it invites someone to conclude the 91% floor is
   unsustainable and walk it back.

2. The row assigned the verify.yaml raise to 'Lane B'. PR #25 (AUD-S09/S14),
   the workflow-owning lane, merged 2026-08-08 -- before this lane landed. No
   open lane owns .github/workflows/**, so the split-gate residual is UNOWNED,
   and a reader who trusts the old wording would assume it is covered while CI
   quietly keeps enforcing 90%.

Row text only; no gate behavior change.
konih added a commit that referenced this pull request Aug 8, 2026
…t skew (D-128)

The fence that kept AUD-S13 out of .github/workflows/** was PR #25, which
merged. So the split gate this lane flagged is now closable, and the right
fix is not to raise a second literal -- that only resets the drift clock.

verify.yaml no longer re-implements the coverage gate. The 'install Task'
step moves above it and the step body is just `task coverage`, so CI runs
the same gate developers run. The threshold now exists in exactly one place:
Taskfile.yml's coverage: task var COVERAGE_MIN, interpolated into the echo
and the awk comparison, with desc naming the var instead of the number.
verify.yaml carries no coverage threshold at all any more.

Proven at both polarities rather than assumed: COVERAGE_MIN=99 reds the gate
with the right message. That control matters specifically because an
unrendered {{.COVERAGE_MIN}} would yield min="", min+0 == 0, and a gate
that passes everything -- a templating typo would have been a silent
fail-open, not a visible break.

hack/lint/workflow_pins_test.sh stays green (39 controls), including 'both
Task installs interpolate a single workflow-level TASK_VERSION' after the
step move; verified the YAML still parses and the step order is right.

Also logs the two fail-opens AUD-S13 found while writing its tests, as
OQ-27 (toCEL lexical compare returns a silently wrong boolean when BOTH
operands are over-range -- and evaldecode.go:61 understates the known
residual, so it is a docs-truth defect too) and OQ-28 (repo_file enforces
path containment but not filesystem containment; os.DirFS is not a security
boundary and the escaping symlink can arrive in the MR under evaluation).
Neither is fixed here -- both are routed for a severity ruling. No new
D-row: they are undecided, so they are questions, not decisions.

D-128 updated: its split-gate residual is closed, and the closure mechanism
recorded so a future editor does not re-inline the shell.
konih added a commit that referenced this pull request Aug 8, 2026
The aggregate ./internal/... total sat at 90.28% against a 90% floor, so the
gate had stopped MEASURING and started STEERING: any lane adding a branch
without a test reddened task check for a reason unrelated to that lane, and
the cheapest green was a filler test. AUD-S13's three behavior families lift
it to 91.010% (4479 -> 4515 of 4961 statements), so the floor moves with them.

Taskfile.yml coverage: desc, echo, awk threshold and FAIL message all move
together -- a half-updated gate prints a message that lies. GUIDELINES.md's
'>=90% coverage gate' claim moves with it.

Logged as D-128, which also records the two things a future reader needs:
the margin is 0.01pp (about half a statement), and .github/workflows/
verify.yaml still enforces 90% because that path belongs to Lane B / PR #25 --
so CI enforces the OLD floor until Lane B raises it.

D-128 supersedes the AUD-S13 spec's 'Not in scope: raising the D-010 floor'
line; the operator ruled otherwise for this change.
konih added a commit that referenced this pull request Aug 8, 2026
…nership line

Two corrections to the row landed one commit ago, both material to how a
future reader acts on it.

1. The margin was stated as '0.01pp, about half a statement'. That reads the
   raw ratio, but the gate compares the ONE-DECIMAL total that
   `go tool cover -func` prints -- pct is the string '91.0', not '91.010'.
   Anything rounding to 91.0 passes, so the effective floor is ~90.95% and the
   true minimum is 4513 of 4961 statements. Actual slack is 2 statements, not
   half of one. Understating it invites someone to conclude the 91% floor is
   unsustainable and walk it back.

2. The row assigned the verify.yaml raise to 'Lane B'. PR #25 (AUD-S09/S14),
   the workflow-owning lane, merged 2026-08-08 -- before this lane landed. No
   open lane owns .github/workflows/**, so the split-gate residual is UNOWNED,
   and a reader who trusts the old wording would assume it is covered while CI
   quietly keeps enforcing 90%.

Row text only; no gate behavior change.
konih added a commit that referenced this pull request Aug 8, 2026
…t skew (D-128)

The fence that kept AUD-S13 out of .github/workflows/** was PR #25, which
merged. So the split gate this lane flagged is now closable, and the right
fix is not to raise a second literal -- that only resets the drift clock.

verify.yaml no longer re-implements the coverage gate. The 'install Task'
step moves above it and the step body is just `task coverage`, so CI runs
the same gate developers run. The threshold now exists in exactly one place:
Taskfile.yml's coverage: task var COVERAGE_MIN, interpolated into the echo
and the awk comparison, with desc naming the var instead of the number.
verify.yaml carries no coverage threshold at all any more.

Proven at both polarities rather than assumed: COVERAGE_MIN=99 reds the gate
with the right message. That control matters specifically because an
unrendered {{.COVERAGE_MIN}} would yield min="", min+0 == 0, and a gate
that passes everything -- a templating typo would have been a silent
fail-open, not a visible break.

hack/lint/workflow_pins_test.sh stays green (39 controls), including 'both
Task installs interpolate a single workflow-level TASK_VERSION' after the
step move; verified the YAML still parses and the step order is right.

Also logs the two fail-opens AUD-S13 found while writing its tests, as
OQ-27 (toCEL lexical compare returns a silently wrong boolean when BOTH
operands are over-range -- and evaldecode.go:61 understates the known
residual, so it is a docs-truth defect too) and OQ-28 (repo_file enforces
path containment but not filesystem containment; os.DirFS is not a security
boundary and the escaping symlink can arrive in the MR under evaluation).
Neither is fixed here -- both are routed for a severity ruling. No new
D-row: they are undecided, so they are questions, not decisions.

D-128 updated: its split-gate residual is closed, and the closure mechanism
recorded so a future editor does not re-inline the shell.
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