diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 31dcac5e..2b871899 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -25,6 +25,11 @@ // cannot infer it. Resolves the mirrored asset through the real Releases reader // behind a pathToFileURL(main) guard. "packages/github-cache/src/roundtrip/read-back.ts", + // Cross-OS hash-parity gate entry: ci.yml's hash-parity-compare job invokes it + // as dist/hash-parity/assert-parity.js via node; never imported, so reachability + // analysis cannot infer it. Reads both legs' records and calls compareHashParity + // behind a pathToFileURL(main) guard (CORR-03, D-19). + "packages/github-cache/src/hash-parity/assert-parity.ts", // Consumer JS action entry: esbuild.action.mjs bundles it to // start-cache-server/index.js, which the runner invokes as action.yml's `main`; // it is never imported, so reachability analysis cannot infer it (D-09, Phase 6). @@ -35,6 +40,11 @@ // npm tarball file-list guard: the `pack:check` package script + the ci.yml // pack-check job invoke it as a standalone node bin; never imported (T-06-01-01). "packages/github-cache/pack-check.cjs", + // Nx task-hash capture instrument: the `capture:hashes` package script + the + // ci.yml hash-parity capture job invoke it as a standalone node bin; never + // imported, so reachability analysis cannot infer it. Deliberately NOT an Nx + // target -- a cached capture would replay instead of measure (D-01b). + "capture-hashes.mjs", // Integration vitest config: consumed by the `integration` target's // `vitest run --config` command (project.json), not by any import. fallow // auto-credits vitest.config.mts but not the .integration. variant, so declare it. @@ -60,6 +70,7 @@ // string match (not glob); exempts each from BOTH unused-dependency and // unlisted-dependency detection. Removing any of these breaks the build. "ignoreDependencies": [ + "@nx/eslint", // Nx plugin that INFERS the `lint` target via nx.json (D-01) "@nx/vitest", // Nx plugin that INFERS the `test` target via nx.json "@swc-node/register", // SWC transpile hook for the test runner "@swc/helpers", // SWC runtime helpers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06b20df0..ea810e17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,36 @@ on: permissions: contents: read +# Supersede an in-flight run only when a NEWER commit lands on the SAME pull request. +# This workflow fans out to twenty-two jobs, three of which are multi-minute Windows-arm +# legs, so a rapid re-push otherwise pays for the whole matrix twice with the first copy +# already obsolete. +# +# `cancel-in-progress` IS CONDITIONAL, and the condition is the load-bearing part. On a +# `push` to main this workflow WRITES: `publish` uploads Release assets and `dogfood-seed` +# PUTs a cache entry. Cancelling one of those mid-upload is how a shard acquires a +# zero-byte asset under a name a reader will resolve and a publisher will never overwrite +# -- first-write-wins arbitration is keyed on the FILENAME, so a torn asset is permanent +# until the retention window rolls it off. Push runs therefore queue rather than cancel; +# only pull-request runs, which write nothing outside their own merge-ref cache scope, +# are superseded. +# +# READ THAT ENUMERATION PRECISELY: since CR-18, `dogfood-seed` PUTs on same-repo PR runs +# too, so the cancellable runs are exactly the ones that now perform that PUT. It is safe, +# and the reason is recorded here rather than left to be re-derived: the seed key is +# nx-cache-bead, so a torn PR-side upload leaves at most an absent entry +# under a run id that is never reused. There is no first-write-wins filename arbitration to +# poison -- that hazard belongs to the Release-asset uploads, which stay push-only. +# +# The group is keyed on `github.ref` so the two event kinds cannot share a group either. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: format-check: runs-on: ubuntu-24.04-arm + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 @@ -23,6 +50,55 @@ jobs: # default git-diff base, so diff-based format:check always fails here. - run: npx nx format:check --all + # `nx run-many -t lint` gates every project against the root flat ESLint + # config -- the two recommended sets plus the ambient-platform-read ban that + # keeps unit specs from branching on process.platform, which is what makes a + # unit spec's result the same on both runners (LINT-01, LINT-02, CORR-06). + # Kept a distinct named job like format-check and fallow so a lint regression + # surfaces as a red `lint` leg instead of inside the build/test battery, and + # so it still runs when an earlier gate fails. No background sidecar block: it + # runs in seconds, and a fifth cache producer would add a fifth mirrored hash + # family to the cross-OS investigation this milestone exists for (D-33). No + # build step either -- lint has no dependsOn, and the config's global ignores + # block exists precisely so lint output does not depend on whether build ran. + lint: + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + # Exit 0 is NOT sufficient here, and that is the whole reason this is a + # scripted step rather than `- run: npm run lint`. `npm run lint` is + # `nx run-many -t lint`, and run-many with no matching target ANYWHERE + # prints "NX No tasks were run" and exits 0 (measured). The `lint` target + # is INFERRED by @nx/eslint/plugin (D-01) rather than declared, and D-35 + # leaves "does @nx/eslint infer lint identically on both OSes?" UNVERIFIED + # BY DESIGN -- so "the target exists on this runner" is a live accepted + # risk, and a bare exit-code check converts it into a GREEN leg instead of + # a red one. Demanding the success LINE is RESEARCH's named discriminator + # for LINT-01: it prints only when a task actually ran. + # + # NO_COLOR is load-bearing, not tidiness. With colour on, Nx bolds the + # target name mid-phrase -- "ran target [1mlint[22m for project" + # -- so the plain-text match never fires and the check fails on every run + # including the good ones. A gate that always fails gets deleted just as + # fast as one that never fires. + # + # The other half of CR-01 (nx.json silently losing the plugins[] entry) is + # guarded locally by nx-target-inputs.spec.ts; this leg is what catches the + # target failing to materialise on a RUNNER rather than in the config. + - name: Lint, and prove the lint target actually ran + env: + NO_COLOR: '1' + run: | + set -euo pipefail + npm run lint 2>&1 | tee lint.log + grep -q 'Successfully ran target lint' lint.log + # `fallow dead-code --fail-on-issues` gates the whole repo against dead code # (unused files/exports/deps + reachability). It is config-declared-clean via # .fallowrc.jsonc and base-independent, so it works identically on push and on @@ -32,6 +108,7 @@ jobs: # diff-scoped gating once the repo grows large enough to want it. fallow: runs-on: ubuntu-24.04-arm + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 @@ -50,6 +127,7 @@ jobs: # named job so a drift surfaces as a red action-bundle-drift job. action-bundle-drift: runs-on: ubuntu-24.04-arm + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 @@ -72,6 +150,7 @@ jobs: # runs npm run build before the guard. Dependency-free node guard; distinct job. pack-check: runs-on: ubuntu-24.04-arm + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 @@ -95,6 +174,7 @@ jobs: # (expect a human_needed/live confirm on the CI run). ppe: runs-on: ubuntu-24.04-arm + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: ./ppe @@ -117,11 +197,26 @@ jobs: # backend there as well (pull_request is host-gated-trusted on github.com). # Read-only on PRs is enforced by GitHub's PLATFORM -- cache scope isolation # (a PR write lands in the PR ref's scope, invisible to the default branch) - # plus the read-only-cache token for untrusted triggers, whose saveCache - # no-ops through the backend's benign 409 path -- NOT by this code. - # * NOT push-gated, unlike the proof jobs: push runs WRITE the entries, PR runs - # READ the default-branch ones. `if: github.event_name == 'push'` here would - # throw away the whole PR read benefit. + # -- NOT by this code. The restriction is on the DEFAULT BRANCH'S SCOPE and + # never on a run's own writes: GitHub documents that only high-trust triggers + # can create or overwrite caches IN THE DEFAULT BRANCH'S SCOPE, while a + # pull_request run -- fork included -- still CREATES its cache, in the merge + # ref (refs/pull/.../merge). + # CORRECTED. This clause used to end "plus the read-only-cache token for + # untrusted triggers, whose saveCache no-ops through the backend's benign 409 + # path", which dropped the scope qualifier and turned CONTAINMENT into DENIAL + # -- contradicting the scope-isolation half of its own sentence, and implying + # the integration job's 200-only positive control must redden on every fork + # PR. It does not: that probe reads the same merge-ref scope its own run + # RESTORED OR WROTE. Dropping the qualifier is an easy misreading, which is + # why the qualifier is now spelled in capitals rather than assumed. + # CITED from GitHub's docs, not reproduced here -- this repo has had no fork + # PR on record. Same standing, and the same caveat, as the !cancelled()-past-a- + # FAILED-needs citation on the publish job. + # * NOT push-gated -- like the dogfood proof pair since CR-18, and unlike the + # publish pair: push runs WRITE the entries, PR runs READ the default-branch + # ones. `if: github.event_name == 'push'` here would throw away the whole PR + # read benefit. # * The NX_* vars stay strictly per-job. Hoisting them to workflow `env:` would # point every sidecar-less proof job at a dead server, so each of their Nx # invocations would try (and fail) to reach 127.0.0.1:3000. @@ -243,14 +338,29 @@ jobs: - run: npm ci # Sidecar dogfood block. Rationale, and the traps it avoids, are documented # once above the build job (whose copy also carries the inline trap notes). - # The EXECUTABLE shell must stay identical across all four wired jobs -- only - # the final `npm run ` line may differ, and only build's copy carries - # extra comments. Keep it that way, so the windows-11-arm integration leg - # never drifts from the others. This is an unguarded invariant: nothing fails - # if it drifts. cleanup-workflow.spec.ts is the precedent for asserting on a - # workflow from a spec if that ever becomes worth enforcing -- note ci.yml is - # NOT in nx.json's test inputs (only cleanup.yml is), so such a guard would - # need ci.yml added there or it goes stale behind a cache hit. + # The EXECUTABLE shell must stay identical across all seven wired jobs -- + # build, typecheck, test, integration, build-windows, typecheck-windows and + # test-windows -- only the final `npm run ` line may differ, and only + # build's copy carries extra comments. Keep it that way, so the windows-11-arm + # legs never drift from the ubuntu producers they exist to reuse. + # + # SEVEN WIRED JOBS, EIGHT COPIES, and those are two different sets on purpose. + # The step occurs EIGHT times in this file; the eighth copy is in + # `consumer-smoke`, which drives a scripted PUT/GET rather than an Nx target and + # is deliberately OUTSIDE this invariant. (MEASURED before Phase 12: five copies, + # four wired. XOS-04 added three legs, so both counts moved by three.) A note + # saying "five become eight" without that distinction is describing a different + # set than the invariant governs. Do not weaken the invariant to accommodate the + # three new copies. + # + # This is an unguarded invariant: nothing fails + # if it drifts. CORRECTED, and the replacement fact rather than a bare deletion: + # this note used to say ci.yml was NOT in nx.json's test inputs, so such a guard + # would first need it registered there. PARITY-08 registered it in + # targetDefaults.test.inputs, and + # dogfood-cross-os.spec.ts and docs-same-os-claims.spec.ts now both assert on this + # file, so the precondition is already met -- only the drift guard itself would be + # new work. cleanup-workflow.spec.ts remains the precedent for the shape. - name: Pre-set the Nx cache client vars for the sidecar shell: bash run: | @@ -333,12 +443,400 @@ jobs: - run: npm run test - cancel: cache-server + # THE THREE windows-11-arm REUSE LEGS (XOS-04), and they are the CONSUMER half of + # this milestone. Each runs the SAME Nx target as its ubuntu counterpart above, and + # build/typecheck/test carry NO platform discriminator (only `integration` does -- + # see its comment below), so each Windows leg computes the SAME task hash its ubuntu + # producer just wrote and can restore that entry. That restore IS the XOS-05 + # observation; without these jobs there is nothing on Windows to observe. + # + # THE `needs:` EDGE IS A PRODUCER-TO-CONSUMER DEPENDENCY FOR HIT-ABILITY, NEVER AN + # ORDERING-CORRECTNESS CONTROL. PROJECT.md locks that cross-OS sharing rests on target + # platform-agnosticism and NEVER on leg ordering, and XOS-06 forbids ordering from + # becoming a correctness control: no result computed here changes if the order + # changes, and a leg that ran first would MISS and execute, which is slow rather than + # wrong. What the edge buys is that the producer has finished before the consumer + # starts. WITHOUT it the two legs compute the same hash at the same time, so both + # MISS, both execute, and both call saveCache(nx-cache-H) -- the race TRUST-11 + # predicted in advance (.planning/phases/10-os-invariant-releases-mirror/ + # 10-SECURITY.md, the Q1 section). The edge removes the RACE. It does NOT remove the + # SECOND PRODUCER: from these jobs onward a windows-11-arm runner writes + # build/typecheck/test entries too, and that half is recorded in the same Q1 section + # rather than left implicit. + # + # `integration` IS NOT THE WIRING PRECEDENT, and that is worth writing down because it + # is the only existing two-OS Nx-target job, so it is the first thing a reader reaches + # for. Two independent reasons its matrix cannot serve here: + # (a) `needs:` is per-JOB and never per-LEG, so a matrix leg cannot depend on its + # sibling -- a matrix cannot express XOS-08 at all; and + # (b) `integration`'s two legs compute DIFFERENT hashes, because of the + # `{ runtime: "node --no-warnings -p process.platform" }` discriminator in + # its own target inputs, so parallelism is harmless there and lethal here, + # where the two legs computing the SAME hash is the entire point. + # + # And do NOT "fix the asymmetry" by giving `dogfood-seed` a Windows leg while in this + # file. It is ubuntu-ONLY BY DESIGN and its own VACUITY CONDITION comment says why: + # its key is one per RUN, so a Windows seed leg would make the Windows verify leg + # restore a Windows-written entry. These legs key on the task HASH, so that reasoning + # does not transfer. + build-windows: + # Consumer of the ubuntu `build` job's entry: a bare scalar naming exactly ONE + # producer (XOS-08). See the block above for why this is a HIT-ability edge. + needs: build + runs-on: windows-11-arm + # Generic hang insurance -- see the build job. + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + # Sidecar dogfood block -- see the build job above, plus one line the ubuntu + # producers deliberately do not carry: CACHE_READ_ONLY. + # + # That knob is ROLE, not TRUST (TRUST-14, D-02a). This leg is a CONSUMER of the + # ubuntu producer's entry, and producer-versus-consumer role is not derivable + # from any GitHub-supplied env fact: `push` and same-repo `pull_request` are both + # correctly write-TRUSTED, and GitHub exposes no per-job read-only lever. The + # workflow author supplies the one thing the runner cannot. Declining the write + # is what makes the count gate at the end of this job SOUND -- see that step. + # + # It goes in THIS step rather than in the sidecar's own `env:` because a REGULAR + # step's $GITHUB_ENV writes reach later steps while a BACKGROUND step's do not, + # which start-cache-server/action.yml's own comment records. And it can only + # NARROW: select-backend.ts reads it as the LAST branch, after every branch that + # has already returned a read-only backend or thrown, so it cannot resurrect an + # outcome control never reaches it from (TRUST-05, D-02b). + - name: Pre-set the Nx cache client vars for the sidecar + shell: bash + run: | + set -euo pipefail + echo "NX_SELF_HOSTED_REMOTE_CACHE_SERVER=http://127.0.0.1:3000" >> "$GITHUB_ENV" + token="$(node -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))')" + echo "::add-mask::${token}" + echo "NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=${token}" >> "$GITHUB_ENV" + echo "CACHE_READ_ONLY=1" >> "$GITHUB_ENV" + - uses: ./start-cache-server + id: cache-server + background: true + with: + port: '3000' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Wait for the loopback sidecar + shell: bash + run: | + set -euo pipefail + auth="Authorization: Bearer ${NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN}" + code=000 + for _ in $(seq 1 30); do + code=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -H "${auth}" "${NX_SELF_HOSTED_REMOTE_CACHE_SERVER}/v1/cache/deadbeef" || true) + if [ "${code}" = "404" ] || [ "${code}" = "200" ]; then + break + fi + sleep 1 + done + if [ "${code}" != "404" ] && [ "${code}" != "200" ]; then + echo "sidecar not ready on ${NX_SELF_HOSTED_REMOTE_CACHE_SERVER} after 30 attempts (last status ${code}, wanted 404 or 200)" >&2 + exit 1 + fi + # Tee'd, and named, for the reason the integration job's copy states: the bare + # `- run:` form leaves this leg with NO runtime cache observation at all. A leg + # that MISSED and executed locally exits 0 identically to one that HIT, so these + # three -- which exist for NOTHING BUT the HIT observation -- could not report the + # one thing they are for. An @actions/cache bump that broke cross-OS restore would + # leave all three GREEN and hash-parity green (it compares hashes, not storage). + # The dogfood canary DOES catch that bump -- that is its stated job as the + # ROBUST-03 upgrade canary, its verify leg MISSes and reddens, and since CR-18 it + # does so on same-repo PRs too. But it catches a DIFFERENT thing from these + # records: it drives a DIRECT scripted PUT/GET on a run-scoped key, so it never + # observes whether a REAL Nx build/typecheck/test task got a remote HIT. These + # per-target records are the only runtime observation of THAT -- and on a fork + # pull request, where the dogfood pair is skipped, the only cross-OS signal at all. + # Under set -euo pipefail the tee preserves a failing Nx exit code. + - name: Run the build target and tee its output + shell: bash + run: | + set -euo pipefail + npm run build 2>&1 | tee build-nx.log + # GATED at a floor of 1, and the floor is sound ONLY because the step above made + # this leg unable to write. The silent failure that ungated it until XOS-09: a + # leg that can save launders its own failure, because a broken cross-OS restore + # makes it MISS, execute the target and SAVE its own entry -- and a re-run of the + # same commit then HITs that self-produced entry, taking a floor check green with + # cross-OS reuse still dead. A gate a re-run can launder is worse than no gate, + # because it reads as coverage. + # CACHE_READ_ONLY removes the premise rather than mitigating it. With no write + # path, no windows-produced entry for these hashes can ever exist, so a + # `[remote cache]` label here is NECESSARILY a restore of the ubuntu producer's + # entry. That property is INDUCTIVE: it holds of every run, and rests on nothing + # about job ordering, about this being a first run, or about anything else being + # true right now. + # Keep LIVENESS separate from correctness, because they are two arguments and + # only one of them is this step's. XOS-08's `needs:` edge is why the entry is + # PRESENT at all -- the ubuntu producer repopulates it earlier in the SAME run. + # Read-only-ness is why a green MEANS something. Neither substitutes for the + # other, and a rationale resting this gate on the `needs:` edge has confused them. + # Second-order effect, and it is the intended one: a MISS is now PERMANENT for + # that hash, because no self-produced entry ever backfills it. A red gate here + # stays red until the producer side is fixed rather than going green on a re-run. + # What this gate still does NOT cover: these three legs run the COMMITTED public + # `./start-cache-server` bundle, while the dogfood pair runs + # `uses: ./packages/github-cache` built in-job. Nothing here ties the bundle to + # the source it was built from -- `action-bundle-drift` is the control that does. + # Over-reading a green here as evidence about uncommitted source is the mistake + # CR-18 caught the first time. + # The `|| true` keeps a legitimate zero REACHING the comparison: under pipefail a + # zero-match grep exits 1 and would abort the step before the gate ran, turning a + # clear failure carrying an annotation into an opaque one carrying none. + # `-a` FORCES TEXT, and its absence was a fail-OPEN route rather than a tidiness + # lapse. A single NUL byte anywhere in the tee'd log -- routine for + # PowerShell-invoked tooling on windows-11-arm -- makes grep classify the file as + # BINARY and print one summary line instead of the matches, so `wc -l` returns 1 on + # ZERO real labels and clears a floor of 1. The same byte on a grep that routes that + # notice to stderr yields 0 instead, reddening this leg and blaming cross-OS restore + # for an encoding artefact. `-a` removes both directions at once. + # THE SHAPE CHECK IS WHAT MAKES THE COMPARISON A GATE, and it closes a fail-OPEN + # hole rather than hardening a working one. MEASURED: `[ "${count}" -lt 1 ]` sits in + # an `if` CONDITION, where `set -e` is SUSPENDED -- so a non-integer `count` prints + # `[: ...: integer expected` to stderr, the test reports FALSE, control takes the + # else branch, and THE STEP EXITS 0. The identical test outside a condition exits 2 + # and aborts. So every non-decimal value -- empty, a grep notice, a truncated read -- + # silently PASSED the leg's only gate while printing "GATED at a floor of 1". A gate + # a malformed count can launder is the same defect XOS-09 removed from the re-run + # direction, arriving through the comparison instead. Nx renders no count as anything + # but decimal digits, so `''|*[!0-9]*` rejects exactly the values that cannot be + # compared -- the same all-decimal idiom the o3-witness applies to its own hash + # record, for the same reason: never compose a verdict from an unvalidated value. + - name: Gate on the cross-OS remote-cache label count for this leg + shell: bash + run: | + set -euo pipefail + count=$({ grep -a -o -F '[remote cache]' build-nx.log || true; } | wc -l | tr -d '[:space:]') + echo "remote-cache label occurrences on windows-11-arm (build): ${count} -- GATED at a floor of 1" + case "${count}" in + ''|*[!0-9]*) + echo "::error::build-windows could not COUNT the [remote cache] labels: '${count}' is not a decimal number, so the floor below was never evaluated. This is a FAILURE and never a pass -- an unevaluated gate must not read as coverage. Check the tee'd log for a binary-file notice (a NUL byte in build-nx.log) or a truncated read." + exit 1 + ;; + esac + if [ "${count}" -lt 1 ]; then + echo "::error::build-windows got ${count} [remote cache] labels, but this leg is read-only and can only get one by restoring the ubuntu build job's entry. Either cross-OS restore is broken (an @actions/cache regression or a cache-version drift), the ubuntu producer never populated the entry this run, or Nx stopped printing the literal '[remote cache]' label this grep counts (an Nx upgrade renames it and fails all three legs at once on a HEALTHY cache). Check the producer job's own log: a HIT there points at cross-OS restore, a save without a HIT at the producer, and no label at all on a green producer at the Nx rename." + exit 1 + fi + - cancel: cache-server + + typecheck-windows: + # Consumer of the ubuntu `typecheck` job's entry -- same edge, same reasons as + # build-windows above. One producer, bare scalar, HIT-ability not ordering. + needs: typecheck + runs-on: windows-11-arm + # Generic hang insurance -- see the build job. + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + # Sidecar dogfood block -- see the build job above; the CACHE_READ_ONLY role + # knob carries the build-windows leg's rationale. + - name: Pre-set the Nx cache client vars for the sidecar + shell: bash + run: | + set -euo pipefail + echo "NX_SELF_HOSTED_REMOTE_CACHE_SERVER=http://127.0.0.1:3000" >> "$GITHUB_ENV" + token="$(node -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))')" + echo "::add-mask::${token}" + echo "NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=${token}" >> "$GITHUB_ENV" + echo "CACHE_READ_ONLY=1" >> "$GITHUB_ENV" + - uses: ./start-cache-server + id: cache-server + background: true + with: + port: '3000' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Wait for the loopback sidecar + shell: bash + run: | + set -euo pipefail + auth="Authorization: Bearer ${NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN}" + code=000 + for _ in $(seq 1 30); do + code=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -H "${auth}" "${NX_SELF_HOSTED_REMOTE_CACHE_SERVER}/v1/cache/deadbeef" || true) + if [ "${code}" = "404" ] || [ "${code}" = "200" ]; then + break + fi + sleep 1 + done + if [ "${code}" != "404" ] && [ "${code}" != "200" ]; then + echo "sidecar not ready on ${NX_SELF_HOSTED_REMOTE_CACHE_SERVER} after 30 attempts (last status ${code}, wanted 404 or 200)" >&2 + exit 1 + fi + # Tee'd, and named, for the reason the integration job's copy states: the bare + # `- run:` form leaves this leg with NO runtime cache observation at all. A leg + # that MISSED and executed locally exits 0 identically to one that HIT, so these + # three -- which exist for NOTHING BUT the HIT observation -- could not report the + # one thing they are for. An @actions/cache bump that broke cross-OS restore would + # leave all three GREEN and hash-parity green (it compares hashes, not storage). + # The dogfood canary DOES catch that bump -- that is its stated job as the + # ROBUST-03 upgrade canary, its verify leg MISSes and reddens, and since CR-18 it + # does so on same-repo PRs too. But it catches a DIFFERENT thing from these + # records: it drives a DIRECT scripted PUT/GET on a run-scoped key, so it never + # observes whether a REAL Nx build/typecheck/test task got a remote HIT. These + # per-target records are the only runtime observation of THAT -- and on a fork + # pull request, where the dogfood pair is skipped, the only cross-OS signal at all. + # Under set -euo pipefail the tee preserves a failing Nx exit code. + - name: Run the typecheck target and tee its output + shell: bash + run: | + set -euo pipefail + npm run typecheck 2>&1 | tee typecheck-nx.log + # GATED at a floor of 1 -- the build-windows leg above carries the full rationale; + # this is the one-line form. The floor is sound because CACHE_READ_ONLY makes this + # leg a pure CONSUMER: with no write path it cannot save an entry of its own, so a + # `[remote cache]` label is NECESSARILY a restore of the ubuntu producer's entry + # and a re-run cannot launder a zero into a green. + # + # Stated ONCE rather than three times over: the argument was 13 lines verbatim on + # each of the three legs, which triples the blast radius of the next correction -- + # and this file is the evidence that N-copy prose drifts PARTIALLY. The same commit + # that widened the dogfood trigger had to fix eight sites making one stale claim, + # several of them copies that a previous edit had missed. Don't re-expand this. + # The `|| true` keeps a legitimate zero reaching the comparison instead of + # aborting the step under pipefail. `-a` and the all-decimal `case` are the + # fail-CLOSED half -- build-windows carries the measurement and the full rationale. + - name: Gate on the cross-OS remote-cache label count for this leg + shell: bash + run: | + set -euo pipefail + count=$({ grep -a -o -F '[remote cache]' typecheck-nx.log || true; } | wc -l | tr -d '[:space:]') + echo "remote-cache label occurrences on windows-11-arm (typecheck): ${count} -- GATED at a floor of 1" + case "${count}" in + ''|*[!0-9]*) + echo "::error::typecheck-windows could not COUNT the [remote cache] labels: '${count}' is not a decimal number, so the floor below was never evaluated. This is a FAILURE and never a pass -- an unevaluated gate must not read as coverage. Check the tee'd log for a binary-file notice (a NUL byte in typecheck-nx.log) or a truncated read." + exit 1 + ;; + esac + if [ "${count}" -lt 1 ]; then + echo "::error::typecheck-windows got ${count} [remote cache] labels, but this leg is read-only and can only get one by restoring the ubuntu typecheck job's entry. Either cross-OS restore is broken (an @actions/cache regression or a cache-version drift), the ubuntu producer never populated the entry this run, or Nx stopped printing the literal '[remote cache]' label this grep counts (an Nx upgrade renames it and fails all three legs at once on a HEALTHY cache). Check the producer job's own log: a HIT there points at cross-OS restore, a save without a HIT at the producer, and no label at all on a green producer at the Nx rename." + exit 1 + fi + - cancel: cache-server + + test-windows: + # Consumer of the ubuntu `test` job's entry -- same edge, same reasons as + # build-windows above. One producer, bare scalar, HIT-ability not ordering. + needs: test + runs-on: windows-11-arm + # Generic hang insurance -- see the build job. + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + # Sidecar dogfood block -- see the build job above; the CACHE_READ_ONLY role + # knob carries the build-windows leg's rationale. + - name: Pre-set the Nx cache client vars for the sidecar + shell: bash + run: | + set -euo pipefail + echo "NX_SELF_HOSTED_REMOTE_CACHE_SERVER=http://127.0.0.1:3000" >> "$GITHUB_ENV" + token="$(node -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))')" + echo "::add-mask::${token}" + echo "NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=${token}" >> "$GITHUB_ENV" + echo "CACHE_READ_ONLY=1" >> "$GITHUB_ENV" + - uses: ./start-cache-server + id: cache-server + background: true + with: + port: '3000' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Wait for the loopback sidecar + shell: bash + run: | + set -euo pipefail + auth="Authorization: Bearer ${NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN}" + code=000 + for _ in $(seq 1 30); do + code=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -H "${auth}" "${NX_SELF_HOSTED_REMOTE_CACHE_SERVER}/v1/cache/deadbeef" || true) + if [ "${code}" = "404" ] || [ "${code}" = "200" ]; then + break + fi + sleep 1 + done + if [ "${code}" != "404" ] && [ "${code}" != "200" ]; then + echo "sidecar not ready on ${NX_SELF_HOSTED_REMOTE_CACHE_SERVER} after 30 attempts (last status ${code}, wanted 404 or 200)" >&2 + exit 1 + fi + # Tee'd, and named, for the reason the integration job's copy states: the bare + # `- run:` form leaves this leg with NO runtime cache observation at all. A leg + # that MISSED and executed locally exits 0 identically to one that HIT, so these + # three -- which exist for NOTHING BUT the HIT observation -- could not report the + # one thing they are for. An @actions/cache bump that broke cross-OS restore would + # leave all three GREEN and hash-parity green (it compares hashes, not storage). + # The dogfood canary DOES catch that bump -- that is its stated job as the + # ROBUST-03 upgrade canary, its verify leg MISSes and reddens, and since CR-18 it + # does so on same-repo PRs too. But it catches a DIFFERENT thing from these + # records: it drives a DIRECT scripted PUT/GET on a run-scoped key, so it never + # observes whether a REAL Nx build/typecheck/test task got a remote HIT. These + # per-target records are the only runtime observation of THAT -- and on a fork + # pull request, where the dogfood pair is skipped, the only cross-OS signal at all. + # Under set -euo pipefail the tee preserves a failing Nx exit code. + - name: Run the test target and tee its output + shell: bash + run: | + set -euo pipefail + npm run test 2>&1 | tee test-nx.log + # GATED at a floor of 1 -- the build-windows leg above carries the full rationale; + # this is the one-line form. The floor is sound because CACHE_READ_ONLY makes this + # leg a pure CONSUMER: with no write path it cannot save an entry of its own, so a + # `[remote cache]` label is NECESSARILY a restore of the ubuntu producer's entry + # and a re-run cannot launder a zero into a green. + # + # Stated ONCE rather than three times over: the argument was 13 lines verbatim on + # each of the three legs, which triples the blast radius of the next correction -- + # and this file is the evidence that N-copy prose drifts PARTIALLY. The same commit + # that widened the dogfood trigger had to fix eight sites making one stale claim, + # several of them copies that a previous edit had missed. Don't re-expand this. + # The `|| true` keeps a legitimate zero reaching the comparison instead of + # aborting the step under pipefail. `-a` and the all-decimal `case` are the + # fail-CLOSED half -- build-windows carries the measurement and the full rationale. + - name: Gate on the cross-OS remote-cache label count for this leg + shell: bash + run: | + set -euo pipefail + count=$({ grep -a -o -F '[remote cache]' test-nx.log || true; } | wc -l | tr -d '[:space:]') + echo "remote-cache label occurrences on windows-11-arm (test): ${count} -- GATED at a floor of 1" + case "${count}" in + ''|*[!0-9]*) + echo "::error::test-windows could not COUNT the [remote cache] labels: '${count}' is not a decimal number, so the floor below was never evaluated. This is a FAILURE and never a pass -- an unevaluated gate must not read as coverage. Check the tee'd log for a binary-file notice (a NUL byte in test-nx.log) or a truncated read." + exit 1 + ;; + esac + if [ "${count}" -lt 1 ]; then + echo "::error::test-windows got ${count} [remote cache] labels, but this leg is read-only and can only get one by restoring the ubuntu test job's entry. Either cross-OS restore is broken (an @actions/cache regression or a cache-version drift), the ubuntu producer never populated the entry this run, or Nx stopped printing the literal '[remote cache]' label this grep counts (an Nx upgrade renames it and fails all three legs at once on a HEALTHY cache). Check the producer job's own log: a HIT there points at cross-OS restore, a save without a HIT at the producer, and no label at all on a green producer at the Nx rename." + exit 1 + fi + - cancel: cache-server + # Integration tests hit real OS surface (real sockets, real filesystem/tmpdir), - # so their Nx hash carries a `{ runtime: "node -p process.platform" }` - # discriminator (see nx.json's integration inputs): the Linux and Windows hashes + # so their Nx hash carries a + # `{ runtime: "node --no-warnings -p process.platform" }` discriminator (see + # nx.json's integration inputs): the Linux and Windows hashes # differ, so both matrix legs run and a Linux cache never satisfies a Windows - # run, while a local Windows `nx integration` computes the same "win32" hash and - # hits that cache. process.platform is compiled into node, so it is stable across + # run -- and from VER-01/VER-03 that Nx hash is the ONLY thing separating them, + # because the cache STORAGE no longer partitions by OS. A local Windows + # `nx integration` computes the same "win32" hash and hits that cache. + # process.platform is compiled into node, so it is stable across # shell (PowerShell/Git-Bash/cmd) and across x64-vs-arm64 or arm64 emulation -- # unlike `env:RUNNER_OS` (unset off-CI) or a plain `env` var (whose key MSYS # uppercases while Nx's env hasher is case-sensitive). fail-fast off so a @@ -356,7 +854,10 @@ jobs: # and Git Bash themselves are both present on the Windows runner). Cross-OS is # safe as-is: the OS discriminator above means the Linux and Windows legs compute # DIFFERENT Nx task hashes, so neither leg can ever restore the other's entry -- - # exactly the CORR-01 namespacing the store already relies on. port 3000 on both + # and from VER-01/VER-03 that hash is the SOLE separation, because the STORE no + # longer partitions by OS. XOS-03 is explicit that this divergence is a statement + # about Nx HASHES and not about cache storage: a storage-level probe for the Linux + # key from a Windows runner would now HIT. port 3000 on both # legs is fine; each matrix leg is its own isolated runner. integration: strategy: @@ -367,6 +868,31 @@ jobs: # Generic hang insurance -- see the build job. timeout-minutes: 20 steps: + # D-21's in-run observation of step debug logging, and it is ECHO-ONLY BY + # DECISION rather than the fatal gate 11-RESEARCH.md recommended. A step that + # exits 1 when runner.debug is not 1 becomes a PERMANENT tripwire the moment the + # ACTIONS_STEP_DEBUG repository variable is unset after the proving run, and this + # file already records the rule on the publish job: a tripwire that fires on + # correct work gets disabled, with OBS-04 as this repo's own record of it + # happening. The reason RESEARCH wanted a gate -- failing DURING the run, so a run + # is not wasted -- is recovered instead by plan 11-07's two read-only pre-flight + # commands, which run BEFORE the push and so verify the configuration before a run + # is spent. The echo keeps RESEARCH's other two reasons in full: it prints a line + # that goes straight into 11-EVIDENCE.md as a recorded fact, and it observes the + # EFFECT rather than the configuration, so it is immune to the documented trap + # that an ACTIONS_STEP_DEBUG SECRET takes precedence over the variable. + # + # First step in the job, before the checkout, because it needs no repo content and + # the recorded fact belongs at the top of each leg's log. The context value + # reaches the script through env: rather than being interpolated into the run: + # body -- the same rule the hash-parity capture step records for its matrix value. + - name: Record whether step debug logging is active + shell: bash + env: + RUNNER_DEBUG_OBSERVED: ${{ runner.debug }} + run: | + set -euo pipefail + echo "runner.debug=${RUNNER_DEBUG_OBSERVED:-} -- RECORDED, never gated" - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -405,9 +931,995 @@ jobs: echo "sidecar not ready on ${NX_SELF_HOSTED_REMOTE_CACHE_SERVER} after 30 attempts (last status ${code}, wanted 404 or 200)" >&2 exit 1 fi - - run: npm run integration + # Explicit `name:`, and the name is a CONTRACT rather than decoration: the + # o3-witness job selects this step out of the runs/{id}/jobs REST payload by its + # RENDERED name, and the bare `- run: npm run integration` form renders as + # `Run npm run integration`, which adding a tee would silently change. If this + # name is ever edited, the witness's jq selector must be edited in the SAME + # commit. Under set -euo pipefail the tee preserves a failing Nx exit code, so the + # step still fails loud; the log exists so the steps below can count a literal in + # it instead of re-running Nx to observe the same run twice. + - name: Run the integration target and tee its output + shell: bash + run: | + set -euo pipefail + npm run integration 2>&1 | tee integration-nx.log + # THE IMMEDIATELY NEXT STEP, with no step of any kind in between, and that + # immediacy is load-bearing: every nx invocation OVERWRITES .nx/cache/run.json, so + # one convenience nx call here would upload the wrong hash with nothing going red. + # read-integration-hash.mjs's own run.command guard is the second line of defence, + # never the first. It also THROWS rather than writing an empty hash, because + # if-no-files-found: error below only checks that a file EXISTS. Invoked with no + # arguments so it uses its two defaults (.nx/cache/run.json, integration-hash.txt), + # and it prints the structured per-task cacheStatus on the same line. + - name: Read this leg's integration hash from run.json + shell: bash + run: | + set -euo pipefail + node read-integration-hash.mjs + # ONE step in a matrix job, so both legs get byte-identical treatment by + # construction rather than by two conditioned copies staying in step -- the reason + # this file already states on the hash-parity build step, and the reason nothing + # added to this job carries an `if: matrix.os` expression. The artifact NAME must + # be unique per leg: v4-onward names are immutable, so two legs uploading to one + # name is an error rather than a merge, and the matrix OS value is the natural + # discriminator. if-no-files-found: error is not tidiness -- the default is warn, + # so without it a leg whose instrument produced nothing uploads an EMPTY artifact + # and the failure surfaces inside o3-witness, one job further from its cause. + # upload-artifact@v7 pairs with download-artifact@v8 throughout this repo. + - uses: actions/upload-artifact@v7 + with: + name: integration-hash-${{ matrix.os }} + path: integration-hash.txt + if-no-files-found: error + # D-17 step (a), and D-17 sub-lock 2 governs what may be done with the number: it + # is RECORDED and never GATED (the o3-witness block carries the full reason). A + # zero is the EXPECTED windows outcome, and it is equally correct on a re-run at + # the same commit, where a LOCAL hit precedes any remote read -- so a gate here + # would fire on correct work, and a tripwire that fires on correct work gets + # disabled. The reader above prints the structured cacheStatus value, which is the + # remote-vs-local discrimination this count cannot make (D-24). + # + # Four mechanics. The matrix value reaches the script through env: and is never + # interpolated into the run: body, so it never lands on a command line the shell + # re-parses. The label is matched as a FIXED string, because square brackets are a + # regex character class. OCCURRENCES are counted with an -o match piped into + # wc -l, never with a line-count flag, because two labels can share one line. And + # grep exits 1 on no match, which under pipefail would abort the leg on the very + # outcome that is expected here -- hence the tolerant brace group INSIDE the + # pipeline, so a legitimate zero prints 0 instead of reddening the leg. + - name: Record the remote-cache label occurrence count for this leg + shell: bash + env: + LEG_OS: ${{ matrix.os }} + run: | + set -euo pipefail + count=$({ grep -o -F '[remote cache]' integration-nx.log || true; } | wc -l | tr -d '[:space:]') + echo "remote-cache label occurrences on ${LEG_OS}: ${count} -- RECORDED, never gated" + # D-16's positive control, and it asks a DIFFERENT question from the readiness poll + # above, with a DIFFERENT acceptance set: + # the acceptance set is 200 ALONE -- a 404 here is a control FAILURE + # the readiness poll accepts 404 or 200 on purpose; do not collapse them + # The poll proves REACHABILITY against a hash nothing creates; this probe proves the + # service really round-tripped an entry THIS RUN'S OWN SCOPE HOLDS -- the leg either + # RESTORED it from that scope or WROTE it into that scope, and both routes end in the + # same place, because the GET resolves through an UNCONDITIONAL cache.restoreCache in + # actions-cache-backend.ts that is not conditioned on this run having written first. + # THE DISJUNCTION IS MEASURED, not defensive: on runs 30907575624 and 30910935382 + # every integration leg took a remote-cache HIT and wrote NOTHING -- four + # leg-observations across those two runs -- and every control still returned 200. This + # comment used to attribute the key's presence to the leg's task having written it, + # which those runs falsify. + # A NON-200 SAYS DIFFERENT THINGS AT ITS TWO CODES. 000 is the sidecar not answering + # at all, and the `|| true` paragraph below carries that mechanism in full. But a + # 404 means the sidecar DID answer -- 404 is the readiness poll's own proof-of-life + # signature, and this file must not read one status code as proof of life at the poll + # above and as proof of death in this block. The fault is DOWNSTREAM of the HTTP layer: + # handleGet's catch in server.ts degrades EVERY backend fault to a 404 MISS (SRV-05), + # which is what makes a broken read path indistinguishable from a genuine absence at + # this boundary. That degradation, not a dead sidecar, is the masquerade agent this + # control exists to catch. + # THE CONSEQUENCE DEPENDS ON THE LEG'S OUTCOME, and both branches are stated because + # which one applies is a property of the run, not of this file. + # * On a MISS-then-write leg -- the load-bearing case -- a read path that cannot + # produce a key this run itself wrote could equally have MANUFACTURED the MISS: a + # token-less sidecar falls to createReadOnlyMemoryBackend in select-backend.ts and + # 404s everything. The MISS observation is INADMISSIBLE. + # * On a HIT leg the leg's own remote-cache-hit already proved the round trip through + # this same sidecar process, and the backend is chosen ONCE at sidecar startup, so + # a HIT excludes the degraded one. A 404 retracts nothing already observed; it + # reports read-path NON-DETERMINISM inside one job. That leaves the step + # REDUNDANT AS A LIVENESS PROOF and nothing weaker: its ENFORCEMENT is not + # redundant at all, because cacheStatus is RECORDED and never GATED, so on such a + # leg this step is the only thing in the job that hard-fails on a broken read path. + # THE FAILURE DIRECTION IS NEVER OBSERVED. No 404 has ever come back from this step, + # so every claim above about what a non-200 would MEAN is derived from the code rather + # than measured; only the presence disjunction is measured. Two probes, two acceptance + # sets, two questions -- collapsing them into one helper with the looser set would + # leave both steps green while destroying the control, so do not reuse the poll's + # deadbeef hash here either. + # + # THE FORK-PR CASE, RESOLVED -- the premise HOLDS on a fork, so this control needs + # no trigger gate and keeps its 200-only acceptance set. It was briefly recorded + # here as an open contradiction: the `build` job's comment claimed untrusted + # triggers get a token "whose saveCache no-ops", which would mean a fork PR saves + # nothing, this probe 404s, and BOTH legs go red blaming a dead sidecar that was + # alive. + # THAT CLAIM WAS THE WRONG ONE, and it is corrected at its own site. GitHub's + # read-only restriction is SCOPED: only high-trust triggers can create or overwrite + # caches in the DEFAULT BRANCH'S SCOPE, while a pull_request run -- fork included -- + # still CREATES its cache, in the merge ref (refs/pull/.../merge). This step's GET + # runs inside that same run and therefore reads that same scope, so the key is + # known-present in this run's own scope on a fork exactly as it is on the same-repo + # branch PRs every run to date has exercised. + # AND THE DISJUNCTION ADDS A SECOND, INDEPENDENT LEG here. The restore disjunct + # needs NO WRITE PERMISSION at all -- a fork PR that HITS restored the entry from a + # scope it only had to READ, and GitHub's restriction is on WRITING -- so that + # disjunct carries a HIT run with zero reliance on the fork-write citation below, + # while the merge-ref argument above still carries a MISS run. Two independent legs + # where there was one. That is NOT the fork question closed: see the escape hatch. + # The misreading is named because it is an easy one to repeat: drop the "in the + # default branch's scope" qualifier and containment reads as denial. CITED from + # GitHub's docs, not reproduced -- no fork PR on record -- with the same standing as + # the !cancelled() citation on the publish job. + # IF A FORK PR EVER DOES REDDEN HERE, that is new evidence about the platform, and + # the fix is to gate this step on the trigger. Never admit 404 into the acceptance + # set: a set that ADMITS THE DEGRADATION cannot detect it, and that degradation is + # the one failure this control exists to detect -- handleGet's catch in server.ts + # turning any backend read fault into a 404 MISS (SRV-05), which is indistinguishable + # at this boundary from the genuine absence the leg is trying to report. Not a dead + # sidecar: that yields 000, as the paragraph below records. + # + # No retry loop, deliberately: the key is known-present in this run's own scope, + # restored or written, so a retry could only paper over a real failure. The naive + # objection INVERTS -- a transient fault that a retry would HIDE IS exactly the + # read-path NON-DETERMINISM this control exists to surface on a HIT leg, so adding + # the retry would delete the finding rather than stabilise the step. And a 200 can + # only have come from a real cache-service hit -- the backend calls restoreCache + # BEFORE any local read and returns a miss when the service does not match, and + # server.ts degrades any backend fault to a 404 -- so this control is strictly + # conservative: it can false-negative, never false-positive. + # + # Placement: AFTER the Nx run, and the leading reason is premise-INDEPENDENT -- this + # step's own INPUT DOES NOT EXIST before the Nx run. own_hash is read from + # integration-hash.txt, which the run.json reader step writes, and that reader needs + # .nx/cache/run.json, which only the Nx run produces. Secondary, and Case-A specific: + # on a MISS-then-write leg the write is what makes the key present, so an earlier + # placement would 404 every time, breaking the control exactly where it is + # load-bearing. On a HIT leg an earlier placement would merely be harmless, which is + # why the mechanical reason leads. And BEFORE `cancel: cache-server`, because the + # probe goes through the sidecar. The bearer token is built in a shell variable from + # the env var the pre-set step already ::add-mask::ed, never interpolated, and only + # the HTTP code is echoed. + # + # THE `|| true` IS LOAD-BEARING, and its absence defeated this control on the ONE + # case it exists to detect. On connection-refused -- a DEAD SIDECAR -- curl exits 7, + # the command substitution INHERITS that status, and `set -e` aborts the step + # immediately: before the echo, and before the explanatory failure message below. + # The step still went red, but with a bare curl exit and nothing in the log saying + # the MISS observation had been invalidated, which is the opposite of this block's + # stated intent. Swallowing the exit lets curl's own `-w` report `000`, the + # `!= "200"` branch fires, and the explanation reaches the log. The readiness poll + # above already carries this for the same reason; this step did not inherit it. + # Note `|| echo 000` is still WRONG here, for the reason recorded on that poll. + - name: Positive control on this leg's own key, which must return 200 + shell: bash + run: | + set -euo pipefail + auth="Authorization: Bearer ${NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN}" + own_hash="$(cat integration-hash.txt)" + code=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -H "${auth}" "${NX_SELF_HOSTED_REMOTE_CACHE_SERVER}/v1/cache/${own_hash}" || true) + echo "positive control: GET /v1/cache/${own_hash} -> ${code} (wanted 200)" + if [ "${code}" != "200" ]; then + echo "positive control FAILED on ${own_hash}: observed ${code}, wanted 200 -- the sidecar and backend were not proven alive, so this leg's MISS observation is not evidence" >&2 + exit 1 + fi - cancel: cache-server + # The O3 EXISTENCE WITNESS (XOS-03, TEST-09, D-17). It asserts, post hoc and from the + # cache service's OWN metadata, that the `nx-cache-` entry already existed a + # stated margin BEFORE the Windows `integration` step started. That is what makes the + # Windows leg's remote-cache MISS attributable to the Nx hash divergence rather than to + # an entry that simply had not been written yet. Two authed read-only REST GETs, `curl` + # plus `jq`, both runner-provided on ubuntu. No `gh`, of which this file contains zero + # invocations (D-17 sub-lock 4), and keeping every REST call on this ubuntu job removes + # the windows-11-arm availability question entirely. + # + # THIS IS NOT AN ORDERING DEPENDENCY, and that distinction is the whole design. The + # proof is an ASSERTION evaluated on recorded metadata, so if the margin ever collapses + # the witness fails LOUD and no O3 proof is recorded for that run -- which is the + # correct failure mode. An ordering dependency would instead let a reordered run + # produce a WRONG verdict silently. Leg order is therefore NOT a correctness control, + # and XOS-06 and PROJECT.md's platform-agnosticism row are untouched by this job. + # MEASURED ubuntu-first 11 of 11 runs, 182 s max on run 30471772954 + # -- floor 109 s, and the cause is structural rather than lucky: `npm ci` costs about + # 19 s on ubuntu against about 180 s on windows-11-arm, so the ubuntu leg's whole job + # finishes before the Windows leg's install is halfway done. A measurement is not a + # documented guarantee, which is exactly why the comparison below demands a STATED + # 30-second minimum margin -- roughly four times headroom against that floor -- rather + # than a bare `<` that a timestamp-truncation artefact could satisfy. + # + # WHAT THIS JOB DELIBERATELY DOES NOT ASSERT: the absence of the remote-cache label. + # RECORDED and never GATED: a zero count is CORRECT on the windows leg + # and equally correct on any re-run at the same commit, where a LOCAL hit precedes any + # remote read -- so gating on it would redden the workflow for being right, and a + # tripwire that fires on correct work gets disabled (OBS-04 is this repo's own record + # of that happening). The integration job RECORDS that count; nothing anywhere gates on + # it. The structured cacheStatus value read-integration-hash.mjs prints is the + # remote-vs-local discrimination the count cannot make (D-24). + # + # needs: integration and NOT hash-parity, which is D-17 sub-lock 1. On a pull_request + # event hash-parity pins the pull request's HEAD sha while integration takes + # actions/checkout's default MERGE commit, so those two jobs measure DIFFERENT TREES and + # their task hashes are not commensurable. H_linux must come from the integration leg's + # own record. For the same reason inverted, this job does NOT copy + # hash-parity-compare's `ref:` checkout pin. + # + # EXACT KEY EQUALITY, never a count, and this is the clause whose loss would be least + # visible: + # ?key= is a PREFIX match, so total_count > 0 is NOT an existence test + # -- measured: `?key=nx-cache-1` returns 40 entries, and a full key minus its last + # character still returns 2, so a shorter hash that happens to be a prefix of a longer + # one satisfies any count-based test. Therefore + # the witness compares .key for EXACT string equality, never a count + # and it filters on `ref` as well, because ONE hash holds TWO entries on TWO DIFFERENT + # refs (measured: the same key under refs/heads/main and under refs/pull/N/merge), so a + # key-only match can return an entry from the wrong ref. The extraction is also + # TERMINATED with `// empty`, so an absent match yields a definitively EMPTY string + # rather than the literal four-character null a raw jq output would print -- an + # emptiness test against `null` is false, so without the terminator the guard would PASS + # on absence. Both omissions ship the same way: a count-based or null-blind check passes + # on the happy path and is wrong only in the case this job exists to detect, so nothing + # about a green run would reveal the regression. + # + # NO continue-on-error AND NO ADVISORY PERIOD. This job is build-gating from its first + # commit. Nothing in its `if:` may govern whether it PASSES: the verdict comes from the + # recorded cache-service and run metadata only, and there is deliberately no + # needs.*.result reference anywhere below. It is also ungated by event -- integration is + # not push-gated either, so the witness runs on pull requests too, which is useful + # rehearsal signal because it is read-only and fails loud. The O3 proof itself is + # recorded ONLY from the push run (D-20, plan 11-07). + o3-witness: + needs: integration + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04-arm + # Generic hang insurance -- see the build job. Matches the non-matrix siblings at 15 + # rather than the matrix jobs' 20: this job downloads one small text file and issues + # two authed REST GETs. No npm ci, no build, no checkout. + timeout-minutes: 15 + # A job-level permissions block REPLACES the workflow grant (contents: read) + # WHOLESALE -- it does NOT merge -- so every scope this job still needs has to be + # named here or it is silently dropped rather than reported. Hence: + # o3-witness restates contents: read because a job-level block REPLACES + # the workflow-level grant, and actions/download-artifact loses its grant without it. + # And: + # o3-witness does NOT request actions: write, the cache DELETE verb + # -- both REST endpoints this job reads are satisfied by actions: read, and a reader + # who "fixed" a 404 by widening to write would hand a read-only witness the ability to + # delete cache entries. Least privilege: exactly these two, nothing more. + # + # THE JOB DOES NOT CHECK OUT THE REPOSITORY, and contents: read is restated anyway. + # It needs no repo file -- it is curl plus jq against two endpoints, with no + # comparator to build, unlike hash-parity-compare whose comparator is TypeScript in + # dist/. The restatement is what keeps this block from silently narrowing the + # workflow's grant, and actions/download-artifact does need it. + permissions: + contents: read # RESTATED, not redundant -- see the block comment above + actions: read # /actions/caches and /actions/runs/{id}/jobs + steps: + # mkdir -p BEFORE the download, following hash-parity-compare's precedent, so the + # reader below never ENOENTs even when nothing arrived. + - name: Create the record directory before the download + shell: bash + run: | + set -euo pipefail + mkdir -p integration-hash-records + # THE ARTIFACT NAME IS A CONTRACT, on the same footing as the integration STEP name + # above and previously undocumented. THREE literals must move together: the + # integration job's `matrix.os` value, its upload name `integration-hash-${{ + # matrix.os }}`, and this hardcoded download name. A matrix bump that edits only the + # first two leaves this job requesting an artifact nobody produced, and the symptom + # is a download-artifact error HERE -- one job away from the cause, with no message + # about why. The witness's own `runs-on:` assertion does not cover this: that pins + # this job's runner, not the integration matrix's label. `dogfood-cross-os.spec.ts` + # now reads the label out of this line and asserts it still appears in the + # integration job, so the two fail loud when they drift APART while staying green on + # a legitimate coordinated bump. + - uses: actions/download-artifact@v8 + with: + name: integration-hash-ubuntu-24.04-arm + path: integration-hash-records + # GITHUB_TOKEN through the step env:. No PAT and no new secret, so the THREAT-MODEL + # ledger is unchanged. + # + # H_linux IS READ IN ITS ONE CONSUMING STEP AND NEVER ROUTED THROUGH $GITHUB_ENV, and + # that deletion is the fix rather than a filter on the old sink. The previous shape + # exported the value with a `-z` emptiness check as its only guard. Command + # substitution strips only TRAILING newlines, $GITHUB_ENV is parsed LINE BY LINE, and + # GitHub runs `run:` steps as `bash -e {0}` -- which sources BASH_ENV. So a record + # holding `123\nBASH_ENV=/tmp/evil.sh` would have defined a second variable for every + # later step in this job and executed arbitrary code. The value is ARTIFACT-CONTROLLED: + # it is produced by read-integration-hash.mjs in the integration job, which executes + # PR-authored code, and this job is ungated by event so it runs on pull_request from + # forks. This block already anchors the verification grep at `^` BECAUSE the record + # controls interpolated values; the same record must not reach a documented Actions + # injection sink. + # + # THE SHAPE CHECK IS THE SECOND HALF, and it subsumes the old emptiness check. Nx + # renders a task hash as an ALL-DECIMAL string, so `''|*[!0-9]*` rejects both an empty + # record and any value that is not a hash -- a cache key must never be composed from + # one. if-no-files-found: error on the upload side proves only that a file EXISTS, and + # read-integration-hash.mjs's own GUARD 3 is the upstream half of the same check. + # + # THE GREP IS REQUIRED ON TOP OF THE EXIT CODE, the same double signal + # hash-parity-compare records: the exit code is the first verdict, and the anchored + # content assertion is the second, proving the verdict was actually PRINTED rather + # than the step succeeding through an early exit. The `^` anchor is load-bearing + # rather than tidy, because both streams merge into the one log this grep reads and + # the failure detail interpolates values the downloaded record controls, so an + # unanchored match could hit a mid-line substring of a failure line. + - name: Assert the H_linux cache entry existed before the Windows integration step started + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The repository's own default branch, read from the event payload rather than + # hardcoded: both triggers (`push` and `pull_request`) carry `repository`, and a + # literal `main` here would silently stop matching the day the branch is renamed -- + # failing OPEN, since a ref that matches nothing simply drops out of the allowlist. + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + { + h_linux="$(cat integration-hash-records/integration-hash.txt)" + case "${h_linux}" in + ''|*[!0-9]*) + echo "o3-witness: FAIL -- the downloaded integration-hash record is empty or is not an Nx task hash (got '${h_linux}') -- refusing to compose a cache key from it" >&2 + exit 1 + ;; + esac + echo "o3-witness: H_linux=${h_linux}" + auth="Authorization: Bearer ${GITHUB_TOKEN}" + accept='Accept: application/vnd.github+json' + api="https://api.github.com/repos/${GITHUB_REPOSITORY}" + key="nx-cache-${h_linux}" + # The allowed BASE ref, empty on every event except pull_request (which is the + # only event that sets GITHUB_BASE_REF). The `:-` default form is required, not + # tidy: this step runs under `set -euo pipefail`, so reading an unset variable + # bare would abort the step on a push. + base_ref='' + if [ -n "${GITHUB_BASE_REF:-}" ]; then + base_ref="refs/heads/${GITHUB_BASE_REF}" + fi + # THE DEFAULT BRANCH IS THE THIRD READABLE SCOPE, and omitting it made the + # allowlist NARROWER than the scope this run can genuinely read -- while the + # paragraph below names that very scope as the reason the server-side filter was + # dropped. `on.pull_request` carries no `branches:` filter, so a STACKED pull + # request (base != the default branch) triggers this job, and on the exact shape + # the widening was built for -- Case B, every ubuntu producer HITs so nothing is + # written to this run's own ref -- the row proving prior existence lives on the + # DEFAULT branch. With only own-ref and base-ref allowed, the jq below discards a + # row the request already returned and the witness reports "no cache entry on any + # ref this run can read" about a ref it demonstrably CAN read: the misattributing + # wrong-cause report this block was rewritten to eliminate, reproduced one scope + # over. On a push to the default branch, and on a PR whose base IS the default + # branch, this arg duplicates one of the other two -- harmless, and the reason the + # gap stayed invisible. + default_ref='' + if [ -n "${DEFAULT_BRANCH:-}" ]; then + default_ref="refs/heads/${DEFAULT_BRANCH}" + fi + # NO SERVER-SIDE ref NARROW ON THE REQUEST, and dropping it is the fix rather + # than a simplification. A `&ref=` parameter can only ever return rows on the + # ref being asked about. On a Case-B run -- every producer HITs, so nothing is + # written to this run's own ref, and the entry the Windows legs actually read + # lives in the DEFAULT-branch scope -- the one row that proves prior existence + # is filtered out AT THE SERVER and never reaches jq at all, and the witness + # then reports "the entry may never have existed" about an entry that does + # exist. MEASURED on run 30768540898. Widening the jq while leaving the URL + # narrowed would therefore fix nothing. + # + # THE REF CONSTRAINT MOVES CLIENT-SIDE AND BECOMES AN ALLOWLIST over the scope + # a run can genuinely read: its own ref, plus the base branch when there is + # one. It is never DROPPED -- one hash holds entries on two refs, so a key-only + # match is not an existence proof and an unrelated ref's entry must not be + # allowed to satisfy the witness. + # + # Two things keep the widened response bounded: `key=` is still a prefix filter + # on a full all-decimal hash, so the returned rows differ essentially only by + # ref; and if the response ever DID truncate at 100 the witness fails LOUD + # rather than passing. + # + # `key` needs no encoding: CR-01's shape check upstream has already proven + # ${h_linux} is all-decimal, so the key is URL-safe by construction. Neither + # ref is interpolated into the URL any more, so the percent-encoding this block + # used to describe has no remaining subject. + # + # THE MATCHING ENTRY IS SELECTED AS AN OBJECT, not as a bare timestamp, so the + # ref it matched on can be printed below. EARLIEST rather than arbitrary-first: + # the assertion is a LOWER BOUND on prior existence, and the earliest match is + # the strongest form of it. ISO-8601 sorts lexicographically, so a plain sort + # is correct here. + # + # A TIMELESS ROW IS EXCLUDED BEFORE THE SORT, and that guard is not decoration. + # MEASURED with jq 1.8.1: sort_by places a null created_at FIRST, so a matching + # row carrying no timestamp wins `first` DETERMINISTICALLY even when a perfectly + # good row is in the same array -- `created` then comes out empty and the message + # below reports "the entry never existed" about an entry jq had in hand. That is + # the misattributing wrong-cause report this whole block was corrected for, + # reached one field over. `created_at` carries no `required` marker in the + # cache-list schema, so this is unlikely, not impossible. Excluding rather than + # coalescing is deliberate: a row with no timestamp cannot supply the LOWER BOUND + # this witness asserts, so it is not evidence and must not be selected as such. + # A response of ONLY timeless rows still fails loud, which is the honest verdict. + # + # READ `matched_ref` AS THE PROVENANCE OF THE LOWER BOUND, not as a claim about + # which run created the entry. Because the EARLIEST readable copy is selected, a + # run that also wrote its own copy still reports the base-branch ref when an + # older one exists there. That is correct -- the older copy is the stronger + # evidence -- but a reader who takes the printed ref for "this run did not + # produce it" would draw the wrong conclusion. + # + # THE RESPONSE IS PROVEN TO BE AN `actions_caches` ARRAY BEFORE ANYTHING IS READ + # OUT OF IT, mirroring the jobs-API guard thirty lines below rather than + # inventing a second shape. MEASURED under `set -euo pipefail`: an error payload + # (`{"message":"Not Found"}` on a permissions fault, `{"message":"API rate limit + # exceeded"}`, or any non-JSON body) makes `.actions_caches[]` fail with + # `jq: error (at :0): Cannot iterate over null (null)`, the subshell exits + # 5, and the step dies with NO `o3-witness:` message at all. Without the guard + # the best case is worse than silence: the empty result reaches the absent-entry + # message below and reports "the entry never existed" about an API fault, which + # is the same wrong-cause report the sibling block was corrected for. jq's own + # parse error still reaches stderr alongside the verdict -- that is extra + # evidence, not noise, so it is deliberately not suppressed. + # + # THE GUARD READS THE CONTAINER, SO THE SELECT READS THE ELEMENTS, and the two + # halves are not redundant. MEASURED with jq 1.8.1: `{"actions_caches":["scalar", + # 3]}` returns `ok` from the guard -- the type IS array -- and then the entry + # expression below exits 5 with `Cannot index string with string "key"`. Because + # `entry=$(...)` is a plain assignment under `set -euo pipefail`, that kills the + # whole brace group: nothing reaches o3-witness.log, the trailing grep fails on an + # empty log, and the step reports only jq's stderr with NO `o3-witness:` verdict -- + # verbatim the failure the guard above claims to have eliminated, one nesting + # level down. `select(type == "object")` is measured to turn the same payload into + # a clean empty result, which reaches the absent-entry message and its + # explanation. The guard above still earns its place: it is what distinguishes an + # API/permissions fault from an empty result, which this select cannot. + # THE `|| true` IS LOAD-BEARING HERE FOR THE REASON THE INTEGRATION JOB'S + # POSITIVE CONTROL ALREADY RECORDS, and this call did not inherit it either. + # curl exits non-zero for TRANSPORT faults that never produce a body -- 7 + # connection refused, 28 on the `--max-time 30` ceiling, 6 DNS, 35 TLS -- and the + # command substitution INHERITS that status, so `set -e` aborts the step BEFORE + # the array guard below can run. `-s` has already suppressed curl's own message, + # so the o3 proof would read as a bare non-zero exit naming no subsystem: exactly + # what the guard below exists to prevent, defeated from one line above it. + # Swallowing the exit lets the EMPTY body reach that guard, which is measured to + # handle it -- jq returns nothing, the capture is empty, `!= "ok"` fires and the + # explanatory message reaches the log. This job's own `if: !cancelled()` condition + # guarantees it runs even on a failed integration leg, which is the run where an + # operator most needs a verdict rather than a bare exit code. + # + # NEVER write a workflow EXPRESSION in its brace-template form anywhere inside a + # `run:` body, not even in a shell comment. GitHub templates the whole body before + # the shell ever sees it, so a commented expression is still EVALUATED -- and + # status functions like `cancelled()` do not exist outside an `if:`, so it rejects + # the entire file with "Unrecognized function: 'cancelled'" and NOT ONE job starts. + # Measured the hard way: this very comment did exactly that. Name such conditions + # in prose, as the line above now does. A spec clause counts these to zero. + caches_body=$(curl -s --max-time 30 -H "${auth}" -H "${accept}" \ + "${api}/actions/caches?key=${key}&per_page=100" || true) + if [ "$(printf '%s' "${caches_body}" | jq -r 'if (.actions_caches | type) == "array" then "ok" else "bad" end')" != "ok" ]; then + echo "o3-witness: FAIL -- the caches API returned no \`actions_caches\` array, so nothing was read about the entry at all. This is an API, rate-limit or permissions fault, NOT evidence about the entry and NOT attributable to the Windows MISS." >&2 + exit 1 + fi + entry=$(printf '%s' "${caches_body}" \ + | jq -c --arg key "${key}" --arg ref "${GITHUB_REF}" --arg baseref "${base_ref}" --arg defaultref "${default_ref}" \ + '[.actions_caches[] | select(type == "object") | select(.key == $key and (.ref == $ref or ($baseref != "" and .ref == $baseref) or ($defaultref != "" and .ref == $defaultref)) and (.created_at | type) == "string")] | sort_by(.created_at) | first // empty') + created=$(printf '%s' "${entry}" | jq -r '.created_at // empty') + matched_ref=$(printf '%s' "${entry}" | jq -r '.ref // empty') + if [ -z "${created}" ]; then + echo "o3-witness: FAIL -- no cache entry whose key is EXACTLY ${key} on any ref this run can read (own ref ${GITHUB_REF}, base ref '${base_ref}', default ref '${default_ref}'). Two causes, and this message cannot tell them apart: the entry never existed, or it exists only OUTSIDE this run's readable scope. Either way the Windows MISS is NOT attributable." >&2 + exit 1 + fi + # Both selectors are literals that must match the integration job's step name + # and the rendered matrix job name character for character. Asserting the + # result is NON-EMPTY is what makes a rename fail loud instead of comparing + # against an empty string. + # + # THE STEP OBJECT IS EXTRACTED, NEVER `.started_at` ALONE, because those are + # THREE outcomes and not two. Selecting the timestamp directly collapses "the + # step is ABSENT" and "the step EXISTS but never STARTED" into the same empty + # string, and the single message then blamed a RENAME for both. MEASURED + # against a fixture: a step reported `status: queued, started_at: null` -- what + # the jobs API returns when the Windows leg dies before reaching its run step, + # a wedged `npm ci` being the obvious way -- yields exactly the empty string a + # rename yields. So a correct witness printed "a rename in the integration job + # must land in the SAME commit", naming an edit nobody made and hiding the + # upstream failure that actually happened. Splitting the extraction is what + # lets each cause carry its own message. + # PAGED EXPLICITLY, because per_page=100 is the endpoint's MAXIMUM and not a + # guarantee of completeness. This workflow already runs ~25 job legs; crossing + # 100 (a wider matrix, more jobs) would silently TRUNCATE the response, drop the + # Windows leg off the page, and make a CORRECT run fail with the absent-step + # message below -- the same misattribution one layer up. MEASURED against a + # fixture whose first page is 100 filler jobs: the single-page form returns the + # empty string and blames a rename, the walk finds the step on page 2. The + # sibling caches query is UNPAGED, and is safe by a DIFFERENT and WEAKER + # mechanism since the Case-B widening: it is server-filtered by KEY ONLY -- a + # full all-decimal hash, so the rows returned differ essentially only by ref (1-3 + # in practice). It is NOT ref-filtered any more; the `&ref=` parameter was + # removed because it could not return the base-scope row the witness needs. If + # that key ever stops being a full hash, page it like this one. The two calls DO + # now share the array guard below, which is the only protection they have in + # common. This paragraph asserted the opposite for one commit -- planting a false + # comment is the CRITICAL this project has already shipped once, and `codeLines` + # strips `#` lines so no guard in dogfood-cross-os.spec.ts can ever catch the + # drift. A reader is the only thing protecting it. + # + # `gh` is deliberately unavailable here (D-17 + # sub-lock 4), so this is a plain page walk: stop on the first match, or when a + # SHORT page proves there is no next one. The 20-page ceiling is a runaway guard + # at 2000 job legs, not a limit any real run approaches. + # + # The `jobs`-is-an-array check is not defensive noise: without it an error + # payload (`{"message":"Not Found"}`) yields `length` 0, breaks the loop, and + # arrives at the absent-step message -- reintroducing exactly the wrong-cause + # report this block was just corrected for. + step='' + page=1 + while [ "${page}" -le 20 ]; do + # `|| true` for the same transport reason as the caches query above -- and it + # matters MORE inside this loop, where an abort also discards the pages already + # walked, so a mid-walk timeout reports nothing about the pages that did return. + body=$(curl -s --max-time 30 -H "${auth}" -H "${accept}" \ + "${api}/actions/runs/${GITHUB_RUN_ID}/jobs?per_page=100&page=${page}" || true) + if [ "$(printf '%s' "${body}" | jq -r 'if (.jobs | type) == "array" then "ok" else "bad" end')" != "ok" ]; then + echo "o3-witness: FAIL -- the jobs API returned no \`jobs\` array on page ${page}, so the run's job list was never read. This is an API or permissions fault, not a rename and not a missing step." >&2 + exit 1 + fi + step=$(printf '%s' "${body}" | jq -c 'first(.jobs[] | select(.name == "integration (windows-11-arm)") | .steps[] | select(.name == "Run the integration target and tee its output")) // empty') + if [ -n "${step}" ]; then + break + fi + if [ "$(printf '%s' "${body}" | jq -r '.jobs | length')" -lt 100 ]; then + break + fi + page=$(( page + 1 )) + done + if [ -z "${step}" ]; then + echo "o3-witness: FAIL -- no step 'Run the integration target and tee its output' in job 'integration (windows-11-arm)'. Both are literal selectors; a rename in the integration job must land in the SAME commit as the change here." >&2 + exit 1 + fi + started=$(printf '%s' "${step}" | jq -r '.started_at // empty') + if [ -z "${started}" ]; then + step_status=$(printf '%s' "${step}" | jq -r '.status // ""') + step_conclusion=$(printf '%s' "${step}" | jq -r '.conclusion // ""') + echo "o3-witness: FAIL -- the step EXISTS but never started (status=${step_status} conclusion=${step_conclusion}), so there is no start time to compare against. The Windows integration leg failed or was skipped UPSTREAM of its run step; this is NOT a rename and no O3 proof is recorded for this run." >&2 + exit 1 + fi + # The cache side carries sub-second precision and the step side is + # whole-second, so parsing both to epoch SECONDS is itself the floor. + created_epoch=$(date -u -d "${created}" +%s) + started_epoch=$(date -u -d "${started}" +%s) + delta=$(( started_epoch - created_epoch )) + echo "o3-witness: key=${key} created_at=${created} started_at=${started} delta=${delta}s margin=30s matched_ref=${matched_ref}" + # ON A CASE-B RUN THE DELTA IS HOURS OR DAYS rather than minutes, because the + # matched entry was written by an earlier run on the base branch. That is + # CORRECT and needs no allowance: the assertion is a lower bound on prior + # existence, so a LARGER delta is STRONGER evidence, not weaker. Do not "fix" + # it with an upper bound. + if [ "${delta}" -lt 30 ]; then + echo "o3-witness: FAIL -- observed delta ${delta}s, wanted at least 30s. The H_linux entry was not demonstrably present before the Windows integration step started, so no O3 proof is recorded for this run." >&2 + exit 1 + fi + echo "o3-witness: EXISTENCE OK key=${key} created_at=${created} started_at=${started} delta=${delta}s margin=30s matched_ref=${matched_ref}" + } 2>&1 | tee o3-witness.log + grep -q '^o3-witness: EXISTENCE OK' o3-witness.log + + # The two-leg hash-parity CAPTURE job (PARITY-03, PARITY-06; the capture half of + # D-17). It runs capture-hashes.mjs on both runner OSes and uploads one record per + # leg, so 08-ROOT-CAUSE.md can hold FOUR observation points -- two hand-captured on + # a developer workstation, these two from real runners -- all taken at ONE commit. + # + # CAPTURE ONLY. The build-gating comparison is a separate job, deferred to plan + # 08-06 (D-15 + D-18): pre-fix, the roadmap's own success criterion 1 says cold + # ubuntu differs from cold windows for EVERY target, so a gate wired now would be + # red for a reason the record has not established yet -- and D-18 forbids softening + # that with continue-on-error. + # + # NOT push-gated -- like the dogfood proof pair since CR-18, and unlike the publish + # pair. Every job in this file that STILL carries that gate does so because it WRITES + # (publishes a release asset, round-trips through a write-trusted sidecar) or because + # it reads back something only the push path produces (publish-verify, which is itself + # read-only). This one only measures. Same rule the read-side jobs state at :170-172. + # + # fail-fast: false carries a SECOND reason here on top of the one at :392-393 ("so + # a Windows-only failure never hides the Ubuntu result"): with fail-fast ON, a + # Windows leg failure CANCELS the Ubuntu leg, and 08-06's compare job then reports + # "fewer than two records" for a reason that has nothing to do with hash parity. + # + # shell: bash on the capture step, for the reason recorded at :399-404 -- GitHub's + # DEFAULT shell on windows-11-arm is pwsh, which fails on the constructs this + # file's scripted steps use. A bare `- run:` with no shell key inherits pwsh there. + # + # NO SIDECAR BLOCK, deliberately rather than by oversight. build, typecheck, test + # and integration each carry a ~35-line pre-set / background start-cache-server / + # readiness-poll / cancel: block because they run CACHED Nx tasks. This job runs + # none -- it computes hashes -- so it needs no cache client at all, and leaving the + # block out removes an entire class of flake from a measurement that has to be + # trusted. + # + # NO JOB-LEVEL permissions BLOCK, for the trap recorded in full at :943-946: such a + # block REPLACES the workflow grant WHOLESALE rather than merging it, so adding one + # "to be explicit" silently drops scopes. The workflow-level contents: read (:9-10) + # covers checkout plus an artifact upload, and this job requests no secret. + # + # BUILD STEP, added by plan 08-05 and REPLACING a documented no-build design. The + # original rationale is re-stated here rather than quietly reversed, because it was + # half right and the half that was right still constrains this job. + # + # It read: typecheck declares a dependentTasksOutputFiles input (nx.json:137) which + # hashes the CONTENT of packages/github-cache/dist/, so a leg that built hashes a + # populated directory while a leg that did not hashes an empty one; both legs must + # be IDENTICAL in that respect, and "neither builds" is the identical option that + # is also the fastest. + # + # The symmetry requirement is CORRECT and is preserved -- see the note on the step + # itself. The resolution was wrong: "both build" is equally symmetric, and it is + # the only one of the two that measures a number any machine actually computes. + # + # What the original missed: typecheck carries an INFERRED dependsOn of + # ["build", "^typecheck"], produced by @nx/js/typescript and absent from nx.json, + # so Nx defers typecheck's hash until build has produced its outputs. A real + # `nx typecheck` therefore computes the SAME hash whether or not dist/ was + # populated beforehand -- measured both ways at 8949082127832201885 on the + # maintainer's workstation, recorded in 08-ROOT-CAUSE.md under "CORRECTION: D-11's + # consequence is FALSIFIED". Without a build step this job hashed typecheck OUTSIDE + # that dependency chain and recorded 1284533355439392975 on both legs: internally + # consistent, cross-OS identical, and computed by nothing. The cross-OS COMPARISON + # was still valid (both legs were wrong the same way); the absolute value was not, + # and 08-06 gates on these records. + # + # A reader comparing against pack-check and publish-verify (both of which also + # build first) should read this paragraph rather than going looking: those jobs RUN + # a dist/ bin, they do not measure a hash, so they build for an unrelated reason. + # + # CHECKOUT REF -- the one deliberate deviation from every other job in this file. + # On a pull_request event actions/checkout resolves the MERGE commit by default: a + # SHA no developer can reproduce locally, over a TREE that differs from the + # branch's whenever the default branch has moved. Either one silently breaks + # PARITY-03's "four values per target at ONE commit". Pinning to the PR head SHA + # and falling back to github.sha on push makes the same step measure the branch + # tree on both events. The instrument records BOTH the checked-out HEAD + # (meta.commit) and the runner-provided event SHA (meta.githubSha) so the pin is + # inspectable rather than assumed. + # + # WHAT CHECKS THAT PIN, AND IN WHICH DIRECTION -- corrected here, because an + # earlier wording of this block had it backwards and implied a check no code + # performs. GITHUB_SHA on a pull_request event IS the merge commit, set by the + # runner from the event payload; it does not follow what the job checked out. So + # AGREEMENT between the two fields on a pull_request is the pin FAILING (the + # checkout landed on the merge commit), and disagreement in the specific direction + # "commit == PR head, githubSha == merge commit" is the pin WORKING. On a push + # they agree, and that is also correct. The right reading is therefore + # "meta.commit must equal the anchor", never "the two fields must agree". + # + # The comparator deliberately does NOT assert on githubSha, and that is not an + # oversight: the correct direction is EVENT-dependent and the comparator has no + # event context, githubSha is null off a runner by design, and HashParityRecord + # does not model a field this module would neither check nor use. What IS enforced + # is the failure this pin actually produces -- the two legs measuring different + # trees -- via the cross-leg `meta.commit` equality clause (`not-like-for-like` in + # compare.ts), added because meta.os had been the only one of the seven meta keys + # compared across the pair. 08-ROOT-CAUSE.md carries the one-off API cross-check + # (gh api .../pulls/9) that confirmed the pin held on both legs at the anchor. + # + # THE REPO HAD NO PRIOR ARTIFACT USAGE. The complete third-party action inventory + # across both workflows was actions/checkout and actions/setup-node, so there is no + # in-repo major to match and no precedent to copy -- upload-artifact@v7 here and + # download-artifact@v8 in 08-06 are asymmetric ON PURPOSE (v8 of the download + # action pairs itself with v7 of the upload action throughout its own README). + # The artifact NAME must be unique per leg: v4 onward made names immutable, so two + # legs uploading to one name is an error rather than a merge, and the matrix OS + # value is the natural discriminator. if-no-files-found: error is not tidiness -- + # the default is `warn`, so without it a leg whose instrument produced nothing + # uploads an EMPTY artifact, the download merges it into nothing, and the + # comparator reports "fewer than two records": the correct verdict with the wrong + # blame, one job further from the cause than necessary. A leg that measured nothing + # must fail ITS OWN leg. + # + # CORRECTED, and supplying the REPLACEMENT FACT is the point rather than retracting the + # old one. This block used to claim it was the ONLY place that rationale could live, on + # the ground that ci.yml was not an nx.json `test` input, so a spec asserting on this + # file's content would serve a stale cached PASS. Both halves are now false. A bare + # DELETION would have left a future reader holding a documented case for REMOVING the + # registration -- and removing it would turn every ci.yml content guard into a replay of + # a pass computed before its subject existed, which is exactly how Phase 9 shipped a + # regression. So, read from the config rather than from a comment: + # ci.yml IS in nx.json's targetDefaults.test.inputs (PARITY-08, Phase 9) + # -- so an edit to this file rotates the `test` hash and no ci.yml guard can go stale + # behind a cache hit. This file's job shape and its comment prose are now + # asserted by dogfood-cross-os.spec.ts and docs-same-os-claims.spec.ts + # respectively: the first reads it comment-STRIPPED, so it sees the YAML but + # structurally cannot see a comment, and the second reads it RAW, so it is the only + # guard in the repo that can. Two harnesses, one question each. This block is therefore + # no longer the only place the rationale CAN live -- it is simply where a reader looks + # first, so it keeps carrying it. + hash-parity: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04-arm, windows-11-arm] + runs-on: ${{ matrix.os }} + # Generic hang insurance -- see the build job. + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + # Hash typecheck INSIDE its dependency chain. typecheck's inferred dependsOn + # is ["build", "^typecheck"], and the instrument calls hashTask directly, so + # without this step the recorded typecheck value is one no real run produces + # -- see the BUILD STEP paragraph above. + # + # This is ONE step in a matrix job, so both legs get byte-identical treatment + # by construction rather than by two copies staying in step. That symmetry is + # the half of the original no-build rationale that survives: whatever this job + # does about dist/, it must do the same on ubuntu-24.04-arm and on + # windows-11-arm, or the leg-to-leg comparison stops comparing like with like. + # + # Still no sidecar block: `npm run build` here resolves through Nx's LOCAL + # cache only, because the cache client is env-driven and this job sets none of + # those variables. A fresh runner therefore always really builds. + - run: npm run build + # The instrument is invoked as a plain node script, never through `nx run` + # (D-01b): an Nx-CACHED instrument would REPLAY a stale record instead of + # measuring. --install-mode is REQUIRED and has no default, and `ci` is the + # honest value because the step above ran a clean install. + - name: Capture the Nx task hashes on this runner + shell: bash + env: + # Disabling the daemon is the CALLER's job BY DESIGN: the instrument + # MEASURES daemonEnabled rather than forcing it, so a leg that forgot is + # visible in the record instead of being silently reclassified. + NX_DAEMON: 'false' + # Passed in through env rather than interpolated into the script body, so + # the matrix value never lands on a command line the shell re-parses. + RECORD: hash-parity-${{ matrix.os }}.json + run: | + set -euo pipefail + node capture-hashes.mjs --install-mode ci --out "$RECORD" + # TEST-08's MECHANICAL PREMISE ASSERTION, and it is wired HERE because an + # assertion nothing executes is documentation. The mode landed with no CI step, no + # npm script and no test behind it, so its own stated purpose -- "an Nx upgrade that + # changes the inferred dependsOn must fail loud instead of quietly weakening the + # control" -- was unreachable: an Nx upgrade that changed it would have changed + # nothing, because nothing ran the assertion. + # + # THIS JOB rather than a new one: the mode resolves a task graph and measures no + # hash, so it needs no sidecar and no build, and this job has already installed. It + # runs on BOTH matrix legs, which is the point -- the premise is about the WINDOWS + # leg's resolved graph, so a ubuntu-only check would assert it on the wrong runner. + # Under set -euo pipefail a failed assertion reddens the leg, and the --out record + # is the evidence TEST-08 asks for rather than a discarded pre-flight check. + # + # CORRECTED BY XOS-04, with the replacement reason rather than a bare deletion, + # because a comment carrying a false reason is a documented argument for undoing + # the work. This block used to read the assertion as establishing PRODUCER + # ATTRIBUTION -- "any build/typecheck/test hash in the store is Linux-produced" + # (D-12, D-14 row 1). Three things, and the order matters: + # (a) WHAT IT STILL GUARDS, unchanged and still a real gate: the GRAPH PROPERTY + # that `nx run-many -t integration` resolves no build/typecheck/test task. + # An Nx upgrade that changed the inferred dependsOn must fail loud here + # instead of quietly weakening the control. XOS-04 does not touch that: the + # new windows-11-arm legs run different commands and declare no new target + # and no new dependsOn, so `integration`'s resolved set is untouched and this + # assertion keeps passing. + # (b) WHAT IT NO LONGER ESTABLISHES: producer attribution. That inference rested + # on a CONJUNCTION -- the graph premise AND the fact that Windows CI ran only + # `integration`. The build-windows / typecheck-windows / test-windows legs + # falsify the second conjunct PERMANENTLY, so the premise alone can no longer + # carry it. The second-producer consequence is priced in + # .planning/phases/10-os-invariant-releases-mirror/10-SECURITY.md's Q1. + # (c) WHERE THE ATTRIBUTION RECORD IS FROZEN: + # .planning/phases/11-live-proofs-o1-o2-o3/11-EVIDENCE.md's O1 section, + # captured at proof time precisely because no future session can re-derive + # it. Read that record; do not try to reconstruct the attribution from this + # assertion. + # + # --install-mode is deliberately absent and the script REJECTS it here: the mode + # measures no hash, so recording an install mode against it would make the record + # claim a provenance it never measured. + - name: Assert the TEST-08 graph premise on this runner + shell: bash + env: + NX_DAEMON: 'false' + PREMISE: graph-premise-${{ matrix.os }}.json + run: | + set -euo pipefail + node capture-hashes.mjs --assert-graph-premise --out "$PREMISE" + - uses: actions/upload-artifact@v7 + with: + name: hash-parity-${{ matrix.os }} + path: hash-parity-${{ matrix.os }}.json + if-no-files-found: error + # A SEPARATE ARTIFACT, and the name deliberately does NOT match `hash-parity-*`. + # hash-parity-compare downloads that pattern with merge-multiple: true and its + # loader reads EVERY `.json` in the merged directory, so folding the premise record + # into the capture artifact would hand the comparator four records where it demands + # exactly two and redden `wrong-record-count` on a CORRECT run -- a tripwire firing + # on correct work, which this file already records as the thing that gets guards + # disabled. + - uses: actions/upload-artifact@v7 + with: + name: graph-premise-${{ matrix.os }} + path: graph-premise-${{ matrix.os }}.json + if-no-files-found: error + + # The BUILD-GATING hash-parity comparison (CORR-03, D-15's fourth and last step, + # D-17's compare half, D-18, D-20, D-21, D-23). It downloads both capture legs' + # records and runs the unit-proven comparator over them, so PARITY-03 is ENFORCED + # on every run rather than measured once in 08-ROOT-CAUSE.md and then trusted. + # + # WHAT IT ASSERTS, via packages/github-cache/src/hash-parity/compare.ts: exactly + # two platform records with a non-empty hash and a non-empty node map for all five + # targets; DIFFERENT meta.os on the two; `integration` DIVERGENT between them; and + # `build`, `typecheck`, `test` and `lint` IDENTICAL between them. Note the sign on + # `integration`: a MATCHING integration hash is a discriminator FAILURE, not a + # parity success, because that target is the only one declaring a platform runtime + # input (nx.json:101, D-14). `lint` is the FOURTH invariant target rather than a + # recorded observation -- D-21's PRIMARY branch, taken because plan 08-05 measured + # `lint` byte-identical across both legs (6930879416208693542 on each). D-21's + # named fallback (downgrade the clause to a recorded finding) therefore does NOT + # apply, and either way the clause is never DELETED: a deleted clause is + # indistinguishable from one that never existed. + # + # NO continue-on-error AND NO ADVISORY PERIOD (D-18). This job is build-gating from + # its first commit. A later addition of continue-on-error would be invisible in a + # green run, which is why the observed-RED record in 08-ROOT-CAUSE.md's + # "The gate can fail" section carries run references as the reference point. + # + # if: !cancelled() -- CHOSEN DELIBERATELY, not by reflex. D-17 names `always()`, + # and what D-17 actually REQUIRES is that a FAILED or SKIPPED capture leg still + # arrives at the assertion: "fewer than two records is a FAILURE, not a skip" is + # unreachable if the compare job simply skips alongside its dead upstream. The two + # forms differ on exactly one case -- cancellation -- and a cancelled run producing + # a red gate is noise, not signal. `!cancelled()` covers failure and skip, and it + # is ALREADY this file's house form for a dependent job (`publish` at :926 uses + # exactly it). So the narrower expression satisfies D-17's intent and matches + # precedent at the same time. + # + # The if: expression governs whether this job RUNS. NOTHING in it may govern + # whether this job PASSES (D-23): the verdict comes from record CONTENT only, never + # from needs.*.result. A leg can succeed and still have uploaded a truncated + # record, and a leg can fail AFTER a valid upload -- deriving the verdict from job + # status inverts both cases. There is deliberately no needs.*.result reference + # anywhere below. + # + # A SINGLE ubuntu runner, not a matrix. This job compares two records; it is not + # per-OS. It is also the FIRST job in this file that collapses a matrix's legs into + # ONE downstream job that sees both -- every other `needs` here either fans in from + # a single job (`dogfood-verify` needs `dogfood-seed`, `consumer-smoke` needs + # `action-bundle-drift`) or is itself a matrix pairing up leg-by-leg + # (`publish-verify` needs `publish`). A reader looking for an in-repo precedent for + # the fan-in shape should read this sentence rather than go looking: there is none. + # + # NO JOB-LEVEL permissions BLOCK, for the trap recorded in full at :943-946: such a + # block REPLACES the workflow grant WHOLESALE rather than merging it, so adding one + # "to be explicit" silently drops scopes. The workflow-level contents: read (:9-10) + # covers checkout plus an artifact download, and this job requests no secret. + # + # NO EVENT GATE. Every job in this file STILL carrying `if: github.event_name == + # 'push'` does so because it WRITES (publishes a release asset, round-trips through a + # write-trusted sidecar) or because it reads back something only the push path + # produces (publish-verify, which is itself read-only). This one only reads and + # asserts -- same rule the read-side jobs state at :170-172. A gate that only runs on + # the default branch is not a gate on a pull request. + # + # CHECKOUT REF pinned to the PR head SHA, as in the capture job (see its CHECKOUT + # REF paragraph at :525-535 for the full reasoning). The narrower reason HERE: the + # records were captured from the branch tree, so the comparator that judges them + # must be the branch's comparator. On a default merge-commit checkout, a clause + # WEAKENED on the default branch would silently judge this branch's records -- a + # false-green channel, since the merge tree can carry a removal the branch never + # made. + # + # THE BUILD STEP IS MANDATORY and easy to forget: the comparator is TypeScript, so + # dist/hash-parity/assert-parity.js does not exist until `npm run build` has run, + # and without it the step fails for the wrong reason. pack-check (:121-131, reason + # spelled out at :119-120) and publish-verify (:1043-1050) carry the same + # build-then-run ordering. + # + # RECORDS DIRECTORY CREATED BEFORE THE DOWNLOAD, so the loader always has a + # directory to read rather than an ENOENT, even when nothing arrived. RESIDUAL + # LIMITATION, accepted and recorded rather than papered over: if the download + # action ITSELF errors on a zero-match pattern, this job is still RED but the blame + # lands one step earlier than the comparator's named `wrong-record-count` reason. + # The fixture case for that clause is unit-proven regardless (08-02: a one-record + # directory exits 1 naming `wrong-record-count` plus its three suspects), so the + # clause is covered; only the blame location shifts. + # + # THE GREP IS REQUIRED ON TOP OF THE EXIT CODE (D-23), and it is not optional. The + # pipeline's failure mode is the FIRST half -- a non-zero exit from the bin fails + # the step under pipefail. The grep is the SECOND: it asserts the comparison + # actually RAN and PRINTED its verdict, rather than the step succeeding because the + # bin was absent, the directory was empty in a way the loader tolerated, or a future + # refactor turned the assertion into a no-op. The `lint` job at :66-72 is the exact + # precedent and its comment is this file's statement of the rule; Phase 7's + # measurement that `nx run-many -t ` prints "No tasks were run" and exits 0 + # is why the precedent exists. Unlike that job this one needs NO NO_COLOR: the + # asserted prefix is the comparator's own literal (`SUCCESS_PREFIX` in + # assert-parity.ts), deliberately colour-free and not Nx-formatted, so no reporter + # change can break the match. + # + # THE `^` ANCHOR IS LOAD-BEARING, not tidiness, and calling this signal + # INDEPENDENT is only true WITH it. Every failure detail the comparator prints + # interpolates values the DOWNLOADED RECORD controls (a target key, meta.os, a + # hash), and both streams are piped into the one log this grep reads. So a record + # could carry the success prefix as record CONTENT and have the FAILURE path print + # it: unanchored, it matches as a mid-line substring of the `PARITY FAILED` line, + # and with a line break in the injected value it matches as a whole line. The + # anchor closes the substring half -- the failure line begins `hash-parity: PARITY + # FAILED`, so `^hash-parity: PARITY OK` cannot match it. The line-break half is + # closed in code, at the single choke point `compare.ts:fail` documents, and pinned + # by `compare.spec.ts`. NEITHER HALF SUFFICES ALONE; do not "simplify" either away. + # Until both landed this signal was not independent at all -- the step was red only + # because a non-zero exit under pipefail aborts before the grep runs, which is + # exactly the exit-code-alone verdict D-23 forbids relying on. + # + # CORRECTED. This block used to open NO SPEC ASSERTS ON THIS FILE and gave that as the + # reason it carries so much rationale, on the further ground that ci.yml was not an + # nx.json `test` input, so a content guard would serve a stale cached PASS. Two specs + # assert on this file now, and the inputs claim was already false when it was written + # here. The replacement fact rather than a bare deletion, because a deletion would leave + # the case for REMOVING the registration standing unopposed -- which is how Phase 9 + # shipped a regression: + # ci.yml IS in nx.json's targetDefaults.test.inputs (PARITY-08, Phase 9) + # -- so an edit here rotates the `test` hash, and this job's own shape and the prose + # below are + # asserted by dogfood-cross-os.spec.ts and docs-same-os-claims.spec.ts + # -- the comment-STRIPPED reader for the YAML, the RAW reader for the comments. The + # rationale still lives here because this is where a reader looks, not because nothing + # else can see it. + hash-parity-compare: + needs: hash-parity + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04-arm + # Generic hang insurance -- see the build job. Matches the non-matrix siblings at + # 15 rather than the capture matrix's 20: this job installs, builds, downloads two + # small JSON files and runs one node script. + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + - run: npm run build + - name: Create the records directory before the download + shell: bash + run: | + set -euo pipefail + mkdir -p hash-parity-records + - uses: actions/download-artifact@v8 + with: + pattern: hash-parity-* + merge-multiple: true + path: hash-parity-records + - name: Compare the two records, and prove the comparison actually ran + shell: bash + run: | + set -euo pipefail + node packages/github-cache/dist/hash-parity/assert-parity.js hash-parity-records 2>&1 | tee hash-parity.log + grep -q '^hash-parity: PARITY OK' hash-parity.log + # The dogfood-seed / dogfood-verify pair is this phase's LIVE proof that the # cache really works (ROADMAP SC5) and doubles as the upgrade canary for the # exact-pinned @actions/cache dependency (ROBUST-03): a version bump that changes @@ -421,15 +1933,51 @@ jobs: # server inert behind a green result (Pitfall 6). NO job-level permission block is # added on purpose: the runtime cache credentials (injected only into a JS action) # are independent of the workflow token grant, and a job-level block would REPLACE - # the workflow-level grant wholesale rather than extend it. Both jobs run ONLY on - # the default-branch push trigger, because the write gate trusts no other trigger - # (push/schedule only) and the read-only path is exhaustively unit-tested instead. + # the workflow-level grant wholesale rather than extend it. Both jobs run on the + # default-branch push trigger AND on SAME-REPO pull requests, so the cross-OS proof + # is available BEFORE a merge rather than only after one. A `pull_request` run is + # write-trusted here -- HOST_GATED_EVENTS in lib/trust.ts admits it, and + # isWriteTrusted returns trusted for it on github.com -- and the entry it writes + # lands in the PR's own merge-ref scope, invisible to main's cache, ageing out under + # the standard 7-day-unaccessed policy. FORK pull requests are DELIBERATELY excluded + # rather than overlooked: fork `pull_request` cache behaviour is CITED from GitHub's + # docs and has never been reproduced in this repo, so a gating check is not bet on + # it, and merged code still gets this proof through the push path. # action.yml's main points into the build output, so each job builds first. dogfood-seed: - # run id as the cache hash: unique per run and already all-decimal, so it - # satisfies the server's ^[a-f0-9]{1,512}$ hash validator with no massaging. - if: github.event_name == 'push' + # The marker word `bead` followed by the run id as the cache hash: unique per run, + # and still inside the server's ^[a-f0-9]{1,512}$ hash validator because the marker + # word is hex LETTERS. Being hex-letter-LEADING is the load-bearing half (D2), not + # the mere validity: both competing key spaces -- workflow run ids and Nx task + # hashes -- are all-decimal, so a marked key is STRUCTURALLY separable from a real + # task hash. That is what lets the publish mirror skip a PRIOR run's copy of this + # single-use seed instead of re-enumerating and re-restoring it on every run + # forever. The bare run id this replaced was valid but indistinguishable, which is + # exactly the property that made it unfilterable. Same convention as + # `cafe` (consumer-smoke) and `feed` (the publish leg), with a + # DISTINCT word per family so a shard listing can tell the three apart. + # + # dogfood-verify's `hash:` MUST carry the identical value -- the two are one + # round-trip, and a one-sided edit surfaces only as a live-CI MISS. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-24.04-arm + # Generic hang insurance -- see the build job. A hung step otherwise holds a runner + # for the 360-minute account default. + # + # WHY THIS JOB AND THE TWO PUBLISH JOBS, and not every job in the file: the publish + # pair is push-only background work, and nobody watches that the way a PR author + # watches a check, so a hang there is invisible rather than merely slow -- the job is + # not RED, it is simply never done, and the next push starts a fresh one. This job + # inherited the same cap when it was push-only too. Since CR-18 it is PR-visible, and + # the cap is strictly correct either way: a capped PR check fails loud, an uncapped + # one just hangs. + # + # The short PR gates (format-check, lint, fallow, action-bundle-drift, pack-check, + # ppe) are still uncapped and are deliberately left so. A hang there blocks a visible + # required check on somebody's open PR, which is self-reporting. Named rather than + # left as an apparent oversight, so a reader does not have to re-derive whether the + # omission was considered. + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 @@ -440,7 +1988,7 @@ jobs: - run: npm run build - uses: ./packages/github-cache with: - hash: ${{ github.run_id }} + hash: bead${{ github.run_id }} operation: seed env: # selectBackend hands back the writable Actions-cache backend only when a @@ -449,11 +1997,53 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} dogfood-verify: - # Reads back exactly what dogfood-seed wrote, keyed on the same run id, so a real - # cross-job HIT is the only way this job passes; a MISS fails the job loudly. - if: github.event_name == 'push' + # Reads back exactly what dogfood-seed wrote, keyed on the IDENTICAL `hash:` value + # (the `bead` marker word plus the run id -- see dogfood-seed), so a real + # cross-job HIT is the only way this job passes; a MISS fails the job loudly. TWO + # LEGS, and the Windows one is VER-06's whole proof: it restores a body a LINUX + # runner produced, so a green windows-11-arm leg is the live evidence that an + # Actions-cache entry saved on Linux is readable on Windows. The action compares + # against the LITERAL 'linux' producer stamp folded into the payload bytes, so this + # is a PROVENANCE check, not a presence check -- see action/index.ts's verify branch. + # fail-fast off so a Windows-only failure never hides the ubuntu result. + # + # THE VACUITY CONDITION -- read this before adding a Windows dogfood-seed leg. + # dogfood-seed is ubuntu-ONLY BY DESIGN and must stay that way. The seed key is + # nx-cache-bead: ONE key per RUN, not per OS. So a Windows seed leg would + # make the Windows verify leg restore a WINDOWS-written entry and pass even with + # cross-OS restore completely dead -- the proof would go green while proving nothing. + # If a Windows seed leg is ever genuinely wanted, the expected-producer literal in + # action/index.ts's verify branch has to change in the same commit. + # dogfood-cross-os.spec.ts asserts BOTH halves of this shape from disk (this job has + # the two-leg matrix; dogfood-seed has no matrix) so neither can drift unnoticed. + # + # The ubuntu verify leg is KEPT rather than traded for the Windows one: it preserves + # the v0.0.1 same-OS round-trip close instead of swapping one proof for another. Both + # legs assert a LINUX-produced body -- trivially true for the ubuntu leg, and the + # entire point for the Windows one. + # + # A GREEN dogfood-verify IS NOT ROBUST-04 EVIDENCE. Both dogfood jobs run + # `uses: ./packages/github-cache`, whose dist/action/index.js is built from source + # in-job, so they never execute the COMMITTED start-cache-server/index.js -- which is + # what four of the five bundle sites actually run. action-bundle-drift (no `if:`, so + # it runs on PRs too) is the only control tying the two together. The misreading is + # natural, so it is recorded here where a reader will look. + # + # SAME CONDITION AS dogfood-seed, belt-and-braces. `needs: dogfood-seed` plus the + # default success() status check would already SKIP this job when the seed skips + # (a skipped need skips its dependents; it is never left queued and never fails), + # so the restated condition is redundant by construction -- and kept anyway, + # matching the publish / publish-verify pairing this file already uses. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository needs: dogfood-seed - runs-on: ubuntu-24.04-arm + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04-arm, windows-11-arm] + runs-on: ${{ matrix.os }} + # Generic hang insurance -- see the build job. Matches the `integration` job's value; + # this job had none before it grew a matrix. + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 @@ -464,7 +2054,7 @@ jobs: - run: npm run build - uses: ./packages/github-cache with: - hash: ${{ github.run_id }} + hash: bead${{ github.run_id }} operation: verify env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -536,10 +2126,14 @@ jobs: - name: Nx cache round-trip over the loopback sidecar env: # `cafe` prefix keeps the key valid hex (^[a-f0-9]+$) yet DISTINCT from the - # dogfood-seed / publish `nx-cache-` key. All three ubuntu jobs share - # this run's Actions-cache scope; a bare run_id collides -- this job's PUT - # would land in the very key dogfood-verify reads back (Actions cache is - # first-write-wins), returning consumer-smoke's payload as wrong data. + # dogfood-seed `nx-cache-bead` and publish `nx-cache-feed` + # keys. All three ubuntu jobs share this run's Actions-cache scope; a bare + # run_id would collide -- this job's PUT would land in the very key + # dogfood-verify reads back (Actions cache is first-write-wins), returning + # consumer-smoke's payload as wrong data. A DISTINCT marker word per family is + # the convention rather than a coincidence, and since D2 all three families + # carry one: it is what lets a shard listing -- and the publish mirror's seed + # filter -- tell the three apart. RUN_HASH: cafe${{ github.run_id }} run: | set -euo pipefail @@ -563,9 +2157,9 @@ jobs: echo "sidecar never accepted connections on ${server} after 30s" >&2 exit 1 fi - # PUT a payload keyed on the run id (all-decimal -> valid ^[a-f0-9]+$), - # then read it back and byte-compare: a real round-trip through the - # write-trusted Actions-cache backend. + # PUT a payload keyed on the marker-prefixed run id (hex letters keep it + # inside ^[a-f0-9]+$), then read it back and byte-compare: a real + # round-trip through the write-trusted Actions-cache backend. printf 'consumer-smoke-%s' "${RUN_HASH}" > payload.bin put=$(curl -s -o /dev/null -w '%{http_code}' -X PUT --data-binary @payload.bin -H "${auth}" "${server}/v1/cache/${RUN_HASH}") test "${put}" = "200" @@ -576,22 +2170,131 @@ jobs: # The per-OS publish matrix is this phase's LIVE mirror path (D-03, TRUST-02): on a # default-branch push each leg enumerates the default-branch Actions-cache entries via - # getActionsCacheList, restores the nx-cache-* ones IT CAN restore on ITS OWN OS, and - # uploads them to the current month-shard GitHub Release. The matrix is LOAD-BEARING and - # self-enforcing: @actions/cache folds the per-OS tmpdir path + a windows-only salt + the - # compression method into the cache version hash, so an ubuntu leg can NEVER restore a - # Windows-saved entry (restoreCache returns undefined and the engine skips it). Collapsing - # this to one OS SILENTLY drops the other OS's entries (D-03) -- keep BOTH legs; fail-fast + # getActionsCacheList, restores the nx-cache-* ones, and uploads them to the current + # month-shard GitHub Release. The matrix is LOAD-BEARING but it + # is no longer SELF-ENFORCING at the storage layer: @actions/cache still folds the + # compression method into the cache version hash unconditionally (cacheUtils.js:162-163), + # but from VER-01/VER-03 the archive path is workspace-relative and enableCrossOsArchive + # suppresses the windows-only salt, so an ubuntu leg CAN now restore a Windows-saved entry. + # The reason to keep both legs is now the Nx HASH, not the store: the Windows leg is still + # the ONLY leg that produces Windows-hash entries at all, because the integration and + # hash-parity platform discriminator keeps those task hashes distinct. So collapsing this to + # one OS STILL SILENTLY drops the other OS's entries (D-03) -- now because those tasks never + # RUN there, rather than because the store partitions -- keep BOTH legs; fail-fast # off so a Windows-only failure never hides the ubuntu result. It runs as `operation: publish` # on the node24 JS action, NOT a `run: node` step, because @actions/cache restoreCache needs # the JS-action-only ACTIONS_RUNTIME_TOKEN/ACTIONS_RESULTS_URL runtime (a plain run: step - # silently MISSes -- verified Phase 2). needs: build (NOT test): a failing test leg must never - # skip the mirror and drop already-built entries (RESEARCH Anti-Patterns). if: !cancelled() && - # push -- the mirror runs only on the trusted push trigger, and isSyncTrusted re-checks the - # default branch in-process as the load-bearing gate (D-01), not the workflow if: alone. + # silently MISSes -- verified Phase 2). if: !cancelled() && push -- the mirror runs only on the + # trusted push trigger, and isSyncTrusted re-checks the default branch in-process as the + # load-bearing gate (D-01), not the workflow if: alone. + # + # WHY the if: also carries `&& !github.event.forced` (D-U2Q). The temporary `main` window is + # this project's only sanctioned instrument for observing a push-only job: push the feature tip + # to `main`, let the push jobs run, then force-push `main` back to its restore point. That + # RESTORE push is itself a push to the default branch, so before this clause it fired the whole + # production mirror path a SECOND time, over a tree that was already mirrored. Suppressing it + # from the commit message is structurally unavailable -- the restore re-pushes an EXISTING + # commit and cannot alter its message without changing the very SHA it is restoring. + # EFFECTIVE DATE -- READ THIS BEFORE TRUSTING THE CLAUSE. This clause is DORMANT for a + # restore until `main` itself contains it. A `push` event runs the workflow file at the + # PUSHED TIP (GITHUB_SHA is documented as "Tip commit pushed to the ref", and the push + # trigger explicitly "includes workflows that are not merged into the default branch" -- + # which is only possible if the workflow is read from the pushed commit). A restore pushes + # the pre-window commit, so it runs THAT commit's workflow. While the restore point predates + # this clause, the restore still fires the production mirror path exactly as before, and the + # window's final hop needs out-of-band suppression (disable this workflow for that one push). + # Every audit of this change missed that, in the same way, because all of them read the + # workflow in the working tree and none asked WHICH COMMIT'S COPY EXECUTES. + # A forced push CAN still be proven gated without merging: rewind to a tip that DOES carry + # the clause, and the gate applies because that tip's workflow is the one that runs. + # FORCE-PUSH MECHANISM: the git push wire protocol carries no force bit -- the update command + # on the wire is ` `, and --force is a CLIENT-side policy that + # send-pack applies locally and never transmits. So GitHub can only be computing `forced` + # server-side from non-fast-forwardness. Consequence worth having: an operator who types + # --force-with-lease on a fast-forward window-OPEN push still gets `forced: false`, and that + # push still publishes. The discriminator is robust against operator habit, not against it. + # MEASURED PUSH SHAPES: 70 pushes to refs/heads/main, observed 2026-08-08 while + # origin/main == fe25a3f -- 11 restores (tip -> fe25a3f, every one a rewind), 11 window-opens + # (fe25a3f -> tip, every one a fast-forward), the single PR #7 merge push that CREATED + # fe25a3f (e56e5d2 -> fe25a3f, a fast-forward, and it SHOULD have published), and 47 older + # pushes predating that restore point. 23 in the fe25a3f era, separating 23/23 with zero + # misclassification. ANCHORED on purpose: the events feed is a rolling window and this record + # is meaningless once `main` moves, so re-measure it rather than carry the numbers forward. + # THAT SEPARATION IS BY ANCESTRY, NOT BY AN OBSERVED `forced`. The Events API does not expose + # the field, so these 23 have no truth column: they establish that rewinds and fast-forwards + # sort cleanly into restores and window-opens, NOT that GitHub sets `forced` from the same + # distinction. That second step rests on the FORCE-PUSH MECHANISM argument above and nothing + # else. The field has never been read in either direction; the first window whose rewind lands + # on a tip carrying this clause is the first observation of it. + # THE INCIDENT, run 30825636788: a restore-shaped run that reached + # POST /repos/op-nx/github-cache/releases and got 422 -- a real production write ATTEMPTED + # under this job's `contents: write` grant, not a dry run. The 422 itself is the separate, + # already-documented burned-shard defect in + # .planning/debug/publish-verify-422-empty-shard.md; do not conflate the two. FOUR of the + # restore-shaped runs concluded `success` and completed their uploads. NOT five: there are + # 12 push runs at headSha fe25a3f and 5 concluded `success`, but one of those 12 is the + # PR #7 MERGE push that CREATED fe25a3f (mergedAt 11:49:27Z, push 11:49:28Z, run + # 30200859202 at 11:49:29Z) -- fe25a3f did not exist before that moment, so no restore can + # predate it. That fifth success is the merge push this same comment says SHOULD have + # published. Counting it as an incident counts a CORRECT publish as a defect, which is + # what the raw 12-and-5 query does if you do not subtract the creating push. + # SKIP DIRECTION: a wrongly-skipped publish means no production write at all -- that push's + # entries stay in the Actions cache and the next default-branch push mirrors them. This is + # not a new failure mode: it is verbatim the trade the needs: paragraph below already + # accepts, one recoverable gap rather than a wrong artifact on the world-readable mirror. + # HISTORY-REWRITE EXCEPTION: push one empty forward commit -- a fast-forward, so it publishes + # -- if the mirror is wanted immediately. A deliberate shared-history rewrite of `main` is + # the one legitimate force-push here and it skips publish; otherwise the rewritten tree + # simply mirrors on the next ordinary push. + # RESTATEMENT DECLINED: publish-verify is NOT separately gated. It inherits the skip through + # `needs: publish` plus the implicit success(), observed on run 30825636788 where publish + # FAILED and publish-verify shows `skipped`. Restating the condition there -- this file's + # usual pairing habit -- was CONSIDERED and declined for the minimal diff. Recorded so a + # reader does not re-derive the choice and assume it was an oversight. + # NO TRIPWIRE: a wrongly-skipped publish is silent in the general case, and that was WEIGHED + # rather than overlooked. It fails CLOSED and the gap is recoverable by construction, so a + # tripwire asserting "the mirror received this commit" would fire on correct behaviour in + # exactly the way this repo's OBS-04 record warns about. Deliberately not built. + # WINDOW PROCEDURE: open the window with a plain `git push origin HEAD:main`, never --force. + # And never open a second window while a previous window is still open: a tip that does not + # descend from the tip already on `main` is a REWIND, so it would silently skip publish and + # lose the very measurement the window exists to take. + # + # WHY needs: [build, typecheck, test, integration] and not the needs: build it replaced + # (XOS-07). The comment that stood here argued for `needs: build (NOT test)` so that a failing + # test leg could never skip the mirror and drop already-built entries. That concern is STILL + # REAL -- it is stated here rather than deleted, so nobody reconstructs it from scratch and + # narrows needs: straight back. It is simply no longer what needs: has to carry, because a + # different mechanism already covers it: + # MECHANISM: !cancelled() runs this job even when a needs: dependency FAILED. + # GitHub documents that a job whose if: expression causes it to continue is NOT skipped when a + # needs: dependency fails -- the FAILED case, not merely the skipped one -- so a red test leg + # does not skip the mirror under the wide list. + # BOUNDED FAILURE MODE: a skipped mirror, never a wrong artifact. + # Named because that !cancelled()-past-a-FAILED-needs: behaviour is CITED from GitHub's docs + # and has never been reproduced in this repo (no run on record has a failed publish ancestor). + # If the citation is wrong or the semantics change, publish is SKIPPED on a red-test push and + # that push's entries simply stay in the Actions cache until the next green push mirrors them. + # A recoverable GAP, never a wrong artifact reaching the world-readable mirror. That is the + # whole trade, and an operator diagnosing a missing mirror should find the cause written here. + # MEASURED on run 30400231720, which is why this widening is a fix and not a precaution: + # publish (ubuntu-24.04-arm) enumerated the Actions cache about 122 seconds BEFORE + # integration (windows-11-arm) finished, so the Windows integration entry did not exist yet + # when the enumeration snapshot was taken -- and the shard carries the fingerprint, task + # hash 8059758544828235640 present ONLY under a -windows suffix. Each leg's view of the + # Actions-cache key set is ONE listCacheEntries() call taken at that leg's publish step + # start and is never re-read (the `const entries` line in publish-mirror.ts), so what a leg + # can mirror is a function of its START TIME. Waiting on every producer is what makes one + # default-branch push mirror that push's full task set instead of racing job completion. + # WALL CLOCK: publish used to start ~40s into the run; it now waits for integration + # (windows-11-arm), roughly 3 minutes. Acceptable for a push-only background mirror job. + # DOWNSTREAM, and it changes which pushes sample OBS-05: publish-verify carries + # if: github.event_name == 'push' with NO !cancelled(), so it is skipped whenever publish + # FAILS. The wide list makes publish run on a red-test push, so publish-verify now runs + # there too. Desirable, and named here rather than discovered. publish: - if: ${{ !cancelled() && github.event_name == 'push' }} - needs: build + if: ${{ !cancelled() && github.event_name == 'push' && !github.event.forced }} + needs: [build, typecheck, test, integration] strategy: fail-fast: false # max-parallel 1 serializes the OS legs (WR-01). Run concurrently, each leg @@ -603,10 +2306,50 @@ jobs: # mirrors only the entries IT can restore); the only cost is a little wall-clock on # this push-only background mirror job. fail-fast stays off so a Windows-only # failure never hides the ubuntu result. + # + # THIS KNOB IS NOT a correctness control (XOS-06). No requirement may depend on + # which OS leg wins the first-write-wins race, and the reason is mechanical rather + # than a policy preference: for a given hash both legs restore the SAME single + # Actions-cache entry and upload it VERBATIM without re-executing the task + # (TRUST-11), so whichever leg wins, every reader receives identical bytes. What a + # served artifact's correctness DOES rest on is CORR-05's target platform-agnosticism. + # + # REJECTED ARGUMENT: ubuntu-first ordering makes the stricter Linux verdict win. + # Named so a future reader does not reconstruct it and quietly promote this knob into + # a correctness control. It is rejected because it would rest a WRONG-RESULT + # guarantee on CI job scheduling -- read PROJECT.md's Key Decisions row (cross-OS + # sharing rests on target platform-agnosticism, NEVER on publish-leg ordering) and + # XOS-06 in REQUIREMENTS.md rather than this loose restatement of them. + # + # THE ONE THING THAT DOES DEPEND ON THIS KNOB is publish-verify's ability to DETECT a + # dead publish leg -- see read-back.ts, which reads the mirrored-by label of the + # asset its own leg seeded. That check needs the two legs NOT TO OVERLAP: run them + # concurrently and the other leg can win the upload race for this leg's seed asset, + # so removing max-parallel: 1 would redden publish-verify on a CORRECT + # implementation. That is guard sensitivity, not a wrong-result guarantee, and the + # two must never be conflated -- a lost guard means a real defect goes unnoticed, not + # that a wrong artifact reaches a developer. Naming the coupling is deliberate: a + # tripwire that fires on correct work gets disabled, and OBS-04 is this repo's own + # record of that. The distinction is written in BOTH places so neither comment reads + # as contradicting the other. + # The leg ORDER, by contrast, is relied on NOWHERE, and that is the point of the + # label assertion. It was MEASURED 5/5 across the default-branch push runs on + # record, margins 150-190 seconds (run 30401077417: publish (ubuntu-24.04-arm) + # step 7 ends 2026-07-28T21:33:39Z, publish (windows-11-arm) step 6 starts + # 21:36:32Z). A measurement is not a documented guarantee: GitHub documents matrix + # CREATION order only, names runner availability as a scheduling input, and these + # two legs draw from DIFFERENT hosted runner pools, so per-leg availability is an + # independent variable. read-back.ts's label assertion is what replaced that + # ordering dependency with the non-overlap one above. max-parallel: 1 matrix: os: [ubuntu-24.04-arm, windows-11-arm] runs-on: ${{ matrix.os }} + # Generic hang insurance -- see the build job. 20 rather than 15, matching the other + # matrix jobs, and it must accommodate max-parallel: 1 above: the two legs are + # SERIALIZED, but timeout-minutes is per-LEG, not per-job, so the serialization does + # not eat into this bound. + timeout-minutes: 20 # A job-level permissions block REPLACES the workflow grant (contents: read) WHOLESALE -- # it does NOT merge. So this block MUST restate BOTH scopes: contents: write (create the # month-shard release + upload assets) AND actions: read (getActionsCacheList; omitting it @@ -622,12 +2365,27 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build - # Seed a known entry keyed on the run id (all-decimal, satisfies the - # ^[a-f0-9]{1,512}$ hash validator) so each OS leg has a DETERMINISTIC entry to - # mirror -- and so the live cross-OS round-trip has a producer: publish-verify - # reads back through the real Releases reader below (the leg deferred - # from Phase 3). Seed runs as a JS-action operation for the same reason publish - # does (the Actions-cache runtime is JS-action-only). + # Seed a known entry keyed on a seed only THIS leg can produce, so each OS leg has + # a DETERMINISTIC entry to mirror -- and so the live cross-OS round-trip has a + # producer: publish-verify reads that same derived seed back through the real + # Releases reader below (the leg deferred from Phase 3). Seed runs as a JS-action + # operation for the same reason publish does (the Actions-cache runtime is + # JS-action-only). + # + # `operation: mirror-seed`, NOT `seed`, and the two are deliberately separate verbs + # (OBS-05, D-13). `seed` writes ONE key per RUN, which dogfood-seed/dogfood-verify + # REQUIRE -- seeding that shared key per OS is exactly the vacuity trap VER-06 + # closed. This job needs the opposite: a key only this leg can produce, so a + # publish-verify leg that finds its own seed proves its OWN publish path ran. + # + # `hash:` STAYS `${{ github.run_id }}` and that is load-bearing (D-14). The per-leg + # derivation happens in TypeScript, at BOTH call sites, from the ONE helper + # mirrorSeedHash in packages/github-cache/src/lib/mirror-seed.ts -- the mirror-seed + # operation writes it and roundtrip/read-back.ts reads it back. A per-leg + # `${{ matrix.os == 'windows-11-arm' && 'x' || 'y' }}` expression here would put the + # OS mapping in TWO languages, which is the drift class this repo guards against + # everywhere else: YAML cannot import the tuple, so the two mappings would be free + # to disagree and the symptom would be a SILENT publish-verify MISS. # # The seeds are NO LONGER the only nx-cache-* entries in play. build, typecheck, # test and integration now route their real Nx tasks through the sidecar, so a @@ -637,14 +2395,53 @@ jobs: # outcome and is deliberately ACCEPTED here (the Releases mirror now holds real # task outputs, which is the whole point of a remote cache), with two consequences: # - # (a) Shard growth. On top of the ~3 run_id-keyed assets a push already added, - # expect ~5 real ones per input-changing push: 4 from the ubuntu leg (build, - # typecheck, test, integration-linux) and 1 from the windows leg - # (integration-win32, which only the Windows leg can restore). So roughly - # 125 default-branch pushes inside ONE calendar month before that month's - # shard reaches RELEASE_ASSET_CAP -- and the cap already degrades to - # skip-and-warn (D-11), never a hard failure, while retention prunes shards - # past the window. Not a concern at this repo's push rate. + # (a) Shard growth, and CORR-02 has now corrected this estimate for the THIRD + # time -- the first DOWNWARD one. There are ~5 distinct real task hashes per + # input-changing push (build, typecheck, test, integration-linux, + # integration-win32). + # CORRECTION HISTORY, recorded so a fourth reader does not re-derive it. + # (1) It first read ~5 real assets per push: 4 from the ubuntu leg plus 1 + # from the windows leg, because each leg could restore ONLY its own OS's + # saves. (2) Phase 9 raised it UPWARD to ~10: VER-01 + VER-03 removed + # that partition, so each leg restored all ~5 and uploaded them under + # its OWN `-` suffix -- a duplicate pair per hash. (3) CORR-02 + # brings it back DOWN, because the suffix is GONE. One asset name per + # hash means the second leg to publish finds the name already present + # and skips it as a first-write-wins no-op, so a hash is mirrored ONCE + # no matter how many legs restore it. + # THE ARITHMETIC, from this estimate's own stated inputs. ~5 real assets per + # push (back to roughly the distinct-task-hash count, not double it) plus the + # ~3 run_id-keyed seeds = ~8 per push. RELEASE_ASSET_CAP is 1000, so + # 1000 / 8 = ~125 default-branch pushes inside ONE calendar month before that + # month's shard reaches the cap -- roughly DOUBLE the ~75 Phase 9's figure + # implied, and back to the pre-Phase-9 number by the same arithmetic. The cap + # degrades to skip-and-warn (D-11), never a hard failure, and retention prunes + # shards past the window, so this was never a concern at this repo's push rate + # and is now half as much of one. + # THE WINDOWS LEG STILL MIRRORS EXACTLY ONE ASSET -- ITS OWN PUBLISH SEED -- + # and that carve-out is why the per-leg counts are still NOT equal. Zero REAL + # TASK assets, one asset in total. `max-parallel: 1` runs ubuntu first, so + # ubuntu mirrors every name it can restore and windows finds them all present. + # So `publish (windows)` reports `mirrored: 1`, NOT 0 -- the all-restore-MISS + # warning requires `mirrored === 0` and will not fire -- and that seed is + # precisely the asset OBS-05 reads back, which is why "the windows leg mirrors + # zero assets" is the wrong summary of this change and "zero REAL TASK assets, + # one seed" is the right one. + # ZERO REAL TASK ASSETS DEPENDS ON XOS-07's WIDENED `needs:`, NOT ON THE RENAME + # ALONE, and the distinction is measurable rather than pedantic. Before the + # widening, ubuntu's enumeration snapshot PREDATED the windows `integration` + # job, so windows was the sole mirrorer of that one hash -- i.e. one real task + # asset, not zero (MEASURED on run 30400231720: ubuntu enumerated at + # ~21:21:11Z while windows integration did not finish until ~21:23:13Z, and the + # shard carries the fingerprint -- task hash 8059758544828235640 exists ONLY + # under a `-windows` suffix, never under `-linux`). `needs:` above now includes + # `integration`, so ubuntu's snapshot contains that hash and windows skips it. + # Reverting that `needs:` list silently restores the one-real-asset case. + # CONSEQUENCE FOR THE DEFERRED SINGLE-LEG COLLAPSE, recorded here because this + # is where a reader arrives with the idea: the windows leg's ONE asset is the + # only thing OBS-05 has to read back, so collapsing the matrix to one leg would + # DESTROY OBS-05's dead-publish detection. The full both-halves record is + # .planning/phases/10-os-invariant-releases-mirror/10-SC6-NOTES.md. # (b) Wall clock. Uploads are throttled to ~1/sec, so publish gains a few # seconds per push. # @@ -662,18 +2459,22 @@ jobs: # real task entries live in the Actions cache alone. Not done now -- it would # throw away the real cross-context mirror this change exists to prove. # - # Key namespaces stay disjoint, but NOT for the reason one might assume: real Nx - # task hashes here are ALL-DECIMAL 18-20 digits (Nx renders a 64-bit hash as a - # decimal string; verified over 153 local cache entries, zero containing a-f), so - # they are the same character class as a run id, not a contrasting hex. What keeps - # them apart is numeric range plus construction: a run id is ~11 digits, and - # `cafe` is not all-decimal at all so it can never equal a task hash. - # A `nx-cache-` collision would need a 64-bit task hash whose decimal form - # exactly equals THIS run's id -- about 1 in 1.8e19 per task. Negligible. + # Key namespaces stay disjoint STRUCTURALLY, and since D2 that holds for ALL THREE + # seed families rather than two of them. Real Nx task hashes here are ALL-DECIMAL + # 18-20 digits (Nx renders a 64-bit hash as a decimal string; verified over 153 + # local cache entries, zero containing a-f), and so is a workflow run id -- the + # same character class, not a contrasting hex. What separates the seeds is the + # hex-LETTER marker word every one of them now carries: `cafe` + # (consumer-smoke), `bead` (dogfood-seed) and `feed` (this + # step). None of the three can equal an all-decimal value, so the disjointness is + # a construction rather than a probability. The bare-run-id shape was the LAST + # family resting on a 1-in-1.8e19 collision argument; D2 retired it, and that is + # also what lets the publish mirror skip a PRIOR run's single-use seed instead of + # re-enumerating and re-restoring it on every run forever. - uses: ./packages/github-cache with: hash: ${{ github.run_id }} - operation: seed + operation: mirror-seed env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: ./packages/github-cache @@ -685,19 +2486,37 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The live cross-OS publish/read-back round-trip (the leg deferred from Phase 3). It - # runs after the WHOLE publish matrix and, per OS, resolves the run-id entry that - # matrix mirrored THROUGH THE REAL GitHub Releases reader -- invoked DIRECTLY + # runs after the WHOLE publish matrix and, per OS, resolves the entry that leg itself + # seeded THROUGH THE REAL GitHub Releases reader -- invoked DIRECTLY # (createReleasesReadBackend(createReleasesReadClient(process.env)) in read-back.ts), # NOT selectBackend, which in a push (write-trusted) context returns the writable - # Actions-cache backend rather than the reader (TRUST-05). Each OS leg reads back ONLY - # its own-OS asset (- via releaseAssetName), so this proves the same-OS - # publisher->reader contract against REAL published assets and exercises the 04-02 - # shard-window walk live; the wrong-OS MISS stays unit-proven. The reader is native - # fetch (no ACTIONS_RUNTIME_TOKEN), so this is a plain `node` step, not a JS action. - # It needs only the workflow-default contents: read to fetch a release asset, so it - # adds NO job-level permissions block and never touches the write gate. Mirrors the - # dogfood-verify shape: push-gated, needs the producer, a MISS fails the job loud - # (the reader swallows faults to a MISS, so any MISS is a round-trip failure). + # Actions-cache backend rather than the reader (TRUST-05). + # WHAT IT PROVES, AND WHY THE OLD REASON IS GONE (D-21). Each OS leg still reads back + # ONLY the asset its own leg seeded -- but the mechanism is a per-leg SEED derivation, + # NOT a per-leg asset name. The key is mirrorSeedHash(github.run_id, that leg's OS) and + # the asset is releaseAssetName(mirrorSeedHash(...)), so the separation lives in the KEY + # and survives CORR-02 collapsing the OS out of the name. The old justification was the + # name's own -os suffix, which that rename deletes outright. + # IT DOES NOT PROVE A SAME-OS PUBLISHER-TO-READER CONTRACT. That claim stood here until + # Phase 10 and was flatly false by then: VER-01 + VER-03 made cross-OS restore work, so + # a leg can mirror bytes produced on another OS entirely. What this job proves instead + # is that each leg's OWN publish path uploaded the asset that leg reads back, evidenced + # by the mirrored-by label read-back.ts asserts -- a PUBLISHER identity, never a + # producer identity. Different axes; only the label carries the second, which is why + # the byte comparison alone cannot detect a dead publish leg. + # WHY THE STALE CLAIM SURVIVED: Phase 9's same-OS sweep was scoped to DOCUMENTATION and + # missed read-back.ts's own comment plus a ci.yml capacity comment, both found only + # after that sweep had declared itself complete. A same-OS-invariant sweep must + # enumerate EXECUTABLE CODE and CI PROSE, not only docs. + # It exercises the 04-02 shard-window walk live. The reader is native fetch (no + # ACTIONS_RUNTIME_TOKEN), so this is a plain `node` step, not a JS action. It needs only + # the workflow-default contents: read -- for the asset download and for the release and + # asset-listing reads the label check makes -- so it adds NO job-level permissions block + # and never touches the write gate. Mirrors dogfood-verify's shape in the two respects + # that matter here: it needs the producer, and a MISS fails the job loud (the reader + # swallows faults to a MISS, so any MISS is a round-trip failure). NOT in its trigger -- + # dogfood-verify now also runs on same-repo pull requests, whereas this job stays + # push-only, because the mirrored asset it reads back is written by the push path alone. publish-verify: if: github.event_name == 'push' needs: publish @@ -706,6 +2525,9 @@ jobs: matrix: os: [ubuntu-24.04-arm, windows-11-arm] runs-on: ${{ matrix.os }} + # Generic hang insurance -- see the build job. 20 rather than 15, matching the + # other matrix jobs: the windows-11-arm leg is consistently the slow one. + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 diff --git a/.github/workflows/windows-regression-detector.yml b/.github/workflows/windows-regression-detector.yml new file mode 100644 index 00000000..b2ff9f33 --- /dev/null +++ b/.github/workflows/windows-regression-detector.yml @@ -0,0 +1,120 @@ +name: Windows regression detector + +# The scheduled --skip-nx-cache windows-11-arm run (XOS-05/D-08). This is a +# SEPARATE scheduled workflow, never a job inside ci.yml: adding a schedule +# trigger to ci.yml would fire EVERY ONE of its jobs on a calendar cadence +# unless every one of them grew an `if:` gate, and cleanup.yml's own header +# already records why a scheduled concern lives in its own file. (This line used +# to say "all nineteen of its jobs". ci.yml declares 21 job keys at HEAD -- 18 +# before XOS-04 added three -- so the number was wrong on the day this file was +# authored and wrong again three jobs later. The ARGUMENT never depended on it, +# so the count is dropped rather than restated: an unguarded number in a comment +# rots, and a comment carrying a false claim is a documented argument for undoing +# the work it explains.) +# +# WHY IT EXISTS, in XOS-05's own words, because the job below is unreadable +# without them: the success signal for O4 -- every target reporting +# [remote cache], wall time collapsing to sidecar overhead -- is the IDENTICAL +# observation to a Windows-only regression being invisible forever. A green O4 +# leg IS a Windows leg that did not run the code. Reading a green O4 CI as +# evidence that the targets are PORTABLE is circular, and REQUIREMENTS.md's Out +# of Scope table names it as such: a restored task does not execute. +# +# So once XOS-04 lands, this workflow is the ONLY path in the repository that +# EXECUTES build, typecheck or test on a Windows runner. That is its whole +# justification, and it is why it HARD FAILS rather than warning. It is NOT the +# "tripwire that fires on correct work" class OBS-04 records disabling: a red +# leg here means a genuine Windows-only regression in the code under test, +# never correct work. +on: + # Once daily, off the top of the hour: GitHub delays scheduled runs under + # top-of-hour load, and this check has no deadline, so an off-peak minute is + # fine. Both fields differ from cleanup.yml's '17 3 * * *' so the two + # scheduled workflows do not contend. + schedule: + - cron: '23 4 * * *' + # workflow_dispatch is how the detector gets re-run ON DEMAND AFTER merge. + # It does NOT close the "does this go green on a real runner" question before + # merge, and must never be documented as if it could: GitHub only dispatches a + # workflow whose file exists on the DEFAULT branch, so a brand-new file on a + # feature branch does not appear in the UI at all and the API rejects it. + # cleanup.yml, the shape precedent, carries a schedule trigger ONLY -- so this + # addition is deliberate, and its justification is post-merge re-runs. + workflow_dispatch: + +# Least privilege: this job checks out, installs and runs four targets, and +# writes nothing -- no Releases asset, no Actions-cache entry, no package. It +# therefore does not copy cleanup.yml's write scope (cleanup needs one because +# it DELETES release assets), and it requests no Actions scope either. +permissions: + contents: read + +# No concurrency group here, deliberately. cleanup.yml needs single-writer +# serialization because concurrent DELETEs are unsafe; this job deletes nothing +# and writes nothing, so two overlapping runs are harmless and the mechanism +# would be unrequested. + +jobs: + detect: + runs-on: windows-11-arm + # 20 rather than the 15 the ci.yml jobs carry: this is the only job in the + # repository whose wall time is FULL cold execution of all four targets + # rather than a cache restore, so it needs more headroom than a leg that + # HITs. `lint` joined the set without raising this: it is the cheapest of + # the four and the ceiling was already sized for the three expensive ones. + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + # NO sidecar block and no remote-cache client variable, deliberately + # (D-08). --skip-nx-cache skips the remote READ and the remote WRITE at + # Nx 23.1.0 -- source-traced: doNotSkipCache gates the read AND is passed + # as postRunSteps' shouldCache argument -- so a cache server here would be + # dead weight and an extra cache producer this milestone does not want. + # With no remote-cache client variable in the environment there is no + # remote tier at all, regardless. No separate build step either: the + # run-many invocation below IS the build. + # + # Exit 0 is NOT sufficient, and that is the whole reason this is a + # scripted step rather than a bare `- run:`. nx run-many with no matching + # target ANYWHERE prints "NX No tasks were run" and exits 0 (measured in + # this repo). Worse for a four-target run: source-traced at + # node_modules/nx/dist/src/tasks-runner/life-cycles/formatting-utils.js:37 + # Nx FILTERS the printed target list down to targets that actually + # resolved a task, so a needle matching only the singular prefix still + # PASSES when three of the four ran. The needle below therefore names all + # four, in -t argument order. The trade: it pins the -t ARGUMENT ORDER + # too, so reordering the flags reddens a CORRECT run -- and the fix for + # that is to update the needle and its clause in + # windows-regression-detector.spec.ts in the SAME commit, never to + # shorten the needle. + # + # One invocation rather than four single-target steps: one graph load on + # the slowest runner in the fleet, and Nx already names the failing target + # in its own output. + # + # NO_COLOR is MORE load-bearing here than in ci.yml's lint job, not less. + # Nx bolds each target name INDIVIDUALLY while the ", " separators are + # not, so with colour on the combined line carries four escape-sequence + # pairs interleaved with the commas and a plain-text match cannot fire. A + # gate that always fails gets deleted just as fast as one that never + # fires. + # + # shell: bash is REQUIRED, not stylistic: the default shell on + # windows-11-arm is pwsh, which fails on set -euo pipefail, on the pipe + # and on tee. The grep is runner-side POSIX inside a workflow body and is + # correct -- this project's never-use-grep rule governs the agent's own + # shell, not workflow bodies, and ci.yml's lint job already uses grep -q + # for exactly this purpose. + - name: Execute build, typecheck, test and lint on Windows with the Nx cache bypassed + shell: bash + env: + NO_COLOR: '1' + run: | + set -euo pipefail + npm exec -- nx run-many -t build typecheck test lint --skip-nx-cache 2>&1 | tee detector.log + grep -q 'Successfully ran targets build, typecheck, test, lint for project' detector.log diff --git a/.gitignore b/.gitignore index ba615fb8..276ed2d3 100644 --- a/.gitignore +++ b/.gitignore @@ -44,12 +44,66 @@ Thumbs.db # fallow dead-code analysis cache (self-ignored via .fallow/.gitignore too) .fallow/ +# capture-hashes.mjs measurement records, written at the workspace ROOT by the +# hash-parity CI job and by a local `npm run capture:hashes` / `assert:graph-premise`. +# Ignored so they never dirty the working tree the NEXT record measures: both modes +# record `workingTreeClean`, which exists to reveal an uncommitted nx.json edit, and +# an untracked sibling record would peg the field to false and destroy that signal. +# The COMMITTED copies of these records live under .planning/, never here. +/hash-parity-*.json +/graph-premise-*.json + +# O3 probe records, written at the workspace ROOT by ci.yml's integration and +# o3-witness steps and by a local `node read-integration-hash.mjs`. Ignored for the +# same reason as the records above -- an untracked sibling would dirty the tree the +# next `workingTreeClean` measurement reads -- and because integration-hash.txt's +# contents become a cache key, so it must never be committed and mistaken for one. +/integration-nx.log +/integration-hash.txt +/o3-witness.log + +# The remaining workspace-ROOT logs the CI jobs tee, for the SAME reason as the two +# above and completing that set: `build-nx.log`, `typecheck-nx.log` and `test-nx.log` +# (the three Windows reuse legs' runtime cache observation), `lint.log`, +# `hash-parity.log` (the `^hash-parity: PARITY OK` grep target) and `detector.log` +# (windows-regression-detector.yml). Only the two O3 logs were listed, which made the +# rule look like an O3 special case rather than what it is: a `tee` at the workspace +# root leaves an UNTRACKED file, and `capture-hashes.mjs` records `workingTreeClean` +# from `git status --porcelain`, so any of these sitting beside a capture pegs that +# field to false and destroys the signal it exists to carry. Latent today only because +# no job both tees and captures in one checkout -- which is a scheduling accident, not +# a guarantee, and the hash-parity job already tees `hash-parity.log`. +/build-nx.log +/typecheck-nx.log +/test-nx.log +/lint.log +/hash-parity.log +/detector.log + +# The OPERATOR-side counterpart to the list above, for the identical reason. AGENTS.md's +# "Capturing test-battery output" section tells a human (or an agent) to tee each battery +# iteration to `test--.log` at the workspace root so a failing run's output +# survives -- Nx caches terminal output for SUCCESSFUL runs only, so the re-run destroys the +# evidence. Those logs are untracked by construction and would peg `workingTreeClean` to +# false in exactly the way the paragraph above describes. A glob rather than an enumeration +# because the iteration suffix is unbounded; it subsumes `/test-nx.log`, which is kept above +# so the CI-teed set stays readable as a complete list. +/test-*.log + # op-nx/github-cache act test harness (test-only, never a runtime dep) .act-cache .act-artifacts .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md +# GSD research-tool response cache (hash-named JSON, written by the `research-*` +# query handlers). Generated, never authored, and worthless as history -- but it is +# NOT merely noise: it lands untracked under `.planning/`, and `capture-hashes.mjs` +# records `workingTreeClean` from `git status --porcelain`, so a research probe run +# in the same checkout as a hash capture pegs that field to false and destroys the +# signal, exactly as the workspace-root logs above would. Same rule, new writer. +.planning/research/.cache/ + .claude/worktrees .claude/settings.local.json .nx/polygraph diff --git a/.planning/ARCHITECTURE-DECISION.md b/.planning/ARCHITECTURE-DECISION.md deleted file mode 100644 index 1692b697..00000000 --- a/.planning/ARCHITECTURE-DECISION.md +++ /dev/null @@ -1,91 +0,0 @@ -# Architecture Decision Record: Storage Model & CREEP-Safety Posture - -**Status:** Accepted — FOUND-01 resolved to **GitHub Releases** and FOUND-03 (Docker container form) deferred to a later milestone, on the completed FOUND-01 spike (`.planning/spikes/001-005`); everything else was already decided. -**Date:** 2026-07-17 (rev. after the D1-D4 security review + a 6-member advisor panel + triage; FOUND-01/03 locked after the reader-adapter spike) -**Scope:** Supersedes the rewound v0.0.1 roadmap. Grounds the re-derivation of REQUIREMENTS.md and ROADMAP.md. - -## Framing: the current implementation is a spike / proof-of-concept - -The shipped architecture, implementation, and delivery mechanism are a **spike/PoC** — a reference to learn from, **not** an asset to preserve. **Sunk cost is zero.** No decision here is justified by "it is already built/tested," and any component may be rebuilt. Consequently the reader-adapter choice is made on **forward merits only**, and known PoC hazards (e.g. the duplicated `TRUSTED_EVENTS` copies, the `gh`-CLI stderr coupling) are to be fixed at the **root** in the rebuild, not parity-patched. - -## Nx contract (fixed constraint) - -The current self-hosted path is the **"Nx custom remote cache specification"** expressed in **OpenAPI 3.0.0** (embedded as JSON in the Nx docs source; no standalone artifact). Local HTTP server: `PUT /v1/cache/{hash}` → success / 401 / 403 / **409 (cannot override existing record)**, required `Content-Length`; `GET` → 200 / 403 / 404; single bearer token (server decides RO vs RW). The deprecated custom task-runner API and `@nx/*-cache` Powerpack plugins are out of scope. - -**Contract-drift caveat (verified):** the PUT success code changed **202 → 200 between Nx 20 and Nx 21 while `info.version` stayed `1.0.0`** — so watching `info.version` does **not** detect drift. The conformance fixture must **hash the full vendored spec** and **pin a named Nx version**. The server returns `200` (Nx 21 behavior); the declared floor is **Nx 21+** — **verified against the Nx client (`HttpRemoteCache`)**: PUT success is matched **strictly** as `200` (`409`/`403` are graceful client no-ops; any other status errors the store), so a `202`-returning server breaks the current client and the Nx 21+ floor is *hard*, not "any 2xx." (The Nx client also hardens tarball extraction against `..`/absolute/symlink/hardlink escape — a malicious server cannot zip-slip the client; inherited protection worth a docs note.) - -## Decision 1 — One backend per process, selected by context (not a composition framework) - -`selectBackend(env)` returns **exactly one** `CacheBackend` per process, chosen by runtime context; there is **no** runtime composite/registry. The `CacheBackend` port is **`get`/`put`** (keyed by Nx hash); `put` returns `PutResult` and the `'conflict'`/409 path enforces no-overwrite at PUT (no `exists` verb). Write-sync to any second store is the **separate, out-of-band publish step** (today's `publish-mirror`), not a composition primitive. - -- **Default:** Actions-cache CI-RW only — no sync, no second store, no cleanup job. -- **Opt-in (deploy-time configuration, not a per-call mode):** enabling a reader/cross-context store, and untrusted-CI/local reads from it. **RW-vs-RO stays fully context-derived** — no caller-facing flag a consumer can get wrong (a load-bearing CREEP property). "Enable store X" is config; "this request is RW" is never config. -- **Deferred (YAGNI until a real consumer needs them):** multiple simultaneous stores, synchronous write fan-out, a local read-write store. - -**Publisher/retention seam (explicit):** only the serve-time **read** path is behind the `CacheBackend` port. The **publish + retention/cleanup subsystem is reader-specific and behind no port** — every reader choice requires building its own publish/cleanup; there is no symmetric publisher port unless a second reader is ever shipped. Do not assume the publisher is pluggable. - -## Decision 2 — Write-trust = allowlist-only; sync gate is separate and minimal - -- **Write-trust = an allowlist** (configured replaces default; else the default implicit allowlist); default-deny; **no denylist**. The dangerous shared-default-scope events (`pull_request_target`, `issue_comment`, fork-`workflow_run`, `discussion_comment`, `fork`, `watch`, …) are refused by construction. -- **`pull_request`/`release` are safe to write-trust because GitHub scope-isolates them (non-default-branch scope) — but that safety leans on GitHub's server-side guard that issues a read-only cache token to untrusted triggers resolving to the *default-branch* scope (2026-06-26).** That guard ships to **github.com + Data Residency only; no GA GHES has it** (GHES 3.21 GA'd 2026-06-11, before the change; earliest possible is an unannounced 3.22+). It is **not directly queryable**, so it's inferred from the host: **`GITHUB_SERVER_URL` host == `github.com` or `*.ghe.com` → widened write-trust ON; every GHES host → OFF (fail-closed)** — a pure function of a runner-injected env var, **no caller/mode flag**. Optional cross-check: absence of `/meta` `installed_version` + the `X-GitHub-Enterprise-Version` header (catches a spoofed `GITHUB_SERVER_URL`); a dormant version-gate knob stays OFF until a GHES floor is published. The in-code allowlist stays fork-spoofable defense-in-depth; the guard + scope-isolation are load-bearing. (Nuance: PR scope is activity-type-dependent — `[closed]` runs base-scope RO; a blocked PR write is a benign 409/no-op. `.ghe.com` Data-Residency suffix is assumed — verify before trusting it.) -- **Sync gate = a separate, narrower predicate = literally `{push, schedule}`** on the default branch. It is **not** the write allowlist. Test-lock it to **reject** `pull_request`, `release`, `repository_dispatch`, `workflow_dispatch`, `merge_group`, `delete`, `registry_package`, `page_build`, and non-default refs — because `repository_dispatch`/`workflow_dispatch` carry attacker-influenced inputs into trusted default-branch code whose output would then be laundered into a shared store. - -## Decision 3 — Storage primitives (reader LOCKED = GitHub Releases; forward-merits) - -- **CI read-write adapter: GitHub Actions cache** — native LRU + GitHub ref-scope isolation + server-side read-only-token backstop; structurally CI-only. (Unchanged; the default composition.) -- **Reader / cross-context adapter: LOCKED = GitHub Releases (v0.0.1)**, decided on **forward merits only** (the "Releases is already built" argument is void per the Framing) against the completed FOUND-01 spike's symmetric ledger (`.planning/spikes/001-005`). Both stores round-tripped authenticated private keyed lookups (byte-identity + digest verified); no GHCR-killer survived, so the tiebreak is on the axes a poisoning-class tool weights highest: - - **Decisive reason — fewer incident-response/operational hazards + a real public poison-*remediation* gap on GHCR.** GHCR carries the larger safety-critical surface: mutable-tag→pull-by-digest (C6), untagged child-manifest cleanup (C13), the delete-credential nuance (C11), the visibility fail-closed assert (C18), and the **>5000-download-undeletable wall (C10)** — a popular *public* poisoned entry cannot be deleted via API (GitHub Support only), so the max-blast-radius incident has **no self-service remediation**. Releases has none of these; cleanup uses the same `contents:write` token that publishes. This reason is store-existence-independent (it holds if neither were built), so it is **not** sunk-cost. - - **CREEP-orthogonality (scope check):** FOUND-01 does **not** move the primary threat — CREEP is defended at the write/sync gates (C1/C2/C5) regardless of reader. This is a *remediation/operational* win, not a CREEP-prevention one; it also lowers the stakes of the choice (reinforcing reversibility below). - - **Weighting caveat (recorded honestly):** total control surface is ~a wash — Releases carries its own (the **1000-asset/release cap → month-sharding + window-walk**, and the **~2 GiB/asset ceiling colliding with the 2 GB body cap**). Releases wins under a **remediation/safety-weighted** lens (its surface is scaling-correctness; GHCR's is incident-hazard), a defensible judgment for this tool, not a raw-count fact. - - **GHCR's edges do not cash out in v0.0.1:** native content-addressing/digest-pin is a minor, self-inflicted edge (see below); size headroom only matters at the ~2 GiB boundary (spike 003); registry/Docker synergy is deferred with FOUND-03. GHCR is also slower per authenticated read and needs a per-reader token exchange (spike 001/005). - - **Read-time integrity note:** GHCR digest-pin verifies transport integrity of the stored bytes, not correctness-for-the-key. The Releases equivalent is **store-and-verify a published content-sha256** (asset metadata at publish, verify on read) — **not** `sha256(blob) == {hash}` (the Nx key hashes task *inputs*, not the stored bytes). Low-priority defense-in-depth for either store; defends nothing against CREEP (C5). Optional, not required for v0.0.1. - - **Reversibility:** only the reader **read path** is behind the `CacheBackend` port; publish/cleanup is reader-specific. Adopting GHCR later is **additive** (multi-store + synced writes), not a switch — v0.0.1 Releases keeps serving. A wrong pick costs a later-milestone publish/cleanup build + re-populate, with no consumer-contract or migration impact. -- **GHCR → later-milestone revisit trigger (re-run the ledger, not a committed switch):** re-evaluate GHCR as an *additional* synced store when **FOUND-03 (Docker container form) + PROV-01 (cosign attestation) graduate together** — then GHCR's cost drops (already operating the registry) and its benefit rises (native cosign provenance for image + cache). Until then GHCR loses the standalone ledger. -- **Out:** git-native (clone bloat, no clean eviction) and Actions build artifacts (not content-keyed). - -## Decision 4 — CREEP-safety control ledger - -CVE-2025-36852 (CVSS 9.4, CWE-829, GHSA-rrr2-jcr8-7q3x, no patched version): poison at **construction, before hashing**; **first-to-cache-wins**; any PR-privileged contributor. Fix = write-scope isolation aligned to VCS trust; **signing/integrity is ineffective** against it. Controls scale with composition — the default (Actions-cache CI-RW only) carries only C1 + C4 + docs. - -| # | Control | -|---|---------| -| C1 | Write-trust allowlist (default-deny); `pull_request`/`release` on **only where GitHub's untrusted-default-branch cache guard exists — detected from `GITHUB_SERVER_URL` (`github.com`/`*.ghe.com` → ON; all GHES → OFF, fail-closed; no caller flag)**; dangerous set refused by construction | -| C2 | Sync gate = separate predicate = `{push, schedule}` only; test-locked to reject all other events + non-default refs | -| C3 | No-overwrite/409 per adapter — **contract-mandated**, CREEP value **conditional on C1/C2** (not standalone). Actions cache native; **GHCR has no atomic create-if-absent (confirmed absent from the OCI spec and GHCR) → best-effort check-then-write**, which is low-severity: same-hash trusted writes are byte-identical under CORR-01 (idempotent overwrite), and an untrusted overwrite is C2's job, not atomicity's. Reinforced by pull-by-digest (C6) | -| C4 | Repo-wide PPE hygiene: a **shipped installable gate** (reusable workflow / composite action) running `zizmor`/`actionlint` for named patterns (no `pull_request_target`+PR-checkout; no `issue_comment`/`workflow_run` executing PR code). **Best-effort/advisory** — heuristic linters cannot verify novel/obfuscated evasions, so it is NOT load-bearing; containment is **C2 (untrusted writers kept out of the shared store) + default-branch protection** | -| C5 | No content signing for CREEP (ineffective — trusted producer signs poisoned bytes) | -| C6 | Pull-by-digest mandatory iff GHCR; the `{hash}→digest` map is **designed out** (tag == hash) or its single writer + concurrency pinned — never a mutable shared index | -| C7 | Deferred (a later milestone): asymmetric provenance attestation (cosign keyless), reader-verified — never HMAC | -| C8 | Retention: native Actions LRU + age-only RO + **no manifest** (no mutable retention state) | -| C9 | Cleanup delete path: **list phase aborts with zero deletions on any non-404 fault / incomplete pagination**; delete phase isolates per item | -| C10 | GHCR >5000-download refusal handled non-fatally; documented age-floor exception; recorded as a **poison-remediation gap** (weighs in Decision 3) | -| C11 | Cleanup credential: **prefer keeping GHCR in-repo so a job-scoped `GITHUB_TOKEN` suffices** (no long-lived PAT). Fine-grained PATs / GitHub App tokens are **unsupported for GHCR deletion**, so an org-owned/unlinked package forces a **classic PAT (`delete:packages`)** — gate it **behind an Actions Environment with required reviewers** and **document its org-wide-package-deletion blast radius** as an accepted trade-off. Never referenced in a PR-triggered workflow | -| C12 | First-party Octokit cleanup (the delete credential never enters a third-party action) | -| C13 | GHCR child-manifest cleanup gated on a reference check (fail-closed); reader degrades a missing/partial child to MISS, never truncated bytes | -| C14 | Docs: github.com-only backstop + GHES floor; **never enable fork-PR "send write tokens"/"send secrets"**; default-branch-protection + ephemeral-single-tenant-runner prerequisites | -| C15 | Docs: retention is storage-hygiene, **not** poison-containment | -| C16 | Mirror filter admits **only server-produced keys** (distinguishing namespace/prefix), not "any 1-512 hex" — **must ship before/with** enabling the mirror for any private repo (else unrelated hex-keyed CI artifacts leak); docs warn every mirrored key is world-readable | -| C17 | Observability: a whole-run sync/publish failure **fails loud** (annotation + non-zero exit); ship a "how do I know the cache is working" signal | -| C18 | (GHCR) Publish-time **package-visibility fail-closed assert**: the publish pipeline verifies package visibility matches the repo (private repo → private package) and **fails the run** on mismatch — not a docs-only step | - -## Decision 5 — Retention / LRU - -Age-based cleanup (`CACHE_MIRROR_MAX_AGE_DAYS`, one coupled setting) is the mandatory floor. LRU is native on the Actions-cache CI tier; the RO tier is age-only. A **stateful LRU manifest is out of scope** (security-negative + no GHCR last-accessed signal). On GHCR the age-floor has a documented exception for >5000-download entries (C10). The month-shard model + the "no second knob" invariant are Releases-shaped and, with **FOUND-01 = Releases locked, now bind** (they were reader-conditional pre-lock). - -## Decision 6 — Cross-OS correctness (Core Value) - -The cache keys on the opaque Nx **input** hash; Nx does not include the runner OS by default. Serving a Linux-produced entry to a Windows reader is a **wrong result, not a MISS** — a Core-Value violation the CREEP controls do not cover. **Default to OS-namespacing the store** (or require the consumer to OS-discriminate non-portable outputs, documented). The reader-adapter spike must **round-trip both an OS-invariant and an OS-sensitive hash from each CI OS** through the chosen store. - -## Consequences & spike scope - -- **Spike (COMPLETE — `.planning/spikes/001-005`, symmetric ledger):** 001 both stores validate authenticated private keyed lookup (byte-identity + digest); 002 cold-read fan-out is a WASH (both amortize, both under rate limits when authed); 003 GHCR mild size-headroom edge, throughput a wash, ROBUST-02 = the 2 GB body cap binds both; 004 GHCR carries a real multi-part cleanup burden (delete needs `delete:packages`; mutable tags; orphans; >5000 wall); 005 CORR-01 is store-agnostic (OS-namespacing fixes both — not a differentiator) and the in-repo `GITHUB_TOKEN` deletes a same-owner GHCR package (softens C11 to org/cross-owner). **Verdict: GitHub Releases (Decision 3).** (Resolved on paper, off the spike: GHCR atomic create-if-absent unavailable/low-severity; Nx PUT floor hard `200`/Nx-21+; write-trust backstop host-detected fail-closed.) -- **Distribution (FOUND-03 = defer Docker to a later milestone):** v0.0.1 ships **npm package + JS Action** (the JS Action is mandatory for the Actions-cache CI-RW role). The **Docker container form is deferred to a later milestone** — its primary motivation (a CI `services:` sidecar) is better served by running `npx ... serve` as a GitHub Actions **background step** (GA, stably named: `background`/`wait`/`wait-all`/`cancel`/`parallel`), which runs in *step context* (so the default Actions-cache backend works) and **cross-OS** (unlike `services:`, which is Linux-hosted-only), with a plain `&` background process as the portable fallback for GHES / older runners. Docker's residual niche is genuinely hermetic / non-Node CI — a later-milestone / on-demand item. Two requirements fall out: (a) **`serve` must handle `SIGTERM` gracefully** — `cancel` sends `SIGTERM` then `SIGKILL` after a short grace, so the write path must flush in-flight writes / finalize async backfill on shutdown (else a RW job loses its last writes at teardown); (b) **docs must show the background-step pattern with an explicit `cancel` teardown** — the runner runs an implicit `wait-all` before post-job cleanup, and a never-exiting `serve` would hang the job without it. **Action-form note:** a *composite* action cannot declare `background:` internally, so the consumption Action stays a **JS action** (or the consumer applies `background: true` to the step that `uses:` it). -- **Governance (project hygiene, required for a poisoning-class OSS tool):** SECURITY.md (vulnerability-disclosure policy), LICENSE, and a versioned consumer-contract / semver statement. -- **Residual:** CREEP containment is single-layer at the write/sync gates + the (heuristic) PPE gate; the only true second layer is reader-side provenance attestation (C7, deferred). Gate correctness is therefore load-bearing with no backstop. - -## References - -CVE-2025-36852 / GHSA-rrr2-jcr8-7q3x / NVD (CVSS 9.4, CWE-829); Nx blog + HeroDevs `nx.app/files/cve-2025-06`; Nx self-hosted caching + the 2026-06-26 read-only-cache changelog; GitHub dependency-caching (scope isolation); CodeQL cache-poisoning; Adnan Khan "Cacheract"; Wiz PPE; OCI distribution spec (tag mutability); GHCR has no immutable tags; sccache/bazel-remote/Turborepo; `nixcite/nixcache-oci`. Full corpus: `.planning/research/*`. - ---- -*Recorded: 2026-07-17. Rev after an independent Sonnet `/lz-security-review`: C1 fail-closed detection; C4 PPE gate advisory; C11 in-repo-GHCR preference; C16 sequenced before private mirror; C18 visibility assert. Rev after targeted research: C1 detection is host-based (`GITHUB_SERVER_URL` github.com/`*.ghe.com` → ON, all GHES → OFF; GHES floor unpublished) and the backstop is a default-branch-poisoning guard (not a PR/release read-only); C3 GHCR no-overwrite is best-effort (atomic create-if-absent confirmed unavailable) — low-severity, C2-covered; Nx PUT floor is a hard `200`/Nx-21+ (client requires exactly 200). **Rev after the FOUND-01 spike (`.planning/spikes/001-005`): reader adapter LOCKED = GitHub Releases (Decision 3); FOUND-03 Docker deferred to a later milestone (CI sidecar covered by the GA background-step pattern); GHCR-conditional controls (C6/C10/C11/C13/C18) move to the later-milestone GHCR revisit trigger; new requirements — `serve` graceful `SIGTERM` shutdown + documented background-step `cancel` teardown.** diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 0db7c41e..d0995bb3 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -14,7 +14,7 @@ public and private** GitHub repositories - not only for dogfooding in this repo. **one backend per process, chosen by runtime context** (default: Actions-cache CI-RW only); an opt-in reader/cross-context store and its publish/cleanup are a separate, reader-specific step. Write-trust is an allowlist; the full CREEP control ledger is in -`.planning/ARCHITECTURE-DECISION.md`. **v0.0.1 (the greenfield MVP rebuild) shipped +`.planning/THREAT-MODEL.md`. **v0.0.1 (the greenfield MVP rebuild) shipped 2026-07-22** — merged to `main` and tagged. The reader adapter is **LOCKED = GitHub Releases** (FOUND-01 spike, forward merits) and the Docker container form is **deferred to a later milestone** (FOUND-03); GHCR/OCI is the later-milestone revisit trigger (with cosign + Docker). @@ -27,6 +27,30 @@ never let an untrusted trigger write - correctness and CREEP-safety come before feature. If everything else fails, reads must stay best-effort (a fault degrades to a MISS, never a broken build) and writes must stay gated. +## Current Milestone: v0.0.2 OS-invariant cross-OS sharing + +**Goal:** A Windows developer reuses Linux CI's portable task artifacts, and Windows CI reuses +them too, with the OS-sensitive target still provably separated. Proven by dogfooding this repo, +then documented as a recipe consumers can copy. + +**Target outcomes** (the acceptance frame; every requirement serves one): + +- **O1** - local Windows dev gets cache HITs for `build`/`typecheck`/`test` produced by Linux CI +- **O2** - local Windows dev gets cache HITs for `integration` produced by Windows CI +- **O3** - Windows CI gets cache MISSes for `integration` produced by Linux CI +- **O4** - Windows CI gets cache HITs for `build`/`typecheck`/`test` produced by Linux CI + +**Mandatory ordering:** O1 must be PROVEN before O4 is enabled. Windows CI today runs only +`integration`, so any local Windows HIT on the other three targets is unambiguously Linux-produced. +Enabling O4 makes Windows CI a second producer of those hashes and permanently destroys that clean +attribution. + +**Key context:** the work splits across two independent layers - the Releases mirror (O1, O2) and +the Actions cache (O3, O4) - which is what makes the ordering achievable. O1's dominant blocker is +Nx task-hash parity, not the asset name: `build` currently hashes differently on ubuntu CI and +Windows CI for the same commit. O3 already holds today via the declared platform discriminator on +`integration`. + ## Requirements ### Validated @@ -49,8 +73,27 @@ Shipped and verified in **v0.0.1 Greenfield MVP Rebuild** (all 7 phases verified ### Active -_None — v0.0.1 shipped the full MVP requirement set. Run `/gsd:new-milestone` to define the -next version (fresh REQUIREMENTS.md)._ +**v0.0.2 OS-invariant cross-OS sharing.** Full requirement set with REQ-IDs: +`.planning/REQUIREMENTS.md`. Summary of the scope: + +- [ ] Releases mirror asset names carry no OS discriminator (CORR-02), superseding CORR-01's + "OS-namespaced by default" branch in favour of the documented-consumer-discrimination + alternative that the CORR-01 row in `## Key Decisions` below already sanctions +- [ ] OS-sensitive targets stay separated by their declared Nx input, proven behaviourally (CORR-03) +- [x] Nx task-hash parity for `build`/`typecheck`/`test` across Windows and Linux, root-caused + before it is fixed (PARITY-01..05) -- Phase 8, complete 2026-07-28 +- [x] The `@actions/cache` archive path becomes a deliberate OS-invariant constant instead of an + inherited `os.tmpdir()` value, with `enableCrossOsArchive` hardcoded (VER-01..07, plus + PARITY-08, ROBUST-04, OBS-04, DOCS-08) -- Phase 9, complete 2026-07-28. Closed + BEHAVIOURALLY: a `windows-11-arm` runner read back a Linux-produced entry and asserted the + bytes were `'linux'`-produced (run `30400231720`). The cache version is now OS-invariant, so + the publisher-equals-producer identity no longer holds -- Phase 10's OBS-05 owns the + consequences, and the pre-change producer attribution is preserved in + `phases/09-*/09-EVIDENCE.md` +- [x] ESLint adopted, with the ambient-platform-read ban enforced in unit specs and allowed in + integration specs (LINT-01..06, CORR-06) -- Phase 7, complete 2026-07-27 +- [ ] Live cross-OS proofs for O1-O4 in the mandated order (XOS-01..07, TEST-08..10, OBS-02..05) +- [ ] Consumer-facing cross-OS adoption recipe, drift-guarded (DOCS-07/08) Later-milestone revisit triggers carried out of v0.0.1 (re-evaluate together per the FOUND-01 ledger): @@ -95,6 +138,7 @@ Later-milestone revisit triggers carried out of v0.0.1 (re-evaluate together per ## Constraints - **Tech stack**: TypeScript (strict, ESM, `module: nodenext`), Node 24 LTS, Nx 23, Vitest - relative imports carry `.js`; the two GitHub JS actions must be dependency-free CommonJS (they run before `npm ci`). +- **Nx contract**: the self-hosted-cache HTTP contract is an OpenAPI 3.0.0 spec embedded in the Nx docs source with no standalone artifact, and the Nx 21+ floor is HARD - the Nx client (`HttpRemoteCache`) matches PUT success strictly as `200`, so a `202`-returning server breaks it - which is why the conformance fixture pins a named Nx version and hashes the full vendored spec rather than watching `info.version`; the endpoint/status table, the `202`->`200` drift and the reason `info.version` cannot detect it live in `.planning/research/STACK.md` section 1. - **Platform**: GitHub-native only - candidate storage primitives under verification (Actions cache, Release assets, ghcr.io/OCI, GitHub Packages, git-native), via `@actions/cache`, `@octokit/rest`, the `gh` CLI, and/or git; no hosted deployment; runs as a loopback sidecar. - **Auth / repo scope**: local environments are assumed authenticated to GitHub; the design MUST work for private repositories and MUST NOT depend on anonymous/public access. Anonymous read is an optional OSS-only convenience. - **Security**: writes gated to trusted trigger events; server binds `127.0.0.1` only; GitHub's server-side read-only cache token (since 2026-06-26) is the load-bearing CREEP control, the in-code gate is defense-in-depth (env is fork-spoofable). @@ -105,16 +149,22 @@ Later-milestone revisit triggers carried out of v0.0.1 (re-evaluate together per | Decision | Rationale | Outcome | |----------|-----------|---------| -| **One backend per process, context-selected** (`selectBackend`); default = Actions-cache CI-RW only; opt-in reader store + its publish/cleanup are a separate reader-specific step | Matches the ecosystem norm; minimal default, pay-as-you-compose; the publisher/cleanup subsystem is reader-specific (not port-isolated) | [OK] Decided (see ARCHITECTURE-DECISION.md) | +| **One backend per process, context-selected** (`selectBackend`); default = Actions-cache CI-RW only; opt-in reader store + its publish/cleanup are a separate reader-specific step | Matches the ecosystem norm; minimal default, pay-as-you-compose; the publisher/cleanup subsystem is reader-specific (not port-isolated) | [OK] Decided - the project-level CREEP control ledger C1-C18 backing this and every other trust decision is `.planning/THREAT-MODEL.md`; re-read and reconcile it at each milestone Key Decisions audit (updated 2026-07-26) | | Reader / cross-context adapter: **GitHub Releases** (v0.0.1) | Forward merits (FOUND-01 spike): fewer incident/operational hazards + no public poison-remediation gap (vs GHCR's >5000 wall, child-manifest, delete-cred, visibility); reversible/additive. GHCR = later-milestone trigger with cosign + Docker | [OK] LOCKED (FOUND-01) | | **Write-trust = allowlist-only** (default-deny; no denylist); `pull_request`/`release` on **only where GitHub's untrusted-default-branch cache guard exists — host-detected from `GITHUB_SERVER_URL`** (`github.com`/`*.ghe.com` → ON; all GHES → OFF, fail-closed; no caller flag) | In-code gate is fork-spoofable defense-in-depth; the host-based check is a pure env-var function; no GA GHES has the guard yet (floor unpublished) | [OK] Decided | | **Sync gate = a separate predicate = `{push, schedule}` only**, test-locked to reject all other events + non-default refs | Syncing a PR- or dispatch-influenced entry into a shared store recreates the CREEP precondition | [OK] Decided (load-bearing) | | **Shipped installable PPE-hygiene gate** (best-effort/advisory) + default-branch-protection prerequisite | Heuristic linters can't catch novel evasions, so the load-bearing containment is the `{push,schedule}` sync gate + branch protection; the gate is defense-in-depth | [OK] Decided | | **No content signing as a CREEP control**; digest-pin iff GHCR | CVE-2025-36852: poison precedes hashing, so signing is ineffective; CREEP is defended at the write/sync gates | [OK] Decided | | Retention: native Actions LRU (CI tier) + age-only (RO tier); **no LRU manifest** | A manifest adds mutable retention state (security-negative); GHCR exposes no last-accessed signal | [OK] Decided | -| **OS-namespace the store by default** (or documented consumer OS-discrimination) | Cross-OS cache hit must never serve a wrong-OS artifact (Core Value: never a wrong result) | [OK] Decided | +| **OS-namespace the store by default** (or documented consumer OS-discrimination) | Cross-OS cache hit must never serve a wrong-OS artifact (Core Value: never a wrong result) | [WARN] SUPERSEDED in v0.0.2 - switched to the second branch (see below) | | Runtime-context backend selection instead of a mode flag | No caller can misconfigure read-write vs read-only | [OK] Good | | Publish/cleanup I/O uses Octokit (`error.status`) from the start, never `gh` stderr text-matching | `gh` gives no structured errors for already-exists/404 and is version-fragile; Octokit discriminates structurally | [OK] Decided (greenfield - no gh-CLI to migrate from) | +| **v0.0.2: take CORR-01's SECOND branch** - the store is OS-INVARIANT and OS discrimination lives only in the consumer's declared Nx input | The CORR-01 row above sanctions both branches ("or documented consumer OS-discrimination"); the first cost a Windows dev every cross-OS hit. Ecosystem norm is trust-the-hash (`nx-remotecache-custom` keys on `hash + ".tar.gz"`, no OS component) | [OK] Decided (v0.0.2) | +| **v0.0.2: the `@actions/cache` archive path becomes a deliberate OS-invariant constant**, not an inherited `os.tmpdir()` value | `tmpdir()` in the version-hashed path was ACCIDENTAL correctness - it also silently over-partitions on any runner with a different `TMPDIR`, username, or container, costing hits invisibly. Upstream docs forbid absolute paths cross-OS | [OK] Decided (v0.0.2) | +| **v0.0.2: no OS-separation knob** | YAGNI - this repo is the only consumer, and the knob is additive if that changes. NOTE: TRUST-05 does NOT forbid it; TRUST-05 is scoped to RW-vs-RO only, and an earlier draft mis-cited it | [OK] Decided (v0.0.2) | +| **v0.0.2: Releases asset name is `nx-cache-`** (prefix, single-sourced from `CACHE_KEY_PREFIX`) | Satisfies C16's "distinguishing namespace/prefix" literally; a suffix accept-list on a DELETE filter would grow per scheme revision | [OK] Decided (v0.0.2) | +| **v0.0.2: cross-OS sharing rests on target platform-agnosticism, NEVER on publish-leg ordering** | An ordering-based argument (ubuntu-first wins the first-write-wins race) was proposed and REJECTED as brittle: it would rest a wrong-result guarantee on CI job scheduling - a third accidental-correctness dependency in a milestone whose premise is removing two | [OK] Decided (v0.0.2) | +| **v0.0.2: the `test`-agnostic / `integration`-OS-specific split is enforced by lint**, not documentation | The strategy already existed (`ci.yml:336-337`) but three spec files silently violated it. This repo has no linter today, so ESLint 9 flat config is adopted as its own phase; intentional opt-outs require a described disable annotation and stale disables fail | [OK] Decided (v0.0.2) | ## Evolution @@ -134,4 +184,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-07-22 after v0.0.1 milestone (Greenfield MVP Rebuild) complete. Shipped 7 phases / 33 plans: the Nx self-hosted-cache HTTP server (SRV-01..05), Actions-cache CI-RW backend + context-derived `selectBackend` (TRUST-05, ROBUST-04), authenticated GitHub Releases reader with OS-namespacing (FOUND-01/02, CORR-01), `{push,schedule}`-gated publish/cleanup + coupled retention + fail-loud observability (TRUST-02, RETAIN-01/03, ROBUST-01/02/05, OBS-01), host-detected trust-widening + server-produced-key filter + advisory PPE gate (TRUST-01/06/08), and npm package + `start-cache-server` JS action + docs/governance (DOCS-01..06, GOV-01..03). Merged via PR #3, tagged v0.0.1. Milestone audit passed (6/6 E2E flows wired, all threats closed). Later-milestone triggers: GHCR-01, PROV-01, FOUND-03 (Docker). See milestones/v0.0.1-* and ARCHITECTURE-DECISION.md.* +*Last updated: 2026-07-29 after Phase 9 (OS-Invariant Actions-Cache Version) complete -- 8 plans, all 11 requirements closed, verification/security/validation all `passed`. v0.0.2 is 3 of 6 phases done (7, 8, 9); Phase 10 (OS-Invariant Releases Mirror) is next. Two live-CI items remain `human_needed` at the real merge: `publish-verify (windows-11-arm)` green with a `'linux'` producer line, and OBS-04's rotation signal is SPENT (sampled on run `30400231720`; a later merge shows all-HIT, so read `09-EVIDENCE.md`'s ADDENDUM, not the merge run). Prior update: 2026-07-26 at v0.0.2 milestone start - see the Current Milestone section and REQUIREMENTS.md. Prior update: 2026-07-22 after v0.0.1 milestone (Greenfield MVP Rebuild) complete. Shipped 7 phases / 33 plans: the Nx self-hosted-cache HTTP server (SRV-01..05), Actions-cache CI-RW backend + context-derived `selectBackend` (TRUST-05, ROBUST-04), authenticated GitHub Releases reader with OS-namespacing (FOUND-01/02, CORR-01), `{push,schedule}`-gated publish/cleanup + coupled retention + fail-loud observability (TRUST-02, RETAIN-01/03, ROBUST-01/02/05, OBS-01), host-detected trust-widening + server-produced-key filter + advisory PPE gate (TRUST-01/06/08), and npm package + `start-cache-server` JS action + docs/governance (DOCS-01..06, GOV-01..03). Merged via PR #3, tagged v0.0.1. Milestone audit passed (6/6 E2E flows wired, all threats closed). Later-milestone triggers: GHCR-01, PROV-01, FOUND-03 (Docker). See milestones/v0.0.1-* and THREAT-MODEL.md.* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000..1baabb55 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,797 @@ +# Requirements: @op-nx/github-cache v0.0.2 + +**Defined:** 2026-07-26 +**Core Value:** Correct and safe caching on GitHub infrastructure, for public and private repos, +with nothing extra to host. A remote cache must never serve a wrong or poisoned artifact. + +## Milestone goal + +A Windows developer reuses Linux CI's portable task artifacts, and Windows CI reuses them too, +with the OS-sensitive target still provably separated. Proven by dogfooding this repo, then +documented as a recipe consumers can copy. + +## Framing: the four target outcomes + +Maintainer-stated acceptance outcomes. Every requirement exists to serve one. + +| # | Outcome | Layer | +|---|---------|-------| +| O1 | Local Windows dev gets cache HITs for `build`/`typecheck`/`test` produced by Linux CI | Releases mirror | +| O2 | Local Windows dev gets cache HITs for `integration` produced by Windows CI | Releases mirror | +| O3 | Windows CI gets cache MISSes for `integration` produced by Linux CI | Actions cache | +| O4 | Windows CI gets cache HITs for `build`, `typecheck` and `test` produced by Linux CI | Actions cache | + +**Testing strategy (pre-existing, restored by this milestone).** `test` is +platform/OS/arch/filesystem-AGNOSTIC by design; `integration` is the target that "hit[s] real OS +surface (real sockets, real filesystem/tmpdir)" and therefore carries the platform discriminator +(`ci.yml:336-337`, `STATE.md:192`). Cross-OS sharing of `test` is correct UNDER that strategy. +Three spec files currently VIOLATE it by reading live platform state, which would make a restored +Linux verdict green on Windows without executing them -- so the violations are removed (CORR-05), +not used as grounds to excuse `test` from sharing. + +`typecheck` and `test` cache a pass/fail VERDICT rather than files, so a WRONG VERDICT is the +severe failure mode. Correctness rests on the target being platform-agnostic (CORR-05), NOT on +which OS wins the first-write-wins race: if the verdict is platform-independent, both directions +are safe and leg ordering is irrelevant. + +An earlier draft argued that ubuntu-first ordering made the "stricter" Linux verdict win. That +argument is REJECTED as brittle -- it would rest a wrong-result guarantee on CI job scheduling, +which is the same accidental-correctness pattern VER-01 exists to remove, and it would be the +THIRD such dependency in a milestone whose premise is removing two. The filename-casing axis that +motivated it is closed by an explicit mechanism instead: `forceConsistentCasingInFileNames` is +`true` in both `tsconfig.lib.json:8` and `tsconfig.spec.json:12` (and is the TypeScript 6 +default), so a casing mismatch errors on EVERY platform, not only on a case-sensitive one. + +**Mandatory ordering:** O1 must be PROVEN before O4 is enabled. Windows CI today runs only +`integration`, so any local Windows HIT on the other targets is unambiguously Linux-produced. +Enabling O4 makes Windows CI a second producer and permanently destroys that attribution, so the +evidence must be captured at proof time (TEST-08). + +**Adopter context:** this repo is currently the ONLY consumer. Adopter-migration concerns +(opt-out knobs, read-fallback chains, changelog signalling, version-bump signalling) are +deliberately deferred; they are additive if an adopter ever appears. Correctness of THIS repo's +cache, and the public-repo exposure surface, are not deferred. + +## Decisions locked before requirements + +| ID | Decision | Basis | +|----|----------|-------| +| D2-01 | The store is OS-INVARIANT; OS discrimination lives exclusively in the declared Nx input | The CORR-01 row in `.planning/PROJECT.md` `## Key Decisions` - its "or documented consumer OS-discrimination" branch | +| D2-02 | No new env knob and no new action input | Zero adopters, so no exit is needed yet (YAGNI); additive later. NOT justified by TRUST-05, which is scoped to RW-vs-RO only | +| D2-03 | The Releases asset name is `nx-cache-`, single-sourced from the existing `CACHE_KEY_PREFIX` | C16's "distinguishing namespace/prefix" read literally; a suffix accept-list on a DELETE filter grows per scheme revision | +| D2-04 | The archive path is a workspace-relative forward-slash literal under `.nx/cache/` | `@actions/cache` docs forbid absolute paths cross-OS; `.nx/cache` is gitignored by `nx init` and excluded from Nx's file map. NOT `node_modules/.cache/`, which our own AGENTS.md junctions across worktrees | +| D2-05 | Ecosystem norm is trust-the-hash | `nx-remotecache-custom` keys on `hash + ".tar.gz"`; no Nx cache implementation documents cross-OS correctness | +| D2-06 | Ship as v0.0.2 | The three-group consumer surface is untouched and there are no adopters to signal | + +## v0.0.2 Requirements + +### Cross-OS correctness (CORR) + +- [x] **CORR-02**: The Releases mirror asset name is `nx-cache-` -- a distinguishing prefix + with no OS component -- derived by both reader and publisher from the single `releaseAssetName` + helper, and recognisable to the cleanup filter. Supersedes CORR-01's "OS-namespaced by default" + branch. + +- [x] **CORR-03**: A single cross-OS measurement, run as a build-gating CI job over BOTH matrix + legs at one commit, asserts: (a) exactly two platform records exist, each carrying a non-empty + hash per target -- fewer than two is a FAILURE, not a skip; (b) the `integration` hash DIFFERS + between legs; (c) `build`, `typecheck` and `test` hashes are IDENTICAL. Clause (c) is the + non-vacuity control for (b): with every other input demonstrably shared, the only surviving + explanation for (b) is the declared discriminator. The discriminator command's raw stdout AND + stderr are recorded per leg. A textual assertion that `nx.json` contains the input does NOT + satisfy this. + +- [x] **CORR-04**: `integration` declares a platform discriminator in its Nx inputs, and is the + ONLY target that does. After VER-03 this is the SOLE mechanism separating OS-sensitive targets; + removing it is a Core-Value regression. + +- [x] **CORR-05**: Every target shared cross-OS (`build`, `typecheck`, `test`) is + platform-agnostic -- its RESULT does not depend on the OS, architecture, or filesystem semantics + of the machine that produced it. This is what makes first-write-wins safe in EITHER direction, + and is why no ordering control is needed. Assertions that read live platform state belong in + `integration`, per the recorded strategy (`ci.yml:336-337`, `STATE.md:192`). + **FOUR violation sites in three files, and one is NOT removed by this milestone:** + + | Site | Removed by | + |------|-----------| + | `cache-archive-path.spec.ts:1` (`import { tmpdir }`) and `:26` | VER-02, Phase 9 | + | `releases-backend.spec.ts:38` (wrong-OS fixture from `process.platform`) | CORR-02, Phase 10 | + | `release-asset-name.spec.ts:39` (`releaseAssetName(hash, process.platform)`) | CORR-02, Phase 10 | + | `release-asset-name.spec.ts:60` (`cachePlatform()` vs `cachePlatform(process.platform)`) | **NOTHING** | + + Site 4 survives because OBS-03 deliberately KEEPS `cachePlatform` (it derives the asset label), so + the default-argument test stays meaningful and stays an ambient read. Phase 10 makes an explicit + call on it -- recommended: move it to `server/public-server.integration.spec.ts`, where LINT-02 + allows it. Without that call CORR-05 cannot become true. + + **Sequencing consequence:** after Phase 7 all four sites FAIL `lint` on a green-required build, + while LINT-03 requires them confirmed CAUGHT before removal. So Phase 7 lands a described + `eslint-disable-next-line` at each site, and LINT-06's `reportUnusedDisableDirectives: 'error'` + then forces each one out together with its violation in Phases 9 and 10. That is the mechanism + working as designed, but a planner who does not know it will either leave the build red or delete + the violations early and destroy LINT-03's evidence. + + Related, and not covered by the above: `releases-backend.spec.ts:103-118` is the DOCUMENTED + non-vacuity proof for CORR-01, and CORR-02 destroys it on purpose. Phase 10 must name the + replacement -- assert the reader requested EXACTLY ONE asset name, equal to the imported + `releaseAssetName(hash)`, containing no platform token -- or coverage drops silently. + +- [x] **CORR-06**: The strategy is MECHANICALLY enforced, not documented: a guard fails the `test` + target when a non-integration spec reads AMBIENT platform state -- `process.platform`, + `process.arch`, any `node:os` accessor (`tmpdir`, `EOL`, `platform`, `arch`, `homedir`, `type`, + `release`), or `path.sep`/`path.delimiter`/`path.win32`/`path.posix`. Scoped by the partition + that already exists: `vitest.config.mts` includes `**/*.{test,spec}.ts` and excludes + `*.integration.spec.ts`, which `vitest.integration.config.mts` exclusively owns -- so the same + APIs stay ALLOWED in `integration`, where OS-specific assertions belong. + Injected or explicit platform values are NOT banned -- `cachePlatform('win32')` is the canonical + allowed shape. (Do NOT use `releaseAssetName(hash, 'win32')` as the example: CORR-02 deletes that + parameter in Phase 10, three phases after Phase 7 writes the rule, and `fallow` will then flag it. + OBS-03 keeps `cachePlatform`, so it is the stable substitute.) Only deriving an expectation from + the RUNNING machine is prohibited. + Enforced by the lint rules in LINT-02. + +### Lint toolchain (LINT) + +This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own phase. + +- [x] **LINT-01**: ESLint is adopted with a v9 FLAT config and a `lint` target wired into the CI + battery. v9 is mandatory, not preference: Nx 23.1 dropped ESLint v8 support (`@nx/eslint@23.1.0` + peers `eslint ^9 || ^10`). New dev dependencies are exact-pinned AND their NAMES are added to + `pinned-deps.spec.ts`. These are two separate tasks: that guard is a hard-coded name list with one + `it()` per package, NOT a blanket "every dependency is exact" rule -- the workspace deliberately + carries ranges (`typescript ~6.0.3`, `vitest ~4.1.0`, `prettier ^3.8.1`). Pinning without adding + the names leaves them unguarded, and a later `npm install eslint@latest` passes every check. The + ROBUST-03-class decision is recorded in the spec's comment, since the precedent is genuinely + ambiguous: `esbuild` IS in the list, `prettier` is NOT. + +- [x] **LINT-02**: The rules ban AMBIENT platform reads in unit specs and ALLOW them in integration + specs. **TWO rules are required, not one** -- `no-restricted-syntax` is an AST-selector matcher + and cannot see a destructured named import, and one of the four CORR-05 sites is exactly that + shape (`import { tmpdir } from 'node:os'`); conversely `no-restricted-imports` cannot ban a member + of a namespace import. Both are ESLint core, no new dependency. + Scoped by the partition that already exists, **mirroring its full extension set**: `files: + ['**/*.spec.{ts,mts,cts}']` with `ignores: ['**/*.integration.spec.{ts,mts,cts}']`. The `.ts`-only + form INVERTS the rule for `.mts`: `vitest.integration.config.mts` includes + `{src,tests}/**/*.integration.spec.{ts,mts,cts}`, so an `*.integration.spec.mts` would be linted + as a unit spec and its LEGITIMATE platform read would fail, while a `*.spec.mts` unit spec would + slip the ban entirely. A drift spec asserts the ESLint globs and the two vitest configs agree -- + the repo already ships this guard class for the `trust.ts`/`sync-gate.ts` allowlists. + Banned: `process.platform`, `process.arch`, every `node:os` accessor (`tmpdir`, `EOL`, `platform`, + `arch`, `homedir`, `type`, `release`), and `path.sep`/`path.delimiter`/`path.win32`/`path.posix`. + NOT banned: injected or explicit platform values -- `cachePlatform('win32')` is the canonical + allowed shape. Only deriving an expectation from the RUNNING machine is prohibited. + +- [x] **LINT-03**: The rule set is proven RED before GREEN. The fixture covers the EVASION shapes, + not only the four extant CORR-05 sites -- `const { platform } = process`, `const p = process; + p.platform`, `import { platform } from 'node:os'`, `import * as os from 'node:os'`, `const + k = 'platform'; process[k]`, and `await import('node:os')`. Each of the four CORR-05 sites is + confirmed CAUGHT while it still exists, before Phases 9 and 10 remove it. A rule that matches + nothing is indistinguishable from a rule that is not wired up, and a rule proven only against the + cases that already exist is proven against the easy half. + +- [x] **LINT-04**: The `lint` target's Nx inputs are declared so it cannot serve a stale-cache + false PASS. This repo has already hit that class once: `typecheck`'s inputs excluded `*.spec.ts` + while its command compiled them, so a real error was masked by a cache hit. Three lint-specific + instances: (a) `eslint.config.*` AND anything it imports must be inputs -- otherwise editing a + rule replays a cached PASS, and since LINT-03 IS the activity that edits rules, the false PASS + would surface during LINT-03 itself and read as "the rule does not fire"; (b) every file ESLint + actually reads must be hashed, which is wider than `src/**` (config files, + `start-cache-server/entry.ts`, `*.cjs` helpers); (c) do NOT enable type-aware linting -- none of + LINT-02/05/06's rules need `parserOptions.projectService`, and it would make `lint` sensitive to + every file in the TypeScript program plus the tsconfigs. Extend `nx-target-inputs.spec.ts` rather + than building a new mechanism; note its own caveat that reading `nx.json` from a spec is safe only + because `{workspaceRoot}/nx.json` is a `test` input, and only `test` declares it. + +- [x] **LINT-05**: An intentional violation opts out ONLY via an inline disable annotation + carrying a DESCRIPTION that names the reason -- `// eslint-disable-next-line -- `. + A bare disable is itself a lint error, enforced by a require-description rule + (`@eslint-community/eslint-plugin-eslint-comments`'s `eslint-comments/require-description` or + equivalent), so an opt-out can never be silent. The same discipline applies to TypeScript + suppressions: `@typescript-eslint/ban-ts-comment` is configured `allow-with-description`, so a + bare `@ts-expect-error`/`@ts-ignore` is also an error. + +- [x] **LINT-06**: `linterOptions.reportUnusedDisableDirectives` is `error`. A disable left behind + after its violation is removed must FAIL, not linger -- a stale annotation silently pre-authorises + a future violation on that line, which is the same silent-widening failure class as a dead + allowlist entry. For a unit spec specifically, the reason text must say why the assertion cannot + move to `integration`, since the recorded strategy is that OS-specific assertions belong there. + +### Nx task-hash parity (PARITY) + +- [x] **PARITY-01**: The divergence is root-caused node-by-node and RECORDED before any fix is + applied, controlling for BOTH axes the pre-flight probe identified + (`research/v0.0.2/PROBE-RESULTS.md`): + (a) a real OS axis -- cold-ubuntu differs from cold-windows for every target; and + (b) a FRESHNESS axis that perfectly masquerades as it -- a stale `.nx/workspace-data` on Windows + reproduces the Linux result exactly (measured: warm-local-Windows `build`/`test` equal + cold-ubuntu-CI to the digit, and cold-local-Windows equals cold-windows-CI to the digit). + **Every prior cross-OS measurement in this repo read a confounded variable, including the pair in + `STATE.md` attributed to "ubuntu CI" vs "windows CI".** No difference may be attributed to the OS + until freshness is pinned. Leading hypothesis, now specific: a Windows-only inference difference + visible ONLY on a cold graph -- the `@nx/vitest` / `@nx/js/typescript` OS-dependent + `ProjectConfiguration` class, freshness-gated, which is why the v0.0.1 fixes appeared to hold. + +- [x] **PARITY-02**: The named capture instrument emits the per-NODE hash `details` map + (`TaskHashDetails.details`). `nx show target inputs` is NOT sufficient and a "no difference" + result from it is not evidence: it SKIPS `ProjectConfiguration` (per `HashPlanInspector`'s own + API doc) and reports file PATHS rather than content hashes -- both of v0.0.1's named suspects are + invisible to it. `nx-target-inputs.spec.ts` is the in-repo precedent for reaching into + `nx/src/hasher/*`. `.nx/cache/run.json` is the per-TASK surface and is complementary, not a + substitute; it is overwritten by every `nx` invocation, so read it immediately. + +- [x] **PARITY-03**: `build`, `typecheck` and `test` compute a byte-identical Nx task hash for the + same commit at all three observation points -- native Windows workstation (O1's precondition), + windows-11-arm runner (O4's precondition), ubuntu-24.04-arm runner -- with the Windows workstation + measured in BOTH graph states. Four values per target, not two. Enforced continuously by + CORR-03(c), not measured once. + +- [x] **PARITY-04**: "A warm local box computes the hash cold CI published" is a SEPARATE named + acceptance question from cross-OS parity. If it is false, O1 is unreachable regardless of OS + parity. It MUST NOT be resolved silently by `nx reset`: TEST-10's mandated reset clears + `.nx/workspace-data` too and forces the COLD state, which is convenient for the proof and + misleading as evidence of the everyday developer experience. Record which question each proof + answers. + +- [x] **PARITY-05**: `integration` computes a byte-identical hash between the native Windows + workstation and windows-11-arm (O2's precondition). + +- [x] **PARITY-06**: Every measurement records the Nx version, the Node version, the install mode + (`npm ci` vs `npm install`), and the GRAPH STATE (cold / warm `.nx/workspace-data`). The + 23.0.2 -> 23.1.0 hash-planner rewrite makes cross-version measurements non-comparable, and + `.node-version` is a moving alias (`lts/krypton`). Note `typecheck` carries a THIRD variance + source beyond OS and freshness -- four distinct values across the four probe measurements -- + plausibly install mode reaching it via `dependentTasksOutputFiles` or `externalDependencies`. + +- [x] **PARITY-07**: The public-surface guard passes unchanged -- no new env knob, no new action + input, no new package export (D2-02). + +- [x] **PARITY-08**: `{workspaceRoot}/.github/workflows/ci.yml` is registered as a `test` input and + `nx.json`'s explicit input list is comment-locked. `nx.json` lists `cleanup.yml` and NOT `ci.yml`, + so any spec asserting on `ci.yml` serves a stale cached PASS -- the same false-pass class the + `typecheck` target already shipped once. Consumers: DOCS-08, OBS-05, XOS-06, XOS-07, DOCS-07's + drift guard. The comment lock must record WHY the list is explicit: `targetDefaults` inputs + REPLACE rather than merge, and `@nx/vitest`'s inferred `test` target carries `{ env: 'CI' }` -- + true on every runner, unset on a workstation -- which would make **O1 structurally impossible for + `test`**. That safety currently holds by accident and nothing records it. Lands in Phase 9 so its + hash rotation collapses into VER-01's existing window. + +### Cache-version hardening (VER) + +- [x] **VER-01**: The path string passed to `@actions/cache` is byte-identical on Windows and + Linux for a given hash: a hardcoded forward-slash, workspace-relative literal under `.nx/cache/`. + It MUST NOT be built with `node:path` (`join`/`resolve`/`sep`/`normalize`), MUST NOT be + absolutized, and MUST NOT derive from `os.tmpdir()`, `RUNNER_TEMP`, or `~`. `@actions/cache` + sha256s the raw path strings into the cache version, so any separator difference is a silent + cross-OS MISS. + +- [x] **VER-02**: The two version-determining inputs are pinned by spec -- the archive-path + literal is byte-identical for `win32` and `linux`, and `enableCrossOsArchive` is `true` at every + call site. The derived version itself is NOT assertable: `getCacheVersion` is not on + `@actions/cache`'s exported surface (verified: `ERR_PACKAGE_PATH_NOT_EXPORTED`). + `cache-archive-path.spec.ts:25-26` is REPLACED, not relaxed -- it currently pins + `dirname === tmpdir()`. + +- [x] **VER-03**: `enableCrossOsArchive: true` is hardcoded at ALL THREE `@actions/cache` call + sites -- `restoreCache` (read, `:46`), `saveCache` (write, `:101`), and the `lookupOnly` + existence probe (`:107`). It is a POSITIONAL argument at a different index in each function, and + upstream's JSDoc documents the wrong order. A spec asserts the argument list of each call and + the call count, so a fourth site added later fails. + +- [x] **VER-04**: The process asserts, ONCE at `createActionsCacheBackend()` construction, the + CONJUNCTION: cwd is the Nx workspace root AND (`GITHUB_WORKSPACE` is unset OR + `resolve(GITHUB_WORKSPACE) === resolve(cwd)`), compared case-normalised. "The Nx workspace root" + alone is the WRONG variable -- `@actions/cache` never reads it. A relative path is resolved + against three anchors: glob expansion uses `process.cwd()`, while the tar manifest and `tar -C` + use `GITHUB_WORKSPACE ?? cwd`; our own `readFile`/`writeFile` use `cwd`. When they diverge the + restore reports a HIT, extraction lands under `$GITHUB_WORKSPACE`, `readFile` throws ENOENT under + `$CWD`, and `server.ts` `handleGet` converts it to a 404 -- a permanent silent all-MISS while + `@actions/cache` logs `Cache hit for:`. Assert at construction, not per request: a per-request + check fires inside `get()` and is swallowed by the same catch. Keep `cacheArchivePath` a pure + string function. MEASURED 2026-07-26: the identity HOLDS on both runners today + (`research/v0.0.2/PROBE-RESULTS.md` Q2), so this is a drift guard, not a fix for a live break -- + nothing currently defends it. Record the asymmetry: the same fault is LOUD in `publishMirror` and + SILENT in `serve`, so a green publish job is not evidence the serve path is healthy. + +- [x] **VER-05**: The resolved `@actions/cache` compression method is surfaced in the publish + summary. It is a third version component, pushed into the version UNCONDITIONALLY -- before and + independent of the `enableCrossOsArchive` branch -- so the flag cannot rescue a mismatch. The + value is NOT readable from the library: the exports map is `{".": ...}` only and + `getCompressionMethod` is internal, the same `ERR_PACKAGE_PATH_NOT_EXPORTED` wall VER-02 + documents. So this is an independent re-implementation and must mirror upstream EXACTLY: run + `zstd --quiet --version`, collect stdout AND stderr into one string, swallow a throw to `''`, and + branch on `versionOutput === '' ? Gzip : Zstd` -- the parsed semver is computed and then NOT used, + so a broken-but-present zstd still selects zstd. Comment-lock it to the pinned version pointing at + `cacheUtils.js`, and add "re-read `getCompressionMethod`; VER-05 duplicates it" to the + `@actions/cache` bump checklist. Surfaced, NOT gated. MEASURED 2026-07-26: zstd v1.5.7 and GNU tar + 1.35 ARE present on `windows-11-arm`, so O4 is not blocked -- but zstd comes from `C:\tools\zstd`, + NOT bundled by Git for Windows as the debug report claimed, which makes its presence a runner-image + provisioning choice and MORE likely to move than assumed. + +- [x] **VER-06**: The cross-OS behavioural close is a `dogfood-verify` leg on windows-11-arm that + reads back the entry `dogfood-seed` wrote on ubuntu-24.04-arm. A MISS fails the job. This, not a + unit spec, is the load-bearing control: a spec runs in one process on one OS and cannot observe + a two-OS property. It asserts PROVENANCE, not presence -- the seed key is + `nx-cache-`, one key per RUN and not per OS, so the moment a Windows `dogfood-seed` + leg exists the Windows verify would restore the Windows-written entry and pass even if cross-OS + restore were completely broken. Extend `dogfoodBody` to encode the producing OS and assert the + Windows leg read a LINUX-produced body. The vacuity condition is written into the job comment. + This is the Actions-cache mirror image of the Releases-side trap OBS-05 closes; the asymmetry was + an omission, not a decision. + +- [x] **VER-07**: The archive directory exists and the literal stays gitignored. `put()` calls + `writeFile` before anything creates `.nx/cache`, so on a fresh runner or after `nx reset` that is + ENOENT, which rethrows (not a `ReserveCacheError`) into a 500 and fails the build -- writes are + fail-closed by design. One `mkdir` with `{ recursive: true }` at construction covers it. The read + path self-heals because `extractTar` runs `io.mkdirP`; the write path does not. `.gitignore` + covers `.nx/cache`, NOT `.nx/` wholesale -- so a later tidy to `.nx/github-cache/` would put a + transient multi-megabyte file into Nx's workspace file map and produce a self-referential, + intermittent task-hash perturbation. Comment-lock the literal as chosen because it is GITIGNORED, + not merely because it is workspace-relative. `nx reset` deletes `.nx/cache`, and TEST-10 mandates + a reset, so the Phase 11 proof order is reset FIRST, then start the sidecar. + +- [x] **ROBUST-04**: `npm run build:action` runs in the SAME COMMIT as any edit to a + `serve()`-reachable source. The committed `start-cache-server/index.js` INLINES both + comment-locked helpers and `getCacheVersion`'s `windows-only` branch, and four `ci.yml` sidecar + jobs run that committed bundle from the git ref rather than a build output. Drift means the + sidecar writes at one cache version while the publish action restores at another -- **the mirror + silently stops receiving anything**, surfacing only as the all-restore-MISS warning that OBS-04 + has just told everyone to expect exactly once. `action-bundle-drift` catches it, but only on push, + after the misleading signal has already been rationalised. + +- [x] **VER-08**: The read-only Actions-cache backend is the SAME implementation as the writable + one's read path, per D-01. Exactly ONE `cache.restoreCache(...)` READ call site survives in the + package, and `createActionsCacheBackend` COMPOSES `createReadOnlyActionsCacheBackend` rather than + duplicating it; both factories live in + `packages/github-cache/src/backend/actions-cache-backend.ts`. The VER-04 cwd/`GITHUB_WORKSPACE` + guards and the VER-07 construction-time `mkdirSync` live in the shared read core; `put`'s second + `mkdirSync` and its `lookupOnly` existence probe -- the SECOND `restoreCache`, write path only -- + stay on the write path unchanged and MUST NOT be "unified" with the read path. A sibling + `*actions-cache*.ts` module is invisible to the FILE-scoped ordered-member guard at + `actions-cache-backend.spec.ts:517-531`, so a second version computation would ship with the whole + suite green -- and an OS-dependent cache version is precisely the bug Phase 9 existed to fix. + D-09's escape hatch was LIVE and was NOT taken: the roadmap allowed this phase to SHRINK to a + documented decision if no shape satisfied D-01's criterion. One does, and it needs ZERO changes to + the guard that enforces the criterion -- the ordered `cache.*` member array is byte-identical under + the composed shape. So the criterion is not something this phase establishes; it is something this + phase must not break. + +- [x] **VER-09**: The `@actions/cache` drift guard widens from FILE scope to PACKAGE scope: exactly + one non-spec module under `packages/github-cache/src/` imports `@actions/cache`. VER-03 asserts the + argument list and the call count WITHIN `actions-cache-backend.ts`, and a file-scoped scan + structurally cannot see a sibling module -- so a second importer added later would carry its own + version computation past a green guard. That is the future-sibling evasion D-01's criterion exists + to foreclose, and it is the one hole the D-01 shape does not close by itself. True today + (`actions-cache-backend.ts` is the sole importer); this requirement is what keeps it true. + +### Cross-OS outcomes (XOS) + +- [x] **XOS-01**: A local Windows developer gets a cache HIT for `build`, `typecheck` and `test` + from artifacts produced by Linux CI, via the Releases mirror. (O1) + +- [x] **XOS-02**: A local Windows developer gets a cache HIT for `integration` from artifacts + produced by Windows CI. Measured BEFORE the CORR-02 rename as a baseline and AFTER as a + non-regression. (O2) + +- [x] **XOS-03**: Windows CI gets a cache MISS for `integration` produced by Linux CI. **This is a + statement about Nx HASHES, not about cache storage.** After VER-01/VER-03 the storage layer no + longer partitions by OS, so a storage-level probe for the Linux hash from a Windows runner would + HIT -- asserting a 404 there would assert a property this milestone deliberately destroyed. The + MISS occurs because Windows never ASKS for the Linux key. See TEST-09 for the proof shape. + MEASURED 2026-07-26: `integration` hashes differ across the two runners as designed + (`8865876519165210738` vs `1822904335635353663`), so CORR-04's mechanism is confirmed working at + this commit. (O3) + +- [x] **XOS-04**: `ci.yml` runs `build`, `typecheck` and `test` on a windows-11-arm leg in addition + to the ubuntu leg. Without this there is no Windows job that could exhibit O4's HIT. Note the + `integration` matrix is NOT the wiring precedent here -- see XOS-08. + +- [x] **XOS-05**: Those Windows legs get a cache HIT for all three targets from entries saved by + the ubuntu leg. Whether they also WRITE is an explicit recorded decision; if they write, the loss + of clean Linux attribution is recorded alongside TRUST-11/12, **and a scheduled + `--skip-nx-cache` windows-11-arm job becomes required rather than optional** -- once Windows + replays Linux verdicts for all three targets, a Windows-only regression in the code under test is + otherwise invisible forever, and the success signal for O4 (every target `[remote cache]`, wall + time collapsing to sidecar overhead) is the identical observation. (O4) + +- [x] **XOS-08**: The O4 proof has an explicit producer-to-consumer ordering: the Windows legs + declare `needs:` on the corresponding ubuntu jobs, mirroring `dogfood-seed` -> `dogfood-verify`. + The `integration` matrix precedent does NOT transfer -- its two legs compute DIFFERENT hashes, so + parallelism is harmless; the new legs compute the SAME hash, so run in parallel they both MISS, + both execute, and both race `saveCache`. The cross-push alternative is also foreclosed: once + PARITY-08 lands, the very commit that ADDS the Windows legs invalidates the `test` hash, so + proving it on a later push would need a second no-op push. + +- [x] **XOS-06**: `max-parallel: 1` is RETAINED for its existing reasons (serialised legs, no + concurrent shard-creation or delete races) but MUST NOT become a correctness control. No + requirement may depend on which OS leg wins the first-write-wins race -- cross-OS sharing is made + safe by CORR-05's platform-agnosticism, not by ordering. A comment records this explicitly so a + future reader does not reconstruct the rejected ordering argument. + +- [x] **XOS-07**: `publish` depends on every job producing a mirrored entry (`build`, `typecheck`, + `test`, `integration`), not on `build` alone, so one default-branch push mirrors that push's full + task set. Otherwise the O1 proof races job completion and can fail on a correct implementation. + +- [x] **XOS-09**: All three Windows legs (`build-windows`, `typecheck-windows`, `test-windows`) + construct the read-only backend, and their `[remote cache]` counts are GATED at `>= 1` per leg. + Per D-04, convert all three and not a subset -- one writable leg left behind keeps a launderable + path open and invites a future reader to "make the others consistent" in the wrong direction. Per + D-05 the threshold is a FLOOR, not an exact pin: these counts are emitted by Nx's task graph and + legitimately vary with it, so the per-target numbers stay as printed diagnostics while the gate + asserts the floor. The soundness argument is INDUCTIVE, not per-run -- once the consumer legs + cannot write, no Windows-produced entry for those hashes can ever exist, so any HIT is NECESSARILY + Linux-produced, regardless of run ordering, re-runs, or what an earlier run did. Keep that SEPARATE + from LIVENESS: XOS-08's `needs:` edge is why the entry is present at all, and it is why the gate is + not spuriously red. Without the read-only backend the same gate is LAUNDERABLE -- a broken cross-OS + restore makes the leg MISS, execute and SAVE its own entry, and a re-run then HITs that + self-produced entry and takes `count >= 1` green with cross-OS reuse completely dead. A gate a + re-run can launder is worse than no gate, because it reads as coverage. The gate's failure message + names BOTH causes a zero can have (cross-OS restore broken vs. the producer never populated the + entry) so a red gate is actionable rather than merely alarming. + +### Retention and cleanup (RETAIN) + +- [x] **RETAIN-04**: The cleanup asset filter admits BOTH the new `nx-cache-` name and the + legacy `-` names, so legacy assets age out through the existing + `CACHE_MIRROR_MAX_AGE_DAYS` window instead of accumulating. MUST land in the SAME COMMIT as + CORR-02 -- a publisher writing the new name against an unextended filter silently stops pruning. + `CACHE_OS_VALUES` is retained and annotated as intentionally-kept legacy support so `fallow` + dead-code analysis does not prune it. Proven by specs over both name families plus a cleanup + dry-run over a mixed shard. + +- [x] **RETAIN-05**: Three things RETAIN-04 does not cover. (a) The shard already holds ~50 PoC-era + `.tar.gz` assets that match NO filter, before or after RETAIN-04 -- they have never been + prunable and are permanent occupants of the 1000-asset per-release cap, whose overflow degrades to + skip-and-warn rather than an error. Decide explicitly: prune once by hand, add a third accept + branch, or record as accepted dead weight with a count. Do not leave the question unasked. + (b) The two accept branches are asserted MUTUALLY EXCLUSIVE directly -- non-overlap is currently a + property of the last-`-` split, not of the design. (c) After D2-03, `CACHE_KEY_PREFIX` becomes + QUADRUPLY load-bearing: the Actions-cache key, `isServerProducedKey`, the asset name, and the + cleanup filter. Changing it silently orphans the entire mirror, and RETAIN-04's legacy branch + would NOT cover the orphans because it only knows `-`. Pin the literal by spec and + comment-lock it as governing four things. + +### CREEP trust posture (TRUST) + +- [x] **TRUST-10**: C1 (write-trust allowlist), C2 (sync gate) and C16's enumeration-side filter + (`isServerProducedKey`, over Actions-cache keys) are unchanged, verified rather than assumed. + C16's Releases-side filter (`isServerProducedAssetName`) DOES change under CORR-02/RETAIN-04; + the change is additive. The `ref` scoping of `listCacheEntries` (`action/index.ts:40-43`) is + pinned by spec and comment-locked: with the OS-version barrier removed it becomes the ONLY + in-repo control keeping non-default-branch trusted writes (`TRUSTED_EVENTS` includes `push` with + no ref check) out of the world-readable mirror. + +- [x] **TRUST-11**: The phase threat model records where first-write-wins arbitrates between + NON-identical payloads -- **at `saveCache`, not at the Release upload.** Two publish legs never + produce differing payloads: for a given hash the Actions cache holds exactly ONE entry, and both + legs restore it and upload it verbatim without re-executing the task, so the uploaded bytes are + byte-identical. The real race appears only once XOS-04 puts `build`/`typecheck`/`test` on a + Windows leg: two jobs then compute the same hash H and both call `saveCache(nx-cache-H)`, and the + winner owns the entry INCLUDING its OS-specific captured terminal output (`ci.yml:652`). That race + IS ordering-dependent, because the legs run in parallel. XOS-06 is satisfied because no + requirement DEPENDS on the winner, not because the race does not exist. **This moves TRUST-11's + residual risk into the XOS-05 write decision** -- a cross-phase consequence. + Two clauses that remain correct as originally written: the month-shard newest-first read walk + makes the winner shard-dependent when a hash is mirrored into two shards; and cross-OS restore is + byte-faithful (tar-in-tar, inner entry names forward-slash-normalised by `resolvePaths`), so the + out-of-scope file-mode question applies to the Nx client's extraction of the INNER tar, not to our + transport. Separately and regardless: `publish-mirror.ts:159`'s "byte-identical under CORR-01" + comment is rewritten in the SAME COMMIT as CORR-02 -- byte-identity survives, but its REASON + changes from OS-namespacing to one-entry-per-hash. + +- [x] **TRUST-12**: The phase threat model records that VER-01/VER-03 remove the incidental + within-scope OS partitioning, leaving CORR-04's declared discriminator as the sole separation + mechanism; and records the public-repo EXPOSURE DELTA -- a single-OS publish leg can now restore + and mirror every OS's entries, so the captured terminal output of every CI job on every OS + crosses into the anonymously-readable Releases mirror. + +- [x] **TRUST-13**: TRUST-11 and TRUST-12 are classified by gsd-security-auditor in SECURITY.md, + not self-certified. The proposed classification (neither crosses a trust boundary, because the + Actions cache's boundary is ref scope, not OS) is offered as INPUT to that audit, not as its + conclusion. + +- [x] **TRUST-14**: The producer-vs-consumer ROLE signal is a strictly-narrowing env knob read by + `selectBackend` as its LAST branch -- per D-02a the signal is per-LEG by ROLE and never per-EVENT + (an event-derived signal cannot be widened back for `dogfood-seed`, which legitimately writes on a + `pull_request`), and per D-02b its shape is an env knob, the only option that works on + `build-windows` without building the package before the measured `npm run build`. + `selectBackend.length` stays 0. The guarantee is BRANCH ORDER, not validation: every branch above + has already returned a read-only backend or thrown, so the knob's only reachable effect is + writable -> read-only, and it cannot resurrect the Releases branch, the fail-closed throw, or the + memory-degrade branch. TRUST-05's one-way ratchet is therefore satisfied -- it forbids REQUESTING + write, not DECLINING it. Proven BEHAVIOURALLY over an enumerated env table, never by comment: a + comment cannot fail when someone moves the check earlier, and moving it earlier is exactly what + breaks the property. A construction-time `readOnly` factory argument stays REJECTED (D-03) -- + RW-vs-RO is which factory constructs, never a caller-facing mode flag, and a flag re-opens the hole + `PutResult` was narrowed to close when `'forbidden'` was deleted from the union. The distinction + that makes this knob legitimate and D-03 not: this signal is read from the ENV BAG by the SELECTOR; + D-03's is an argument to the FACTORY. The witness / `created_at` variant is dead at the API level + (D-02c) -- `@actions/cache@6.2.0`'s `restoreCache` returns only the matched key string, so no + creation timestamp is observable to compare against. + +### Documentation (DOCS) + +- [x] **DOCS-07**: A consumer-facing cross-OS adoption recipe whose PRIMARY instruction is + safe-by-default: declare the platform discriminator across all cacheable targets first, then + remove it per target only after proving that target's output is portable. The portability + checklist is the SECOND section, framed as how to earn a removal, and its items are derived from + PARITY-01's root-cause record rather than prejudged. Names architecture and libc as axes + `process.platform` does not cover (this repo cannot exercise them -- every machine here is + arm64). The documented discriminator command must be stderr-immune, since `hash_runtime` hashes + stdout AND stderr. Registered in `nx.json`'s `test` inputs and guarded against drift. + +- [x] **DOCS-08**: Every location asserting same-OS restore as a load-bearing invariant is + corrected, since VER-03 inverts it. The list is FOUR, not two: `docs/advanced.md:54-57`, + `docs/advanced.md:45`, `ci.yml:577-583`, and `ci.yml:356-360` (the integration job's comment makes + the same now-false claim and is easy to miss). `ci.yml:693` and `read-back.ts:10-31,52-56` assert + the same contract and belong to Phase 10's CORR-02/OBS-05 work rather than here. + `README.md:125` and `docs/trust-and-security.md:155` are a DIFFERENT case and must not be + "corrected" as though they were wrong: both frame "never a wrong result" as a consequence of FAULT + DEGRADATION ("every read fault degrades to a MISS"), which stays true. The edit there is ADDITIVE + -- a new precondition about target platform-agnosticism -- not a contradiction. + +- [x] **DOCS-09**: Every site justifying the UNGATED counts is corrected in the SAME commit that + gates them, per D-06. Once the legs cannot write, that rationale is FALSE, and a comment carrying a + false reason is a documented argument for undoing the work -- this repo has corrected exactly this + class of defect four times on this branch (`fd75d83`, `7e777b3`, `9e949e4`, quick task + `260801-vyy`). The list is SEVEN, not four: the three `ci.yml` per-leg rationale comment blocks, + the three `ci.yml` printed `echo` strings (which are CODE, survive the spec's `#`-strip, and are + what an operator actually reads in the job log), and the one `dogfood-cross-os.spec.ts` + `cacheObservation` reason string. The count is written down rather than left to a reader's sweep, + because a partially-corrected N-copy comment is this repo's recurring defect. Record the SCOPE + LIMIT with it: two further `RECORDED, never gated` sites in `ci.yml` -- the `runner.debug` record + and the `integration` matrix leg's count -- remain factually TRUE and MUST NOT be swept. The + outcome-COUNT sites are NOT this requirement: `docs/configuration.md:92` and `docs/versioning.md` + belong to DOCS-10, because neither ever argued the counts could not be gated. + +- [x] **DOCS-10**: The knob D-02b lands on is documented and its arrival is reconciled everywhere the + backend-selection outcome count is written down. It gets a `docs/configuration.md` table row AND a + `### ` resolution section (`docs-adoption.spec.ts` asserts each knob is documented), and a + listing in `docs/versioning.md`'s consumer env-knob group -- a pure addition, with no prior false + claim to correct. `docs/advanced.md`'s "How the backend is selected" grows from four outcomes to + five INCLUDING its hardcoded prose count, and every remaining "four backend-selection outcomes" + site is corrected: there are FIVE such sites, not two -- `docs/advanced.md:21`, + `docs/configuration.md:92`, `memory-backend.ts:59`, and the describe title PLUS its comment at + `docs-adoption.spec.ts:116-117`. The contract change lands as a reviewable diff in the explicit + `public-surface.spec.ts` literal, never a snapshot: the ninth knob is a consumer-visible surface + addition on a shipped package and must be readable in the diff, per the house rule the pinned + enumerations exist to enforce. + +### Verification (TEST) + +- [x] **TEST-08**: Each of O1-O4 has a recorded live proof executed in the mandated order. + Evidence is defined: the workflow run URL (O3/O4) or captured terminal output (O1/O2), the Nx + hash observed, and the literal `[remote cache]` label. The O1 proof additionally captures + PRODUCER ATTRIBUTION at proof time -- per hit hash, the Actions-cache entry list and shard asset + list with `created_at`, cross-referenced against job windows -- because enabling O4 permanently + destroys the ability to re-derive it. **The attribution window closes at Phase 9, not Phase 12** -- + once the version is OS-invariant the ubuntu publish leg starts mirroring the Windows `integration` + entry too, so the shard stops being "everything here is ubuntu's" before Phase 11 runs. Capture + `created_at` and the OBS-03 label per asset, not just the asset list. + The premise that Windows CI produces no `build`/`typecheck`/`test` hash is asserted mechanically + against the RESOLVED task graph for the Windows leg's actual command, not assumed from the job + list, and the assertion output is captured as evidence rather than discarded as a pre-flight + check. The premise is a property of the CURRENT graph, not of the config: `integration`'s + `dependsOn: ["^build"]` resolves to zero tasks only because this is a single-project workspace, + and the `typecheck` job already touches the `build` hash as a dependency. + Every "the job was green" claim is paired with a COUNT that would differ under the failure + hypothesis, named in the plan rather than after the run; `ACTIONS_STEP_DEBUG` is on for the + proving run, since restore MISSes log at `core.debug` and are otherwise absent from the log. + +- [x] **TEST-09**: The O3 proof is an Nx-HASH proof, not a storage probe, and runs AFTER VER-01 and + VER-03 have landed. A storage-level probe is now INVALID: with the version OS-invariant, + `restoreCache([path], 'nx-cache-')` on windows-11-arm would HIT, so asserting a 404 would + assert a property this milestone deliberately destroyed -- and if it DID 404, the likeliest cause + is a compression-method divergence (VER-05's third component), which is exactly the + "passes for the pre-change reason" failure this requirement exists to prevent, inverted. + Three parts: (1) cite CORR-03(b)'s build-gating record that `H_linux != H_win` for `integration` + at the commit -- Phase 11 cites it, it does not re-derive it; (2) show the Windows `integration` + task EXECUTED, carrying no `[remote cache]` label, in a run where `nx-cache-` + demonstrably existed in the Actions cache at the time; (3) a POSITIVE CONTROL in the same job -- a + scripted authed GET on a known-present key returns 200 through the same sidecar and backend, so + part 2 is not an artifact of a dead sidecar. `ci.yml`'s existing readiness GET is already that + shape, so this extends a proven pattern. A run that MISSes everything is not a valid proof. + So reframed, the proof is STRONGER: it shows the declared Nx input is the only thing separating + the two targets, which is CORR-04's actual claim. + +- [x] **TEST-10**: The O1/O2 local proofs begin from a cleared local Nx cache (`nx reset`), and the + order is reset FIRST, THEN start the sidecar -- `nx reset` deletes `.nx/cache`, which is where + VER-07 puts the archive, so resetting under a running sidecar makes the next PUT 500. A HIT + recorded without a preceding reset is not accepted: a local cache hit short-circuits before the + remote is ever queried, and a warm-graph copy can serve tasks locally from an artifact directory + containing no artifacts, never consulting the remote at all. + The proof records WHICH QUESTION it answers. `nx reset` clears `.nx/workspace-data` as well as + `.nx/cache`, so it forces the COLD state -- which makes the proof "does a cold Windows box hit", + not "does my everyday box hit". Both are legitimate questions and PARITY-04 names the second one; + do not let the reset silently substitute one for the other. + A soundness probe runs BEFORE the measurement, not after: a 401-vs-404 pair on a known-absent hash + proves auth and reachability together, and a differential against a dead port proves the requests + actually left the process. Record the probe's timestamp as preceding the first Nx run. The + `Cache: n/m hit` line is recorded and explicitly marked NON-DISCRIMINATING in both directions -- + a `0%` prints identically with no sidecar at all, and a non-zero count includes local hits. + +- [x] **TEST-11**: `dogfood-cross-os.spec.ts` pins the SEMANTIC change per leg, per D-07 -- the + read-only knob write is present, the count COMPARISON is present, and the ungated-count revert + marker is absent. Pin the semantic outcome, not the presence of a string: without it a silent + revert to a writable sidecar reopens CR-18 with every other clause still green. Asserting a bare + `exit 1` is FORBIDDEN, and the reason is recorded here rather than left to be rediscovered: + `ci.yml:527` is already `exit 1` (the sidecar readiness poll) inside `jobBlock('build-windows')`, + so an `/exit 1/` clause is GREEN before the phase changes anything -- a guard that can pass over a + wrong payload is not coverage. Non-vacuity is MUTATION-proven and the measurement is recorded in + the clause comment, matching the `cacheClient` precedent at `dogfood-cross-os.spec.ts:886-893`. + The file is the right home rather than a sibling: it already reads `ci.yml` from disk and owns + cross-OS CI shape. + +### Observability (OBS) + +- [x] **OBS-02**: Proof evidence is a non-zero count of tasks carrying the literal `[remote cache]` + label, named per target. Nx 23.1's end-of-run performance report is supporting context only -- + it cannot separate local from remote, cannot attribute a producer OS, and prints an identical + `0%` line for a run with no sidecar at all. It renders to the job summary in CI and to the + terminal locally. + +- [x] **OBS-03**: Every mirrored asset records `mirrored-by: ` in Release asset metadata that + is NOT part of the lookup name (the free-form `label` field). The store stays OS-invariant for + lookup; only attribution is preserved. CORR-02 otherwise removes the only means of attributing a + served artifact to a producer -- an incident-response gap of the same class the ADR weighed + decisively when choosing Releases over GHCR. + **It is `mirrored-by`, NOT "producing OS", and the distinction is load-bearing.** The label can + only derive from the PUBLISHING leg's `cachePlatform()`; `listCacheEntries` returns `{ key }` + only, and the Actions-cache API exposes no producing-OS field. Publisher-OS equals producer-OS + today only because restore is same-OS -- and VER-03 is precisely what breaks that identity, so + from Phase 9 the ubuntu leg can mirror a Windows-produced entry and would label it `linux`. + Claiming "producing OS" would therefore be WRONG in exactly the cross-OS case the label exists to + serve. Any stronger claim -- in particular that the label answers "whose bytes did the developer + get" -- is explicitly RETRACTED and must not appear in TRUST-11/12 or DOCS-08. + Requires a seam widening no other requirement mentions: `uploadReleaseAsset(releaseId, name, + bytes)` gains a `label` parameter, plumbed through `action/index.ts` and every fake in + `publish-mirror.spec.ts`. + +- [x] **OBS-04**: The all-restore-MISS warning's message drops the now-false "different OS" + explanation and names cache-version rotation as a candidate cause. The expected signal of the + first post-change push is recorded IN ADVANCE (all-miss on both publish legs, `mirrored == 0`). + The tripwire is gated on **two consecutive all-miss pushes with NO version-affecting change in + between**, not on a raw push counter: there are THREE legitimate rotation windows in this + milestone, not one -- Phase 7's inferred `lint` target rotates `hash_project_config` (and `nx.json` + is itself a `test` fileset input, so registering the plugin rotates `test` twice over), VER-01 + rotates the cache version on every OS, and CORR-02 rotates the asset name. A tripwire that fires + on correct work gets disabled, and then it is not a tripwire. It stays a warning, not a hard + failure. Note `enableCrossOsArchive` alone rotates only WINDOWS entries -- on Linux and macOS the + flag is a no-op on the version -- so the first-push all-MISS on BOTH legs is caused by the PATH + change, not the flag. + +- [x] **OBS-05**: Each `publish` matrix leg seeds a leg-DISTINGUISHABLE hash and each + `publish-verify` leg reads back its OWN leg's asset. Today both legs seed + `GITHUB_RUN_ID` (`read-back.ts:37`) and are separated only by the OS suffix, so CORR-02 would + make the Windows leg read the ubuntu-produced asset and pass even if the Windows publish path + were entirely dead. + +## Sequencing constraints + +Consumed by the roadmapper as phase dependencies. + +| Before | After | Why | +|--------|-------|-----| +| LINT-01 | PARITY-01 | `@nx/eslint` is an INFERENCE plugin: an inferred `lint` target changes `hash_project_config`, which is folded into EVERY task hash. Adding it after the root-cause work would invalidate that work -- and an OS-divergent lint inference would be a NEW parity bug of exactly the `@nx/vitest` / `@nx/js/typescript` class | +| LINT-01 | LINT-02, LINT-03, LINT-04 | Toolchain before rules | +| LINT-02 | CORR-05 violation removal | The rule must be proven to CATCH all FOUR violations before they are removed, or nothing shows the rule works. Phase 7 lands a described disable at each site so the build stays green; LINT-06 then forces each out with its violation | +| PARITY-08 | any spec asserting on `ci.yml` | Without the `test` input the spec serves a stale cached PASS | +| VER-07 | VER-01 | The archive directory must exist before the first `writeFile` at the new path | +| PARITY-01 | PARITY-03 | Root-cause before fixing | +| PARITY-01 | DOCS-07 | The checklist is derived from the findings | +| PARITY-03 | XOS-01 | Hash parity is O1's precondition | +| ROBUST-04 | (same commit as any `serve()`-reachable edit) | Otherwise the sidecar and the publish action compute different cache versions and the mirror silently stops receiving | +| RETAIN-05 | (same commit as CORR-02) | The `CACHE_KEY_PREFIX` lock and branch-disjointness assertions guard the same change RETAIN-04 makes | +| XOS-08 | XOS-05 | Without the producer-to-consumer ordering the two legs both MISS and race `saveCache`, so the HIT cannot occur | +| CORR-02 | XOS-01, XOS-02 | The rename is what enables the cross-OS read | +| RETAIN-04 | (same commit as CORR-02) | A new name against an unextended filter silently stops pruning | +| OBS-05 | CORR-02 | Or `publish-verify` goes vacuous the moment the rename lands | +| VER-01, VER-03 | TEST-09 | Otherwise the O3 proof passes for the pre-change reason | +| XOS-01 proven | XOS-04, XOS-05 | Enabling O4 destroys O1's attribution permanently | +| A default-branch push republishing under the new name | XOS-01, XOS-02 proofs | The mirror must be warm under the new scheme | + +## Out of Scope + +| Item | Reason | +|------|--------| +| Executor portability classification | Not knowable a priori and project-dependent. The Nx hash is the classification ONLY GIVEN the DOCS-07 declaration; the residual risk is recorded in TRUST-11 | +| Empirical divergence-detection subsystem | STRUCTURAL, not merely disproportionate: no surveyed build cache detects a portability violation at serve time, because every detector that exists re-executes the task (Nix `nix-store --realise --check`, exit code 104; Debian `reprotest`; Develocity's out-of-band scripts). A cache that re-runs tasks is not a cache. NOTE: "O4's green CI is the portability evidence" is NOT the reason -- that argument is circular, since a restored task does not execute, and Nix's `--check` exists precisely because an existing store path proves nothing until you rebuild. One exception is carried as a CONDITIONAL clause on XOS-05, not as a subsystem | +| Per-job or per-target OS-invariance flag | D2-02. The stronger reason is LAYER, not adopter count: every comparator puts the portability knob in the task DECLARATION (`@CacheableTask`, a `runtime` input, REAPI `Platform`), never in the cache BACKEND -- a backend-level knob would be an ecosystem inversion. "Wrong layer" does not expire the way "zero adopters" does. NOT forbidden by TRUST-05, which is scoped to RW-vs-RO | +| Read-fallback across old and new asset names | No adopters; our own mirror repopulates on the next default-branch push | +| Adopter-migration signalling (changelog, `v0` tag policy, version-bump signal, rotation notice) | No adopters to signal; all additive later | +| Collapsing the publish matrix to one leg | Only safe AFTER XOS-05 is proven; a follow-on decision | +| Archive file-mode handling across the OS boundary | Unverified; carried as an XOS-05 investigation item, not a requirement | + +## Traceability + +Populated during roadmap creation (2026-07-26). Every v0.0.2 requirement maps to exactly one +phase; 43/43 mapped, no orphans, no duplicates. Phase detail and the sequencing-constraint +honour table: `.planning/ROADMAP.md`. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| LINT-01 | Phase 7 | Complete | +| LINT-02 | Phase 7 | Complete | +| LINT-03 | Phase 7 | Complete | +| LINT-04 | Phase 7 | Complete | +| LINT-05 | Phase 7 | Complete | +| LINT-06 | Phase 7 | Complete | +| CORR-06 | Phase 7 | Complete | +| PARITY-01 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- both axes separated with their own diffs, zero `nx.json` commits in `7bfe64f..eeace53`) | +| PARITY-02 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- per-node `details` instrument, `hash.details.nodes` at `capture-hashes.mjs:305`, M4 re-proves instrument == Nx) | +| PARITY-03 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED with the commit-spread qualification recorded -- M3 plus M5/M6, four values per target) | +| PARITY-04 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- warm-local vs cold-CI kept as its own Q2-only section, M2 proves no reset in the recipe) | +| PARITY-05 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- same-OS pair, zero differing nodes across all 430 post-fix) | +| PARITY-06 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- all four fields on every record and on both CI legs) | +| PARITY-07 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- both `git diff --name-only` runs empty, M9 asserts the tarball exclusion) | +| CORR-03 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- build-gating job, (a) and (c) observed RED on real legs with matching GREENs, (b) fixture-proven) | +| CORR-04 | Phase 8 | Complete (08-VERIFICATION.md Requirements Coverage: SATISFIED -- re-derived from `nx.json`: exactly one `{ "runtime": ... }`, at `:101`) | +| PARITY-08 | Phase 9 | Complete (`nx.json:69`; landed FIRST, before any spec asserted on `ci.yml`) | +| VER-01 | Phase 9 | Complete | +| VER-02 | Phase 9 | Complete | +| VER-03 | Phase 9 | Complete | +| VER-04 | Phase 9 | Complete (construction-time conjunction guard; identity MEASURED to hold) | +| VER-05 | Phase 9 | Complete (surfaced, never gated; `zstd-without-long` OBSERVED on both real runners) | +| VER-06 | Phase 9 | Complete (closed LIVE on windows-11-arm, run `30400231720`; asserts provenance, not presence) | +| VER-07 | Phase 9 | Complete (before VER-01's first write) | +| ROBUST-04 | Phase 9 | Complete for Phase 9; RECURS in Phase 10 (and Phase 7 if autofix touches those files) | +| OBS-04 | Phase 9 | Complete (reworded message + two-push tripwire + advance record `e7018d0`; signal SAMPLED, prediction rows not met -- see `09-EVIDENCE.md` ADDENDUM) | +| DOCS-08 | Phase 9 | Complete (four corrections + two additive, phrase-pinned; `read-back.ts` and `ci.yml:693` were always Phase 10 scope) | +| CORR-02 | Phase 10 | Complete | +| RETAIN-04 | Phase 10 | Complete (landed in 10-07, the same commit as CORR-02; row was stale against its own ticked checkbox) | +| RETAIN-05 | Phase 10 | Complete (10-VERIFICATION.md:107 round-1 row `RETAIN-05(a)(b)(c): tar.gz disposition, mutual exclusivity, 4-consumer lock` VERIFIED; corroborated by the `:137` ticked list) | +| CORR-05 | Phase 10 | Complete (10-VERIFICATION.md:55 `### #12 CORR-05 -- VERIFIED (was Uncertain)` and `:116` `CORR_05_SITES` empty with the positive assertion present; site 4 called explicitly as D-17, `release-asset-name.spec.ts:60`; corroborated by the `:137` ticked list) | +| OBS-03 | Phase 10 | Complete | +| OBS-05 | Phase 10 | Complete (both clauses observed live on run 30471772954; see 10-EVIDENCE-LIVE-CI.md) | +| XOS-06 | Phase 10 | Complete | +| XOS-07 | Phase 10 | Complete (live full-task-set census DONE on run 30471772954: publish started 3s after the last needs: dep; see 10-EVIDENCE-LIVE-CI.md) | +| TRUST-10 | Phase 10 | Complete | +| TRUST-11 | Phase 10 | Complete | +| TRUST-12 | Phase 10 | Complete | +| TRUST-13 | Phase 10 | Complete (10-SECURITY.md section 1; auditor adopted B2 and refined both legs) | +| XOS-01 | Phase 11 | Complete (live-workstation measurement taken in 11-03: 1 `[remote cache]` occurrence each for `build`, `typecheck`, `test`; see 11-EVIDENCE.md O1) | +| XOS-02 | Phase 11 | Complete (baseline captured in Phase 10 before CORR-02; post-rename non-regression measured in 11-03 against BOTH baseline halves; see 11-EVIDENCE.md O2) | +| XOS-03 | Phase 11 | Complete (live-CI: push run 30500255530; Windows `integration` EXECUTED with 0 remote-cache label occurrences and `cacheStatus=cache-miss`; see 11-EVIDENCE.md O3) | +| TEST-08 | Phase 11 | Complete (11-EVIDENCE.md:1004 `## O4 (XOS-04, XOS-05)`; `:1006` records the reservation as DISCHARGED by plan 12-06, appended IN PLACE per D-22 -- the append the placeholder was waiting on) | +| TEST-09 | Phase 11 | Complete (all three parts on push run 30500255530: inequality CITED from CORR-03(b), o3-witness delta 144s against a 30s margin, positive control 200 on both legs; see 11-EVIDENCE.md O3) | +| TEST-10 | Phase 11 | Complete | +| OBS-02 | Phase 11 | Complete | +| XOS-04 | Phase 12 | Complete (three `windows-11-arm` legs in `ci.yml`; reuse OBSERVED live on run 30586177358, the FIRST run of same-repo PR #12; see 11-EVIDENCE.md O4) | +| XOS-05 | Phase 12 | Complete (live-CI: `[remote cache]` counted per leg at 1/2/1, total 4, matching the counts pre-registered in `f5d03b0` before the run; every ubuntu leg MISS-and-saved in the same run. The conditional scheduled-detector clause is discharged too -- run 30603713356 went green on a real `windows-11-arm` runner with the plural success line present and zero `[remote cache]` markers. **SUPERSEDED by quick 260803-mew:** that discharge is on the THREE-target needle at `e757d4c`; `9e79009` replaced it with the FOUR-target form and `git merge-base --is-ancestor 9e79009 e757d4c` is FALSE, so run 30603713356 cannot speak to the needle at HEAD. Re-discharged on the four-target form in BOTH directions -- run 30825110047 PASS at headSha 41f65e1 with `lint` observed executing, and run 30825602626 FAIL at the needle's grep with nx at exit 0 in the same step; see 260803-mew-EVIDENCE.md) | +| XOS-08 | Phase 12 | Complete (bare single-producer `needs:` scalar per leg, guarded in `dogfood-cross-os.spec.ts`; the ordering is what made the 1:1 per-target attribution readable within run 30586177358) | +| DOCS-07 | Phase 12 | Complete (`docs/cross-os.md`, safe default FIRST, registered as an `nx.json` `test` input in the same commit as the doc and drift-guarded; the stderr-immune discriminator is single-sourced and A1 closed by measurement on both legs; recipe accuracy reviewed in 12-UAT.md test 4 after code-review finding CR-01 was fixed) | +| VER-08 | Phase 13 | Complete | +| VER-09 | Phase 13 | Complete | +| TRUST-14 | Phase 13 | Complete | +| XOS-09 | Phase 13 | Complete (three read-only Windows legs gated at a floor of 1 in `ci.yml`; the gate OBSERVED green live on run 30744366870, attempt 1, at gate counts 1 / 2 / 1 against counts pre-registered in that run's own head commit 631a2e7. **Case A only** -- the base-scope read half stays open as ROADMAP Phase 13 Live-CI item 2. See 13-EVIDENCE.md) | +| TEST-11 | Phase 13 | Complete (six per-leg clauses in `dogfood-cross-os.spec.ts`, non-vacuity proven by three recorded mutations, plus the two-direction survivor pin added by `7968f21`. The live gate REDDENING was subsequently OBSERVED on run 30745558383 -- `build-windows` red at the gate step at count 0 carrying the gate's own `::error::`, with `typecheck-windows` and `test-windows` green at 2 and 1 as a same-run positive control. Note the scope: the perturbation produced the zero by skipping the cache, not by breaking a restore; that a broken restore yields zero is carried by the inductive read-only argument. See 13-EVIDENCE.md ADDENDUM) | +| DOCS-09 | Phase 13 | Complete | +| DOCS-10 | Phase 13 | Complete | + +**Coverage:** 57 requirements, 57 mapped, 0 orphans, 0 duplicates. Distribution: Phase 7 = 7, +Phase 8 = 9, Phase 9 = 11, Phase 10 = 12, Phase 11 = 7, Phase 12 = 4, Phase 13 = 7. Verified +mechanically by set-differencing the defined IDs against the traced IDs in both directions. + +--- +*Requirements defined: 2026-07-26* +*Revised 2026-07-26 after adversarial review by five independent critics (52 findings triaged: +15 independently verified, 4 inter-critic conflicts resolved, 5 rejected).* +*Traceability populated 2026-07-26 at roadmap creation (Phases 7-12).* +*Amended 2026-07-26 after the four-dimension milestone research and the live cross-OS pre-flight +probe (`research/v0.0.2/SUMMARY.md`, `PROBE-RESULTS.md`): 11 blocking corrections, 5 new +requirements (PARITY-08, VER-07, ROBUST-04, RETAIN-05, XOS-08) plus 3 new PARITY IDs from the +freshness-axis discovery, and one conditional clause on XOS-05. Phase count, phase order and +per-phase ownership are unchanged -- the research explicitly endorsed the committed sequence.* +*Amended 2026-08-02 with Phase 13's seven requirements (VER-08, VER-09, TRUST-14, XOS-09, TEST-11, +DOCS-09, DOCS-10), registered in 13-01 BEFORE any code claims to satisfy them. Coverage 50 -> 57. The +"43/43 mapped" sentence above the traceability table is left as written: it is a dated statement +about roadmap creation, not the live count. Note the two files assert DIFFERENT totals and both are +correct -- this file counts the full DEFINED set (57), `ROADMAP.md` counts the ROADMAPPED subset +(53). The difference is exactly FOUR IDs that predate Phase 13 and have a row here but none there: +`PARITY-08`, `VER-07`, `ROBUST-04`, `RETAIN-05`. If a future edit makes +that difference anything other than those four, one of the two tables has drifted.* + +*Roadmapped subset corrected 2026-08-08 (quick task `260808-lpt`), 51 -> 53. `ROADMAP.md`'s Phase 8 +traceability block carried seven rows against the nine this file assigns, with four of the seven +labels shifted; PARITY-02 and PARITY-04 had no row there at all. Adding them moves the roadmapped +subset 51 -> 53 and the difference above SIX -> FOUR. This is the shift closure, NOT a scope change: +no requirement was added, removed or rescoped, and the DEFINED set is unchanged at 57.* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 43762c28..ac32a922 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -9,26 +9,73 @@ degrades to a MISS, never a broken build) and writes stay gated. Foundations are LOCKED (grounding, not phase work): reader = GitHub Releases (FOUND-01); default composition = Actions-cache CI-RW only, one backend per process via `selectBackend`; write-trust = host-detected fail-closed allowlist; sync gate = `{push, schedule}`; no content -signing; OS-namespacing; Nx PUT floor = hard `200`/Nx-21+; distribution = npm package + JS -Action, Docker deferred (FOUND-03). Decision record + CREEP control ledger C1-C18: -`.planning/ARCHITECTURE-DECISION.md`. +signing; Nx PUT floor = hard `200`/Nx-21+; distribution = npm package + JS Action, Docker +deferred (FOUND-03). Those locked decisions live in the `## Key Decisions` table in +`.planning/PROJECT.md`; the CREEP control ledger C1-C18 that backs them is +`.planning/THREAT-MODEL.md`. + +**v0.0.2 supersedes one locked decision.** CORR-01 was an either/or -- "OS-namespace the store +by default OR document consumer OS-discrimination". v0.0.1 took the first branch. v0.0.2 takes +the second (D2-01): the store becomes OS-INVARIANT and OS discrimination lives exclusively in +the declared Nx input on `integration`. This is a design change to a shipped requirement, not a +bug fix. ## Milestones -- ✅ **v0.0.1 Greenfield MVP Rebuild** — Phases 0-6 (shipped 2026-07-22) — full detail: [milestones/v0.0.1-ROADMAP.md](milestones/v0.0.1-ROADMAP.md) +- [x] **v0.0.1 Greenfield MVP Rebuild** -- Phases 0-6 (shipped 2026-07-22) -- full detail: [milestones/v0.0.1-ROADMAP.md](milestones/v0.0.1-ROADMAP.md) +- [ ] **v0.0.2 OS-invariant cross-OS sharing** -- Phases 7-13 + +## v0.0.2 framing + +**Goal:** A Windows developer reuses Linux CI's portable task artifacts, and Windows CI reuses +them too, with the OS-sensitive target still provably separated. Proven by dogfooding this repo, +then documented as a recipe consumers can copy. + +**Two independent layers.** O1/O2 are Releases-mirror outcomes; O3/O4 are Actions-cache +outcomes. Their independence is what makes the mandated ordering achievable at all. + +| # | Outcome | Layer | Phase that proves it | +|---|---------|-------|----------------------| +| O1 | Local Windows dev HITs `build`/`typecheck`/`test` produced by Linux CI | Releases mirror | Phase 11 | +| O2 | Local Windows dev HITs `integration` produced by Windows CI | Releases mirror | Phase 11 | +| O3 | Windows CI MISSES `integration` produced by Linux CI | Actions cache | Phase 11 | +| O4 | Windows CI HITs `build`/`typecheck`/`test` produced by Linux CI | Actions cache | Phase 12 | + +**Mandatory ordering, expressed as the Phase 11 -> Phase 12 boundary.** O1 must be PROVEN before +O4 is ENABLED. Windows CI today runs only `integration`, so any local Windows HIT on the other +three targets is unambiguously Linux-produced. Enabling O4 makes Windows CI a second producer of +those hashes and permanently destroys that attribution, so the evidence is captured at proof time +(TEST-08) and the two never share a phase. + +**Live-CI-only work is called out per phase.** The v0.0.1 retrospective's top lesson is that +local gates cannot prove GitHub Actions runtime behaviour: three real distribution bugs passed +every local gate AND the verifier and took five live pushes to close. Phases 9-12 each carry a +`Live-CI close` line naming what can only be closed on a real runner, and a default-branch push +is a hard precondition of the Phase 11 proofs (the mirror must be warm under the new scheme). + +**Granularity:** standard (7 phases -- 6 at creation, plus Phase 13 added 2026-08-01 out of the +PR #12 code review; see its entry for why it is in v0.0.2 rather than deferred). **Mode:** `mvp` +is marked on the three phases that build +shippable capability (9, 10, 12); Phase 7 is toolchain adoption and Phase 8 is measurement and +configuration, so MVP slicing does not apply to them. Phase 13 is a correctness/observability +phase with a research-first gate, so MVP slicing does not apply to it either. Phase 11 is proof-LED but NOT proof-only -- +the 2026-07-26 research found it carries real implementation (new `ci.yml` probe steps for the +re-specified O3 proof, and new task-graph assertion tooling for TEST-08), so MVP slicing still +does not apply but plan capacity must be allocated. TDD stays globally on +(`workflow.tdd_mode: true`). ## Phases
-✅ v0.0.1 Greenfield MVP Rebuild (Phases 0-6) — SHIPPED 2026-07-22 +v0.0.1 Greenfield MVP Rebuild (Phases 0-6) -- SHIPPED 2026-07-22 -- [x] **Phase 0: Teardown** — Strip the PoC + its cache-coupled CI; leave the Nx workspace green with a lean, cache-independent baseline CI. (5/5 plans, completed 2026-07-18) -- [x] **Phase 1: Walking Skeleton** — A new lib speaks the Nx self-hosted-cache HTTP contract E2E against a trivial in-process backend, proven by a conformance fixture. (4/4 plans, completed 2026-07-18) -- [x] **Phase 2: Default Cache in CI** — Actions-cache CI-RW backend + context-derived `selectBackend` + conservative write gate + per-hash lock, dogfooded live in this repo's CI. (6/6 plans, completed 2026-07-19) -- [x] **Phase 3: Cross-Context Read** — GitHub Releases read-only reader + authenticated private-repo local read + OS-namespacing, so a cross-OS hit never serves a wrong-OS artifact. (3/3 plans, completed 2026-07-19) -- [x] **Phase 4: Publish + Retention + Observability** — The `{push,schedule}`-gated publish/sync engine + safe age-based cleanup + fail-loud observability + storage-cap graceful degradation. (6/6 plans, completed 2026-07-20) -- [x] **Phase 5: Trust-Widening + PPE Gate** — Host-detected fail-closed `pull_request`/`release` write-trust + single-source allowlist + server-produced-key mirror filter + shipped PPE-hygiene gate. (4/4 plans, completed 2026-07-20) -- [x] **Phase 6: Distribution + Docs + Governance** — npm package + JS Action + background-step CI pattern + enumerated/tested public surface + adoption docs + SECURITY.md/LICENSE/semver. (5/5 plans, completed 2026-07-21) +- [x] **Phase 0: Teardown** -- Strip the PoC + its cache-coupled CI; leave the Nx workspace green with a lean, cache-independent baseline CI. (5/5 plans, completed 2026-07-18) +- [x] **Phase 1: Walking Skeleton** -- A new lib speaks the Nx self-hosted-cache HTTP contract E2E against a trivial in-process backend, proven by a conformance fixture. (4/4 plans, completed 2026-07-18) +- [x] **Phase 2: Default Cache in CI** -- Actions-cache CI-RW backend + context-derived `selectBackend` + conservative write gate + per-hash lock, dogfooded live in this repo's CI. (6/6 plans, completed 2026-07-19) +- [x] **Phase 3: Cross-Context Read** -- GitHub Releases read-only reader + authenticated private-repo local read + OS-namespacing, so a cross-OS hit never serves a wrong-OS artifact. (3/3 plans, completed 2026-07-19) +- [x] **Phase 4: Publish + Retention + Observability** -- The `{push,schedule}`-gated publish/sync engine + safe age-based cleanup + fail-loud observability + storage-cap graceful degradation. (6/6 plans, completed 2026-07-20) +- [x] **Phase 5: Trust-Widening + PPE Gate** -- Host-detected fail-closed `pull_request`/`release` write-trust + single-source allowlist + server-produced-key mirror filter + shipped PPE-hygiene gate. (4/4 plans, completed 2026-07-20) +- [x] **Phase 6: Distribution + Docs + Governance** -- npm package + JS Action + background-step CI pattern + enumerated/tested public surface + adoption docs + SECURITY.md/LICENSE/semver. (5/5 plans, completed 2026-07-21) Full phase detail, success criteria, traceability, and coverage validation archived to [milestones/v0.0.1-ROADMAP.md](milestones/v0.0.1-ROADMAP.md). Requirements archived to @@ -37,12 +84,876 @@ Full phase detail, success criteria, traceability, and coverage validation archi
-### 🚧 Next milestone (planning) +### v0.0.2 OS-invariant cross-OS sharing + +- [x] **Phase 7: Lint Toolchain and the Ambient-Platform-Read Ban** - Adopt ESLint 9 flat config and a `lint` target, then make "unit specs must not read the running machine" a build failure instead of a convention. (completed 2026-07-27) +- [x] **Phase 8: Nx Task-Hash Parity** - Root-cause the cross-OS hash divergence node by node, fix it, and keep `integration` the only target that diverges -- enforced by a build-gating CI measurement. (completed 2026-07-28) +- [x] **Phase 9: OS-Invariant Actions-Cache Version** - Make the `@actions/cache` version stop depending on the OS: one hardcoded forward-slash path plus `enableCrossOsArchive` at every call site, closed behaviourally by a Windows runner reading back a Linux-written entry. (completed 2026-07-28) +- [x] **Phase 10: OS-Invariant Releases Mirror** - One `nx-cache-` asset name with no OS component -- still prunable, still attributable, with the trust consequences classified rather than assumed. (completed 2026-07-29) +- [x] **Phase 11: Live Proofs -- O1, O2, O3** - Record the three live proofs in the mandated order, including the producer attribution that enabling O4 destroys forever. (completed 2026-07-30) +- [x] **Phase 12: Windows CI Reuse (O4) + Consumer Recipe** - Add the Windows `build`/`typecheck`/`test` legs, prove they HIT on Linux-produced entries, and ship the safe-by-default adoption recipe. (completed 2026-07-31) +- [x] **Phase 13: Read-Only Actions-Cache Backend** - Make "read the Actions cache, never write it" representable, so the three Windows reuse legs can be GATED on a genuine cross-OS HIT rather than recording a launderable one -- without giving the cache-version computation a second place to drift. (completed 2026-08-02) + +## Phase Details + +### Phase 7: Lint Toolchain and the Ambient-Platform-Read Ban + +**Goal**: A developer who writes a unit spec that derives an expectation from the running +machine gets a build failure naming the rule, and cannot silence it without writing down why. + +**Depends on**: Nothing (first phase of v0.0.2). It must come FIRST for a hashing reason, not a +tidiness one: `@nx/eslint` is an Nx INFERENCE plugin, so an inferred `lint` target changes +`hash_project_config`, which is folded into EVERY task hash. Adopting it after Phase 8's +root-cause work would invalidate that work -- and an OS-divergent lint inference would be a NEW +parity bug of exactly the `@nx/vitest` / `@nx/js/typescript` class Phase 8 exists to close. + +**Requirements**: LINT-01, LINT-02, LINT-03, LINT-04, LINT-05, LINT-06, CORR-06. + +**Success Criteria** (what must be TRUE): + + 1. A `lint` target runs ESLint 9 flat config across the workspace and is part of the CI + battery; every new dev dependency is exact-pinned under the existing ROBUST-03 discipline + and covered by the `pinned-deps` guard. v9 is forced, not preferred -- Nx 23.1 dropped + ESLint v8. (LINT-01) + + 2. A unit spec that reads `process.platform`, `process.arch`, any `node:os` accessor + (`tmpdir`, `EOL`, `platform`, `arch`, `homedir`, `type`, `release`), or + `path.sep`/`path.delimiter`/`path.win32`/`path.posix` FAILS `lint`; the identical code in + an `*.integration.spec.ts` PASSES, and an injected value such as + `releaseAssetName(hash, 'win32')` PASSES everywhere. (LINT-02, CORR-06) + + 3. The rule set is proven RED before GREEN: a deliberately violating fixture fails `lint`, and + each of the three CORR-05 violations (`cache-archive-path.spec.ts`, + `releases-backend.spec.ts`, `release-asset-name.spec.ts`) is confirmed CAUGHT while it + still exists -- before Phase 9/10 remove it. (LINT-03) + + 4. Editing a linted file re-runs `lint` instead of replaying a cached PASS, proven by + differential rather than by reading the config -- the same defect class that let + `typecheck` serve a stale false PASS (quick 260726-gok). (LINT-04) + + 5. A bare `eslint-disable` and a bare `@ts-expect-error`/`@ts-ignore` are both lint ERRORS + (description required), and a disable directive left behind after its violation is removed + FAILS rather than lingering as a pre-authorised future violation. (LINT-05, LINT-06) + +> SC3 correction, recorded so the verifier does not read it as a miss: SC3 above says "three +> CORR-05 violations". REQUIREMENTS.md, 07-CONTEXT.md D-22 and 07-RESEARCH.md all say FOUR, in +> three files (`release-asset-name.spec.ts` carries two). Use FOUR. There are four error +> POSITIONS; `cache-archive-path.spec.ts:26` is not one of them. + +**Plans**: 4/4 plans complete + +Plans: + +- [x] 07-01-PLAN.md -- Adopt the toolchain: five exact-pinned devDeps, the root flat config, the + D-12 baseline, the ESLint Node-API guard harness, and the `test.inputs` wiring (wave 1) + +- [x] 07-02-PLAN.md -- The ban itself: RED before GREEN over the evasion shapes and the four extant + sites, the two core rules, the four described disables, and the scope-drift guard (wave 2) + +- [x] 07-03-PLAN.md -- Wire the target: `@nx/eslint` registration, the declared `lint` inputs, the + input probes with their negative control, the root script and the CI job (wave 3) + +- [x] 07-04-PLAN.md -- Evidence: the LINT-04 differential with both negative controls, mutations + M1-M9, and the Phase 8 / Phase 9 hand-off records (wave 4) + +### Phase 8: Nx Task-Hash Parity + +**Goal**: `build`, `typecheck` and `test` compute one hash on every machine that matters, and +`integration` is the only target that diverges -- with a CI job that keeps it that way instead +of a measurement taken once. + +**Depends on**: Phase 7 (LINT-01 must land before PARITY-01, or the inferred `lint` target +changes `hash_project_config` and invalidates the root-cause record). + +**Requirements**: PARITY-01, PARITY-02, PARITY-03, PARITY-04, PARITY-05, PARITY-06, PARITY-07, CORR-03, CORR-04. + + +**Success Criteria** (what must be TRUE): + + 1. A recorded root-cause document names, node by node, every hash input that differed, is dated + BEFORE the first fix commit, and separates the TWO axes the 2026-07-26 pre-flight probe + established (`research/v0.0.2/PROBE-RESULTS.md`): a real OS axis (cold-ubuntu differs from + cold-windows for every target) and a FRESHNESS axis that perfectly masquerades as it (warm + local Windows `build`/`test` equal cold ubuntu CI to the digit; cold local Windows equals cold + windows CI to the digit). No difference may be attributed to the OS until freshness is pinned. + The record states that every prior cross-OS measurement in this repo, including the pair in + `STATE.md`, read a confounded variable. Leading hypothesis to test first: a Windows-only + inference difference visible ONLY on a cold graph. (PARITY-01) + + 2. The capture instrument emits the per-NODE hash `details` map. `nx show target inputs` is + recorded as INSUFFICIENT and a "no difference" result from it is not accepted as evidence: it + SKIPS `ProjectConfiguration` and reports file PATHS rather than content hashes, so both of + v0.0.1's named suspects are invisible to it. (PARITY-02) + + 3. For one commit, `build`, `typecheck` and `test` each yield a byte-identical hash at all THREE + observation points -- native Windows workstation, windows-11-arm runner, ubuntu-24.04-arm + runner -- with the workstation measured in BOTH graph states. FOUR recorded values per target, + not two. (PARITY-03) + + 4. "Does a warm local box compute the hash cold CI published" is answered as a SEPARATE named + question, and each proof records which of the two questions it answers. If the answer is no, + O1 is unreachable regardless of OS parity, and that is recorded as a finding rather than + absorbed by a `nx reset` in the proof recipe. (PARITY-04) + + 5. `integration` yields a byte-identical hash between the native Windows workstation and + windows-11-arm, and `integration` is the ONLY target declaring a platform discriminator in + its Nx inputs. (PARITY-05, CORR-04) + + 6. A build-gating CI job over BOTH matrix legs at one commit FAILS when fewer than two + platform records exist, when the `integration` hashes match, or when any of + `build`/`typecheck`/`test` differ -- recording the discriminator command's raw stdout AND + stderr per leg. A textual assertion that `nx.json` contains the input does not satisfy + this. It treats `lint` as a FOURTH target, since `@nx/eslint`'s inference is the newest and + least-tested in the workspace. (CORR-03) + + 7. Every recorded measurement carries the Nx version, the Node version, the install mode + (`npm ci` vs `npm install`) AND the graph state (cold / warm `.nx/workspace-data`), and the + public-surface guard passes unchanged -- no new env knob, no new action input, no new package + export. `typecheck`'s third variance source (four distinct values across the four probe + measurements) is either root-caused or explicitly recorded as open. (PARITY-06, PARITY-07) + +**Plans**: 6/6 plans complete + +Plans: + +- [x] 08-01-PLAN.md -- The instrument: a root-level dev-only ESM capture script proven byte-identical + to Nx's own arithmetic, and the root-cause record opened with its method sections (wave 1) + +- [x] 08-02-PLAN.md -- The comparator: a pure typed verdict over two platform records with an + observed RED per clause, its CI loader, and the tarball exclusion with its assertion (wave 2) + +- [x] 08-03-PLAN.md -- Measure: the two-leg capture job, the anchor commit, and all FOUR observation + points at that one commit plus the `typecheck` outputs enumeration (wave 3) + +- [x] 08-04-PLAN.md -- Record: the root cause named node by node and the fix route written down + BEFORE it is taken, in commits that provably predate every `nx.json` edit (wave 4) + +- [x] 08-05-PLAN.md -- Fix: the `nx.json` `targetDefaults` change with its rationale pinned in the + drift guard, a three-state local plus two-leg CI re-measurement, and U-01 closed by the + maintainer (wave 5) + +- [x] 08-06-PLAN.md -- Gate: the build-gating compare job, and the proof it can fail on a REAL leg + rather than only on a fixture (wave 6) + +> Correction carried into the plans, recorded so the verifier does not read it as drift: SC1 above +> and PARITY-01 both call the second axis a FRESHNESS axis. RESEARCH measured that cold and warm +> agree when `.nx/workspace-data` is FRESH -- only the long-lived directory differs -- so the axis +> is staleness-of-persisted-inference, and `nx reset` is its cure rather than a control. Plan 08-01 +> records the correction with the quoted original. + +**Live-CI close**: PARITY-03's windows-11-arm and ubuntu-24.04-arm observation points and +CORR-03's two-leg job exist only on real runners. Note the probe already supplied one cold +cross-OS reading at `fe25a3f` (`research/v0.0.2/PROBE-RESULTS.md` Q3); Phase 8 must re-take it at +its own commit rather than cite it as current. + +### Phase 9: OS-Invariant Actions-Cache Version + +**Mode:** mvp + +**Goal**: The `@actions/cache` version stops depending on which OS computed it -- one hardcoded +forward-slash path literal and `enableCrossOsArchive: true` at every call site -- proven by a +Windows runner reading back an entry a Linux runner wrote. + +**Depends on**: Phase 8 (the hash-parity work and its measurement job settle before the cache +version is rotated, so a rotation MISS is never confused with a parity MISS). + +**Requirements**: PARITY-08, VER-01, VER-02, VER-03, VER-04, VER-05, VER-06, VER-07, ROBUST-04, OBS-04, DOCS-08. + + +**Success Criteria** (what must be TRUE): + + 1. The path string handed to `@actions/cache` is a hardcoded, workspace-relative, + forward-slash literal under `.nx/cache/`, byte-identical on `win32` and `linux`. It is not + built with `node:path` (`join`/`resolve`/`sep`/`normalize`), not absolutized, and derives + from neither `os.tmpdir()`, `RUNNER_TEMP` nor `~`. The process asserts, ONCE at + `createActionsCacheBackend()` construction, the CONJUNCTION that cwd is the Nx workspace root + AND `GITHUB_WORKSPACE` is unset or resolves case-normalised to the same path -- "the Nx + workspace root" alone is the wrong variable, because `@actions/cache` reads + `GITHUB_WORKSPACE`, and a per-request check would be swallowed by `handleGet` into another + silent MISS. MEASURED 2026-07-26: the identity holds on both runners today, so this is a drift + guard, not a live fix. (VER-01, VER-02, VER-04) + + 1b. The archive directory is created before the first `writeFile` (`put()` otherwise ENOENTs into + a 500 on a fresh runner or after `nx reset`), and the literal's comment lock states it was + chosen because the path is GITIGNORED -- `.gitignore` covers `.nx/cache`, not `.nx/` + wholesale, so a later tidy elsewhere under `.nx/` would put a transient multi-megabyte file + into Nx's own file map. (VER-07) + + 1c. `{workspaceRoot}/.github/workflows/ci.yml` is a `test` input and `nx.json`'s explicit input + list is comment-locked, recording that `targetDefaults` inputs REPLACE rather than merge and + that `@nx/vitest`'s inferred `test` target carries `{ env: 'CI' }` -- which would make O1 + structurally impossible for `test`. This lands BEFORE any spec asserts on `ci.yml`. + (PARITY-08) + + 1d. `npm run build:action` runs in the SAME COMMIT as every `serve()`-reachable source edit in + this phase. The committed bundle inlines both comment-locked helpers, and the four sidecar + jobs run that bundle from the git ref -- drift means the sidecar writes at one cache version + while publish restores at another, and the mirror silently stops receiving. (ROBUST-04) + + 2. A spec asserts the argument LIST and the call COUNT of all three `@actions/cache` call + sites -- `restoreCache`, `saveCache`, and the `lookupOnly` existence probe -- so a fourth + site added later fails. The flag is positional at a different index in each function and + upstream's JSDoc documents the wrong order, so position is asserted, not assumed. (VER-03) + + 3. A `dogfood-verify` leg on windows-11-arm READS BACK the entry `dogfood-seed` wrote on + ubuntu-24.04-arm, and a MISS fails the job. It asserts PROVENANCE, not presence: the seed key + is `nx-cache-`, one key per RUN and not per OS, so the moment a Windows seed leg + exists a presence-only check would pass even if cross-OS restore were completely broken. + `dogfoodBody` encodes the producing OS and the Windows leg asserts it read a LINUX-produced + body; the vacuity condition is written into the job comment. (VER-06) + + 4. The publish summary reports the resolved `@actions/cache` compression method, surfaced and + never gated. The value is NOT readable from the library (`getCompressionMethod` is behind the + exports map), so it is an independent re-implementation that mirrors upstream exactly -- + stdout AND stderr captured, a throw swallowed to `''`, and the branch on `=== ''` rather than + on the parsed semver -- comment-locked to the pinned version. MEASURED 2026-07-26: zstd v1.5.7 + and GNU tar 1.35 are present on windows-11-arm, so O4 is NOT blocked; but zstd lives at + `C:\tools\zstd` and is NOT bundled by Git for Windows as previously recorded, making its + presence a runner-image choice rather than a guarantee. (VER-05) + + 5. The all-restore-MISS warning drops the now-false "different OS" explanation and names + cache-version rotation as a candidate cause; the expected signal of the first post-change + push (all-miss on both publish legs, `mirrored == 0`) is written down IN ADVANCE, and the + tripwire fires on two consecutive all-miss pushes WITH NO VERSION-AFFECTING CHANGE IN BETWEEN + -- there are three legitimate rotation windows in this milestone, and a tripwire that fires on + correct work gets disabled. FOUR locations asserting same-OS restore are corrected: + `docs/advanced.md:54-57`, `docs/advanced.md:45`, `ci.yml:577-583` and `ci.yml:356-360`. + `README.md:125` and `docs/trust-and-security.md:155` get an ADDITIVE precondition, not a + correction -- both frame "never a wrong result" as a consequence of fault degradation, which + stays true. (OBS-04, DOCS-08) + +**Plans**: 8/8 plans complete + +- [x] 09-08-PLAN.md + +- [x] 09-01-PLAN.md +- [x] 09-02-PLAN.md +- [x] 09-03-PLAN.md +- [x] 09-04-PLAN.md +- [x] 09-05-PLAN.md +- [x] 09-06-PLAN.md +- [x] 09-07-PLAN.md + +**Live-CI close**: VER-06's cross-OS `dogfood-seed`/`dogfood-verify` pair and OBS-04's one-time +rotation signal are only observable on a real runner and a real default-branch push. + +### Phase 10: OS-Invariant Releases Mirror + +**Mode:** mvp + +**Goal**: One asset name per hash with no OS component -- still prunable, still attributable to +its producer, and with the trust consequences of collapsing two namespaces into one classified +by an auditor rather than assumed away. + +**Depends on**: Phase 9 (the Actions-cache version is already OS-invariant, so the exposure +delta TRUST-12 records -- a single-OS publish leg restoring and mirroring every OS's entries -- +is real and verifiable in code at audit time, not hypothetical). Also Phase 7, whose lint rule +must be proven to CATCH the three CORR-05 violations before this phase removes the last two. + +**Requirements**: CORR-02, CORR-05, RETAIN-04, RETAIN-05, OBS-03, OBS-05, XOS-06, XOS-07, TRUST-10, TRUST-11, TRUST-12, TRUST-13. + + +**Success Criteria** (what must be TRUE): + + 1. Every mirrored asset is named `nx-cache-` -- a distinguishing prefix with no OS + component -- derived by BOTH reader and publisher from the single `releaseAssetName` + helper; and the cleanup filter admits BOTH that name and the legacy `-` names in + the SAME COMMIT, so legacy assets age out through `CACHE_MIRROR_MAX_AGE_DAYS` instead of + accumulating. Proven by specs over both name families plus a cleanup dry-run over a mixed + shard. `CACHE_OS_VALUES` survives, annotated as intentionally-kept legacy support so + `fallow` does not prune it. (CORR-02, RETAIN-04) + + 1b. The two accept branches are asserted MUTUALLY EXCLUSIVE directly rather than relying on the + last-`-` split; the ~50 PoC-era `.tar.gz` assets that match NO filter -- permanent + occupants of the 1000-asset cap -- get an explicit recorded disposition; and + `CACHE_KEY_PREFIX` is pinned by spec and comment-locked as now governing FOUR things (the + Actions-cache key, `isServerProducedKey`, the asset name, and the cleanup filter), since + changing it would orphan the entire mirror and RETAIN-04's legacy branch would not cover the + orphans. (RETAIN-05) + + 2. No target shared cross-OS has a spec that derives an expectation from the RUNNING machine. + There are FOUR violation sites, not three -- `cache-archive-path.spec.ts:1` and `:26` (gone in + Phase 9 with VER-02), `releases-backend.spec.ts:38` and `release-asset-name.spec.ts:39` (gone + here with CORR-02), and `release-asset-name.spec.ts:60`, which NOTHING in this milestone + removes because OBS-03 keeps `cachePlatform` alive. This phase makes an explicit call on site + 4; recommended is moving it to `public-server.integration.spec.ts`, where LINT-02 allows it. + `releases-backend.spec.ts:103-118` is the DOCUMENTED non-vacuity proof for CORR-01 and CORR-02 + destroys it on purpose, so a named replacement lands with it: assert the reader requested + EXACTLY ONE asset name, equal to the imported `releaseAssetName(hash)`, carrying no platform + token. (CORR-05) + + 3. Each `publish` matrix leg seeds a leg-DISTINGUISHABLE hash and each `publish-verify` leg + reads back its OWN leg's asset -- so a Windows publish path that is entirely dead FAILS + instead of passing on the ubuntu leg's asset; and `publish` waits on every job producing a + mirrored entry (`build`, `typecheck`, `test`, `integration`), not on `build` alone. (OBS-05, + XOS-07) + + 4. Every mirrored asset records `mirrored-by: ` in Release asset metadata that is NOT part + of the lookup name (the free-form `label`), so collapsing the namespace does not also destroy + incident-response attribution. It is `mirrored-by`, NOT "producing OS": the label can only + derive from the PUBLISHING leg's platform, `listCacheEntries` returns `{ key }` only, and + Phase 9 is precisely what breaks the publisher-equals-producer identity -- so a + "producing OS" claim would be wrong in exactly the cross-OS case the label exists to serve. + Requires widening `uploadReleaseAsset` with a `label` parameter through `action/index.ts` and + every fake in `publish-mirror.spec.ts`. (OBS-03) + + 5. `max-parallel: 1` is RETAINED with a comment recording that it is NOT a correctness control + and that no requirement depends on which leg wins the first-write-wins race; C1/C2 and + C16's Actions-cache-side filter are verified unchanged, `listCacheEntries`' `ref` scoping is + pinned by spec and comment-locked as the now-sole control keeping non-default-branch + trusted writes out of the world-readable mirror; and SECURITY.md carries + gsd-security-auditor's classification of TRUST-11 and TRUST-12 -- authored by the auditor, + never self-certified. TRUST-11 is handed to that auditor with its arbitration point CORRECTED: + the differing-payload race is at `saveCache`, not at the Release upload, because two publish + legs restore the SAME single Actions-cache entry and upload it verbatim without re-executing; + the race only appears once Phase 12 adds a second producer, which moves TRUST-11's residual + risk into the XOS-05 write decision. `publish-mirror.ts:159`'s "byte-identical under CORR-01" + comment is rewritten in this same commit -- byte-identity survives, its REASON changes. + (XOS-06, TRUST-10, TRUST-11, TRUST-12, TRUST-13) + + 6. Recorded, not gated: after this phase the Windows publish leg mirrors ZERO real assets + (ubuntu runs first under `max-parallel: 1` and wins every name), which is the strongest + argument for the deferred single-leg collapse -- write it down so v0.0.3 does not re-derive + it. Also record that the Phase 9-to-10 window doubles shard growth, since every hash is + mirrored under both `-linux` and `-windows` until the rename lands: bounded, NOT a correctness + bug, and the existing "~5 assets per push" estimate in `ci.yml` reads about double during it. + +**Plans**: 8/8 plans complete + +- [x] 10-01-PLAN.md +- [x] 10-02-PLAN.md +- [x] 10-03-PLAN.md +- [x] 10-04-PLAN.md +- [x] 10-05-PLAN.md +- [x] 10-06-PLAN.md +- [x] 10-07-PLAN.md +- [x] 10-08-PLAN.md + +- [x] `10-01-PLAN.md` - capture the two perishable pre-rename measurements (D-25 XOS-02 baseline, D-08 census) +- [x] `10-02-PLAN.md` - the `mirrored-by` label seam, engine through real Octokit adapter (OBS-03) +- [x] `10-03-PLAN.md` - widen `publish`'s `needs:`, add the repo's first `needs:` value guard (XOS-07) +- [x] `10-04-PLAN.md` - the per-leg seed helper and the `mirror-seed` operation branch (OBS-05) +- [x] `10-05-PLAN.md` - flip the seed and reader atomically, add the `mirrored-by` read-back control, lock `max-parallel: 1` (OBS-05, XOS-06) +- [x] `10-06-PLAN.md` - the ADD-only assertion-level RED for the rename wave (CORR-02, CORR-05, RETAIN-04/05) +- [x] `10-07-PLAN.md` - THE ONE COMMIT: OS-free name, two-branch filter, prose sweep, rebuilt bundle (CORR-02, CORR-05, RETAIN-04/05) +- [x] `10-08-PLAN.md` - pin the `ref` scoping, hand TRUST-11/12 to the auditor, record SC6 (TRUST-10..13) + +**Live-CI close**: a default-branch push must republish the mirror under the new name before the +Phase 11 proofs can run. Expect the first such push to publish nothing if it coincides with +Phase 9's rotation window (OBS-04). + +**Pre-condition owed to Phase 11**: XOS-02 requires an O2 baseline measured BEFORE the CORR-02 +rename lands. Capture it (or cite the existing pre-rename record in +`quick/260725-w3s-.../260725-w3s-STEP0-RESULTS.md`, which logged a local Windows `[remote cache]` +HIT on `integration` from a Windows-CI-produced asset on 2026-07-26) before the rename plan +executes. Once the rename lands the baseline is unrecoverable. + +### Phase 11: Live Proofs -- O1, O2, O3 + +**Goal**: Three of the four target outcomes are proven live and recorded with defined evidence, +including the producer attribution that Phase 12 destroys permanently. + +**Depends on**: Phase 10 (CORR-02 enables the cross-OS read; a default-branch push must have +republished the mirror under the new name) and Phase 9 (VER-01/VER-03 must have landed, or the +O3 MISS is attributable to the removed `@actions/cache` OS salt rather than to the Nx +discriminator). + +**Requirements**: XOS-01, XOS-02, XOS-03, TEST-08, TEST-09, TEST-10, OBS-02. + +**Success Criteria** (what must be TRUE): + + 1. Starting from a cleared local Nx cache (`nx reset`), a native Windows workstation logs a + non-zero count of tasks carrying the literal `[remote cache]` label for `build`, + `typecheck` AND `test`, named per target, against artifacts Linux CI produced. A HIT + recorded without a preceding reset is not accepted -- a local cache hit short-circuits + before the remote is ever queried. (XOS-01, TEST-10, OBS-02) + + 2. The same local Windows run HITs `integration` from a Windows-CI-produced artifact, compared + against the pre-rename baseline as a non-regression. (XOS-02) + + 3. The O1 evidence captures PRODUCER ATTRIBUTION at proof time -- per hit hash, the + Actions-cache entry list and the shard asset list with `created_at`, cross-referenced + against job windows -- and the premise that Windows CI produces no + `build`/`typecheck`/`test` hash is asserted MECHANICALLY against the resolved Nx task + graph, not assumed from the job list. Each proof records the workflow run URL or captured + terminal output, the Nx hash observed, and the literal `[remote cache]` label. (TEST-08) + + 4. O3 is proven as an Nx-HASH property, NOT as a storage probe: (a) Phase 8's CORR-03(b) record + showing `H_linux != H_win` for `integration` is cited, not re-derived; (b) the Windows + `integration` task is shown to have EXECUTED, carrying no `[remote cache]` label, in a run + where `nx-cache-` demonstrably existed in the Actions cache; (c) a POSITIVE CONTROL + in the same job -- a scripted authed GET on a known-present key returns 200 through the same + sidecar and backend. A storage-level probe for the Linux hash would now HIT and is explicitly + NOT the proof. A run that MISSes everything is not a valid proof. (XOS-03, TEST-09) + + 5. A soundness probe precedes the measurement and is timestamped as such (a 401-vs-404 pair on a + known-absent hash, plus a differential against a dead port); `ACTIONS_STEP_DEBUG` is on for + the proving run; every "the job was green" claim is paired with a COUNT that would differ + under the failure hypothesis, named in the plan rather than after the run; and each recorded + `Cache: n/m hit` line is explicitly marked NON-DISCRIMINATING in both directions. (TEST-08, + TEST-10, OBS-02) + +**Plans**: 7/7 plans complete + +- [x] 11-01-PLAN.md +- [x] 11-02-PLAN.md +- [x] 11-03-PLAN.md +- [x] 11-04-PLAN.md +- [x] 11-05-PLAN.md +- [x] 11-06-PLAN.md +- [x] 11-07-PLAN.md + +- [x] `11-01-PLAN.md` - hash-neutral instruments: the `capture-hashes.mjs` graph-premise mode and the `run.json` reader (TEST-08) +- [x] `11-02-PLAN.md` - D-06 pre-flight: warm capture, the authorised `nx reset`, cold capture (XOS-01, XOS-02, TEST-10) +- [x] `11-03-PLAN.md` - the O1/O2 local proof: soundness probe, the cold run, the per-target counts, `11-EVIDENCE.md` (XOS-01, XOS-02, TEST-10, OBS-02) +- [x] `11-04-PLAN.md` - producer attribution per hit hash, and the pre-rotation sign-off (TEST-08) +- [x] `11-05-PLAN.md` - RED: the `o3-witness` presence guard and the `ci.yml` comment lock (XOS-03, TEST-09) +- [x] `11-06-PLAN.md` - GREEN: the `integration` probe steps and the `o3-witness` job (XOS-03, TEST-09) +- [x] `11-07-PLAN.md` - the O3 proving run and the O3 evidence (XOS-03, TEST-09, TEST-08, OBS-02) + +**Live-CI close**: the whole phase. Nothing here closes locally except the `nx reset` +precondition; O1/O2 need a warm mirror and a real workstation, O3 needs a real Windows runner. + +**Scoping correction (2026-07-26 research)**: this phase is NOT proof-only. MVP slicing still does +not apply, but "no code" does not either -- SC 4(b)/(c) needs new `ci.yml` probe steps, and +TEST-08's mechanical task-graph assertion is new tooling. Allocate plan capacity for both. + +### Phase 12: Windows CI Reuse (O4) + Consumer Recipe + +**Mode:** mvp + +**Goal**: Windows CI reuses Linux CI's portable artifacts, and an outside project can copy the +recipe without inheriting a wrong-result risk. + +**Depends on**: Phase 11 (XOS-01 must be PROVEN first -- enabling O4 makes Windows CI a second +producer of the `build`/`typecheck`/`test` hashes and permanently destroys O1's attribution) and +Phase 8 (DOCS-07's portability checklist is derived from PARITY-01's root-cause findings, not +prejudged). + +**Requirements**: XOS-04, XOS-05, XOS-08, DOCS-07. + +**Success Criteria** (what must be TRUE): + + 1. `ci.yml` runs `build`, `typecheck` and `test` on a windows-11-arm leg in addition to the + ubuntu leg, and those legs declare `needs:` on the corresponding ubuntu jobs. The + `integration` matrix is NOT the wiring precedent: its two legs compute DIFFERENT hashes, so + parallelism is harmless, whereas the new legs compute the SAME hash and in parallel would both + MISS, both execute, and race `saveCache`. The cross-push alternative is foreclosed too -- + once PARITY-08 lands, the very commit that adds these legs invalidates the `test` hash. + (XOS-04, XOS-08) + + 2. Those Windows legs log `[remote cache]` for all three targets against entries the ubuntu leg + saved; whether they also WRITE is an explicit RECORDED decision, and if they write, the + loss of clean Linux attribution is recorded alongside TRUST-11/12. (XOS-05) + + 3. A consumer-facing cross-OS adoption recipe leads with the SAFE default: declare the platform + discriminator across all cacheable targets FIRST, then remove it per target only after + proving that target's output is portable. The portability checklist is the SECOND section, + framed as how to EARN a removal, with items derived from Phase 8's root-cause record. It + names architecture and libc as axes `process.platform` does not cover, and states that this + repo cannot exercise them -- every machine here is arm64. (DOCS-07) + + 4. The documented discriminator command is stderr-immune (`hash_runtime` hashes stdout AND + stderr), and the recipe is registered in `nx.json`'s `test` inputs and guarded against + drift, so it cannot rot silently. (DOCS-07) + +**Plans**: 6/6 plans executed + +- [x] `12-01-PLAN.md` - RED: the three Windows-leg shape guards and the detector-workflow guard, registered in the same commit (XOS-04, XOS-08, XOS-05) +- [x] `12-02-PLAN.md` - GREEN: the three windows-11-arm legs, plus the four claims their existence falsifies (XOS-04, XOS-08, XOS-05) +- [x] `12-03-PLAN.md` - the scheduled --skip-nx-cache Windows regression detector, and its command proven on Windows arm64 (XOS-05) +- [x] `12-04-PLAN.md` - the single-sourced stderr-immune discriminator, and CORR-04's invariant superseded (DOCS-07) +- [x] `12-05-PLAN.md` - DOCS-07: docs/cross-os.md, registered, linked and drift-guarded (DOCS-07) +- [x] `12-06-PLAN.md` - the O4 pre-registration, the proof, and the O4 evidence appended to 11-EVIDENCE.md (XOS-05, XOS-04, XOS-08) + +**Live-CI close**: XOS-05's HIT is only observable on a real windows-11-arm runner after a +ubuntu leg has saved the entries. + +### Phase 13: Read-Only Actions-Cache Backend + +**Goal:** Make "read the Actions cache, never write it" a representable backend, so the three +Windows reuse legs can be GATED on a genuine cross-OS HIT instead of merely recording one. + +**Requirements**: VER-08, VER-09, TRUST-14, XOS-09, TEST-11, DOCS-09, DOCS-10. +**Depends on:** Phase 12 +**Plans:** 6/6 plans complete + +**Why this exists.** CR-18 (PR #12 round-3 review) found the three Windows reuse legs RECORDED +but never GATED. Quick task `260801-vyy` closed the pre-merge signal gap by widening the +`dogfood-seed`/`dogfood-verify` provenance canary to same-repo pull requests, but deliberately +did NOT gate the legs' `[remote cache]` counts, because that gate is **launderable**: those legs +write through a writable sidecar, so a broken cross-OS restore makes them MISS, execute, and SAVE +their own entry -- and a re-run of the same commit then HITs that self-produced entry, taking a +`count >= 1` check green with cross-OS reuse still dead. A gate a re-run can launder is worse +than no gate, because it reads as coverage. + +A read-only Actions-cache backend removes the confound structurally: a leg that cannot write can +only get a `[remote cache]` label from a genuine restore of the ubuntu producer's entry. That is +what makes the count soundly gateable, and it closes the one layer the dogfood canary explicitly +does not cover -- dogfood proves the STORAGE round-trip via a scripted PUT/GET, and by its own +comment never observes whether a REAL Nx task got a remote HIT. + +**The named risk this phase must research, not assume away.** Two Actions-cache backends means +**two places for the cache-version computation to drift** -- and an OS-dependent cache version is +precisely the bug Phase 9 existed to fix. A copy-paste second backend would reintroduce this +milestone's own root cause behind a guard that looks like coverage. Research must compare at +minimum: + +- **Shared read core + writable variant adds `put`** -- one version computation, two exports. +- **Capability narrowing at the seam** -- construct the writable backend and expose it through a + read-only adapter, so there is literally one implementation. + +- **A construction-time flag** -- rejected on sight for TRUST-05 (RW-vs-RO is which factory + constructs the backend, never a caller-facing mode flag), but record WHY so it is not re-raised. + +- **Do nothing** -- keep the counts as diagnostics. The honest baseline: the dogfood canary + already gates the storage layer pre-merge, so this phase buys the Nx-task layer, not the + storage layer. + +The chosen shape must make version drift **unrepresentable**, not merely guarded. If research +cannot find a shape that does, that is a finding and the phase should shrink to a documented +decision rather than ship a second version computation. + +**Prior art already in the tree:** `ReadableBackend` (no `put`) / `WritableBackend` and the +`isWritableBackend` discriminator (`backend/types.ts`); the server already answers a PUT to a +read-only backend with the contract's 403; `memory-backend.ts` is the existing structural +read-only precedent. So the port exists -- only an Actions-cache implementer of it is missing. + +**Adjacent locked stance, check before planning:** `PROJECT.md` Out of Scope lists "Local +read-write mode - by design local is read-only only; only CI may write". That stance is +implemented by pointing untrusted contexts at the *Releases* store. Read-only against the *same* +store CI writes is a new position, not a gap in the existing one -- confirm it does not +contradict CORR-01 or TRUST-05 before planning. + +Plans: + +- [x] 13-01-PLAN.md +- [x] 13-02-PLAN.md +- [x] 13-03-PLAN.md +- [x] 13-04-PLAN.md +- [x] 13-05-PLAN.md +- [x] 13-06-PLAN.md + +- [x] `13-01-PLAN.md` - register the seven requirement IDs in both traceability files, plus the THREAT-MODEL residual note and the Case-B live-CI item (VER-08, VER-09, TRUST-14, XOS-09, TEST-11, DOCS-09, DOCS-10) +- [x] `13-02-PLAN.md` - the composed read-only Actions-cache factory, and the package-scope `@actions/cache` importer scan (VER-08, VER-09) +- [x] `13-03-PLAN.md` - the strictly-narrowing `CACHE_READ_ONLY` knob, read as `selectBackend`'s LAST branch (TRUST-14) +- [x] `13-04-PLAN.md` - the ninth consumer knob enumerated and documented, and the fifth backend-selection outcome (DOCS-10) +- [x] `13-05-PLAN.md` - three read-only Windows legs, their counts gated at the floor, and every stale rationale corrected in the same commit (XOS-09, TEST-11, DOCS-09) +- [x] `13-06-PLAN.md` - the pre-registered counts, the proving run, and `13-EVIDENCE.md` (XOS-09, TEST-11) + +**Live-CI close**: two items. **BOTH CLOSED as of 2026-08-03** -- see the status block after +item 2. The original text of both is kept for the reasoning it records. + +1. **XOS-09's gate** is only observable on a real `windows-11-arm` runner after a ubuntu leg has + saved the entries. It closes on this phase's own landing run. + +2. **The Q4 base-scope READ half (Case B)** is carried as a separate, LATER item, because this + phase's own landing commit CANNOT reproduce it. The commit edits + `packages/github-cache/src/**/*.ts` -- a declared `build` input (`nx.json:114`) that reaches + `typecheck` and `test` the same way -- plus `.github/workflows/ci.yml` and two `docs/` files, all + declared `test` inputs (`nx.json:70,62,64`). So all three hashes rotate, the ubuntu producers + MISS-and-SAVE into the merge-ref scope, and every leg takes the intra-run merge-ref path. That is + Case A: it proves LIVENESS and nothing whatever about the base-scope read. + + **Procedure.** (a) VERIFY Assumption A2 FIRST rather than assuming it -- confirm with + `npx nx show project github-cache --json` that `.planning/**` appears in no target's declared + input set; if it does, the PR below silently becomes Case A and proves nothing. (b) Land Phase 13 + on `main`, so main's default-branch scope holds fresh entries for all three targets. (c) Open a + PR whose diff touches NO declared input of `build` or `typecheck`; a `.planning/`-only diff + qualifies. `test` is deliberately excluded from the claim -- its input list includes `ci.yml` and + five `docs/` files, so it is easier to rotate by accident. (d) Observe that `build-windows` and + `typecheck-windows` still report `count >= 1` while their ubuntu producers logged a HIT and wrote + nothing. That green IS the reproduction: nothing wrote into the merge-ref scope during the run, + so the merge-ref path cannot explain it. Record it in `13-EVIDENCE.md` with the run id, the + producers' HIT lines and the three counts, per the `09-EVIDENCE.md` / `11-EVIDENCE.md` idiom. + + **If the read half does NOT hold**, the BACKEND SHAPE is unaffected -- this is purely about + D-04's threshold and its skip/expected-zero conditions. Fallback: gate on `push` only and keep + the counts as diagnostics on `pull_request`, which loses pre-merge signal but keeps both the + inductive property and the post-merge gate. Note the second-order effect either way: once the + legs are read-only, a Case-B MISS is PERMANENT for that hash on Windows -- no self-produced entry + ever fills it -- so a Case-B failure is a hard red, not a first-run-only red. + +### Live-CI close -- STATUS as of 2026-08-03: both items CLOSED + +| Item | Status | Closed by | Run | +|------|--------|-----------|-----| +| 1. XOS-09's gate on a real `windows-11-arm` runner | **CLOSED** | Phase 13's own landing run, as designed | `30744366870` green at 1 / 2 / 1 against counts pre-registered in that same head `631a2e7`; `30745558383` proved the FAIL direction (`build-windows` red AT THE GATE STEP at count 0, other two green as a same-run control) | +| 2. The Q4 base-scope READ half (Case B) | **PROVEN** | quick `260802-toz` | `30768540898`, draft PR #14, head `7188a66` = the pre-registration commit. All three ubuntu producers HIT with NO `Sent` line, so nothing entered the merge-ref scope, yet all three Windows legs restored `main`-scope keys byte-identically at 1 / 2 / 1 | + +The procedure in item 2 was executed as written, including step (a): assumption A2 was VERIFIED +before the PR was opened, not assumed. The fallback in the last paragraph was therefore never +needed -- the read half holds, so the gate keeps pre-merge signal on `pull_request`. + +**Scope, unchanged from the pre-registration.** Case B's proof does not separate BASE-branch from +DEFAULT-branch scope -- for a PR off `main` they are the same ref. The proven claim is the narrower +one the gate's soundness actually needs: restored from a scope populated before the run, outside +this run's merge ref. + +**A1 was never a ROADMAP item** and no row is invented for it here. For the record, it also closed +(quick `260803-0rr`, by local measurement rather than on the landing run). Both closures are +written up in `13-EVIDENCE.md` ADDENDUM 3; current status of record is `13-VALIDATION.md`'s +Manual-Only table. + +**One NEW follow-up, surfaced by the Case-B run and belonging to neither item.** `o3-witness` +asserts a CREATION ordering -- that the linux entry came into existence before the Windows +integration step began. On a Case-B run nothing is created, because every producer HITs, so the +assertion has no event to observe and the job reddens. The first post-Phase-13 PR touching no +declared input will hit this. It is a FALSE red: the cross-OS property holds, as the three green +read-only legs in run `30768540898` show. The witness silently assumes the Case-A shape. + +### The NEW follow-up -- STATUS as of 2026-08-04: CLOSED IN CODE + +The paragraph above was ACCURATE when it was written (`da462b5`, 2026-08-03 01:09) and is now +SUPERSEDED by quick 260804-h3b -- two commits landed the SAME DAY it was written, both AFTER it, and +neither retired it. The original text is kept for the reasoning it records. + +| Half of the defect | Fixed by | Guard that pins it | +|---|---|---| +| The server-side `&ref=` narrow filtered the caches request down to this run's own scope, so the row proving PRIOR existence never arrived | `40e4d21` `fix(ci): make o3-witness read the base-branch cache scope (Case B)` | `dogfood-cross-os.spec.ts:733` asserts the composed URL `.not.toMatch(/ref=/)` | +| The default-branch scope was absent from the CLIENT-side ref allowlist, so even an arriving row was discarded | `e5d3cd3` `fix(ci): admit the default-branch scope to the o3-witness ref allowlist (D-17)` | `:584` pins the three-ref jq select by exact regex; `:634` pins the `default_ref` derivation block | +| The delta on a Case-B run is hours or days rather than minutes | never a defect -- `ci.yml:1456-1461` states in its own words that a LARGER delta is STRONGER evidence, and forbids an upper bound | `:827` pins the `-lt 30` floor; `:816` pins the `matched_ref` print, so Case A and Case B are distinguishable in the log | + +**Ordering proof.** `git merge-base --is-ancestor da462b5 40e4d21` and `... da462b5 e5d3cd3` are BOTH +true, which is why the paragraph above is STALE rather than wrong. + +**Guard line numbers RE-DERIVED at commit time** (2026-08-04, at `3c67513`), not copied from the +plan: `git grep -n` against `packages/github-cache/src/dogfood-cross-os.spec.ts` measures the +assertions at `:584`, `:634`, `:733`, `:816` and `:827`, inside the `it()` blocks opening at `:559`, +`:616`, `:711`, `:806` and `:819` respectively. One correction worth recording, because this repo +carries a defect class of exactly this shape (`2df3af5` exists to stop citing line numbers that have +moved): the task's own CONTEXT.md cites `:806` for the `matched_ref` guard, which is that test's +`it()` line -- its assertion is at `:816`. `npx vitest run -t "o3-witness"` measured **24 assertions +passed across 2 files** at `3c67513`, matching the count recorded when the fixes landed. + +**Live observation.** The code fix is closed; the LIVE path had never been exercised. See +`.planning/quick/260804-h3b-fix-o3-witness-case-b/260804-h3b-EVIDENCE.md`. Two sub-claims are +reported SEPARATELY there because they prove different things: (a) is the SUBSTANCE of the mechanism +`40e4d21` restored, and (b) is the SPECIFIC clause `e5d3cd3` added, which no real run had ever +matched on -- run `30896484130` matched its OWN merge ref, which is Case A. + +- Sub-claim (a), the prior-existence delta allowance, run `30907575624` (`headSha` EQUALS the pre-registration commit `d4dc093`): `EXISTENCE OK` at `delta=9596s matched_ref=refs/pull/16/merge` -- CLOSED +- Sub-claim (b), the `$defaultref` clause matching, run `30910935382` on stacked draft PR #17 (base = the feature branch, so `base_ref != default_ref`): `EXISTENCE OK` at `delta=1252s matched_ref=refs/heads/main`, with the own-ref and base-ref scopes both measured empty for the key -- CLOSED + +## Traceability + +Every v0.0.2 requirement maps to exactly one phase. + +| Requirement | Phase | Note | +|-------------|-------|------| +| LINT-01 | Phase 7 | ESLint 9 flat config + `lint` target in the CI battery; exact-pinned deps under ROBUST-03. FIRST because `@nx/eslint` inference changes `hash_project_config`. | +| LINT-02 | Phase 7 | `no-restricted-syntax` ban on ambient platform reads, scoped `files: ['**/*.spec.ts']` / `ignores: ['**/*.integration.spec.ts']`. | +| LINT-03 | Phase 7 | RED before GREEN: violating fixture + all three CORR-05 violations confirmed caught while they still exist. | +| LINT-04 | Phase 7 | `lint` Nx inputs declared so it cannot serve a stale-cache false PASS (the `typecheck` defect class). | +| LINT-05 | Phase 7 | Opt-out only via a described disable; bare `@ts-expect-error`/`@ts-ignore` also an error. | +| LINT-06 | Phase 7 | `reportUnusedDisableDirectives: error`; a stale disable fails rather than pre-authorising a future violation. | +| CORR-06 | Phase 7 | The strategy is MECHANICALLY enforced, not documented -- the guard IS the LINT-02 rule set. | +| PARITY-01 | Phase 8 | Node-by-node root-cause record, capture command named, dated before any fix. | +| PARITY-02 | Phase 8 | The capture instrument emits the per-NODE hash `details` map; `nx show target inputs` is recorded as INSUFFICIENT and its "no difference" is not evidence (SC2). | +| PARITY-03 | Phase 8 | Byte-identical `build`/`typecheck`/`test` hash at all THREE observation points. | +| PARITY-04 | Phase 8 | "Does a warm local box compute the hash cold CI published" answered as a SEPARATE named question, not absorbed by an `nx reset` in the proof recipe (SC4). | +| PARITY-05 | Phase 8 | Byte-identical `integration` hash between native Windows and windows-11-arm (O2's precondition). | +| PARITY-06 | Phase 8 | Every measurement records Nx version, Node version and install mode. | +| PARITY-07 | Phase 8 | Public-surface guard passes unchanged (D2-02: no new knob, input or export). | +| CORR-03 | Phase 8 | Build-gating two-leg cross-OS measurement job; clause (c) is (b)'s non-vacuity control. | +| CORR-04 | Phase 8 | `integration` declares the discriminator and is the ONLY target that does. | +| VER-01 | Phase 9 | Hardcoded forward-slash workspace-relative archive-path literal under `.nx/cache/`. | +| VER-02 | Phase 9 | The two version-determining inputs pinned by spec; `cache-archive-path.spec.ts` REPLACED, not relaxed. | +| VER-03 | Phase 9 | `enableCrossOsArchive: true` at all THREE call sites; argument list and call count asserted. | +| VER-04 | Phase 9 | cwd asserted to be the Nx workspace root, failing loud otherwise. | +| VER-05 | Phase 9 | Resolved compression method surfaced in the publish summary; surfaced, not gated. | +| VER-06 | Phase 9 | windows-11-arm `dogfood-verify` reads back the ubuntu-written `dogfood-seed` entry; MISS fails. | +| OBS-04 | Phase 9 | All-restore-MISS warning reworded; expected one-time rotation signal recorded in advance. | +| DOCS-08 | Phase 9 | Corrects the docs VER-03 inverts (`docs/advanced.md:54-57`, `ci.yml:577-583`, README, trust-and-security). | +| CORR-02 | Phase 10 | `nx-cache-` asset name, single-sourced, recognisable to the cleanup filter. Supersedes CORR-01's OS-namespaced branch. | +| RETAIN-04 | Phase 10 | Cleanup filter admits both name families; MUST land in the SAME COMMIT as CORR-02. | +| CORR-05 | Phase 10 | Closes here -- the LAST of the three violations goes with CORR-02 (the first went with VER-02 in Phase 9). | +| OBS-03 | Phase 10 | Producing OS recorded in the free-form Release asset `label`, outside the lookup name. | +| OBS-05 | Phase 10 | Leg-distinguishable publish seed + own-leg read-back; must land BEFORE CORR-02 or `publish-verify` goes vacuous. | +| XOS-06 | Phase 10 | `max-parallel: 1` retained, comment-locked as NOT a correctness control. | +| XOS-07 | Phase 10 | `publish` depends on every mirrored-entry-producing job, not on `build` alone. | +| TRUST-10 | Phase 10 | C1/C2/C16-enumeration verified unchanged; Releases-side filter change is additive; `ref` scoping pinned + comment-locked. | +| TRUST-11 | Phase 10 | Threat model records that the byte-identical premise is FALSE: first-write-wins arbitrates between differing payloads. | +| TRUST-12 | Phase 10 | Threat model records the sole-mechanism collapse and the public-repo exposure delta. | +| TRUST-13 | Phase 10 | gsd-security-auditor classifies TRUST-11/12 in SECURITY.md; the proposed classification is INPUT, not conclusion. | +| XOS-01 | Phase 11 | O1 proof: local Windows HITs `build`/`typecheck`/`test` from Linux CI via the Releases mirror. | +| XOS-02 | Phase 11 | O2 proof: local Windows HITs `integration` from Windows CI; baseline captured pre-rename (see Phase 10). | +| XOS-03 | Phase 11 | O3 proof: Windows CI MISSES the Linux `integration` entry. | +| TEST-08 | Phase 11 | Evidence definition + the O1 producer-attribution capture that Phase 12 destroys permanently. | +| TEST-09 | Phase 11 | O3 negative proof with a POSITIVE CONTROL in the same run; runs after VER-01/VER-03. | +| TEST-10 | Phase 11 | O1/O2 local proofs begin from `nx reset`; a HIT without a reset is not accepted. | +| OBS-02 | Phase 11 | Evidence = non-zero `[remote cache]` label count, named per target; Nx's `0%` report line is context only. | +| XOS-04 | Phase 12 | windows-11-arm `build`/`typecheck`/`test` legs wired through the same sidecar block. | +| XOS-05 | Phase 12 | O4 proof: those legs HIT on ubuntu-saved entries; the write decision is explicit and recorded. | +| XOS-08 | Phase 12 | Producer-to-consumer ordering: each Windows leg `needs:` its ONE ubuntu counterpart. Row ADDED 2026-07-30 -- it was missing while this section's own `**Requirements**` line and SC1 both named XOS-08, so the file contradicted itself (Phase 12 CONTEXT D-01). REQUIREMENTS.md is authoritative and says FOUR. | +| DOCS-07 | Phase 12 | Safe-by-default consumer recipe; portability checklist second, derived from PARITY-01; drift-guarded. | +| VER-08 | Phase 13 | One implementation, not two: the writable factory COMPOSES the read-only one, so exactly one `restoreCache` READ call site survives. D-09's shrink-to-a-decision hatch was live and NOT taken. | +| VER-09 | Phase 13 | The `@actions/cache` drift guard widens FILE -> PACKAGE scope; closes the sibling-module evasion the file-scoped scan structurally cannot see. | +| TRUST-14 | Phase 13 | Strictly-narrowing env knob read as `selectBackend`'s LAST branch; the guarantee is BRANCH ORDER, not validation. `selectBackend.length` stays 0. | +| XOS-09 | Phase 13 | All three Windows legs read-only, counts GATED at a `>= 1` floor per leg. The soundness argument is INDUCTIVE; XOS-08's `needs:` edge stays the separate LIVENESS argument. | +| TEST-11 | Phase 13 | `dogfood-cross-os.spec.ts` pins the SEMANTIC change per leg; the bare `exit 1` clause is forbidden because `ci.yml:527` makes it green before the phase starts. | +| DOCS-09 | Phase 13 | All SEVEN sites justifying the ungated counts corrected in the SAME commit that gates them -- including the three `echo` strings an operator reads in the job log. | +| DOCS-10 | Phase 13 | The ninth knob documented and enumerated; `selectBackend`'s outcome count goes four -> five at all FIVE sites that write it down. | + +**Phase 8 ID shift FIXED 2026-08-08** (quick task `260808-lpt`). This block carried SEVEN Phase 8 +rows while `REQUIREMENTS.md` assigns NINE, and four of the seven labels were shifted: the rows +labelled PARITY-02, PARITY-03, PARITY-04 and PARITY-05 carried the note text of PARITY-03, +PARITY-05, PARITY-06 and PARITY-07 respectively, while REQUIREMENTS' PARITY-02 (the per-NODE +`details` instrument) and PARITY-04 (warm-local-vs-cold-CI as a separate named question) had no row +here at all. `REQUIREMENTS.md` is authoritative; the four labels have been corrected against the +REQUIREMENTS body each note text actually describes, and the two missing rows added from this file's +own Phase 8 Success Criteria 2 and 4. The block now holds nine rows and matches the Phase 8 +`**Requirements**` line at `:168`, which already named all nine correctly. + +The defect was surfaced but deliberately not fixed in Phase 8: `08-ROOT-CAUSE.md` item 5 of "Where +the requirements' own words disagree with a paraphrase or with the measurement", carried forward as +residue (d) in `08-VERIFICATION.md`, which also recorded that an audit reading this table would +have mis-reported PARITY-02. Both of those Phase 8 records cite the pre-fix line numbers and state +the defect is still open; they are sealed and have NOT been back-edited. This note is what closes +the loop. + +## Coverage Validation + +**Assertion: 53/53 v0.0.2 requirements map to exactly one phase. No orphans, no duplicates.** +(Was stated as 43/43, then 44/44; see the two dated notes under the per-phase counts. XOS-08 was +named in Phase 12's `**Requirements**` line and in its SC1, but had no traceability row and was not +counted; Phase 13's seven were added 2026-08-02.) + +Per-phase counts: + +- Phase 7: 7 (LINT-01..06, CORR-06) +- Phase 8: 9 (PARITY-01..07, CORR-03, CORR-04) +- Phase 9: 8 (VER-01..06, OBS-04, DOCS-08) +- Phase 10: 11 (CORR-02, CORR-05, RETAIN-04, OBS-03, OBS-05, XOS-06, XOS-07, TRUST-10..13) +- Phase 11: 7 (XOS-01, XOS-02, XOS-03, TEST-08, TEST-09, TEST-10, OBS-02) +- Phase 12: 4 (XOS-04, XOS-05, XOS-08, DOCS-07) +- Phase 13: 7 (VER-08, VER-09, TRUST-14, XOS-09, TEST-11, DOCS-09, DOCS-10) + +Total mapped: 7 + 9 + 8 + 11 + 7 + 4 + 7 = 53. Source categories: CORR 5, LINT 6, PARITY 7, VER 8, +XOS 9, RETAIN 1, TRUST 5, DOCS 4, TEST 4, OBS 4 = 53. + +**Count corrected 2026-07-30.** The Phase 12 tally read 3 and the total read 43 while this file's own +Phase 12 `**Requirements**` line and SC1 both named XOS-08, and while `REQUIREMENTS.md` (`:658-665`) +said FOUR. `REQUIREMENTS.md` is authoritative; the traceability row for XOS-08 has been added above and +the tallies reconciled. The XOS category is 8, not 7 (XOS-01 through XOS-08). + +**Phase 13 added 2026-08-02.** Its `**Requirements**` line read `TBD` and neither traceability table +had a Phase 13 row, so all seven IDs were orphaned while this assertion still said 44/44. Registered +in plan 13-01, BEFORE any code claims to satisfy them. Total 44 -> 51; VER 6 -> 8, XOS 8 -> 9, +TRUST 4 -> 5, DOCS 2 -> 4, TEST 3 -> 4. Read the three dated notes as one history. + +**Why this file says 53 and `REQUIREMENTS.md` says 57.** Both are correct and neither is drifting: +this table counts the ROADMAPPED subset, `REQUIREMENTS.md` counts the full DEFINED set. The +difference is exactly FOUR IDs that predate Phase 13 and have a row there but none here -- +`PARITY-08`, `VER-07`, `ROBUST-04`, `RETAIN-05`. 57 - 53 = 4. If a future +edit makes that difference anything other than those four, one of the two tables HAS drifted. + +**Phase 8 shift closure 2026-08-08** (quick task `260808-lpt`). The roadmapped subset moved 51 -> 53 +and the documented difference SIX -> FOUR because the Phase 8 traceability block gained the two rows +it was always assigned (PARITY-02 and PARITY-04, see the dated note under the traceability table). +No requirement was added, removed or rescoped: this is bookkeeping catching up with +`REQUIREMENTS.md`, which has assigned nine IDs to Phase 8 throughout. PARITY-06 and PARITY-07 left +the difference list because they now have a row here. + +### Every sequencing-constraint row, and where it is honoured + +| Before | After | Honoured as | +|--------|-------|-------------| +| LINT-01 | PARITY-01 | Phase 7 -> Phase 8 boundary | +| LINT-01 | LINT-02, LINT-03, LINT-04 | Plan ordering within Phase 7 | +| LINT-02 | CORR-05 violation removal | Phase 7 -> Phase 9 (first violation) and Phase 7 -> Phase 10 (last two) | +| PARITY-01 | PARITY-02 | Plan ordering within Phase 8 | +| PARITY-01 | DOCS-07 | Phase 8 -> Phase 12 boundary | +| PARITY-02 | XOS-01 | Phase 8 -> Phase 11 boundary | +| CORR-02 | XOS-01, XOS-02 | Phase 10 -> Phase 11 boundary | +| RETAIN-04 | same commit as CORR-02 | Single commit within one Phase 10 plan (explicitly NOT a phase split) | +| OBS-05 | CORR-02 | Plan ordering within Phase 10 | +| VER-01, VER-03 | TEST-09 | Phase 9 -> Phase 11 boundary | +| XOS-01 proven | XOS-04, XOS-05 | Phase 11 -> Phase 12 boundary (the milestone's load-bearing ordering) | +| A default-branch push republishing under the new name | XOS-01, XOS-02 proofs | Phase 10 live-CI close, gating Phase 11 | + +Three rows are intra-phase by their own wording rather than phase boundaries: RETAIN-04 mandates +the SAME COMMIT as CORR-02, and the LINT-01 -> LINT-02/03/04 and PARITY-01 -> PARITY-02 rows +order plans inside a single deliverable. Splitting any of them into separate phases would either +violate the requirement (RETAIN-04) or produce a non-shippable half-slice. + +### Resolved ambiguities and deviations, with reasons + +- **Phase 9 (Actions cache) before Phase 10 (Releases mirror), not the reverse.** Both are + independent layers, so either order satisfies the constraint table. Actions-cache-first was + chosen because it makes all four TRUST requirements land in ONE phase with verifiable code + behind them: TRUST-11's differing-payload arbitration is created by CORR-02, TRUST-12's + exposure delta is created by VER-01/VER-03 plus CORR-02, and TRUST-13 requires a SINGLE + SECURITY.md classifying both. Reversing the order would leave the auditor classifying a + VER-caused threat before VER exists. + +- **CORR-05 owned by Phase 10 although one of its three violations is removed in Phase 9.** + CORR-05's claim ("every target shared cross-OS is platform-agnostic") is only TRUE once all + three violations are gone. `cache-archive-path.spec.ts` goes with VER-02 (Phase 9); + `releases-backend.spec.ts` and `release-asset-name.spec.ts` go with CORR-02 (Phase 10). It is + owned by the phase where it becomes true. Phase 9's plans must not close it early. + +- **XOS-02's baseline is captured in Phase 10, but XOS-02 is owned by Phase 11.** The + requirement is explicitly a before/after pair straddling the rename, so the measurement it + needs is unrecoverable after Phase 10 lands. Phase 10 carries it as an explicit pre-condition; + the requirement closes in Phase 11 where the after-measurement lands. A qualifying pre-rename + baseline already exists in the 2026-07-26 Step 0 record. + +- **TEST-08 owned by Phase 11 although its O4 evidence row is written in Phase 12.** TEST-08's + load-bearing clause is the O1 producer-attribution capture, which is only possible before O4 + is enabled -- so it must be owned by the phase that performs it. Phase 12 appends the O4 row + to the same evidence record. + +- **Phase 10 is intentionally heavy (11 requirements).** Renaming the asset without extending + the cleanup filter silently stops pruning (RETAIN-04's same-commit rule), and renaming without + OBS-05 makes `publish-verify` vacuous. The rename, its filter, its attribution, its + publish-matrix corrections and its threat classification are one indivisible change; splitting + it would ship a half-state that is worse than either end. + +- **Phase 12 is intentionally light (4 requirements -- count corrected 2026-07-30; it read 3, which + omitted XOS-08).** It is not a granularity artifact -- the + Phase 11 -> Phase 12 boundary IS the milestone's mandatory ordering. O1's attribution is + destroyed the moment O4 is enabled, so the two cannot share a phase however few requirements + that leaves. + +- **`fallow`'s structural pre-pass is a no-op on this workflow.** `code_quality.fallow.enabled` + is `true` in config, but the GSD pre-pass shells out with fallow 1.x flags that fallow 2.x + rejects. Phase 7's `lint` target does not replace it; RETAIN-04's `CACHE_OS_VALUES` annotation + still targets the real `fallow:ci` script, which is invoked directly. + +### Cross-phase couplings (flagged, not gaps) + +- **Phase 7's lint rule constrains Phases 9 and 10.** LINT-03 requires the three CORR-05 + violations to be confirmed CAUGHT while they still exist; they are then removed downstream. + +- **Phase 8's CORR-03 job guards Phases 9-12 continuously.** PARITY-02 is enforced by CORR-03(c) + on every subsequent commit, not measured once -- so a Phase 9 or 10 change that re-diverges + the hashes fails the build immediately. + +- **Phase 9's VER-01 rotates the Actions-cache version**, so the Phase 10 mirror republish may + land in the same all-miss window OBS-04 pre-records. Sequence Phase 10's warming push after + the rotation push has been observed. + +- **Phase 12's XOS-05 write decision feeds back into Phase 10's TRUST-11/12 record.** If the + Windows legs write, the attribution loss is appended to the recorded threat model. + +## Out of Scope for v0.0.2 + +Carried from REQUIREMENTS.md, listed so no phase picks them up: + +- Executor portability classification (not knowable a priori; residual risk in TRUST-11). +- An empirical divergence-detection subsystem (disproportionate; the "green O4 CI is the + evidence" argument is circular -- a restored task does not execute). -Run `/gsd:new-milestone` to define the next version. Later-milestone revisit triggers carried -out of v0.0.1: **GHCR-01** (GHCR/OCI as an additional synced store), **PROV-01** (cosign -keyless provenance), **FOUND-03** (Docker container distribution form) — re-evaluate together -per the FOUND-01 ledger. +- A per-job or per-target OS-invariance flag (D2-02: no adopters, so no exit is needed yet). +- Read-fallback across old and new asset names (our own mirror repopulates on the next push). +- Adopter-migration signalling: changelog, `v0` tag policy, version-bump signal, rotation notice. +- Collapsing the publish matrix to one leg (only safe AFTER XOS-05 is proven; a follow-on). +- Archive file-mode handling across the OS boundary (unverified; an XOS-05 investigation item). +- Later-milestone revisit triggers carried out of v0.0.1: GHCR-01, PROV-01, FOUND-03 (Docker), + PKG-SPLIT. ## Progress @@ -55,7 +966,17 @@ per the FOUND-01 ledger. | 4. Publish + Retention + Observability | v0.0.1 | 6/6 | Complete | 2026-07-20 | | 5. Trust-Widening + PPE Gate | v0.0.1 | 4/4 | Complete | 2026-07-20 | | 6. Distribution + Docs + Governance | v0.0.1 | 5/5 | Complete | 2026-07-21 | +| 7. Lint Toolchain and the Ambient-Platform-Read Ban | v0.0.2 | 4/4 | Complete | 2026-07-27 | +| 8. Nx Task-Hash Parity | v0.0.2 | 6/6 | Complete | 2026-07-28 | +| 9. OS-Invariant Actions-Cache Version | v0.0.2 | 8/8 | Complete | 2026-07-28 | +| 10. OS-Invariant Releases Mirror | v0.0.2 | 8/8 | Complete | 2026-07-29 | +| 11. Live Proofs -- O1, O2, O3 | v0.0.2 | 7/7 | Complete | 2026-07-30 | +| 12. Windows CI Reuse (O4) + Consumer Recipe | v0.0.2 | 6/6 | Complete | 2026-07-31 | +| 13. Read-Only Actions-Cache Backend | v0.0.2 | 6/6 | Complete | 2026-08-02 | --- -*Roadmap collapsed at v0.0.1 milestone completion (2026-07-22). Full v0.0.1 detail archived to -`milestones/v0.0.1-ROADMAP.md`. Next milestone: `/gsd:new-milestone`.* +*v0.0.2 roadmap created 2026-07-26 from `.planning/REQUIREMENTS.md` (43 requirements, revised +after adversarial review by five independent critics) and `.planning/PROJECT.md` (O1-O4 and the +mandatory ordering, plus the CORR-01 `## Key Decisions` row whose documented-consumer- +discrimination branch D2-01 now takes). Granularity: standard (6 phases). Phase numbering +continues from v0.0.1's archived Phases 0-6. Git branching: none (sequential).* diff --git a/.planning/STATE.md b/.planning/STATE.md index 1cb76ced..b5e1410d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,20 +1,19 @@ --- gsd_state_version: 1.0 -milestone: v0.0.1 -milestone_name: Greenfield MVP Rebuild -current_phase: 0.1 -status: PR #6 MERGED (e56e5d2). Quick 260726-gok EXECUTED + VERIFIED (passed, 0 blocking); closes BOTH remaining Deferred Items rows (typecheck stale-cache false-pass + the consumer-doc defects). 5 atomic commits, unpushed at time of writing -stopped_at: "Quick 260725-rk4 (dogfood the github-cache server in CI) EXECUTED (3/3 tasks) and code-reviewed; PR #4 open on gsd/quick-260725-rk4-dogfood-ci. 6 commits: 488bd4f/ab8dc9b/bbf303f (tasks) + 4053e53/9f37739/f0f31c8 (all 6 review findings). Live-close CLOSED -- run 30171443826 build job logged '[remote cache]' + 'Cache: 1/1 hit (100%)', corroborated by the cache entry's last_accessed_at advancing while created_at/size held. Verification status human_needed, narrowed to the merge decision only: the push half of 'green on both events' is structurally unclosable on a feature branch (on.push is branches:[main]), and all 5 push-gated jobs are skipped on PR runs so Task 3's publish-mirror subject is unverified live. Prior: Executed quick 260722-0od (address the 27 upheld PR #3 multi-agent-review findings). 19 bisect-safe atomic commits landed on gsd/v0.0.1-greenfield-rebuild (c0d1ebf..4c64aff), covering F01-F27 except the two deliberately-excluded items (deleted-rationale sweep + v0 tag, both now Deferred Items rows) plus a flake-hardening follow-up. 430 tests (up from the 384 baseline); every commit green on the full battery (format:check, build, typecheck, test, fallow:ci, check:action, pack:check) and typecheck:action from Task 15 on. Task 10 added @octokit/plugin-retry@8.1.0 + @octokit/plugin-throttling@11.0.3 with a linux/arm64-regenerated, additive-only lockfile (no Windows prune)." -last_updated: "2026-07-22T01:31:12.273Z" -last_activity: 2026-07-26 -last_activity_desc: Quick 260726-gok closed the typecheck stale-cache false-pass (one-token nx.json fix, mutation-tested guard) and the consumer-doc defects; 438 tests, verifier passed +milestone: v0.0.2 +milestone_name: framing +current_phase: 13 +current_phase_name: read-only-actions-cache-backend +status: complete +last_updated: "2026-08-09T18:45:00.000Z" +last_activity: 2026-08-09 +last_activity_desc: "Quick 260809-og2: fixed the rollover misattribution 260809-iqe had recorded as open and unfiled -- at the root, not the reported site. BOTH warning branches carried the identical closed enumeration, so any cause outside the named two was structurally unnameable. The task's one trap-quadrant question (may the fix name bundle drift at all, or is that our incident record in a stranger's CI log, which PROJECT.md:146 forbids and 260809-2s6 already paid to delete) was answered by measurement and FLIPPED: HANDOFF.json's refuted-claim 4 has three individually TRUE legs and a REFUTED conclusion -- npm pack ships dist/publish/publish-mirror.js in the consumer tarball, the !dist/action exclusion covering the internal action ENTRY alone, and docs/advanced.md:139-145 sanctions an adopter wiring publish. Research also found 54677af had silently deleted a true cause while removing a separate leak, which is why the fix drops the completeness claim rather than incrementing Two to Three. Plan-check's blocker: the asymmetry the task exists for had NO guard on either side, and the gap originated in RESEARCH's own assertion list that the plan had faithfully lifted -- closed by a paired positive/negative pin, with all four placement outcomes enumerated. Code review then found two High that verification could not: the headline not-exhaustive retraction had no durable guard (deleting it left all 1079 green; the pin was only ever a one-shot rg in a verify block), and a new comment claimed the partial branch fires where every enumerated entry misses, which routes to the total gate. It also caught the asymmetry's stated REASON as a false implication -- mirrored === 0 does not imply an unresolved shard; the premise is readMisses === hashes.length. Conclusion held, justification did not. Verification reproduced both RED transcripts itself. 1079 tests, check:action no drift. 51dadac..538e167. Previously, quick 260809-iqe: applied the one edit left standing after the previous session's two-agent review REJECTED the REQUIREMENTS.md amendment -- the correction lands in the unfrozen todos/pending capture, REQUIREMENTS.md is untouched, ROBUST-04's checkbox stays ticked. The research pass falsified most of the handoff's OWN specified argument for edit 2: eight of nine bundle rebuilds paired (db577db was lockfile-driven, undici 6.27.0 to 6.28.0), NOT one of the seven unpaired commits is a spec file (all comment-only), and 'no action-bundle-drift catch is recorded' is REFUTED -- Phase 7's Q10 catch is recorded five times and sits one day INSIDE the window, and the CI-silence argument is broken anyway because gh run list returns no runs for any of the eight commits, so the gate never evaluated them. Replaced by a measurement: a fresh esbuild build at 23d9207 is byte-identical to the committed bundle, 2474234 bytes, cmp exit 0. Attribution corrected -- aee017c GATED the partial branch, e78a842 introduced it nine hours earlier the same day -- and the orchestrator's own CONTEXT.md cited a publish-mirror.ts path that does not exist. Plan-check found BOTH gates for the C-H reword dead: one needle contradicted its own action text so correct execution would fail, the other matched pre-existing line 25 and passed regardless; two more were vacuous the same way. The planner then caught three blocklisted phrasings echoed verbatim in its own action prose. The verifier passed a stale count the orchestrator did not: 'forty-seven commits later ... at HEAD' was true of 23d9207 and false the moment the amendment committed, now pinned to the tree it was taken on. The rollover misattribution stays OPEN and UNFILED by maintainer choice, recorded rather than dropped. 06ccf16..aceb526. Previously, quick 260809-2s6: stopped the publish mirror re-enumerating prior runs' seed entries -- the defect 260808-wxg's falsified readMisses exposed. Diagnosis inverted the framing: of 63 readMisses only 15 are real Nx hashes (all pre-47597a6); 48 are seeds, and all 22 post-rotation misses are seeds. The miss set is self-perpetuating -- an entry that misses can never be mirrored, so it is never in the shard, so it is retried forever (zero of the 63 were shard-present, structurally). Two designs rejected on the project's OWN requirements: self-cleanup needs actions: write, whose absence IS the mitigation for HIGH threat T-11-01; a new prefix would burn a second namespace. The answer was already in mirror-seed.ts -- hex letters make seed families structurally disjoint from all-decimal Nx hashes, so no shape heuristic is needed. Four plan-check iterations (cap raised to 5): round 1 found a FALSE succession premise originating in the orchestrator's own brief, a threshold that would fire on every correct run, and the run-id plumb having no gate at all (writing nothing passed, so the task would silently no-op in production while every test stayed green). The planner then corrected the CHECKER's arithmetic and recorded unprompted that the guard does not cover its own motivating case. Verification re-applied six mutations itself; all reddened. Code review found 11 warnings verification could not -- five false comments, one of which would have led a reader to delete a live clause. The design's foundation is now pinned by maintainer decision: structural disjointness rests on an unpinned Nx property, and this change had upgraded the cost of it changing from cosmetic to SILENT. 1070 tests. Three items NOT OBSERVED (push-gated), and 10-VERIFICATION's stale readMisses 0 deliberately not rewritten to a derived number. Previously 2026-08-08, quick 260808-wxg: the three-hop temporary main window RAN AND CLOSED in one sitting (open 65m25s, 22:19:01Z-23:24:26Z; commit 6708929). All four live-CI observations sampled from run 31281406708. O-A, O-C, O-D CLOSED; O-A's source row superseded in place because its expectation -- a 'linux' producer on the Windows publish-verify leg -- is the exact condition assertPublishedByThisLeg exists to reject. Hops 1 and 2 are a controlled experiment, not two observations: executable ci.yml held at 823/823 identical, so forced is the only variable -- non-forced publish RAN, forced publish SKIPPED, which is the 260808-u2q gate's first behavioural evidence in the skip direction. O-B did NOT close clean: readMisses is 63, not the pre-registered 0, contradicting a zero that 09-VALIDATION's own OBS-04 section had already falsified at 41/41 on run 30400231720 -- carried forward as an open sub-item, not closed. Every post-window assertion passes; nothing merged" progress: total_phases: 7 completed_phases: 7 - total_plans: 33 - completed_plans: 33 + total_plans: 45 + completed_plans: 45 percent: 100 -current_phase_name: Distribution + Docs + Governance --- # Project State @@ -24,20 +23,139 @@ current_phase_name: Distribution + Docs + Governance See: .planning/PROJECT.md (updated 2026-07-18) **Core value:** Correct and safe caching on GitHub infrastructure, for public and private repos, with nothing extra to host. -**Current focus:** Phase 6 — Distribution + Docs + Governance +**Current focus:** Phase 13 -- read-only-actions-cache-backend ## Current Position -Phase: Milestone v0.0.1 complete (awaiting next milestone); Deferred Items has NO open follow-up rows left -Plan: quick 260726-gok — Resolve the typecheck stale-cache false-pass + the consumer-doc defects (4 of 4 tasks, plus a 5th commit from the verification) -Status: Executed + verified (passed, 0 blocking); 5 atomic commits, PR to open -Last activity: 2026-07-26 — Quick 260726-4cc executed and verified +Phase: 13 (read-only-actions-cache-backend) -- COMPLETE +Plan: 6 of 6 +Status: COMPLETE. All six plans executed, verified 7/7, and the full audit tail closed this session: +secure-phase 27/27 threats closed, validate-phase nyquist_compliant, learnings extracted and pooled +Progress: 7/7 phases complete [##########] 100% +Last activity: 2026-08-04 -- Milestone v0.0.2 AUDITED (`tech_debt`: 51/51 requirements satisfied, +7/7 phases verified, 8/8 integration seams wired, 4/4 outcomes achieved; zero blockers, 11 +bookkeeping/behavioural debt items). Then quick 260804-h3b found the audit's own follow-up #1 was +NOT open: the `o3-witness` Case-B note (`da462b5`, 08-03 01:09) PREDATES both its fixes -- `40e4d21` +(03:13) and `e5d3cd3` (22:27) -- proven by `git merge-base --is-ancestor`, and 24 assertions already +pin it. Note superseded IN PLACE and the audit corrected at four sites. The fix's Case-B path was +then OBSERVED LIVE in two separately-reported sub-claims: (a) the prior-existence delta allowance, +run `30907575624` whose `headSha` EQUALS its own pre-registration commit `d4dc093` -- +`delta=9596s matched_ref=refs/pull/16/merge`, free, no window; and (b) the `$defaultref` clause +`e5d3cd3` added, run `30910935382` on stacked draft PR #17 (base = the feature branch, so +`base_ref != default_ref`) -- `delta=1252s matched_ref=refs/heads/main`. (b) is attributable rather +than merely printed: both other jq arms were MEASURED empty at observation time (own merge ref held +only its run-id seed; base scope returned `total_count: 0`). `main` window open 3m30s, restored to +`fe25a3f` behind a three-way gate. Verifier 8/8, every claim re-derived from raw job logs and the +live caches API. Previously, 2026-08-03: Quick 260803-mew observed BOTH directions of the Phase 12 Windows +regression detector on real `windows-11-arm` runners against the FOUR-target needle at HEAD (GREEN +`30825110047`, RED `30825602626`), closing a gate half that had only ever been measured on the +maintainer's workstation and re-closing a PASS half whose evidence pinned a needle `9e79009` +superseded. Verifier 7/7, `status: human_needed` for ONE newly-surfaced side effect that belongs to +the shared `main`-window PROCEDURE rather than to this task: the STEP 7 restore force-push carries +no `[skip ci]` (it re-pushes an existing commit, so the marker is structurally unavailable) and +therefore fires a full `ci.yml` run -- run `30825636788`, which failed on BOTH `publish` legs +attempting real production writes. Pre-existing and already-tracked, not a new regression, but +undisclosed until now and likely present in every prior window. Earlier the same day: Quick +260803-fcd CLOSED the `publish-verify` blocker, proven live on +run `30807461616` (FULL GREEN, zero failed jobs, fresh `nx-cache-202608` shard with 69 assets). +Earlier the same day: 260803-0rr closed assumption A1 across five artifacts, and 260803-3g1 swept +the status-only-422 defect class. **PR #16 is no longer blocked**; milestone v0.0.2 awaits +/gsd:audit-milestone. 1011 tests. + +**BOTH AUDIT GATES FOUND REAL GAPS, and neither was visible by reading.** Each found a mechanical +control that a plan DECLARED and that did not exist -- and in both cases the STATE was correct, so +nothing looked broken. Had either workflow's clean-path short-circuit been taken (secure-phase skips +its auditor at `threats_open:0`; validate-phase writes VALIDATION.md inline when its gap analysis +finds nothing), both would have shipped as "verified". + +- **T-13-05-D1** (medium): 13-05 declared "exactly two survivors, asserted mechanically". No + assertion existed. The three per-leg `not.toContain` clauses cover only the UNDER-sweep direction; + deleting either surviving diagnostic reddened nothing. Closed by `7968f21` -- both survivors pinned + by their OWN surrounding token (`RUNNER_DEBUG_OBSERVED`, `LEG_OS`) so they cannot cover for each + other, plus a marker-site count pinned at exactly 2. Mutation-proven three ways. +- **T-13-03-E1** (HIGH, the serious one): `select-backend.ts:33-35` and `:80-81` both claimed the + "knob is checked last" guarantee was asserted mechanically. The `indexOf` check was a one-shot from + plan 13-03 that never became a clause. Hoisting the knob above `resolveGitHubToken` -- exactly the + registered shape -- left **978/978 GREEN**. The exhaustive narrowing table is blind to it because + `outcomeOf` collapses both read-only outcomes into one token, so `widened()` stays false while the + fail-safe branch is bypassed. Real consequence: knob set + no resolvable token would build a LIVE + Actions-cache backend instead of the memory stub. Closed by `cbe69ce`. + +The security audit had found T-13-03-E1's guard was not standing and then DISMISSED it, reasoning +through `isWritableBackend` -- the same lens that blinds the table. It retracted that in writing on a +third pass (`934bd98`). Two audits asking different questions were needed; neither found it alone. + +Phase 13's live-CI half is OBSERVED for Case A only, now on THREE runs. Run 30744366870 (attempt 1, +`pull_request`, head 631a2e7) shows all three read-only Windows legs green at gate counts 1 / 2 / 1 +against a floor of 1, matching counts pre-registered in 631a2e7 -- which IS that run's head, so the +prediction was provably in the tree the run measured. Every ubuntu producer was reached: `typecheck` +MISS-and-saved both `build` and `typecheck` (0/2), `test` MISS-and-saved (0/1), and `build` HIT the +entry the `typecheck` job wrote, the race named in advance. Sent equals received per entry (137951 / +98227 / 1309). Independently re-verified during the security audit: 631a2e7 introduced 13-EVIDENCE.md +as 203 insertions / 0 deletions and the file's first 203 lines at HEAD are byte-identical to it, so +the record is pure append and was never back-edited, and `gh run view 30744366870` returns that same +sha as `headSha`. Run 30745558383 proved the FAIL path (`build-windows` red AT THE GATE STEP at count +0, other two green at 2/1 as a same-run positive control). Run 30746080731 reproduced 1 / 2 / 1 on +head e6b3268 -- but note the scope: every commit between 631a2e7 and e6b3268 touches only +`.planning/`, so no task hash rotated and it consumed the SAME producer entries. It reproduces the +observation; it is not an independent Case-A instance. + +TWO ITEMS STAYED OPEN BY DESIGN at the phase's close, and both audits were explicitly instructed +not to close them: the Case-B base-scope read (unprovable by a landing commit that rotates all +three hashes) and RESEARCH assumption A1 (no Windows task MISSed, so no PUT was attempted and the +403 path never ran). **BOTH ARE NOW CLOSED**, by two quick tasks rather than by phase execution -- +Case B by `260802-toz` (run `30768540898`, producers HIT with zero `Sent` yet all three Windows +legs restored `main`-scope keys byte-identically at 1 / 2 / 1) and A1 by `260803-0rr` (local +measurement: four PUTs attempted, each refused 403, Nx silent, build green). A1's route changed +rather than its condition being met -- the landing-run observation condition was never needed, +because `server.ts:128-133` returns the 403 BEFORE `handlePut`, so the backend cannot affect the +PUT path. The phase's audit artifacts still read OPEN and are deliberately FROZEN, each with a +forward pointer; the status of record is `13-VALIDATION.md`'s Manual-Only table and +`13-EVIDENCE.md` ADDENDUM 3. + +**Milestone v0.0.2 gained a SEVENTH phase.** Phases 7 through 12 are Complete in the ROADMAP table +(39 of 39 plans), but `/gsd:audit-milestone` now waits on Phase 13, added by maintainer instruction +to close CR-18. Phase 12's four -- XOS-04, XOS-05, XOS-08, DOCS-07 -- closed at the phase step +rather than per-plan, because every plan deliberately skipped `requirements.mark-complete` after it +falsely closed all three XOS rows on 12-01's RED-only plan. Phase 13's seven are registered Pending +for exactly the same reason and close only as their code lands in 13-02..13-06. + +Phase 12's live-CI half is OBSERVED, not inferred. O4 was measured on run 30586177358, the FIRST run +of same-repo PR #12: `[remote cache]` counted per Windows leg at 1/2/1 (total 4), matching counts +pre-registered in `f5d03b0` BEFORE the run, with every ubuntu leg MISS-and-saved in the same run. +The scheduled detector went green on run 30603713356 on a real `windows-11-arm` runner. Both are +recorded in 11-EVIDENCE.md's O4 section and 12-UAT.md. + +SUPERSEDED by quick 260803-mew, on the detector clause only (the O4 `[remote cache]` counts above +are untouched). Run 30603713356 proved the THREE-target needle at `e757d4c`; `9e79009` replaced the +needle with the FOUR-target form and `git merge-base --is-ancestor 9e79009 e757d4c` is FALSE, so +that run cannot speak to the needle at HEAD. Both directions of the four-target needle are now +observed on real `windows-11-arm` runners: run 30825110047 PASS (headSha 41f65e1, needle as genuine +Nx output, `lint` executing) and run 30825602626 FAIL (throwaway 3-of-4 tree, red at the needle's +grep with nx at exit 0 in the same step). The FAIL half had never been observed on a runner in +either needle form. See +.planning/quick/260803-mew-observe-phase-12-fail-half-on-real-run/260803-mew-EVIDENCE.md. + +RESEARCH assumption A1 is CLOSED by measurement -- both hash-parity artifacts from run 30586177358 +carry the hardened `node --no-warnings -p process.platform` with empty stderr and differing stdout. +`12-VERIFICATION.md:20` and `12-SECURITY.md` still describe it as open; both were correct for the +trees they audited and are superseded by 12-VALIDATION.md rather than back-edited. + +Two non-blocking residuals carried forward, both recorded in 12-SECURITY.md: the `::add-mask::` +ordering in `ci.yml` is now guarded (commit `e73f49c`) after this phase took it from 5 sites to 8; +and code review's CR-01 showed a green structural guard can sit over a wrong payload, which is why +`RENDERED_DISCRIMINATOR_SITES` is pinned at an exact 4 rather than a floor. + +Prior phase: Phase 11 (Live Proofs O1-O2-O3) is COMPLETE, 7 of 7 plans. Phase 10 (OS-Invariant +Releases Mirror) is COMPLETE, 8 of 8, 12/12 requirements closed; its two live-CI items were observed +on run 30471772954 via a temporary push to main, since restored to fe25a3f. See +10-EVIDENCE-LIVE-CI.md. ## Performance Metrics **Velocity:** -- Total plans completed: 30 +- Total plans completed: 45 - Average duration: - min - Total execution time: 0.0 hours @@ -51,6 +169,8 @@ Last activity: 2026-07-26 — Quick 260726-4cc executed and verified | 04 | 6 | - | - | | 05 | 4 | - | - | | 6 | 5 | - | - | +| 09 | 8 | - | - | +| 11 | 7 | - | - | **Recent Trend:** @@ -90,12 +210,63 @@ Last activity: 2026-07-26 — Quick 260726-4cc executed and verified | Phase 06 P05 | 11min | 2 tasks | 3 files | | Phase 06 P02 | 10min | 2 tasks tasks | 2 files files | | Phase 06 P04 | 16 | 3 tasks | 7 files | +| Phase 07 P01 | 40 | 3 tasks | 10 files | +| Phase 07 P02 | ~25 min | 2 tasks | 6 files | +| Phase 07 P03 | ~20 min | 2 tasks tasks | 5 files files | +| Phase 07 P04 | ~55 min | 3 tasks | 2 files | +| Phase 08 P01 | 39min | 2 tasks tasks | 4 files files | +| Phase 08 P02 | 26min | 2 tasks | 6 files | +| Phase 08 P03 | 33min | 3 tasks | 2 files | +| Phase 08 P04 | 28min | 2 tasks | 1 files | +| Phase 08 P05 | 5h 6m | 3 tasks | 4 files | +| Phase 08 P06 | 42min | 2 tasks | 4 files | +| Phase 10 P01 | 50m | 2 tasks | 1 files | +| Phase 10 P02 | 13min | 2 tasks | 4 files | +| Phase 10 P03 | 15min | 3 tasks tasks | 3 files files | +| Phase 10 P04 | 16min | 1 tasks | 6 files | +| Phase 10 P05 | 62min | 3 tasks | 5 files | +| Phase 10 P06 | ~35m | 1 tasks | 6 files | +| Phase 10 P07 | 19m | 3 tasks | 14 files | +| Phase 10 P08 | 23m | 3 tasks | 6 files | +| Phase 11 P01 | 7min | 2 tasks | 3 files | +| Phase 11 P02 | 17min | 3 tasks | 2 files | +| Phase 11 P03 | 18min | 3 tasks | 1 files | +| Phase 11 P04 | 23min | 2 tasks | 2 files | +| Phase 11 P05 | 15m | 2 tasks | 2 files | +| Phase 11 P06 | 25m | 2 tasks | 1 files | +| Phase 11 P07 | 55m | 3 tasks | 2 files | +| Phase 12 P01 | 35min | 2 tasks tasks | 4 files files | +| Phase 12 P02 | 40min | 2 tasks | 3 files | +| Phase 12 P03 | 22min | 2 tasks | 1 files | +| Phase 12 P04 | 38min | 3 tasks tasks | 7 files files | +| Phase 12 P05 | 17min | 3 tasks | 6 files | +| Phase 12 P06 | 30min | 3 tasks tasks | 1 files files | +| Phase 13 P01 | 22m | 3 tasks | 3 files | +| Phase 13 P02 | 20min | 3 tasks | 3 files | +| Phase 13 P03 | 7min | 3 tasks | 4 files | +| Phase 13 P04 | 8min | 3 tasks | 6 files | +| Phase 13 P05 | 20min | 3 tasks | 2 files | +| Phase 13 P06 | 35min | 2 tasks | 1 files | ## Accumulated Context +### Roadmap Evolution + +- Phase 13 added 2026-08-01: Read-Only Actions-Cache Backend. Added to v0.0.2 (not deferred to a + later milestone) by maintainer instruction. Origin: CR-18 from the PR #12 round-3 code review. + Quick task `260801-vyy` closed CR-18's pre-merge signal gap by widening the dogfood provenance + canary to same-repo PRs, but left the three Windows legs' `[remote cache]` counts ungated on + purpose -- those legs write, so a broken cross-OS restore self-heals into a green on re-run, + making a `count >= 1` gate launderable. Phase 13 removes that confound structurally so the + counts become soundly gateable, which is what CR-18 originally asked for. Carries an explicit + research gate on the named risk: two Actions-cache backends means two places for the + cache-version computation to drift, and an OS-dependent cache version is the exact bug Phase 9 + existed to fix. "Do nothing" is a listed candidate outcome -- the dogfood canary already gates + the storage layer pre-merge, so this phase buys the Nx-task layer, not the storage layer. + ### Decisions -Full log in PROJECT.md Key Decisions + .planning/ARCHITECTURE-DECISION.md. Recent decisions affecting current work: +Full decision log in PROJECT.md Key Decisions; the CREEP control ledger C1-C18 backing those decisions is .planning/THREAT-MODEL.md. Recent decisions affecting current work: - FOUND-01: reader / cross-context store = GitHub Releases (forward merits, spike 001-005); GHCR = later-milestone revisit trigger (with PROV-01 + Docker). - FOUND-03: distribution = npm + JS Action; Docker container form deferred to a later milestone (CI sidecar covered by the GA background-step pattern). @@ -128,7 +299,7 @@ Full log in PROJECT.md Key Decisions + .planning/ARCHITECTURE-DECISION.md. Recen - [Phase ?]: [Phase 03]: 03-02: resolveRepoIdentity resolves owner/name from a shape-validated GITHUB_REPOSITORY override else git remote origin (https + scp-like ssh, .git optional); non-GitHub/unparseable -> undefined, never a guess (D-10). GITHUB_REPOSITORY_PATTERN exported from select-backend.ts (1-line diff) and reused; resolveGitHubToken body byte-identical (TEST-01 intact). FOUND-02 checkbox deferred to 03-03 end-to-end wiring. - [Phase ?]: [Phase 03]: 03-03: createReleasesReadClient is the real default ReleaseReadClient (authenticated GitHub REST over native fetch, zero-dep): resolves token then repo BEFORE any request (D-09/D-10, zero-fetch on undefined), paginates assets (per_page=100, never inline release.assets), download drops Authorization on the 302 by spec (no redirect:manual). 404 -> silent undefined; other non-ok -> throw -> port warns+MISS (D-11). selectBackend local branch wires it and stays synchronous (async resolution deferred into fetchAsset, TRUST-05 length 0). shardTag = current-month cache-mirror-YYYYMM single-shard seam. Benign call-time-only circular import select-backend->releases-backend->local-context. - [Phase ?]: 04-01: sync gate is a SEPARATE predicate (isSyncTrusted / SYNC_EVENTS), never reuses the write gate allowlist (D-01 / TRUST-02 / ADR C2 CREEP control) -- [Phase 04]: 04-02: retention.ts is the ONE coupled knob (resolveMaxAgeDays, default 30) + single-source cache-mirror-YYYYMM shard scheme (shardTag moved here); the Releases reader walks shardTagsForWindow newest-first, 404 advances shard, MISS only after exhausting the window (D-07/D-08). RETAIN-01 (cleanup) stays open -> 04-03. — One knob prevents read/retention drift; single-source template prevents silent cross-OS MISS; window walk survives month boundaries without FOUND-02 regression. +- [Phase 04]: 04-02: retention.ts is the ONE coupled knob (resolveMaxAgeDays, default 30) + single-source cache-mirror-YYYYMM shard scheme (shardTag moved here); the Releases reader walks shardTagsForWindow newest-first, 404 advances shard, MISS only after exhausting the window (D-07/D-08). RETAIN-01 (cleanup) stays open -> 04-03. -- One knob prevents read/retention drift; single-source template prevents silent cross-OS MISS; window walk survives month boundaries without FOUND-02 regression. - [Phase 04]: 04-03: cleanupMirror is the list-abort/delete-isolate prune engine behind an injected CleanupClient -- LIST materializes every cache-mirror-* release+asset before any delete (any throw aborts with ZERO deletions, inverting the reader swallow discipline); DELETE prunes by created_at, per-item isolated, 404 benign vs non-404 real fault via statusOf duck-type, core.setFailed on aggregate; OBS-01 summary reports pruned/failed/scanned. Shared octokitFault test factory added (RETAIN-01/TEST-06). - [Phase 04]: 04-04: publishMirror is the injected-client, Octokit-free mirror engine -- nx-cache- filter (D-16) -> same-OS restore (D-03) -> lazy get-or-create current-month shard -> first-write-wins upload; pre-upload ~2 GiB fail-loud whole-run throw (D-12), 1000-asset skip-and-warn (D-11), statusOf duck-type discrimination with per-item upload fault isolated+annotated vs whole-run throw (D-13/OBS-01); asset name via releaseAssetName only (CORR-01). - [Phase ?]: Cleanup bin reuses GITHUB_REPOSITORY_PATTERN + resolveGitHubToken for fail-closed guards (no new code) @@ -150,6 +321,96 @@ Full log in PROJECT.md Key Decisions + .planning/ARCHITECTURE-DECISION.md. Recen - [Phase ?]: [Phase 06]: 06-04 (DOCS-01): README rewritten as the 5-min default CI-RW quickstart (start-cache-server background step + mandatory cancel: teardown); GITHUB_TOKEN passed to the step so selectBackend hands back the writable backend, else every CI write silently MISSes. docs/ nav + pre-1.0 versioning note added. - [Phase ?]: [Phase 06]: 06-04 (DOCS-01/DOCS-06): advanced.md documents opt-in Releases reader / publish-sync / cleanup by capability + trust/runtime requirements only, never presenting the internal dogfood action as the consumer surface; the & fallback is scoped to the token-based Releases reader path ONLY (CI-RW requires the JS action because a plain run:/& step lacks ACTIONS_RUNTIME_TOKEN). - [Phase ?]: [Phase 06]: 06-04 (DOCS-02/DOCS-04): configuration.md documents all 7 consumer env knobs (matching DOCS-05 EXPECTED_ENV_KNOBS) + the 10 GB LRU and no-anonymous-default-local-read notes + MAX_CACHE_BODY_BYTES as a fixed 2 GiB limit; minimal-ci.yml distinct from the dogfood ci.yml. docs-adoption.spec.ts guard wired repo-root docs into nx.json test inputs (explicit paths); 06-05 docs-trust wiring gap logged to deferred-items.md. +- [Phase 07]: 07-01 D-12 call: the ESLint recommended sets produce 10 findings on this tree (reproducing RESEARCH G4 file-for-file, with the low-confidence regex class at zero), closed to ZERO residual by TWO configuration blocks -- ZERO rules turned off repo-wide and ZERO code edits. The one scoped rule-off is @typescript-eslint/no-require-imports limited to **/*.cjs for pack-check.cjs (D-13), recorded rather than claimed away; no-undef kept LIVE there via a four-name inline globals map rather than the shorter no-undef:off, so the only file in the repo where that rule applies keeps its typo check. +- [Phase 07]: 07-01 the global ignores block in eslint.config.mjs is REQUIRED, not hygiene: eslint . walks the filesystem and never consults git, so the gitignored dist/ and out-tsc/ trees WOULD be linted while Nx never hashes them -- lint's result would depend on whether build ran, at an unchanged hash. Measured 64 files linted with the block vs 155 without (mutation M7); invariance across rm -rf dist out-tsc is G5 negative control 2. The .cjs override must sit AFTER the typescript-eslint spread, because typescript-eslint/base has no files key and sets sourceType:module for every file -- an override placed before it is itself overridden and silently does nothing. +- [Phase 07]: 07-01 the TDD RED caught a vacuity bug in the guard's OWN non-vacuity control (the gok lesson recurring): filtering on severity===1 && ruleId===null also matches a LINT-06 unused-directive report at v9's default warn severity, so the control misread a correctly-linted file as never-linted. It passed in GREEN only because 'error' moves those reports to severity 2 -- its correctness depended on the very setting it was meant to be independent of. Fixed with a position check (an ignore/unconfigured result describes the whole file and carries no line; every rule and directive report carries one), plus a self-test asserting the control still detects a genuinely ignored path. +- [Phase 07]: 07-01 Q10's contingency FIRED -- the D-05 linux/arm64 node:24 container lockfile regen re-resolved undici 6.27.0 to 6.28.0 through @actions/*'s ranged transitive deps, drifting start-cache-server/index.js by 88 lines; rebuilt and staged in the SAME commit per SC9, never as a follow-up. Q2 resolved favourably: fallow auto-credits eslint.config.mjs and its three imports, so the contingency entry line was NOT needed and the only .fallowrc.jsonc change is the @nx/eslint ignoreDependencies line. Container invocation needs MSYS_NO_PATHCONV=1 on this host or Git Bash rewrites -w /app into a Windows path. +- [Phase 07]: 07-02: the ambient-platform ban is TWO core rules behind ONE shared BAN_MESSAGE, scoped by files ['**/*.spec.{ts,mts,cts}'] with ignores ['**/*.integration.spec.{ts,mts,cts}'] as a SIBLING key so integration specs keep every other rule and lose only the ban (D-17). P6, the ImportExpression selector, is MANDATORY and proven so: no-restricted-imports at 9.39.5 has no import-expression visitor, so it closes the STATIC import family only and a dynamic import of node:os would otherwise be a silent hole. P7 was INCLUDED rather than declined, so globalThis.process.platform is caught rather than recorded as a ceiling; the two ceilings that ARE recorded are P4/P5's hardcoded namespace binding names and T-07-12's helper-in-another-module read. +- [Phase 07]: 07-02: Q5 and Q6 closed affirmatively -- every esquery-measured selector verdict reproduced under real ESLint with @typescript-eslint/parser (including the two-rule double report on a namespace import), and the non-literal dynamic import of eslint.config.mjs works under both vitest and typecheck, so the D-19 drift guard reads the REAL evaluated config array instead of matching source text. One consequence worth carrying: import * as path from 'node:path' IS an error, because no-restricted-imports reports a namespace specifier whenever the entry lists importNames -- so the path.join false-positive control uses a LOCAL object and the namespace form is asserted as an evasion shape instead. +- [Phase 07]: 07-02: four described disables at FOUR error positions, and there is no fifth. cache-archive-path.spec.ts:26 is a SITE (it leaves with the import under VER-02 in Phase 9) but NOT an error position -- in strict ESM that binding cannot exist without the import, and the import is the chokepoint -- so a directive there would be UNUSED and reportUnusedDisableDirectives:'error' would fail the build through the phase's own opt-out discipline. ROADMAP SC3's "three CORR-05 violations" is a miscount; REQUIREMENTS, CONTEXT and RESEARCH all say FOUR. Both corrections are comment-locked on CORR_05_SITES, which keys on FILE + EXPRESSION TEXT because inserting the disables shifts every later line in the same commit. +- [Phase 07]: 07-03: the lint target is INFERRED, not declared, and its existence was PROVEN via nx show project rather than assumed -- @nx/eslint's createNodes short-circuits and returns nothing SILENTLY when no eslint.config.* is found, so an absent target and a broken plugin look identical from outside. targetDefaults.lint REPLACES the inferred input list: it restates default, ^default, the workspace-root config and the custom-rule directory, widens the inferred single-entry { externalDependencies: ['eslint'] } to all four ESLint packages (that single entry IS the LINT-04 hole -- a typescript-eslint bump would not have invalidated the cache), and pins outputs to [] instead of ['{options.outputFile}']. The real inferred list was read from the INSTALLED PLUGIN, not from STACK.md's quote, which is one entry short (C7): the missing tsconfig-chain entry resolves to tsconfig.base.json, already folded into default via sharedGlobals, so the replacement needed no addition -- a checked conclusion, not an inherited one. +- [Phase 07]: 07-03: build is UNUSABLE as lint's negative control, because lint's inputs start from default so hashing a spec is exactly what lint is SUPPOSED to do -- a build-shaped negative would assert something false about the target. The honest discriminator is a probe path OUTSIDE {projectRoot}. A vacuity mutation (lint.inputs reduced so the self pattern list resolves EMPTY) proved the choice load-bearing: filterUsingGlobPatterns returns the whole probe list on an empty pattern list, so BOTH positive assertions still passed and the negative control was the only glob-resolution assertion that caught it. Separately, this commit rotates EVERY task hash and rotates test twice over ({workspaceRoot}/nx.json is already an explicit test input), so Phase 7's first default-branch push is a LEGITIMATE all-MISS push -- Phase 9's OBS-04 tripwire must be authored as 'two consecutive all-miss pushes with NO version-affecting change in between' (D-36). D-35's hashed-node baseline for Phase 8 CORR-03 is recorded in 07-EVIDENCE.md; options.cwd is the row most likely to diverge across OSes and it IS hashed. +- [Phase 07]: 07-04: LINT-04 is closed BY DIFFERENTIAL, not by reading the config. Editing a RULE and editing a linted SOURCE each make lint EXECUTE (Cache 0/1) where the unperturbed tree replays (1/1), and the same rule edit makes test EXECUTE too -- the D-25 second-order hole, measured BEFORE any mutation result was trusted. The load-bearing one is negative control 1: A and B BOTH pass on a lint target with no declared input block at all, because @nx/eslint's inferred inputs already contain default and the workspace-root config, so A and B alone prove nothing about D-24. Deleting that one entry and watching a real rule change serve Cache 1/1 hit (100%) is the proof. A methodological trap worth carrying: a differential's perturbed side must be run exactly ONCE -- running it twice caches the perturbed hash, so a later repeat of the same edit reads as a HIT indistinguishable from the defect. +- [Phase 07]: 07-04: all nine mutations M1-M9 applied, OBSERVED and reverted first-hand; every one matched, and nothing was committed mutated. M1 and M2 produce DISJOINT red sets, which is the measured form of D-15 (neither ban rule is sufficient alone). M3 -- the one flagged most likely to be silently vacuous -- went RED, so P6 and the dynamic-import shape are genuinely covered; but VALIDATION.md's predicted count is off by one, because the shipped spec folds BOTH dynamic shapes into ONE it() row, so the observed result is 1 failing assertion covering two shapes rather than 2. M8 produces the require-description error and M9 produces BOTH the unsuppressed ban error and a severity-2 unused-directive report, which is what makes LINT-05 and LINT-06 LIVE rather than merely configured -- and is why plans 07-01 and 07-02 correctly left those two requirement boxes unticked until now. requirements.mark-complete was skipped and REQUIREMENTS.md hand-edited, since that tool corrupted the same file in both prior waves. +- [Phase 08]: 08-01: capture-hashes.mjs is the root-level dev-only ESM instrument (D-01/D-02) -- four Nx internal-subpath imports drive createProjectGraphAsync -> createTaskGraph -> createTaskHasher().hashTask() over the five D-05 targets, emitting each target's hash/command/details.nodes map PLUS the merged projectConfiguration node. That last addition is beyond D-04's minimum and is what makes PARITY-01 answerable: the node map holds exactly ONE :ProjectConfiguration entry covering all five targets, so a difference there cannot be localised to a field without the merged node. PROVEN, not asserted: in one uninterrupted session at 5a8f7c5 the instrument's build (15091651677672778193) and test (17043910507556371878) hashes were byte-identical to what nx run wrote into .nx/cache/run.json, with Pitfall 2 discipline held (each copy read before the next nx command; the nx show runs sequenced afterwards). D-01(b) is STRUCTURAL: root package.json nx.includedScripts stays [], so the capture:hashes script cannot become an Nx target and a capture can never be a replay. +- [Phase 08]: 08-01: graphState is derived from workspaceDataEntries ALONE, correcting 08-RESEARCH.md's recommended both-counts-zero recipe. Measured: getNativeFileCacheLocation() is not a hash cache at all -- nx/dist/src/native/index.js:96-107 uses it to hold ONE version-prefixed copy of the .node addon binary, and the instrument's own static import of project-graph.js puts it there before any measurement can run (probed against a fresh empty dir: 0 entries at process entry, 0 after importing native-file-cache-location.js, 1 immediately after importing project-graph.js). The prescribed derivation would therefore have returned `warm` unconditionally forever, mislabelling every Phase 8 observation point -- the same silently-always-passes class D-04 exists to prevent. The record now carries meta.graphStateBasis so it states its own derivation. Two further verification-surface corrections in the same plan: `--inputs` is NOT a flag at Nx 23.1.0 but a SUBCOMMAND, so 08-RESEARCH.md's `nx show target --inputs` exits 0 with the flag INERT (both forms captured so the D-03 evidence cannot be dismissed as the wrong command); and the acceptance check "grep the source for a --graph-state flag and find none" was initially satisfiable by a COMMENT saying no such flag exists, so the comment was reworded -- Phase 7's "a lexical guard can be satisfied by the wrong token" recurring. +- [Phase 08]: 08-02: HashParityRecord models ONLY the fields shapeFault validates -- an unmodelled emitted field cannot become an unchecked assertion about a downloaded artifact +- [Phase 08]: 08-02: a grep-verifiable ABSENCE claim must not spell the token it forbids anywhere in the file, including in the sentence explaining the rule (hit twice this plan) +- [Phase 08]: 08-02: pack-check.cjs enumerated its excluded dist subtrees in FOUR places, not three; all three runtime sites now derive from DIST_SUBTREES, the module header is the one hand-maintained restatement +- [Phase 08]: 08-03: Phase 8 root cause MEASURED at anchor a9a3895: exactly ONE hash node differs cross-OS, @op-nx/github-cache:ProjectConfiguration, and the field is targets.typecheck.outputs (seven entries on linux, one on win32). Zero only-in-* buckets on all five targets. +- [Phase 08]: 08-03: PARITY-04 answered NO at the anchor: a warm-preexisting Windows box computes a different hash from cold on all five targets. Recorded as a finding; the nx reset mitigation is DOCS-07 (Phase 12) and is deliberately absent from the proof recipe. +- [Phase 08]: 08-03: D-21 resolves to its PRIMARY branch: lint diverges cross-OS but nothing lint-specific does (all six D-35 hashed rows match on both legs, options.cwd included), so 08-06 asserts lint as a fourth IDENTICAL target. +- [Phase 08]: 08-03: D-11 ROOT-CAUSED: typecheck's four values decompose into two binary variables -- the outputs classification and whether dist/ is populated (the dependentTasksOutputFiles node). No residue, not left OPEN. +- [Phase 08]: 08-04: U-01's trigger condition is pre-committed as C1-C4 (confirm) / L1-L4 (escalate) / N1-N3 (explicitly NOT triggers). N1 -- typecheck differing between a built workstation and an unbuilt runner -- is D-11's residue, orthogonal to both axes and provably not closed by the fix; without pre-declaring it, the first post-fix comparison would read as U-01 going live. +- [Phase 08]: 08-04: coverage audited against REQUIREMENTS.md's own words, never ROADMAP.md's paraphrase -- which is how ROADMAP.md:530-534's stale PARITY numbering was caught (its PARITY-02 row states PARITY-03's text, and an audit against it would have closed the one row this record does NOT satisfy). Surfaced, not fixed. +- [Phase 08]: 08-05: U-01 RESOLVED as confirm-d12 -- nx.json targetDefaults is a sufficient fix location for this workspace, decided against a condition committed to git before the experiment ran +- [Phase 08]: 08-05: typecheck.outputs pinned to the SEVEN-entry list: the enumeration shows it covers 136 of 136 emitted files, and outputs is what Nx caches and restores, so a stable-but-wrong list is a silent cache-correctness regression +- [Phase 08]: 08-05: The hash-parity job builds before capturing -- typecheck carries an inferred dependsOn on build, so hashing it outside that chain records a value no real run computes +- [Phase 08]: 08-06: the compare job uses if: !cancelled() rather than D-17's always(), chosen deliberately -- it covers failed and skipped legs (D-17's actual requirement) and is already this file's house form on a dependent job; the two forms differ only on cancellation, where a red gate is noise +- [Phase 08]: 08-06: the D-22 real-leg RED used an ADDITIVE OS-sensitive input on build, not a removal of integration's discriminator -- D-14 requires that string byte-identical, and a remove-then-restore of it is a needless risk when an obviously-wrong mutation on a different target reddens the same clause +- [Phase 08]: 08-06: both mutations are kept on the branch as adjacent applied-and-reverted PAIRS rather than squashed away, because a demonstration whose commits are gone is indistinguishable from one that never happened (the same reasoning D-21 applies to a downgraded clause) +- [Phase 10]: 10-01: D-25 baseline splits: pre-rename read path PROVEN LIVE (HTTP 200, 410 bytes on 13758457399293023985-windows) while Nx's own run MISSED (Cache 0/1) -- attributed solely to this branch's four hashes never having been mirrored, since the branch is unmerged +- [Phase 10]: 10-01: Phase 11 XOS-02 inherits an open PRECONDITION, not a read-path defect: a default-branch push must republish the mirror under the OS-free name before any post-rename local read is measurable +- [Phase 10]: 10-01: RETAIN-05(a): 50 PoC-era .tar.gz assets accepted as dead weight with a measured count (50 of 122 in shard cache-mirror-202607, release 354838660). No code change, no third accept branch; manual prune stays operational and not code +- [Phase 10]: 10-01: Release asset label measured EMPTY on all 122 shard assets, so OBS-03's mirrored-by field is genuinely new rather than partially populated +- [Phase ?]: [Phase 10]: 10-02: OBS-03's mirrored-by label is a 4th POSITIONAL parameter on PublishClient.uploadReleaseAsset (the ref precedent on createPublishClient), hoisted above the hash loop so cachePlatform() runs once per run; PublishOptions stays { now }. The adapter edit is the load-bearing half -- TypeScript accepts a contextually-typed adapter declaring only three parameters against a four-parameter interface, so a forgotten adapter typechecks clean, passes every engine test against the fake, and silently drops the label on every real upload. +- [Phase ?]: [Phase 10]: 10-02: the retraction is comment-locked at the construction site with two independent grounds -- listCacheEntries yields { key } only (the adapter maps every row to { key: cache.key }), and Phase 9's VER-01/VER-03 broke publisher-equals-producer, so a producing-OS reading would be wrong in exactly the cross-OS case the label serves. The comment also states what the hoist does NOT guarantee: moving it back inside the loop is behaviourally identical, and only the multi-hash called-ONCE case notices. +- [Phase ?]: [Phase 10]: 10-02: the baseline expected label derives from CACHE_OS_VALUES[0] (windows), never the literal 'linux' -- so on the ubuntu-only test job even the four baseline argument-array assertions redden against an engine that read the ambient platform. Recorded as a bonus, NOT a guarantee: on a Windows workstation the two coincide, and the it.each(CACHE_OS_VALUES) group is the only clause that bites on every machine. +- [Phase ?]: [Phase 10]: 10-02: an acceptance grep asserting zero negated-matchers-inside-toHaveBeenCalledWith returns 1 at HEAD and did so before this plan -- the hit is a COMMENT from Phase 9's gap-closure commit 5184427 explaining why the shape is NOT used. Kept, not deleted; comment-stripped the count is 0. Reproduces Phase 8's lesson that a grep-verifiable absence claim must not spell the token it forbids anywhere in the file, including in the sentence explaining the rule. +- [Phase ?]: [Phase 10]: 10-03: XOS-07 widened publish to needs: [build, typecheck, test, integration] -- the race is MEASURED (run 30400231720: the ubuntu publish leg enumerated the Actions cache ~122s BEFORE integration (windows-11-arm) finished; task hash 8059758544828235640 reached the shard only as -windows). No cycle: all four deps declare no needs: of their own, verified by reading every needs: line. +- [Phase ?]: [Phase 10]: 10-03: the old needs: build (NOT test) argument is RESTATED and then replaced, not deleted -- !cancelled() is the mechanism that keeps a red test leg from skipping the mirror, and it is recorded as CITED (never reproduced here) with the bounded downside named: a SKIPPED mirror, never a wrong artifact. publish-verify has no !cancelled(), so the widening also makes it run on red-test pushes, changing which pushes sample OBS-05. +- [Phase ?]: [Phase 10]: 10-03: the repo's FIRST needs: VALUE guard is superset-proof three ways -- four independent per-producer cases (a toMatch on a list is satisfied by any SUPERSET), an observed 3-of-4 revert split with build staying green, and anchoring at ^ {4}needs: (unanchored, build is satisfied by the job's own - run: npm run build step, making that case a tautology). forbidden: [] on the new drift row is deliberate: an absence check there is satisfied by deleting the whole comment. +- [Phase ?]: [Phase 10]: 10-04: mirror seed encoding is feed -- a hex-word marker following ci.yml consumer-smoke's shipped cafe precedent with a DIFFERENT word, so the two families stay distinguishable in a shard listing +- [Phase ?]: [Phase 10]: 10-04: mirrorSeedHash lives in its own lib/mirror-seed.ts leaf, NOT folded into release-asset-name.ts -- unreachable from serve(), so the bundle delta is ZERO and ROBUST-04 stays whole on plan 10-07 +- [Phase ?]: [Phase 10]: 10-04: OBS-05 NOT marked complete -- only its first half (the mechanism) landed; ci.yml still runs operation: seed and plan 10-05 flips the workflow and read-back.ts together +- [Phase ?]: 10-06: the RED half of the rename pair lands as its OWN ADD-only commit, so 10-07's five converging same-commit rules bind the smallest possible unrecoverable window +- [Phase ?]: 10-06: a grep-verifiable absence criterion must never spell its forbidden token, even in prose -- the new integration spec's header names the contortion and why +- [Phase 10]: The ref-scoping pin is THREE cases, not one assertion — Three distinct regressions each redden a different case: dropping ref reddens the two whole-argument-array cases, adding a second unscoped enumeration reddens only the call-count pin, and hardcoding the ref value reddens only the second of the two constructor-ref cases. A property-scoped assertion catches only the first. +- [Phase 10]: TRUST-13 left OPEN; secure-phase must spawn gsd-security-auditor and must not take the inline short-circuit — The short-circuit fires when the threat register looks clean and was authored at plan time, which is exactly Phase 10's shape. TRUST-13 forbids self-certification in as many words, so 10-TRUST-EVIDENCE.md Part B is INPUT and the verdict is authored by the auditor in 10-SECURITY.md. +- [Phase 10]: C16's unchanged-ness asserted at FUNCTION scope, not file scope — cache-key.ts legitimately changed this phase (+25 comment-only lines widening its prefix lock), so a file-scoped diff prints a non-empty result whose honest reading is unchanged -- the exact trap. Scoped instead by a --unified=0 non-comment-line filter plus the pre-existing seven-case accept/reject pin. +- [Phase 11]: D-13's negative control is typecheck, not test: test's dependsOn is ^build (dependencies' build, none in this single-project workspace) so it resolves ONE task and never proves the resolver expands dependsOn, while typecheck's inferred [build, ^typecheck] resolves TWO with one a FORBIDDEN member — RESEARCH.md verified the inferred dependsOn in @nx/js source; the intersection is what makes the absence assertion over the integration set non-vacuous (D-13) +- [Phase 11]: capture-hashes.mjs's --out writer was duplicated into the new mode rather than extracted to a shared helper — Extraction would edit the capture path plan 11-02's irrecoverable warm capture depends on, and verifying the refactor would mean running that warm capture, which plan 11-01 does not own. Ten duplicated lines is the cheaper risk +- [Phase 11]: Both wave-1 instruments are workspace-root, mechanically confirmed to match none of nx.json's 21 workspaceRoot inputs via node:path matchesGlob with a live eslint.config.mjs positive control — D-10 row 4 / D-11: the perishable O1/O2 window stays intact, so plan 11-02's warm capture is still available (.nx/workspace-data 18 entries, .nx/cache 86, both unchanged) +- [Phase 11]: 11-02: task 2 blocking checkpoint resolved as `literal-reset` -- D-05 locked choice, TEST-10 named mechanism in TEST-10 named order (reset first, sidecar after). NOT the COLD-DIRECTORY variant, so there is NO deviation to record and NO partial-redirection caveat (CACHE_ARCHIVE_DIR is irrelevant here because nothing was redirected). NOT self-approved: auto mode verified inactive (workflow.auto_advance false, _auto_chain_active false) and the checkpoint returned to the orchestrator with all three options plus the numbers the decision was taken against; nx reset ran only after the selection came back. +- [Phase 11]: 11-02: D-06 DISCHARGED via outcome 1 of the three defined -- cold EQUALS warm on all four proof targets (build 17269409342684722256, typecheck 122473981802582055, test 11681410932071446589, integration 8137422034373911537), all four MATCH D-02 exactly, and all four are PRESENT in cache-mirror-202607 created 2026-07-29T16:44Z. D-07 did NOT fire. The mirrored-by: linux label is a PUBLISHER, never a producer (D-14). Recorded as a FINDING and deliberately NOT promoted to a proof: PARITY-04 post-08-05 status looks answered but its does-my-everyday-box-hit question stays out of scope -- TEST-10 reset makes this the COLD question only. Consequence for 11-03: a MISS there is no longer attributable to cold/warm divergence or a stale mirror, since both are excluded by measurement. +- [Phase 11]: 11-02: two results MEASURED that the record did not previously state. (a) A capture-hashes.mjs invocation itself warms .nx/workspace-data from 0 to 13 entries, so the cold number was obtainable in exactly ONE window -- which is why the capture runs once and why a verification re-run would have destroyed it. .nx/cache stays absent, so no local hit can short-circuit the 11-03 remote read. (b) packages/github-cache/dist/lib/trust.js SURVIVES nx reset (dist is a task output dir outside .nx/), so no rebuild is needed before the 11-03 D-08 soundness probe and none must be inserted -- a rebuild would populate outputs that typecheck dependentTasksOutputFiles hashes. +- [Phase 11]: 11-02: T-11-12 pagination guard proven load-bearing by MEASUREMENT, not assertion -- the shard read WITHOUT --paginate returns 30 of 141 assets carrying ZERO nx-cache- names, so an unpaginated read would have reported all four hashes ABSENT and manufactured a false D-07 finding that stopped the phase on a reader artifact. Matcher also proven non-vacuous the other way (rejects 125 non-prefixed assets; a bogus nx-cache-0000000000000000000 reads ABSENT). Separately: requirements mark-complete was deliberately SUPPRESSED -- XOS-01, XOS-02 and TEST-10 stay OPEN because no live proof exists until 11-03, continuing the 11-01 finding. +- [Phase ?]: Plan 11-03: the aggregate dist/ mtime currency check FAILED and the session continued on three stronger content-level checks with NO rebuild -- recorded in 11-EVIDENCE.md as a numbered deviation rather than absorbed +- [Phase ?]: Plan 11-03: producer fingerprints re-taken in-process after a Git Bash MSYS path-mangling FALSE ZERO -- a leading-slash literal is rewritten before rg sees it, so /home/runner read as absent while the path was demonstrably present +- [Phase ?]: Plan 11-03: XOS-01, XOS-02, TEST-10 and OBS-02 flipped complete on the O1/O2 evidence; TEST-08 deliberately NOT flipped -- its text spans O1-O4 and its traceability row pins it Pending until Phase 12 appends the O4 row +- [Phase 11]: 11-04: maintainer selected 'proceed' at the blocking rotation sign-off, authorising 11-05/11-06 to rotate test, typecheck, integration and lint — 4/4 pre-registered counts MET, 4/4 cacheStatus remote-cache-hit, D-07 never fired, and all four hashes attributed with a named means and its limit. Three weaknesses accepted as STATED LIMITS: build/typecheck carried by timing (M3) plus structure (M1) not identity; typecheck's created_at sits on a whole-second truncation edge that does not change the verdict; finite GitHub run-record retention reduces those two rows to M1 once run 30471772954 ages out, mitigated by transcribing the windows +- [Phase 11]: 11-04: producer attribution is established by four independent means with each hash naming which carried it AND that means' limit; mirrored-by is a PUBLISHER label and is not a means — The job-window cross-reference resolves a UNIQUE runner OS for all four hashes, which is what carries build and typecheck where no in-artifact fingerprint exists. D-14's retraction now ships arithmetic: integration was produced on windows-11-arm and published from ubuntu-24.04-arm 55.16s later, which is exactly why its label reads mirrored-by: linux +- [Phase 11]: D-19 is TWO spec edits, not one — Presence and shape go to dogfood-cross-os.spec.ts, whose jobBlock throws on an absent job key -- that throw IS the anti-silent-deletion mechanism. Comment prose goes to docs-same-os-claims.spec.ts, the only guard that reads ci.yml raw; the other strips every # line, so a comment lock there is vacuous by construction. +- [Phase 11]: Row C's not-a-guarantee clause is required in prose but NOT pinned as a phrase — The literal 'A measurement is not a documented guarantee' already exists in ci.yml's publish block, so a row asserting it would pass from the pre-existing occurrence and lock nothing. Same trap row B avoids by keying both phrases on o3-witness by name. Row C pins run 30471772954 rather than run 30401077417 for the same reason. +- [Phase 11]: 11-06: the ci.yml runner.debug recorder is ECHO-ONLY, never a fatal gate. A gate becomes a permanent tripwire once ACTIONS_STEP_DEBUG is unset, and plan 11-07's read-only pre-flight recovers the fail-during-the-run benefit without a second coupled ci.yml edit +- [Phase 11]: 11-06: o3-witness does NOT check out the repository (it is curl plus jq with no comparator to build), yet contents: read is RESTATED, because a job-level permissions block REPLACES the workflow grant wholesale rather than merging it +- [Phase 11]: 11-06: a THIRD stale ci.yml test-inputs comment block was found in the typecheck job beyond the two the plan, RESEARCH and PATTERNS all enumerated, and corrected with the replacement fact. Correcting two of three would meet the letter of the must_have and not its purpose +- [Phase ?]: Phase 11 O3 gate taken in two stages: rehearse-on-pr then proceed; PR #11 closed before the proving push rather than auto-merged +- [Phase 12]: 12-02: the needs: edge is documented as producer-to-consumer for HIT-ability, never as ordering correctness -- and the comment states BOTH that it removes the race and that it does not remove the second producer -- XOS-06 forbids ordering becoming a correctness control; a comment that recorded only the race removal would leave the producer count silently wrong +- [Phase 12]: 12-02: the graph-premise correction supplies a replacement reason at all three sites (ci.yml plus BOTH capture-hashes.mjs attribution sites) rather than deleting the old claim -- D-21 and PATTERNS S-1: a bare deletion leaves a future reader holding a documented argument for undoing the work, which is how Phase 9 shipped a regression +- [Phase 12]: 12-03: gate the detector on the PLURAL three-target success line, one nx run-many invocation -- Nx filters the printed target list down to targets that resolved a task, so the singular prefix passes a two-of-three run. MEASURED: a two-target run exits 0 while failing the plural needle. +- [Phase 12]: 12-03: close the pre-merge risk by proving the COMMAND on Windows arm64, not the job -- GitHub only dispatches a workflow whose file is on the default branch, so workflow_dispatch buys no pre-merge proof. Green-on-runner is a POST-MERGE first-run close. +- [Phase 12]: 12-04 (DOCS-07/D-15): the integration discriminator is now 'node --no-warnings -p process.platform' at all nine tree sites, one string rather than two that happen to match. --no-warnings rather than a redirect because hash_runtime runs the string through exactly ONE shell per OS (%COMSPEC% /C or sh -c), so a redirect breaks one OS; rather than nothing because Nx 23.1.0 hash_runtime.rs:33-35 hashes trimmed stdout CONCATENATED with trimmed stderr and node warnings carry the PID, making an emitted warning a permanent 100% MISS rather than a one-time rotation. CORR-04's byte-identical constraint is SUPERSEDED IN PLACE in 08-ROOT-CAUSE.md for exactly one re-spelling, on the phase-scoped reading its own prose supports. +- [Phase 12]: 12-05 (DOCS-07/D-15): docs/cross-os.md renders the discriminator at BOTH sites (the copy-pasteable config snippet and the verification fence), pinned by an EXACT occurrence count rather than a >= 1 floor -- a floor is as half-locking as the toContain it replaced, and the half it would have dropped is the verification fence, which is the only control an adopter has against a silently collapsed discriminator (T-12-09) +- [Phase 12]: 12-06: O4's verdict is recorded as PENDING -- live-CI, first run of the proving PR, because no such run exists: the branch's remote tip is still 38f9aea, the local tree is 55 unpushed commits ahead, there are zero open PRs, and the newest run in the repo is a schedule run on main at fe25a3f. The O4 section carries the pre-registration (1/2/1, total 4), the five anti-requirements and a six-step handover procedure instead of an observation. Opening the PR is a carried operator decision. +- [Phase 12]: 12-06: RESEARCH assumption A1 stays OPEN and is carried as a human-verify item. The hash-parity artifacts on the newest available run (30500255530) record command 'node -p process.platform' with stdout linux/win32 and stderr EMPTY on both legs -- that CALIBRATES the instrument (a per-leg discriminator block with command/stdout/stderr/status does exist and is readable) but it is the PRE-hardening command, so it does not close A1, which is about --no-warnings on linux/arm64. Closing it by inference was refused. +- [Phase 13]: 13-01: Phase 13's seven requirement IDs are registered as Pending, NOT marked complete -- 13-01 registers traceability only; the code that satisfies them lands in 13-02..13-06 (follows the Phase 12 lesson where a RED-only plan falsely closed three XOS rows). `requirements.mark-complete` deliberately NOT run. +- [Phase 13]: 13-01: No C19 control row for `CACHE_READ_ONLY`. The phase strictly REDUCES capability, opens no attack surface and adds no trust boundary, so it meets THREAT-MODEL.md's own criterion ("keep only what has no canonical home") for a Residual note and not for a control. The no-row decision is written INTO the bullet so the ledger's silence is not read as an omission. +- [Phase 13]: 13-01: REQUIREMENTS.md asserts 57 and ROADMAP.md asserts 51 BY DESIGN -- full defined set vs roadmapped subset. The gap is exactly six pre-existing non-roadmapped IDs (PARITY-06, PARITY-07, PARITY-08, VER-07, ROBUST-04, RETAIN-05). Any other difference means one of the two tables has drifted; verified mechanically at 13-01. +- [Phase 13]: 13-02: createActionsCacheBackend() is now { ...createReadOnlyActionsCacheBackend(), put } -- ONE get closure, ONE cache.restoreCache READ call site, so cache-version drift is unrepresentable rather than guarded (D-01). Both factories MUST stay in actions-cache-backend.ts: the ordered-member scan resolves its subject by NAME, so a sibling file makes it blind, not red. Read-only declared FIRST keeps the asserted array byte-identical, and it passed with ZERO edits. +- [Phase 13]: 13-02: VER-09 widens the @actions/cache importer scan from file to PACKAGE scope, matching the quoted specifier (deep subpaths included) on comment-stripped source -- five non-spec modules mention it in prose. Proven non-vacuous by MUTATION: a throwaway importer scored 1 failed / 30 passed, VER-09 red and both file-scoped clauses green. +- [Phase 13]: 13-02: put's lookupOnly probe stays a SECOND restoreCache on the write path only, with enableCrossOsArchive at the 5th positional -- probing at a different cache version reports absent for a present entry and every Windows write would answer a spurious 409. It does not violate D-01's one-read-site criterion. +- [Phase 13]: 13-03: CACHE_READ_ONLY is read INLINE with bare truthiness as selectBackend's LAST branch -- position is the narrowing guarantee, checked by first-occurrence order, not by comment +- [Phase 13]: 13-03: the knob name and the exact-string equality form it rejects are each written ONCE (in prose where needed), because mechanical greps on the same file are what guard them +- [Phase 13]: 13-04: land the contract guard's RED rather than pre-empt it -- Editing EXPECTED_ENV_KNOBS first and observing public-surface.spec.ts fail (1 failed | 13 passed) before hand-editing the inline literal is the whole point of the explicit-assertion-list idiom -- the failure IS the reviewable artifact a snapshot regen would hide. +- [Phase 13]: 13-04: disambiguate the NEW spec clause, not the old one -- The pre-existing writable-Actions clause matches /Actions-cache backend/i, which the new read-only row also contains. The fifth-outcome clause is line-scoped and requires the knob name, so only the new row satisfies it; the old clause stays loose because it is still correct for the outcome it names. +- [Phase 13]: 13-04: pin a documented COUNT against prose, never a row tally -- A tally re-derives the number from the same table it checks, so it agrees with itself while the sentence above the table lies. The clause asserts the prose sentence a reader actually reads; mutation-measured at 1 failed | 42 passed. +- [Phase 13]: 13-05: the three Windows reuse legs write CACHE_READ_ONLY=1 to $GITHUB_ENV from their REGULAR pre-set step (a background step's writes do not propagate) and gate their `[remote cache]` count at a floor of 1. The floor is gateable only because the legs cannot save: a read-only leg can get that label only by restoring the ubuntu producer's entry, so the gate is sound INDUCTIVELY rather than per-run. Compared with `-lt 1`, not `-eq 0`, to state the floor D-05 locked. Clause B matches the COMPARISON line, never a bare `exit 1` -- measured: with the gate step deleted the block still contains the readiness poll's `exit 1`. +- [Phase 13]: 13-06: pre-register the counts in a commit and then PUSH THAT COMMIT AS HEAD, so the proving run's `headSha` IS the prediction commit. The ordering is then structural and needs no clock -- a stronger anti-repudiation claim than Phase 12's timestamp comparison in `f5d03b0`. +- [Phase 13]: 13-06: the `Headline` table's verdict cells were written as forward references ("see the OBSERVATION section"), so appending the observation required no edit above the fold. The whole file diffs as additions only, which is a stronger property than "the pre-registration section is unchanged". +- [Phase 13]: 13-06: publish the INSTRUMENT CONVERSION, not just the number. The gate counts `-nx.log` and prints 1 / 2 / 1; the job log reads 3 / 4 / 3 because the runner echoes the gate step's body, which carries the literal twice (the `grep` needle and the `::error::` text). The pre-registration predicted +1 per leg from the OLD Record step and was WRONG by one more; the miss is recorded rather than smoothed over. +- [Phase 13]: 13-06: A1 is reported UNEXERCISED, not closed. A run where every pre-registered count is MET is exactly a run where no task executes, so no PUT is attempted and no 403 exists. Reading a clean log as "the 403 is quiet" would infer a property of a path the run never took -- the same shape as reading a MISS as evidence. +- [Phase 13]: 13-06: the gate's floor has a stated blind spot: `typecheck-windows` resolves two tasks, so a 2-to-1 drop clears the floor and stays green. The floor was kept (D-05: the counts follow Nx's task graph), and the per-target counts in 13-EVIDENCE.md are what make such a drop legible. ### Pending Todos @@ -181,6 +442,26 @@ None yet. | 260726-gok | Resolve the `typecheck` stale-cache false-pass and the consumer-doc defects in ONE PR (`--full --auto`). Closes BOTH open Deferred Items rows. **The CI fix is one token** -- `nx.json` `typecheck.inputs[0]` `production` -> `default` (`37f7d63`) -- plus `{workspaceRoot}/nx.json` in `test.inputs` and a new 5-assertion guard, all in the SAME commit because a guard reading `nx.json` without that wiring would replay a cached PASS (a target's `inputs` array and root `namedInputs` are NOT in the ProjectConfiguration hash). PROVEN BY DIFFERENTIAL, twice: a warm cache plus a real spec type error now exits 1 ("Found 2 errors.") where it previously exited 0 at `Cache: 2/2 hit (100%)`; and touching `tsconfig.spec.json` -- a SECOND, previously unreported instance of the same defect -- now re-runs `typecheck` where it previously replayed. MUTATION-TESTED by the verifier: reverting the token turns the guard red on exactly its two spec-hashing assertions, so the guard demonstrably can fail. Docs half: openssl -> node at all 5 sites (`e6430bf`), readiness poll + `timeout-minutes` with their reasoning (`3385cb7`), citation annotation + the now-false doc-lag comment corrected (`5f54049`), and a 5th defect the verification surfaced -- `docs/advanced.md`'s `&`-fallback snippet lacked `shell: bash` despite using `export`/`$(...)`/`&`/`$GITHUB_ENV`, so it would break on the Windows runner its own comment addresses (`58c6e82`). 433 -> 438 tests; full 8-command battery green at EVERY commit. Two upstream corrections worth keeping: the plan-check proved RESEARCH.md's recommended `expandSingleProjectInputs` THROWS on this inputs array (it rejects `dependencies: true` entries), so the shipped guard uses the `splitInputsIntoSelfAndDependencies` -> `extractPatternsFromFileSets` -> `filterUsingGlobPatterns` trio mirroring Nx's own `getTargetInputs`; and it caught that the guard's original non-vacuity control was itself VACUOUS (`filterUsingGlobPatterns` returns the whole input list on an empty pattern list, so every `toContain` would pass together on a resolver that resolved nothing) -- replaced with a negative assertion against `build`. The trap-quadrant UNRESOLVED item (the composite-`background:` claim) resolved as CORROBORATED, so it ships unchanged: had `--auto` locked "drop it", an accurate and now-citable statement would have been deleted. Verifier `passed`, 0 blocking, 4 advisory (1 fixed as `58c6e82`, 1 was this STATE.md hand-off, 2 documented limitations). | 2026-07-26 | 37f7d63..58c6e82 | Verified (passed) | [260726-gok-resolve-typecheck-stale-cache-false-pass](./quick/260726-gok-resolve-typecheck-stale-cache-false-pass/) | | 260726-4cc | Audit and triage Proposals 1-4 from the Windows-publish debug report, then apply what remains -- all four APPLIED at HIGH confidence, nothing dropped (Proposal 2 had a pre-committed DROP condition and survived it on evidence). Four bisect-safe atomic commits: `0b05d1e` `feat(publish)` adds `scanned` + `readMisses` to `PublishResult` and emits 5 summary rows with the miss row labelled `restore-MISS (of skipped)` (a strict SUBSET of `skipped` -- the miss branch increments both, so sibling rows would make every reader double-count); `55dfb87` `perf(publish)` dedups the enumeration to DISTINCT hashes (`listCacheEntries` returns one row per (key,version), so 12 dual-version keys per leg were restored twice); `98c13b9` `docs(advanced)` records the expected per-OS publish asymmetry; `cf91b42` `docs(pitfalls)` corrects Pitfall 7's stale zstd clause (both legs now provide zstd) and its dead `uploadHash` symbol. The all-miss gate predicate stays BYTE-IDENTICAL at all five commits -- research proved it already means "every DISTINCT hash missed" because the restore outcome is a pure function of the hash, so multiplicity cancels from both sides of the equality. Real RED before GREEN on both source tasks (Task 1: 10 failures + a `TS2353` never papered over with a cast; Task 2: exactly 1, its gate-invariance sibling passing on BOTH sides as the empirical confirmation of the proof). 430 -> 433 tests; full 8-command battery green at EVERY commit, not just the last. Verifier `passed`, 0 blocking gaps, 3 advisory -- it reconstructed both intermediate trees to re-observe the REDs independently, proved D3's rewrap reflow-only by whitespace-normalizing the whole file (`removed == ""`), and proved `check:action` structurally unable to drift in this range. Two deviations expanded on the plan and both hold up: D3 rewrapped one bullet (not one word of pre-existing prose altered) and D4 edited `PITFALLS.md:208` in Pitfall **9**, outside the plan's stated "all inside Pitfall 7" scope, because that bullet named the same dead symbol and the plan's OWN verify check demands `uploadHash` return nothing document-wide -- a genuine internal contradiction, resolved toward the clause carrying the intent. SURFACED, NOT FIXED (pre-existing, out of scope): `nx.json`'s `targetDefaults.typecheck.inputs` starts from `production`, which excludes `*.spec.ts` AND `tsconfig.spec.json`, yet the target runs `tsc --build tsconfig.json`, which DOES compile specs -- so a spec-only edit can serve a stale cache HIT with exit 0 while the replayed output itself contains "Found 1 error." Reproduced live on the reconstructed RED tree; nx's own flaky-task detector fires. Same false-pass class as T-06-03-02. | 2026-07-26 | 0b05d1e..cf91b42 | Verified (passed) | [260726-4cc-audit-and-triage-proposals-1-4-then-appl](./quick/260726-4cc-audit-and-triage-proposals-1-4-then-appl/) | | 260722-0od | Address the 27 upheld PR #3 multi-agent-review findings as 19 bisect-safe atomic commits (c0d1ebf..4c64aff): 409-on-ambiguous-write (F01), per-hash lock relocated to the backend (F02), 405+Allow (F19) + tightened 413 asserts, shutdown closeIdleConnections (F18), sidecar port fail-fast (F06), case-insensitive github.com host (F23), cleanup gate narrowed to schedule + warn-on-skip (F07/F08), versioning.md knob guard + anchored fixed-limit (F15/F24), 7-day retention floor + aggressive-retention opt-in (F09), resilient octokit retry/throttle pair (F04), oversized-entry count-not-abort (F13), read-back byte-compare via dogfood-body leaf (F05), PPE actionlint install/audit guards (F10), tarball dist-subtree exclusion + engines (F16/F25), tsconfig.action.json bundle typecheck + consumer-smoke-runs-committed-bundle (F12/F27), add-mask before $GITHUB_ENV (F17), four-branch selectBackend table + publish-concurrency docs (F11/F26), rationale comment corrections (F03/F21/F22; F20 moot). Task 3a (413-flush half of F14) resolved as a documented HTTP/1.1 limitation (ponytail ceiling comment, no behavior change, lead-approved) after a raw-socket repro proved the ECONNRESET is inherent and destroy-on-finish does not fix it. Plus a flake-hardening follow-up (timer-free serialization specs). 430 tests (up from 384); full local battery (fmt/build/typecheck/typecheck:action/test/fallow:ci/check:action/pack:check) all exit 0. Branch push HELD for the lead (outward-facing). | 2026-07-22 | c0d1ebf..cb2832d | Complete (push held for lead) | [260722-0od-address-pr-3-review-findings](./quick/260722-0od-address-pr-3-review-findings/) | +| 260726-pjz | Audit, triage, extract and deduplicate the custom `.planning/THREAT-MODEL.md` into canonical GSD artifacts. Eight duplicated/spent sections removed; the Nx-contract constraint MOVED to `PROJECT.md ## Constraints` (trimmed to the hard Nx 21+ floor plus a pointer, since all four of its facts were already verbatim in `research/STACK.md:16-40`); the C1-C18 CREEP control ledger RETAINED because GSD models security PER-PHASE only (a `` block in PLAN.md plus a per-phase SECURITY.md) and ships no project-level control register -- six falsification probes found none. Content shrank 41% by words (2676 -> 1589) and 43% by bytes (19611 -> 11140); the line count rose 91 -> 117 purely as a wrapping artifact, since the deleted Decision sections were 800-2000-char single lines. The 18 control rows are BYTE-IDENTICAL (sha256 `bb8cd951...d1ce2f`, independently hashed at both `fe25a3f` and the result). **Both plan-check rounds caught a FALSE-GREEN coverage gate, in opposite directions.** Iteration 1 would have silently deleted six orphaned items (the GHES anti-spoof cross-check and dormant version-gate knob, the read-time `content-sha256` integrity note and its explicit NOT-`sha256(blob)==hash` caveat, two YAGNI deferrals, the single-layer/no-backstop residual, and most of the bibliography) because one `git grep` term stood in for a twelve-item list, three terms matched shipped-requirements CHECKLIST TICKS rather than restatements, and two were bare `test -f` existence checks. Iteration 2 caught the inverse: two items were "proven homeless" only because the probes searched the ADR's OWN wording -- `no second knob` has six live homes including shipped code at `retention.ts:5`. Net resolution: 8 residue rows retained IN the ADR under `## Residual notes`, which is what "keep only what has no canonical home" literally requires; an eighth orphan (the Nx client's zip-slip/hardlink extraction hardening, security-adjacent, zero homes repo-wide) was recovered from the MOVE section where it would have been deleted unnoticed. Review cadence attached to the `## Key Decisions` row -- explicitly NOT `## Evolution`, which research proved INERT: `complete-milestone.md:293/298` carry hardcoded checklists and `## Evolution` has writers but no readers. Nine inbound references re-pointed off deleted Decision numbers, asserted as a sorted FILE SET after a +1/-1 swap was demonstrated to pass a count gate at 9 while violating the invariant. File not renamed DURING the quick task (out of scope by scope discipline, not merit) -- but renamed to `.planning/THREAT-MODEL.md` immediately afterwards on maintainer approval, once the file demonstrably held no decisions and the old name had become a misnomer; all 19 live references re-pointed, the ~41 archived ones deliberately left as sealed history. Note the rename gains nothing on `gsd health` W019, which trips identically under any name. `.planning/milestones/`, `.planning/spikes/005-cross-os-roundtrip/` and all of `packages/` verified untouched. RE-DERIVED 2026-08-08 by quick task `260808-lpt`, from executed probes rather than by restating this row; both `human_verification` items in `260726-pjz-VERIFICATION.md` were re-checked. **(a) CLOSED.** Residue item 4 (the git-native / build-artifact rejection rationale) -- the one labelled duplicate that violated the file's own criterion -- was REMOVED from the ADR in commit `83ac4fd` (`docs: close pjz follow-ups, rename the ADR to THREAT-MODEL.md, document inherited zip-slip protection`). All three of its distinctive tokens (`rejected outright`, `content-keyed`, `clean eviction`) now return a genuine no-match against `.planning/THREAT-MODEL.md` at exit 1, with a positive control at exit 0 on the same path. Its fuller treatment survives at `.planning/research/STACK.md:76,78` -- the Actions Artifacts Reject row and the git objects / refs Reject row. Those two line numbers are the resolvable form of the citation this row previously gave as `research/STACK.md:75,77`: no `research/` directory exists at the repo root, so that path resolved to nothing and its search and its positive control BOTH returned exit 1, which reads as absence rather than as a wrong path. **(b) PARTIALLY CLOSED, the remainder accepted as non-operative.** Of the four removed sentences' distinctive tokens, `raw-count` and `self-inflicted` now have homes (both in `.planning/THREAT-MODEL.md`; `self-inflicted` additionally in `10-02-SUMMARY.md` and `13-04-SUMMARY.md`), while `safety-weighted` and `re-populate` remain HOMELESS -- both exit 1 across `.planning` excluding `quick/` and `milestones/`, the same exclusion `260726-pjz-VERIFICATION.md` used, against a positive control at exit 0 on that pathspec. **FINDING, recorded rather than smoothed:** this row's own "8 residue rows retained" is wrong and was wrong when written. `## Residual notes` holds SEVEN bullets, and one of those seven postdates the pjz task by ONE WEEK (`45dd0f4` 2026-07-26 -> `7b45c38` 2026-08-02), so the count at the time the row was written was six. The description above is left as the sealed record of what was claimed. | 2026-07-26 | 4699232..27e2cb6 | Resolved (1 of 2 follow-ups closed) | [260726-pjz-audit-triage-extract-and-deduplicate-arc](./quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/) | +| 260801-vyy | Resolve CR-18 (PR #12 round-3 review): the three Windows cross-OS reuse legs were RECORDED, never GATED, so an `@actions/cache` bump breaking cross-OS restore left all three GREEN, `hash-parity` green (it compares hashes, not storage), and the one sound gate push-gated -- the bump PR merged with no signal anywhere. **Resolved by mechanism, not by gating the counts.** Gating `count >= 1` on the three legs is UNSOUND and was rejected: those legs write through a writable sidecar, so a broken restore makes them MISS, execute, and SAVE their own entry, and a re-run of the same commit then HITs it -- a gate a re-run can launder, which is worse than none because it reads as coverage. Instead the already-sound `dogfood-seed`/`dogfood-verify` pair was widened from push-only to same-repo pull requests: run-scoped key (`nx-cache-`), ubuntu-only seed, and a PROVENANCE assertion against the literal `'linux'` producer stamp (`action/index.ts:422`), so no prior or self-produced entry can satisfy it and a re-run fails again. **The push gate's stated reason was factually false** -- "the write gate trusts no other trigger (push/schedule only)" -- when `HOST_GATED_EVENTS` is `['pull_request','release']` and `isWriteTrusted` returns true for `pull_request` on github.com; this was the THIRD site on this branch carrying that same misconception after `fd75d83` (ci.yml) and `7e777b3` (README). **The recorded ungating rationale was also refuted by measurement**, not argument: it cited OBS-04's lesson, which is CROSS-RUN, but `needs: build|typecheck|test` makes these legs INTRA-run. Run `30717611910` is the proof -- round-3 rotated `test`'s hash, ubuntu `test` genuinely executed (4.4s, plus Nx's uncached-run nudge), and `test-windows` still recorded 1. The counts stay ungated, but now for the true reason. Two commits: `fee5fbe` (widen + 8 stale sites + trigger pin), `70fd31c` (close review findings). Code review found **1 CRITICAL** on the first pass and it is the ironic one -- a task to delete false comments INTRODUCED one, converting a true TIMING claim ("dogfood would not catch it *yet*", true while push-gated) into a false COVERAGE DENIAL, contradicted by `action/index.ts:431` and by a line eight rows below it. Also caught a false-GREEN in the new pin: `\s*$` under `/m` anchors to one line, so a YAML plain-scalar continuation (`&& false`) killed the gate with the clause green -- my own mutation proof had tested only a whole-line revert and missed it. Both fixed, plus 6 warnings (stale "push-only background" prose, `action/index.spec.ts` push-gated claims, a WRITES-exemplar citing a job that no longer carries the gate, the concurrency rationale, and `on: pull_request` now pinned so deleting it cannot silently reopen CR-18). Every guard mutation-tested in BOTH directions with byte-identical restores, three of them re-run independently by the orchestrator. 940 -> 943 tests. Verifier `passed` 6/6, having traced the signal path end to end rather than trusting SUMMARY. Coverage boundary stated rather than over-claimed: this closes the STORAGE half of O4 on PRs; the sidecar-bundle path is covered by `action-bundle-drift` and the hash half by `hash-parity`, both confirmed to carry no job-level `if:`. Follow-up recorded, not done: a read-only Actions-cache backend (the `ReadableBackend` seam already exists) would make the three legs' counts soundly gateable. | 2026-08-01 | fee5fbe..70fd31c | Verified (passed) | [260801-vyy-resolve-cr-18](./quick/260801-vyy-resolve-cr-18/) | +| 260802-toz | Close the two items Phase 13 finished with everything except a live observation -- **Case B** (a restore from the default-branch scope) and **RESEARCH assumption A1** (the 403 path) -- via a maintainer-authorised TEMPORARY push of the Phase 13 tip to `main`, since restored to `fe25a3f`. **Case B is PROVEN** (run `30768540898`): all three ubuntu producers HIT their baseline keys and emitted ZERO `Sent` bytes, so nothing was written that run and no same-run merge-ref entry existed to restore from, while the three Windows legs restored those baseline keys BYTE FOR BYTE (137951 / 98227 / 1299) at counts 1 / 2 / 1. Narrowed honestly and pre-registered as such: it does not separate base-branch from default-branch scope (for a PR off `main` they are one ref), but the load-bearing claim -- restored from a scope populated BEFORE the run -- is proven. **A1 is ANSWERED, not closed** (run `30768554184`): the partial miss was achieved as predicted (typecheck-windows count 1, GREEN, floor cleared, build/test unaffected at 1/1 as same-run control) and a task provably EXECUTED (`Nx read the output ... for 1 out of 2` against the Case-B control `2 out of 2`), with ZERO 403 tokens and no store-failure wording on the leg -- so the refused store generates no log noise. It stays open because absence of noise cannot separate `PUT attempted and silently refused` from `no PUT attempted`; `server.ts:129` answers with an empty body and logs nothing, so neither world leaves a trace. Closing it needs the sidecar to record refused PUTs. **UNPLANNED, merge-blocking: `publish-verify` is BROKEN on this branch.** Run `30767511870` was 24 success / 2 failure, both `publish-verify` legs at the round-trip read-back; it succeeded on all five prior `main` pushes and this branch changes that code by 1888 insertions. NO PR run could ever have caught it -- the job is push-gated and structurally skipped on every pull request, the v0.0.1 retrospective lesson repeating. Measured: the August shard holds ZERO assets, every asset upload returned 422 and was swallowed as benign, and the seed asset is unique per run so its 422 cannot mean already-exists. Mechanism NOT established; the month-boundary hypothesis is unconfirmed (the shard predates the run). **Second unplanned finding: the O3 witness is not Case-B-safe** -- it asserts a CREATION ordering, and on a Case-B run nothing is created, so the first real post-merge PR touching no declared input will redden it as a false red. PR #12 was CLOSED FIRST so the push could not retroactively mark it merged (verified `mergedAt=null` after both the push and the restore) and is replaced by **PR #16**, which is BLOCKED. `gsd-plan-checker` caught that an Nx MISS-and-SAVE does not print its key, so the plan baseline extraction could never satisfy its own GO condition and the one key it could extract was a failed-reserve race artifact for a DIFFERENT task -- a silent mis-attribution that would have sat under the whole Case-B claim. | 2026-08-02 | 095291c..bb5e1f7 | Complete (Case B proven; A1 answered) | [260802-toz-close-case-b-and-research-assumption-a1-](./quick/260802-toz-close-case-b-and-research-assumption-a1-/) | +| 260803-0rr | Close **RESEARCH assumption A1** -- which `260802-toz` left ANSWERED-but-not-closed, because absence of noise cannot separate "PUT attempted and silently refused" from "no PUT attempted" -- and propagate the closure into every artifact still calling it OPEN. **A1 is ANSWERED AFFIRMATIVELY**: Nx DOES attempt a store after a MISS (four PUTs across two runs, one per executed task), the read-only backend refuses each `403`, and Nx swallows it in COMPLETE silence -- the definitive run's full 30-line output has zero occurrences of `403`, `forbidden`, `refus`, `store`, `fail`, `could not`, `unable`, `error` or `warn`, and the build succeeds. **Answered LOCALLY with no source change and no CI cycle.** `gsd-planner` DISSOLVED the orchestrator's design (instrument `server.ts` with `core.debug()` + throwaway CI branch + `gh run rerun --debug`) and was right twice: the instrumentation would have been an architectural defect, because `server.ts` has zero `@actions/*` imports by design and backs `createCacheServer`, the ENTIRE `EXPECTED_VALUE_EXPORTS`, so it would have made the package's only public value export transitively depend on an Actions-only package; and CI was never necessary, because `server.ts:128-133` returns the 403 BEFORE `handlePut`, so no backend method runs on a refused PUT and the backend's identity provably cannot affect the PUT path -- making the existing `createReadOnlyMemoryBackend` exactly equivalent, and PUT-maximal besides (permanently empty, so every task must execute). **Scope stated, not blurred:** what transfers to CI is the narrow client-side property *given a 403 to a store, this pinned Nx emits no output*; no CI run has directly observed a PUT arriving, so the silence on run `30768554184` is now EXPLAINED, not measured. **A vacuous run fired and was caught only by counting requests** -- one harness run reported a clean "Nx said nothing" while adding ZERO tap lines, served entirely from local cache so it never contacted the instrument; rejected. Root cause: **`NX_CACHE_DIRECTORY` is NOT honoured by Nx 23.1.0 here** (a verifiably empty directory still produced `[local cache]` 2/2), so coldness was forced by ROTATING THE HASH instead. **Propagated to FIVE surfaces, not the two the plan named** -- plan-checker B5 found three more, and left silent the repo would have asserted A1 both CLOSED and OPEN across two audit artifacts of the same phase: `13-EVIDENCE.md` ADDENDUM 3 (append-only held -- 100 insertions, **0 deletions**, verified with a revision range), `13-VALIDATION.md` (decision row + both Manual-Only rows + a post-approval note under the intact dated sign-off), `13-SECURITY.md` (**declared FROZEN in writing** -- a signed-off snapshot at `80f3066` whose OPEN items were true of the tree it audited, superseded forward rather than rewritten), `13-RESEARCH.md` (the stale "Confirm on the landing run" instruction replaced; A2 also confirmed HELD), and `ROADMAP.md`. `13-VERIFICATION.md` untouched by instruction. **ROADMAP item 1 was stale too**, not just item 2: XOS-09's gate closed on the phase's own landing run and nothing said so; both `**Live-CI close**` items are now recorded closed with their runs. **B4 discharged** -- the plan's `git diff --numstat -- ` has no revision range, so it compares worktree to index and is VACUOUS once committed; replaced with `..HEAD`. **No standing guard added, deliberately**: the durable half (PUT -> 403) is already covered at `server.spec.ts:359`, and the new half is third-party Nx behaviour that would redden on an unrelated bump. **The finding that outlived the task:** A1's residual had been latent since Phase 10 -- `10-EVIDENCE-PRE-RENAME.md:88` stood on this exact seam with a control row reading "no PUT-to-read-only-backend crash", recording only that nothing crashed, never whether a PUT arrived; it read as coverage for three phases. | 2026-08-03 | 3a46db9..HEAD | Complete (A1 answered and propagated) | [260803-0rr-address-a1-so-that-it-can-be-closed](./quick/260803-0rr-address-a1-so-that-it-can-be-closed/) | + +| 260803-3g1 | Resolve the remaining blockers and defects toward a fully-verified v0.0.2. **The defect was a CLASS, not an instance: branching on an HTTP status without reading the response body.** `e96670e` fixed one instance and its commit body explicitly cleared the sibling by reasoning -- *"the `ensureShardRelease` 422 branch is untouched, that one is a genuine race"* -- and that sibling became the next production blocker on the very next run. **B1+B2:** `ensureShardRelease` treated ANY `createRelease` 422 as "another matrix leg won the race" and re-GET, with the re-GET UNGUARDED, so a non-`already_exists` 422 made it 404 and kill the job. Falsified by measurement on run `30773689490`: afterwards there was no release, no tag ref (exit 1, `cache-mirror-202607` as passing positive control) and no draft, so nothing existed for a race to have created. Now fails CLOSED -- an unreadable, missing or malformed body counts as a FAULT, never a benign skip -- and **surfaces `errors[].message` as well as `code`**, because policy rejections arrive as `code: "custom"` with the diagnostic in the message and a code-only reader would have burned a scarce production window printing "code custom". **The burned-name scare was FALSIFIED, not inherited:** research read GitHub's *"you can delete the tag, but you cannot reuse the same tag name"* and concluded the August shard name was dead; a maintainer-authorized draft probe recreated `cache-mirror-202608` successfully (201, `immutable:false`), and since the documented ban carries no draft exception that falsifies it outright. Two candidates survive for the original 422 and NEITHER is assumed -- a transient window right after the delete (8 min vs the probe's 41), or something specific to the non-draft path -- they get read off the next run, which is exactly what the fix makes possible. **D1:** `o3-witness` asserts a CREATION ordering that has no event to observe when every producer HITs, so the first post-merge no-input PR would redden it FALSELY; the planner then caught what the research missed -- widening the jq `select` alone is INERT while `&ref=` stays on the caches URL, since a server-side narrow cannot return the base-scope row. **D2:** `docs/versioning.md` named 4 of 6 type exports; the doc is fixed AND the guard gap closed, because `docs-adoption.spec.ts` pinned only the env-knob group, which is why the drift survived. **The Q2 defect-class sweep is the real deliverable** -- nine sites tabled with a written justification each, re-derived independently by the verifier. `cleanup.ts:137` is FLAGGED and deliberately not fixed (a DELETE 404 counted as `pruned`, so a 404-masking-403 regression exits green having deleted nothing): a DELETE 404 carries no `errors[]` array, so the read-the-body fix has no analogue there and the right answer is an outcome-shape tripwire, which is a measurement not a commit. **Two vacuity traps caught in flight:** the plan's own M4 regex passed against a mutated workflow because its non-greedy gap walked past the closing `fi` to a distant `exit 1` (rewritten, bounded, paired with a `not.toMatch`), and the executor corrected an overclaim -- `matched_ref` is the provenance of the lower bound, not "Case A versus Case B". 983 -> 995 tests. Verifier `passed` 7/7 having REPRODUCED three RED claims from scratch rather than accepting them. | 2026-08-03 | 3faab10..ccc18c9 | Verified (7/7) | [260803-3g1-resolve-remaining-blockers-and-defects-u](./quick/260803-3g1-resolve-remaining-blockers-and-defects-u/) | + +| 260803-fcd | Close the `publish-verify` blocker for good, in two phases whose ORDER was load-bearing. **PHASE A -- the burned-name skip.** `ensureShardRelease` now treats one specific `createRelease` 422 -- GitHub reporting `tag_name was used by an immutable release` -- as a loud non-fatal skip instead of failing the job; every other 422 stays fatal, including the `pre_receive` ruleset decoy, which is excluded both structurally (wrong `field`) and textually (no substring match). **PHASE B -- the namespace.** `SHARD_TAG_PREFIX` `cache-mirror-` -> `nx-cache-`, so shards are `nx-cache-YYYYMM`. **PHASE C** -- the legacy `cache-mirror-202607` release and tag deleted after Phase B verified green. **The maintainer caught the sequencing and the orchestrator had it backwards:** `cache-mirror-202608` was burned RIGHT NOW and `shardTag()` still returned it, making the burn a PERISHABLE TEST FIXTURE -- rename first and `nx-cache-202608` is fresh, so the skip path becomes unexercisable and Phase A would have shipped a guard seen only in a mock. Because the commits stayed separate, Window A could push `1e5bc10` ALONE (old prefix + skip) while Window B pushed the renamed tip. **Research prevented three sinkings.** (1) `faultReason().message` returns the FIRST `errors[]` entry carrying a message, which on the measured payload is the `pre_receive` DECOY -- so the natural guard `reason.message?.includes('immutable release')` COULD NEVER FIRE; a field-scoped accessor was required, and Phase A's RED printed the decoy text as proof rather than argument. (2) A one-shot sentinel was needed because the lazy shard resolve re-runs per hash -- without it one leg emits ~32 identical warnings; the live run emitted exactly ONE per leg. (3) Phase B's real hazard was a GREEN suite, not a red one: two spec blocks (`retention.spec.ts:44-55`, `cleanup.spec.ts:140-155`) would have gone SILENTLY VACUOUS, their negative fixtures rejected on a PREFIX mismatch instead of the `\d{6}` suffix check they exist to test -- predicted, then OBSERVED (both trap blocks stayed green through the Phase B RED run), then closed by rebasing the fixtures and proving them with a loose-prefix mutation that reddened all seven plus the cleanup clause while `v1.0.0` correctly stayed green. **The collision objection was RAISED AND RETRACTED:** `isServerProducedKey('nx-cache-202608')` is true (`202608` is valid hex) and IRRELEVANT -- `isServerProducedKey` is applied only to Actions-cache keys and `isShardTag` only to release tags, two disjoint keyspaces, and `release-asset-name.ts:101` already documents that the identical-bodied filters are deliberately NOT aliased. The maintainer's rationale is the durable one: a future READ-WRITE Releases backend would make "mirror" a permanent misnomer. `CACHE_MIRROR_MAX_AGE_DAYS` deliberately NOT renamed -- a published consumer contract. **Two live windows.** Window A (run `30803953260`, `1e5bc10`): `publish` GREEN both legs with one warning each quoting GitHub verbatim, `publish-verify` RED -- the shape pre-registered before the result, since a skipped shard mirrors nothing. Window B (run `30807461616`, `70064f5`): **FULL GREEN, zero failed jobs** -- both `publish`, BOTH `publish-verify`, `o3-witness` and `format-check`, with a fresh `nx-cache-202608` shard holding 69 assets. **One regression the orchestrator caused:** `format-check` reddened on `octokit-fault-reason.ts` because the executor brief omitted `format:check`; isolated by bisection (green at `6992553`, red at `1e5bc10`), fixed in `70064f5`, cosmetic. Reusable gotcha: `--skip-nx-cache` must precede `--` or vitest eats it and dies with `CACError: Unknown option --skipNxCache`. 1011 tests, `tsc --noEmit` rc=0. | 2026-08-03 | 1e5bc10..70064f5 | Verified (live, full green) | [260803-fcd-implement-option-2-but-also-change-the-t](./quick/260803-fcd-implement-option-2-but-also-change-the-t/) | +| 260803-mew | Observe BOTH directions of the Phase 12 Windows regression detector on a real `windows-11-arm` runner. **The request named the FAIL half; the entry check found the PASS half had gone stale too, and that widened the scope before any work started.** The detector gates on a literal needle rather than an exit code, because `nx run-many` exits 0 when a target resolves no task and Nx FILTERS the printed target list down to the targets that actually ran. Its FAIL half had only ever been measured on the maintainer's workstation (`12-03-SUMMARY.md:100,241`), while Phase 13's comparable XOS-09 gate closed BOTH directions live -- so Phase 12 was the weaker of the two. **And the one PASS observation on record could no longer speak to the gate at HEAD:** `9e79009` (2026-08-01) added `lint`, making the needle FOUR-target, whereas run `30603713356` ran at `e757d4c` (07-31) and proves the THREE-target line -- `git merge-base --is-ancestor 9e79009 e757d4c` is false. So the gate as it exists had never run on a real runner in EITHER direction. **Research overturned the plan's central move.** The obvious mutation -- drop `lint` -- would have VOIDED the proof: `nx.json:71` declares the workflow file a `test` target input and `windows-regression-detector.spec.ts:154` pins the four-target `-t` line against the file on disk, so any mutation retaining `test` fails its OWN spec, reddens `nx`, and proves the exit code instead of the needle. Dropping `test` instead (`-t build typecheck lint`) also proves Nx filters an INTERIOR element rather than truncating the tail. **Research also found what the PRIOR window never actually established:** whether the workflow BODY comes from the dispatched ref or from `main`. That window's two candidate blobs were byte-identical (`5a7d1962...` on both), so it proved only that the CHECKOUT came from the ref. Fixed for free by ALSO mutating the step NAME, making the jobs REST payload self-authenticating. **The plan-checker caught a self-contradiction that justified the whole `--full` pipeline:** the plan's own `` gates hard-coded happy-path literals (`Process completed with exit code 1`, the three-target Nx line), so under its OWN documented A1-falsified and A3-fallback branches an HONEST report would have FAILED verification -- exactly the pressure to fabricate that its `must_haves` truth #7 forbids. Replaced with a four-line verdict contract asserting the ENUMERATION (`CLOSED|FALSIFIED|VOID|PENDING`), never a particular outcome, with the A1 discriminator anchored to the verdict line so a NEGATED sentence cannot satisfy it. Checker also found a sixth stale closure claim (`12-VALIDATION.md:107`) and a gate literal that matched nothing (`Run 30603713356` -- the real text carries a backtick, and the two sites differ in CASE). **Both directions observed, and the FAIL half ATTRIBUTED rather than merely red** -- the crux, since `nx` and the `grep` share ONE step so a red step conclusion cannot by itself tell a failed `grep` from a failed `nx`. Three log facts separate them: the plural ` NX Successfully ran targets build, typecheck, lint for project` as genuine Nx output (with `set -e` + `pipefail`, a non-zero `nx` could never have reached the `grep`); the four-target needle occurring EXACTLY ONCE in the whole log and only as the ANSI-bracketed echoed command; and `##[error]Process completed with exit code 1.` stamped 4.5s after Nx's summary. `@op-nx/github-cache:test` never ran. A1 CLOSED by measurement: RED step 5 carries ` -- THROWAWAY 3-of-4 MUTATION`, GREEN's does not, and the suffix exists nowhere at HEAD. A3 closed as a bonus -- `lint` had never run on `windows-11-arm` before. **The `main` window was operator-authorised with the force-push named verbatim, and closed clean:** `main` back at `fe25a3f`, detector 404 there, backup ref and throwaway branch both deleted, working tree and index never touched (all plumbing via a `GIT_INDEX_FILE` outside the repo). **The verifier found what the executor did not disclose:** the STEP 7 restore force-push fires a full `ci.yml` run (`30825636788`, both `publish` legs RED on real production writes) because `[skip ci]` is structurally unavailable to it -- the restore re-pushes an EXISTING commit whose message cannot be changed. Pre-existing and already-tracked, no new regression, and outside this task's `must_haves` -- but it is a property of the shared `main`-window PROCEDURE and the five stale `refs/backups/*` on origin suggest five prior undisclosed occurrences. Carried as the sole `human_needed` item. Two honest deviations recorded: Task 1's `git status` gate failed on the orchestrator's own uncommitted PLAN.md revision, and restore check (iii) aborts as literally written because `refs/backups/*` is a remote namespace no local refspec fetches (fetched into `FETCH_HEAD` instead). 1011 tests, four-target needle green locally. | 2026-08-03 | 280c12c..HEAD | Verified (7/7, 1 human_needed) | [260803-mew-observe-phase-12-fail-half-on-real-run](./quick/260803-mew-observe-phase-12-fail-half-on-real-run/) | +| 260804-h3b | Fix `o3-witness` Case-B. **The entry check dissolved the task as stated: there was nothing to fix.** The ROADMAP follow-up calling it open (`da462b5`, 08-03 01:09) PREDATES both its fixes -- `40e4d21` (03:13, dropped the server-side `&ref=` narrow) and `e5d3cd3` (22:27, admitted the default-branch scope) -- proven by `git merge-base --is-ancestor` in both directions, with `ci.yml:1456-1461` already stating a Case-B delta of hours is STRONGER evidence and forbidding an upper bound, and 24 assertions already pinning all three halves. The note was simply never retired, and the milestone audit written 50 minutes earlier had repeated it as open follow-up #1. **So the deliverable became retire-plus-OBSERVE, and the observation split into two sub-claims with different costs -- reported separately on purpose, because (a) passing is not (b) proven.** (a) THE PRIOR-EXISTENCE DELTA ALLOWANCE, free and needing no window: a `.planning/`-only push to PR #16 rotates nothing, so the ubuntu `integration` leg HIT the previous run's entry and the witness matched an entry created 2h40m earlier -- run `30907575624`, `delta=9596s matched_ref=refs/pull/16/merge`, `EXISTENCE OK`, and its `headSha` EQUALS its own pre-registration commit `d4dc093`, so the prediction was provably in the tree the run measured. (b) THE `$defaultref` CLAUSE `e5d3cd3` ADDED, which needed the window: run `30910935382` on stacked draft PR #17, `delta=1252s matched_ref=refs/heads/main`. **The research overturned the plan's central move here** -- with base = `main`, `base_ref == default_ref` and the `$baseref` arm matches FIRST (`ci.yml:1255-1257` names that duplication as "the reason the gap stayed invisible"), so the probe had to be STACKED on the feature branch via `gh stack link` to make `$defaultref` the only satisfiable arm. And it is ATTRIBUTABLE rather than merely printed: both other arms were MEASURED empty at observation time -- the probe's own merge ref held only its run-id seed, and the base scope returned `total_count: 0`, stronger than pre-registered. Outcome (ii) was excluded by measurement too (the hash did not rotate; the ubuntu leg HIT). **The plan-checker earned the `--full` pipeline twice over.** It caught that the append-only gate FAILED BY CONSTRUCTION -- `N=$(wc -l < "$T")` froze task 1's whole file including the four `PENDING` verdict tokens task 2 was instructed to update, so `cmp` would exit 1 and kill task 2 before the window was ever reached (reproduced in a throwaway repo, boundary fixed with an `awk` marker scan truncating BOTH sides; NOT the `260803-0rr` missing-range defect -- the range was correct, the boundary was not). It also caught that STEP 5 could halt with `main` ADVANCED, since only STEP 6 carried "restore anyway" while the trailing blanket said STOP -- fixed with a UNCONDITIONAL RULE paragraph that overrides every other instruction once STEP 4 pushes. And it retracted a discriminator describing an UNREACHABLE outcome: `matched_ref=refs/pull/16/merge` can never print for a different-numbered PR because `ci.yml:1366-1367` binds jq only to that run's own three refs -- caught before it froze into append-only evidence. **The window was open 3m30s** (12:32:08Z-12:35:38Z) because Q5 measured that cache entries SURVIVE a ref rewind (90 main-scope rows outlived the 08-03 rewind), so the probe PR was created AFTER the restore rather than during. Restore verified three ways -- remote SHA at `fe25a3f`, `docs/cross-os.md` 404 on `main` whose 200 positive control was taken DURING the window (which is what makes the 404 evidence rather than an always-404 probe), empty diff against the backup ref -- and only then the backup deleted. Advance run FULL GREEN 26/26, writing real Release assets as authorised; the restore run failed on exactly the two `publish` legs at the burned `cache-mirror-202608` tag, uploading nothing, recorded as AUGUST-BOUNDED rather than as a property. `gh stack link` succeeded at exit 0, so the exit-9 public-preview-rollout fallback went unused. Teardown in-task: stack 0, PR #17 CLOSED never merged, no `obs/*`, five pre-existing backup refs untouched. **A line number had moved, exactly the defect class `2df3af5` exists for** -- CONTEXT.md cited `:806` for the `matched_ref` guard, which is the `it()` line; the assertion is `:816`, re-derived at commit time. Five deviations recorded, the material one being that the audit's frontmatter YAML scalar is a single-line string so it could not be preserved byte-for-byte (every original word survives inside the new scalar; the ROADMAP paragraph IS byte-identical, zero removed lines). **One documentation defect found and deliberately LEFT:** both runs demonstrate `ci.yml:1004`'s "the entry this leg's own task just saved" is narrower than its code's real invariant ("restored OR saved") -- neither leg saved anything, yet both positive controls returned 200. Not fixed because `ci.yml` is a declared `test` input, so touching it rotates the hash and breaks the pre-registered shape. Nothing outside `.planning/` changed; 24 `o3-witness` assertions unchanged. | 2026-08-04 | d4dc093..HEAD | Verified (8/8) | [260804-h3b-fix-o3-witness-case-b](./quick/260804-h3b-fix-o3-witness-case-b/) | +| 260804-lc3 | Close the `ci.yml` positive-control defect `260804-h3b` found and deliberately left. **The report named ONE line; the fix landed at EIGHT sites, because the premise was shared.** Two INDEPENDENT defects, not one. (1) The PRESENCE-MECHANISM claim -- the block said the key is present because the leg's own task just SAVED it, but the key resolves through an UNCONDITIONAL `cache.restoreCache` on the GET path that is not save-conditioned, so the real invariant is RESTORED **OR** SAVED. (2) A SELF-CONTRADICTION inside the same block -- it said a 404 means a dead sidecar, while the same block records that a dead sidecar yields `000` (curl exit 7 swallowed by `|| true`) and the readiness poll treats 404 as its own PROOF-OF-LIFE signature. A 404 proves the sidecar ANSWERED; the real masquerade agent is `handleGet`'s catch in `server.ts` degrading EVERY backend fault to a 404 (SRV-05). Five `ci.yml` sites carried defect (1), two carried defect (2), and row D's own docstring in `docs-same-os-claims.spec.ts` carried BOTH -- in a LIVE guard file, which is why it could not be deferred. **The plan's top constraint was that a freshly-asserted WRONG claim is worse than the inherited defect**, since the inherited one at least predates the evidence: the presence disjunction is MEASURED (runs `30907575624`, `30910935382`; four leg-observations, all HIT, all controls 200), but the failure-implication correction is REASONED FROM CODE and this control's FAIL direction has NEVER been observed. Both halves are marked as such in the shipped prose (`THE DISJUNCTION IS MEASURED` with both run IDs; `NEVER OBSERVED` scoped to every claim about what a non-200 would mean). **Plan-check iteration 2 caught the defect that would have hollowed the whole task.** The `SRV-05 >= 2` gate is a CARDINALITY gate, and cardinality cannot localize: an executor could name `SRV-05` twice inside PART A alone -- which the plan's own instructions invited -- then satisfy the escape hatch by DELETING its false clause, since dropping the trailing `, a dead sidecar masquerading as a cache MISS` leaves `...on the one failure it exists to detect.` grammatical, complete, and replacement-free, with every gate green. That is the DELETION IS NOT CORRECTION failure the plan names and claimed to have closed, at the one site with no literal of its own. Fixed with a site-unique `ADMITS THE DEGRADATION` literal, and the `` overclaim rewritten to state only what a count proves. The same pass caught an execution-HALTING instruction that was measurably false (PRE-FLIGHT expected SIX lines from `own save|just saved`; it returns FIVE -- `:450` reads "just **wrote**" and matches neither alternative, so the executor would have halted on a false alarm or widened the regex into contradicting its own absence gate), plus two claims with no gate behind them (`forbidden: []`, and the docstring's replacement reasoning, which every other spec gate could satisfy from the `required` ARRAY alone because `git grep` cannot tell an array entry from a docstring). **Verified 24/24 by tracing the shipped prose to source rather than to the plan**, and by re-pulling the four integration job logs from both runs: `cache.restoreCache` is the first statement in the `try` with no branch; `handleGet`'s catch is bare (`catch { res.statusCode = 404 }`), so "EVERY backend fault" is literal; `selectBackend` is called ONCE at startup; `read-integration-hash.mjs` RECORDS `cacheStatus` and explicitly refuses to gate it, which is what makes this control the only thing in the job that hard-fails on a broken read path. Zero behaviour change -- acceptance set still 200 ALONE, no-retry-loop intact, placement unchanged, `ci.yml` diff comment-only by an `awk` filter over `diff -U0`. `:450` byte-identical; both legitimate `dead sidecar` claims survive. **The `test` hash rotation was accepted, not worked around, and it paid**: the first post-edit battery reported `Cache: 0/4 hit (0%)`, so row D's two new phrases were proven against a genuinely re-run RAW `toContain` rather than a replayed PASS. **The `rg` undercount trap fired twice more, including on the handoff's own mitigation.** `dead sidecar` is FOUR occurrences, not three: `:1070-1071` wraps across a `#` continuation, so even `rg -U -i 'dead\s+sidecar'` returns 3 with exit 0 -- `\s+` cannot bridge a wrap whose continuation line carries a `#`. The pattern that measures correctly is `dead[\s#]+sidecar`. The undercount reached the SUMMARY (corrected post-verification) but never the shipped prose. Two deviations: the plan's PART D battery command does not parse (`npm exec` eats the flags before `nx` sees them -- `Missing required argument: targets`; the `--` form works and the comma form is what `nx run-many --help` documents), recorded as a PLAN defect rather than a local workaround; and the executor `--amend`ed its own commit to drop a line-distance ("eighty lines later" measured as 92) rather than update it, on the grounds that a rotting line-distance is the same defect class as the `file.ext:NN` citations `2df3af5` exists to prevent. Two items carried EXPLICITLY OPEN and never closed: the control's FAIL direction has never been observed, and fork PR behaviour stays CITED-never-reproduced. `REQUIREMENTS.md` and `ROADMAP.md` deliberately NOT edited -- both already say `a known-present key` and attribute nothing, so **XOS-03 never asserted the narrow premise**; it was narrowed at implementation time, and this correction RESTORES the requirement's own wording rather than revising it. | 2026-08-04 | abb722d | Verified (24/24, 0 gaps) | [260804-lc3-close-the-ci-yml-positive-control-just-s](./quick/260804-lc3-close-the-ci-yml-positive-control-just-s/) | +| 260808-lpt | Fix the v0.0.2 milestone audit's bookkeeping debt -- the stale markers that would otherwise freeze into the sealed milestone archive. **The audit's own item list caught 6 of 20 instances of ONE defect class**, and that undercount is the more interesting result: item 7 said "all five Phase 13 plan checkboxes" when there are SIX, it omitted Phases 10 and 11 entirely (fourteen more boxes of the identical class), and it counted Phase 10 as seven rows when it has EIGHT because `10-01` was already ticked. All nine Phase 8 requirement IDs closed against `08-VERIFICATION.md`'s OWN Requirements Coverage rows with the milestone audit FORBIDDEN as the evidence source (it is hearsay for exactly these nine) and nine separate quotes in the SUMMARY -- the verifier diffed all nine against their source rows and found them verbatim, not paraphrase, which is the one thing no gate can prove. All nine rendered SATISFIED, so the "leave it unticked" hazard rule never fired. Twelve stale traceability cells reconciled, the Phase 8 ID shift corrected in five rows, and the roadmapped-subset arithmetic moved 51 -> 53 with the documented difference SIX -> FOUR across a 6-site self-checking invariant spanning two files -- fixing one number alone would have turned the invariant into the drift it warns against. The verifier re-derived the four difference IDs by computing the set difference rather than reading the claim, and confirmed the HISTORICAL `Total 44 -> 51` survived un-swept. **Planning cost far exceeded execution: three plan-check iterations plus a full aggregate re-derivation over ~95 gate assertions.** Iteration 3 found two blockers in the REVISION itself -- a verification baseline that had rotted TWICE inside the plan's own lifetime (the pause commit landed inside the range, and correcting it to a literal SHA would have rotted a third time on the next commit; now captured at execution start, a relative range having been rejected because it would always show three subjects and assert nothing), and a blanket "every absence gate is paired with a presence gate" claim that measured FALSE. The blanket form of that same claim had already been deleted from this plan once. The aggregate sweep re-derived all 66 counts instead of the two the handoff named and found six wrong numbers it never mentioned, one repeated verbatim at two sites so a half-applied fix was live. Two figures were ATTRIBUTED rather than replaced: the audit's "30 REQ-IDs" matches neither its own scope nor either table and Task 2's edit moves the underlying figure again, and the checkbox census was exactly right at `c84ebd5` and already wrong at `ff21356`, the entire delta being this task's own transient handoff artifacts. **Three discrepancies found against the plan during execution**, none changing the outcome: the honesty clause spanned a line wrap at both sites so a gate FAILED on text that was present (the plan's own documented dead-literal trap, firing at execution rather than authoring); Phase 13 has no `**Plans**:` line so corroboration came from the Progress row; and `` item 4 is UNSATISFIABLE by construction -- T1/T2 scope gates exclude `.planning/STATE.md` while T3 requires it modified-and-unstaged, so no tree state satisfies all three at once. Both scope gates passed at their own task boundary, which is the only point they mean anything, and the verifier re-ran all three scripts byte-identical to the authored versions to confirm no gate had been weakened. **The verifier returned `gaps_found` on ONE item, and it was this task's own defect class inside this task's own output** (`142ea72`): the pjz OPEN clause asserted AS MEASURED that a Residual-notes bullet postdates pjz by about five weeks, when `45dd0f4` 2026-07-26 -> `7b45c38` 2026-08-02 is ONE WEEK -- five weeks lands on 2026-08-30, a future date, so the figure was unreachable rather than merely wrong. The dependent conclusion (the count was six when the row was written) is correct and independently confirmed; only the magnitude was false. It came from the PLAN, which stated it as measured -- the one probe carried forward as text instead of executed -- and then propagated into the SUMMARY twice and into this row. Corrected at five sites across four files, each now carrying both commit SHAs so the interval is re-derivable rather than assertable. **The correction sweep then hit the same trap it was fixing:** the phrase search failed in the plan and succeeded everywhere else because there it spans a line wrap, so a three-of-four sweep would have read as complete. Probe 1's expected mismatch confirmed and NOT smoothed: `## Residual notes` holds SEVEN bullets against the row's claimed eight, and the row was wrong when written; the description is left as the sealed record with the finding beside it. One box stays unticked by design (the v0.0.2 milestone rollup, owned by `/gsd:complete-milestone`), asserted positively so it can be neither ticked nor deleted. The out-of-scope checkbox count is recorded QUALITATIVELY in the sealed audit with the literal sentence "No count is recorded here deliberately", because a precise count in a sealed artifact rots -- which is what produced these eight debt items. Limits stated rather than omitted: G8 cannot prove the quotes are faithful and no grep can (so the verifier did it by hand); T3's G7 covers five of seven audit-note components; G5c cannot distinguish the pjz row's two unresolvable citations, only the OPEN-clause one was in scope. The verifier also found T3's G4b VACUOUS -- it greps a level-two `## Central Question` where the real heading is level three, so unrelated prose satisfied it; the truth it guards holds anyway (the whole file diff deletes three lines), left as found since the plan is executed. Separate finding, out of scope and named so it does not read as a contradiction: `11-EVIDENCE.md`'s O4 VERDICT slot still reads PENDING though the observation closing it landed in two other artifacts. | 2026-08-08 | c3fe74d..142ea72 | Verified (8/9, the one gap closed at `142ea72`) | [260808-lpt-fix-the-v0-0-2-milestone-audit-bookkeepi](./quick/260808-lpt-fix-the-v0-0-2-milestone-audit-bookkeepi/) | +| 260808-u2q | Stop the temporary-main-window restore force-push from firing the production `publish` legs, and triage the unattributed `69bd1b7` test failure. **The fix is one clause:** `publish` now carries `&& !github.event.forced`. A restore is a REWIND and therefore forced; a window-OPEN push is a fast-forward and is not -- so the same condition skips the restore while still letting the open push publish, which item 3 REQUIRES. A fix suppressing both would have broken the measurement it exists to protect. `publish-verify` follows through `needs:` (a SKIPPED publish skips it, no duplicate gate); `dogfood-seed` and `consumer-smoke` deliberately untouched, their writes being run-id-scoped cache entries that age out. **No tripwire for a wrongly-skipped publish, decided rather than overlooked:** it fails CLOSED (missed measurement, no production write, recoverable), and a "the mirror received this commit" assertion would fire on correct behaviour in exactly the way OBS-04 warns about. **Research corrected the premise it was handed, twice.** The handoff said five prior occurrences; it is ELEVEN -- backup refs are reused by name, so counting refs cannot count events. And this was never merely a red run: restore-shaped runs reached `POST /releases` under a real `contents: write` grant. **Two plan-check rounds, each finding what the round before could not.** Round 1 caught the plan about to write a KNOWN FALSEHOOD into tracked files -- it claimed `ci.yml` is not an Nx test input, when `nx.json:70` carries it, three assertions pin it, `ci.yml` says so about itself twice, and a spec failure message exists precisely because both blocks once claimed the OPPOSITE. That claim came from the orchestrator brief, so the revision instruction said not to preserve it out of deference; the planner re-measured, removed it, and DROPPED `--skip-nx-cache` rather than inventing a new justification, since editing `ci.yml` rotates the `test` hash on its own. Round 1 also caught a verification no tree state could satisfy, and a scope gate failing OPEN because shell variables do not survive between tool calls -- an empty baseline makes `git diff ..HEAD` print nothing at exit 0. **Round 2 caught the fix for round 1 having introduced a decorative gate, and PROVED it with a probe rather than arguing it:** Vitest expands the whole FILE when any test in it fails and reprints every title, and the plan guarantees a co-failure in that same file -- so a bare title match passed whether the test failed, passed, or asserted nothing. Only the FAILING title carries a `> ` prefix; that prefix is now the gate, plus a reciprocal control proving the assertion is not simply always-red. The third gate in this task lineage introduced BY a fix. **Execution found four plan discrepancies and one self-inflicted near-miss.** Prettier reformatted the new assertion AFTER the mutation proof ran, so the proof was re-run from scratch against the committed bytes -- both audits later confirmed the evidence at byte level rather than by timestamp. The near-miss: an inline `node -e` hit the documented Git Bash quoting trap, silently applied NO mutation, and its run overwrote `mutation.log` with a GREEN log where the gate expects red -- caught by reading output rather than trusting the exit code. **Code review then found the shipped comment asserting a wrong measured number** (`43f3612`): FOUR restore-shaped runs published, not five. Twelve push runs at that head sha and five succeeded, but one of the twelve is the MERGE push that CREATED the restore point one second before its own run -- the push the same paragraph says SHOULD have published. The raw 12-and-5 counts a correct publish as an incident. **And the capture rule was wrong for its own loop:** `exit ${PIPESTATUS[0]}` terminates the shell, so the battery would run ONCE and return 0 -- a clean pass over a run that never happened. Status now goes to a variable, the log name gains an iteration suffix (one-second timestamps were measured collapsing three iterations into one file), and the logs are gitignored because an untracked root log pegs `workingTreeClean` false. A rule written to stop evidence being lost would have lost evidence. **Half B is a triage, not a fix, and says so:** `69bd1b7` is docs-only, `gh run list --commit` returns empty so no CI run ever existed, and the output was discarded by a redirect -- unactionable until it recurs WITH output, so the work is capture. **NOT PROVEN and deliberately not claimed:** every gate is static text. The research classification is by ANCESTRY with no truth column -- the Events API does not expose `forced`, so it was never observed in either direction and the claim rests on the protocol argument (the push wire format carries no force bit, so a client `--force` cannot reach the server). Verifier `human_needed` 4/6: the two runtime directions arrive on item 3's window run, which is the first behavioural proof of either. **CORRECTED after verification closed, and the correction is the more interesting result:** the skip direction was recorded as "the RESTORE push SKIPS it", which is FALSE and would have failed on a correct gate. A `push` event runs the workflow at the PUSHED TIP -- `GITHUB_SHA` is documented as the tip commit pushed to the ref, and the push trigger "includes workflows that are not merged into the default branch", which is only possible if the workflow is read from the pushed commit. The restore lands on `fe25a3f`, which PREDATES the clause, so it runs the UNGATED workflow and publishes exactly as before. **The fix is correct but DORMANT for restores until `main` itself contains it -- i.e. at merge, which the maintainer has placed LAST, after additional code and security reviews over the proven milestone.** Every audit missed this identically (planner, two plan-check rounds, code review, verifier) because all of them read the workflow in the working tree and none asked WHICH COMMIT'S COPY EXECUTES -- a blind spot shared by five independent passes is a different failure class from a gate nobody checked. The skip direction is still provable WITHOUT merging, by rewinding to a tip that DOES carry the clause: item 3's window becomes THREE hops -- open to the feature tip (fast-forward, publishes), rewind to `43f3612` (forced, gated, must SKIP), then the final restore to `fe25a3f` with `ci.yml` disabled for that one push. `43f3612`'s `ci.yml` is byte-identical to the tip's, so `forced` is the ONLY variable between the two observations -- a controlled experiment the original two-hop design could not have produced. | 2026-08-08 | 92998de..43f3612 | Needs Review (4/6; both runtime directions deferred to item 3) | [260808-u2q-stop-the-temporary-main-window-restore-f](./quick/260808-u2q-stop-the-temporary-main-window-restore-f/) | + +| 260808-wxg | Close the four open-by-design live-CI observations via a three-hop temporary `main` window. **Window open 65m25s** (22:19:01Z-23:24:26Z), opened and closed in one sitting; every post-window assertion passes -- `origin/main` back at `fe25a3f`, CI active, PR #16 OPEN with `mergedAt` null, the five pre-existing `refs/backups/*` untouched, nothing merged. **Hops 1 and 2 are a controlled experiment, not two adjacent observations:** the executable `ci.yml` was held constant at 823/823 identical lines (comment-only delta), so `github.event.forced` is the only variable -- non-forced `publish` RAN, forced `publish` SKIPPED inside a populated 24-job run with the census unchanged. That is the `260808-u2q` gate's first behavioural evidence in the skip direction, with its run direction from the same workflow text. **O-A's source row asked for something impossible by construction** -- a `'linux'` producer on the Windows `publish-verify` leg, which is the exact condition `assertPublishedByThisLeg` exists to reject; superseded in place rather than left open for a false reason. O-C and O-D closed, O-D by measurement (ordering 22:22:07Z -> 22:22:09Z; the Windows integration hash `14313827470950829191` mirrored under `mirrored-by: linux`) rather than by the `needs:` declaration. **O-B did NOT close clean and that is the useful result:** `readMisses` is 63, not the pre-registered 0 -- and not novel, since `09-VALIDATION.md`'s OBS-04 section already recorded 41/41 on run `30400231720`, so the expectation contradicted a measured number in the same milestone when written. Carried forward as an open sub-item, since the count GREW rather than draining to the predicted all-HIT steady state. **Two process lessons:** `gh workflow disable` is denied to the agent by the auto-mode classifier, so hop 3's suppression must be maintainer-run and should be raised at pre-flight, not mid-window; and an observable that lives only in a job summary is invisible to `gh` and to every unauthenticated fetch -- two of the four observations were first filed NOT OBSERVED until a signed-in browser read them, which is what surfaced the falsified `readMisses`. | 2026-08-08 | 6708929..fbfd88a | Verified (8/8; three live-CI items recorded NOT OBSERVED) | [260808-wxg-close-the-two-open-by-design-observation](./quick/260808-wxg-close-the-two-open-by-design-observation/) | +| 260809-2s6 | Stop the publish mirror re-enumerating prior runs' single-use CI seed entries. **Diagnosis first, and it inverted the framing:** of the 63 `readMisses`, only 15 are real Nx task hashes (all pre-`47597a6`, the OS-invariant rotation commit, and the restorable set contains NOTHING created before it); the other 48 are seeds. All 22 post-rotation misses are seeds, zero are real hashes. The miss set is SELF-PERPETUATING -- an entry that misses can never be mirrored, so it is never in the shard, so it is retried forever; measured, zero of the 63 were shard-present, which is structural rather than coincidental. 38 of 87 shard assets were CI scratch, ~4 added per push. **Two designs were rejected on the project's own requirements, not on taste:** self-cleanup (having the round-trip delete its own entry) needs `actions: write`, whose ABSENCE is the registered mitigation for HIGH threat T-11-01, pinned by specs; and a new key prefix would burn a second namespace after the legacy `-` one. The answer was already in the codebase -- `mirror-seed.ts` establishes that hex LETTERS make the seed families STRUCTURALLY disjoint from all-decimal Nx hashes and run ids, so no shape heuristic is needed. **Five decisions:** D1 filter by structural disjointness, D2 give the last ambiguous family (`dogfood-seed`'s bare run id) the marker word `bead` -- chosen over `dead` because `ci.yml` already ships `deadbeef` -- D3 test shard membership BEFORE restoring (78 of 149 restores per leg were pure waste), D4 split the conflated miss metric, D5 add a partial-case guard. **Four plan-check iterations, cap raised to 5 by the maintainer.** Iteration 1 found three blockers: a D3/D5 succession premise that was FALSE (the shard resolves only after a restore HIT, which already falsifies the total-case condition -- mutually exclusive, so the gate's firing set is identical either way) and which originated in the orchestrator's own hazard brief; a D5 threshold that credited D1 with the 28 bare-run-id entries it cannot filter, making the guard fire on EVERY run (43/129 = 33% against a 25% trigger) -- the documented D-28b mode where a tripwire on correct work gets disabled; and the `GITHUB_RUN_ID` plumb having no gate at all, so writing nothing passed and the whole task would silently no-op in production while every test stayed green. The planner then corrected the CHECKER's arithmetic (63/149 = 42% is BELOW one half, so the proposed threshold does not fire there) and recorded, unprompted, that the chosen guard therefore does NOT cover D5's own motivating case. **Verification re-applied six mutations itself rather than trusting the SUMMARY; all six reddened.** **Code review then found what verification could not** -- 11 warnings, five of them comments asserting constraints the code does not have, including one that would have led a future reader to delete a LIVE clause and one false invariant already promoted into the plan. All corrected. **The foundation's own assumption is now pinned by the maintainer's decision:** structural disjointness rests on Nx rendering hashes all-decimal, which nothing enforced -- this change had upgraded the cost of that changing from cosmetic to SILENT. The pin computes 32 hashes through `hashArray`, the function Nx's task hasher composes with, so it exercises Nx-rendered values rather than literals. **NOT OBSERVED and not inferred:** the post-fix `readMisses`/`scanned` ratio, both `publish-verify` legs staying green, and the `bead` round-trip -- all push-gated to `main`. `10-VERIFICATION.md`'s stale `readMisses 0` is DELIBERATELY not rewritten to a derived number; close it from the same live window. 1070 tests. **PLAN 02 (the panel's outcome), commits `deffc67..54677af`.** IN-03 (no minimum on the partial guard's denominator) went to a 7-agent panel -- 1 researcher, 3 advisors, 3 critics -- which produced a real option set and caught real errors, but whose two numeric estimates were BOTH wrong and whose consensus rested on a premise measurement falsified. A 17-minute temporary `main` window (run `31305961054`) settled in one observation what none of them could: ubuntu `scanned 112 / mirrored 10 / readMisses 43 / alreadyPresent 59`, windows `113 / 1 / 43 / 69`. The shipped comment's DERIVED 33% and a review's corrected 41% were both wrong; the truth is **38.4%**. The miss COUNT of 43 was right in both, the DENOMINATOR wrong in both. **The measurement also reversed the orchestrator's own lean:** thresholding on attempted-only (the "coherent" denominator) measures 0.811 ubuntu and 0.977 windows and would fire on BOTH legs of a healthy run, because the second matrix leg finds nearly everything already present -- leg ordering, not cache health. The "incoherent" enumerated denominator is the one stable across leg order, so it stays. Shipped: a Wilson score lower bound replacing the raw ratio, which makes the rule SCALE-INVARIANT (the maintainer's stated criterion -- tuning to our numbers is acceptable while we are the only consumer, provided it moves toward a generic solution) and closes IN-03 by construction with no floor: the bound cannot reach 0.5 below n=4, and at n=4 only 4/4 clears it, which forces `mirrored === 0` and hands priority to the total-case branch. Labelled a small-sample REGULARISER, never a confidence bound -- a restore MISS is deterministic given (cohort, platform), so there is no superpopulation and an interval would have no estimand; calling it inference would ship a false justification. Every figure is now MEASURED with its run id. The warning string's vendor-context leak is gone (it told a stranger's CI about "the seed filter shrinking the denominator"), guarded by a non-vacuous negative assertion over `mock.calls.flat()`. Plan-check found 2 blockers: a verify needle that could not pass on a correct implementation and whose cheapest fix was deleting correct prose, and spec prose that Task 1 falsified in no task's scope -- the same false-comment class this task removed five instances of, nearly re-created in the commit removing them. 1073 tests. **Panel findings NOT actioned here, deliberately:** the OBS-04 total gate is effectively unreachable (it needs EVERY entry to miss; any hit either mirrors or is already-present), which `09-VALIDATION` already recorded unrecognised -- "the advance prediction required `mirrored == 0` and `restore-MISS == scanned`. It was not met" -- while `docs/advanced.md:88` still promises consumers a warning that cannot fire. That is a pre-existing consumer-facing defect larger than IN-03 and belongs in its own task, not in a frozen milestone's diff. | 2026-08-09 | 46f0b8a..54677af | Needs Review (6/7; live-CI half deferred to a window) | [260809-2s6-stop-the-publish-mirror-from-re-enumerat](./quick/260809-2s6-stop-the-publish-mirror-from-re-enumerat/) | + +| 260809-hcr | Resolve the consumer-facing defect quick 260809-2s6's advisory panel surfaced -- and it was neither where the panel said nor what the panel proposed. The panel concluded the OBS-04 all-restore-MISS gate was unreachable and should be repaired by excluding this run's own SEEDS from its denominator. **Investigation before planning falsified the fix and relocated the defect.** Run `31305961054` mirrored ELEVEN assets and SIX are real Nx task hashes written by this run's own build/typecheck/test/integration jobs through the sidecar -- seeds are a MINORITY of same-run hits, so excluding them would not have made the gate fire while weakening the one case where it works. **The gate is correct and untouched (H-D1); `docs/advanced.md` was wrong.** It promised an adopter that bumping the action makes publish 'restore everything as a MISS and mirror nothing' and that 'the warning it emits names the axis'. On a bump the sidecar and publish move to the new cache version TOGETHER, so this run's entries are written at the new version and restore -- `mirrored` is not zero and the total gate stays silent. The project had already MEASURED this without recognising it: `09-VALIDATION.md` records `mirrored 6 / restore-MISS 41 / scanned 47` on the one real rotation, noting only that 'the advance prediction was not met'. **The orchestrator's OWN rationale was falsified twice in this task alone** -- first the panel-inherited 'dogfood-only' framing, then a bundle-drift justification written INTO the locked-decisions file, which the planner caught by reading `action.yml:41` (`main: dist/action/index.js`, so publish and mirror-seed run from the same freshly built dist and the same-run seed still restores under drift). Had it reached the docs it would have been the seventh false claim in this defect class. The planner also killed the task's own code edit for the same reason: the existing branch comment was already accurate. **Shipped: a docs correction only, plus its guard.** The replacement states which warning a bump produces and why, describes the proportional branch as proportional rather than promising it fires (`around 90% of ten entries against around 60% of a hundred`, matching the computed Wilson thresholds), and names the reachable all-MISS case -- a publish-only or scheduled run that wrote nothing of its own, which `isSyncTrusted` admits and the docs already document. Plan-check found the anti-regression guard covered only ONE of the TWO sites of the phrase it forbids, missing the paragraph's bolded lead sentence -- a silently-passing guard over the most-read instance of the claim being deleted. Guard proven by restoring the pre-edit prose over the corrected file: exactly six failures, all three forbidden patterns present and all three required phrases absent, pattern 3 matching on BOTH sites. 1079 tests. **NOT OBSERVABLE and not claimed:** whether a consumer ever sees either warning. **Follow-up filed:** `ROBUST-04` still asserts drift surfaces 'only as the all-restore-MISS warning', which the same finding falsifies; parked as a capture rather than an edit because the milestone is frozen mid-review. | 2026-08-09 | 909a88a | Complete (docs + guard; gate deliberately untouched) | [260809-hcr-resolve-the-unreachable-obs-04-total-gat](./quick/260809-hcr-resolve-the-unreachable-obs-04-total-gat/) | +| 260809-iqe | Amend the ROBUST-04 capture with the four reviewed corrections -- the one remaining task from the previous session's `HANDOFF.json`, left standing after a two-agent review REJECTED the `REQUIREMENTS.md` amendment the orchestrator had proposed. That rejection is honoured: `REQUIREMENTS.md` is untouched, ROBUST-04's checkbox stays ticked, and the correction lands in the unfrozen `todos/pending` capture instead. **The `--full` research pass paid for itself immediately: it falsified most of edit 2's supporting argument, which the handoff had specified verbatim.** "Each of the nine paired with the source change" is FALSE for `db577db`, a lockfile-driven rebuild (`undici` 6.27.0 to 6.28.0). "The unpaired commits are spec files" has NO referent -- not one of the seven is a spec file; all seven edit real bundled `.ts` modules and all seven are comment-only. And "no `action-bundle-drift` catch is recorded anywhere in `.planning`" is flatly REFUTED: Phase 7's Q10 catch is recorded in five places and sits INSIDE the window, one day into it. That argument was also broken on its own terms -- `gh run list --commit` returns NO RUNS for `db577db` or any of the seven unpaired commits, so the gate never evaluated them and its silence proves nothing. **Replaced wholesale by a measurement:** a fresh esbuild build at `23d9207` is byte-identical to the committed `start-cache-server/index.js` (2474234 bytes, `cmp` exit 0), which needs no commit-counting at all. Research also corrected the attribution -- `aee017c` GATED the partial branch on a Wilson bound; `e78a842` introduced it nine hours earlier the same day -- and found the canonical path in the orchestrator's own CONTEXT.md (`src/lib/publish-mirror.ts`) does not exist. The date window 2026-07-26 to 2026-08-09 survived unchanged. **Plan-check found both gates for the C-H reword were dead:** one needle contradicted its own task's action text (`synthetic seed` vs the prescribed `synthetic entry`, so correct execution would FAIL), and its backup matched pre-existing text at line 25, passing whether or not the new prose was ever written. Two more Task 2 needles were vacuous the same way. The planner then found three literal echoes of blocklisted phrasings in its OWN action prose -- in the paragraph explaining why they are forbidden, the place an executor copies from. **The verifier waved through one thing the orchestrator did not:** "Forty-seven commits later ... at HEAD" was true of `23d9207` and became false the moment the amendment committed; pinned to the tree it was taken on, in the past tense, so a capture written to remove aging claims does not ship one. **Deliberately NOT filed:** the rollover misattribution is an open consumer-facing defect of the same species as the `docs/advanced.md` one `260809-hcr` fixed; the maintainer chose the edit alone over edit-plus-filing, so it is recorded in the capture as open and unfiled rather than dropped. | 2026-08-09 | 06ccf16..aceb526 | Verified (6/6 must-haves; 2 CARRY items closed by manual read-through) | [260809-iqe-amend-the-robust-04-capture-with-the-fou](./quick/260809-iqe-amend-the-robust-04-capture-with-the-fou/) | +| 260809-og2 | Address the rollover misattribution -- the defect `260809-iqe` recorded as open and unfiled, now fixed at the root rather than at the site reported. **The scope was wider than the report:** BOTH warning branches carried the identical closed enumeration ("Two candidate causes"), not just the partial one, so any cause outside the two was structurally unnameable. **Research answered the task's one trap-quadrant question and the answer FLIPPED it.** The parked question was whether the fix could name bundle drift at all: if drift were dogfood-only, naming it in a stranger's CI log would be our incident record in their job log, which `PROJECT.md:146` forbids and which `260809-2s6` had already paid to delete from this exact string. `HANDOFF.json`'s refuted-claim 4 argued drift was consumer-unreachable and its three legs are each individually TRUE at HEAD -- but the conclusion is REFUTED: `npm pack --dry-run` ships `dist/publish/publish-mirror.js` in the consumer tarball (the `!dist/action` exclusion covers the internal action ENTRY alone), and `docs/advanced.md:139-145` sanctions an adopter wiring publish. So the trap dissolved and the cause could be named in consumer-general terms. Research also found `54677af` had SILENTLY DELETED a true cause while removing a separate leak, its commit body never mentioning the drop -- which is the argument for dropping the completeness claim rather than incrementing "Two" to "Three". **Shipped:** the completeness claim gone from both messages, a consumer-general version-skew cause in both, and the month-shard rollover on the PARTIAL branch only -- the total gate cannot reach it. **Plan-check's blocker was the sharpest of the session:** the asymmetry the task exists for had NO automated guard on either side, and the gap originated in RESEARCH's own assertion list, which the plan had faithfully lifted. Fixed by a paired positive/negative pin; the four placement outcomes were then enumerated and only the correct one passes both. The planner also tested and rejected a bare `month shard` needle (3 pre-existing hits, green before any edit) and declined a count-based asymmetry gate because cardinality cannot localize. **Code review then found two High that verification could not:** the headline `not exhaustive` retraction had no durable guard -- deleting it left all 1079 green, the "pin" having only ever been a one-shot `rg` in a verify block -- and a new comment claimed the partial branch fires where every enumerated entry misses, which routes to the total gate. It also caught the asymmetry's stated REASON as a false implication: `mirrored === 0` does not imply an unresolved shard; the working premise is `readMisses === hashes.length`. Conclusion held, justification did not. Verification reproduced both RED transcripts itself rather than trusting them, restoring the tree each time. 1079 tests, `check:action` no drift. One review sub-item DECLINED and recorded in the constant's docstring: a run-id needle would match the entry counts the messages legitimately carry. | 2026-08-09 | 51dadac..538e167 | Verified (8/8; 2 High closed, RED reproduced independently) | [260809-og2-address-the-rollover-misattribution-in-t](./quick/260809-og2-address-the-rollover-misattribution-in-t/) | ## Deferred Items @@ -188,20 +469,146 @@ Items acknowledged and carried forward: | Category | Item | Status | Deferred At | |----------|------|--------|-------------| +| Mirror design | **MITIGATED 2026-08-03, still deferred for the full redesign.** Quick `260803-fcd` Phase A makes a burned shard a LOUD NON-FATAL SKIP rather than a hard stop, proven live on run `30803953260`, so re-enabling immutable releases now degrades the mirror instead of breaking `publish`. What remains deferred is the underlying incompatibility below. Recovery is also cheap now and proven: rotate `SHARD_TAG_PREFIX` (one authored literal + a bundle regen), which is exactly what Phase B did. **Immutable releases are structurally incompatible with the monthly-shard mirror.** A shard must accept assets all month; a GitHub immutable release accepts none after publication, so a shard born immutable is permanently empty and every upload 422s. The draft -> attach -> publish workaround is CLOSED OFF: a draft release is not anonymously readable, and anonymous read is the mirror's contract. Deferred by maintainer decision 2026-08-03 after the setting was found enabled and disabled again. **Standing exposure, not a resolved item:** anyone re-enabling the setting (repo OR org level -- which level is unmeasured, `orgs/op-nx/rulesets` needs `admin:org`) silently kills the mirror again. The `e96670e` classifier fix makes that failure LOUD rather than preventing it. Full diagnosis: `.planning/debug/publish-verify-422-empty-shard.md` | a later milestone (needs a mirror redesign, not a patch) | 2026-08-03 | | Storage | GHCR/OCI as an additional synced store (GHCR-01) | later-milestone revisit trigger (with PROV-01 + Docker) | 2026-07-18 | | Provenance | Cosign keyless attestation (PROV-01) | a later milestone | 2026-07-18 | | Distribution | Docker container form (FOUND-03) | a later milestone | 2026-07-18 | | Packaging | Zero-dep barrel vs CLI/Action package split (PKG-SPLIT, PR #3 review code-reviewer #6) | a later milestone (needs a package restructure; peer/optional half-measure would break the published CLI-bin contract) | 2026-07-21 | | Review | Deleted-rationale sweep: diff the deleted comment blocks in origin/main against the greenfield rewrite to find further dropped invariants | deferred to a later milestone by user instruction (quick 260722-0od); value proven -- three were already found this way during the PR #3 review: the get-side hash lock (T2), the 405 handler (T3), and a third that turned out to be deliberate; cost is a full-history comment diff | 2026-07-22 | -| Value | Cross-OS cache HITs for the platform-independent targets (`build`/`typecheck`/`test`) are not achieved, so a Windows/macOS developer's local Releases read MISSES them even though the artifact would be valid. **FRAMING CORRECTED 2026-07-26 (quick 260725-w3s Step 0), read from the requirements rather than from summaries -- the deferral decision and milestone standing are UNCHANGED, but the severity framing below was too weak in the other direction.** This is not merely "compliant" or "deferred value": the MISS is v0.0.1's SPECIFIED, TESTED, and now LIVE-DEMONSTRATED behavior. ROADMAP SC2 (`v0.0.1-ROADMAP.md:277-280`, CORR-01) requires that the store is "OS-namespaced **by default** ... so a Linux-produced entry is **never served to a Windows reader**; the discriminator lives in the key/namespace, not left to chance", and TEST-05 (SC3, `:282-284`) asserts "a correct hit or a MISS -- never a wrong-OS artifact". Phase 3's proof is a deliberately NON-VACUOUS negative test (`SENSITIVE_HASH` seeded only under `OTHER_PLATFORM` -> `{kind:'miss'}`, with a comment noting a positive-only test would still pass with namespacing deleted). Step 0 then demonstrated it live on real infrastructure: a Windows local read of `14522047022641658505` and `12332927989897543193` (both published `-linux` only) returned clean 404s. So there are NO OS-agnostic cache records in v0.0.1 -- by design, not by omission -- and a cross-OS hit for these targets would VIOLATE CORR-01 as currently worded. IMPORTANT: CORR-01 is an either/or -- "OS-namespaced by default (**or** the consumer requirement to OS-discriminate non-portable outputs is documented + enforced)". v0.0.1 took the FIRST branch. Achieving cross-OS hits means taking the SECOND branch, which is a design change to a LOCKED requirement (plus CORR-01's uniform wording in PROJECT.md and two comment-locked single sources), NOT a bug fix. What IS deferred value is the second branch itself. Two independent causes, both measured in quick 260725-rk4. (1) Nx hash parity across OSes was engineered and verified pre-rebuild but is NOT re-established in the greenfield tree: probe run 30173654069 shows `build` computing different hashes on the same commit (ubuntu nx-cache-14522047022641658505 vs windows nx-cache-13655686526929222562), and of the three documented parity fixes the `typecheck.outputs` pin is absent from BOTH `package.json` `nx.targets` AND `nx.json` `targetDefaults` (all four targetDefaults have outputs:null). Because `ProjectConfiguration` is one hash node folded into EVERY task hash, one target's config divergence diverges all of them. Fix home = `nx.json` targetDefaults (D-02 keeps this project free of project.json); root-cause first via the method that worked before -- node-by-node hash comparison, native Windows vs a Linux clone -- because on a current Windows box the inference yields all 7 typecheck outputs, so the missing pin may be latent rather than the active cause. EVIDENCE FRAMING CORRECTION (quick 260725-w3s, 2026-07-25, evidence only -- the deferral decision, severity framing, TEST-05 compliance and milestone standing are unchanged): the cited hash pair does NOT isolate OS as the variable. BOTH values are reproducible on ONE Windows machine at one commit by varying only `.nx/workspace-data` freshness -- warm workspace-data -> 14522047022641658505, fresh/cold workspace-data -> 13655686526929222562 -- so workspace-data-derived state (plugin target re-inference, lockfile re-parse) is an uncontrolled variable in that evidence. The divergence is NOT disproven and this evidence is NOT retracted: CI is always cold, and cold-Windows measured locally equals the recorded cold-Windows value. But the parity investigation must control for workspace-data freshness on BOTH sides before attributing any hash difference to OS. w3s further measured that ALL FOUR cacheable targets (not just `build`) compute a different hash under freshness alone: typecheck 3381254060286801611 (cold) vs 17612203514283256006 (warm), test 5027851155743781967 vs 12332927989897543193, integration 13758457399293023985 vs 18311993323643153366. (2) Even WITH parity, `releaseAssetName` is unconditionally `-` and the reader resolves the RUNNING platform's asset, so a Windows read asks for `-windows` while ubuntu CI published `-linux`; the Windows publish leg cannot create it either (the recorded `publish-mirror cross-OS gap`), so it would require running every target on every consumer OS in CI. Deciding whether the OS suffix should apply only to OS-SENSITIVE targets is the design half, and it touches CORR-01's uniform wording plus the comment-locked `releaseAssetName` single source. Superseded framing note: OS-namespacing is uniform where it arguably should be per-target -- `build`/`typecheck` are `tsc` (output is portable JS / a pass-fail) and `test` is vitest over the same sources -- a Linux-produced result is correct on Windows, so these SHOULD hit cross-OS. Only `integration` is genuinely OS-sensitive (binds sockets, spawns processes, touches tmpdir), which is what its explicit `{"runtime":"node -p process.platform"}` discriminator is for. Today a cross-OS hit is impossible at every layer: the Releases reader resolves the RUNNING platform's `-` asset (per-OS publish matrix populates each), and even with identical Nx keys `@actions/cache` version-hashes `join(tmpdir(),...)`. Measured in quick 260725-rk4 (probe run 30173654069): a windows-11-arm `build` MISSED and wrote a DIFFERENT key (nx-cache-13655686526929222562) than ubuntu's (nx-cache-14522047022641658505) -- so build/typecheck/test ALSO diverge in the Nx task hash, incidentally, which is currently pointless rather than protective (the store is per-OS regardless) but means hash parity is a second thing to fix. COST: a matrix consumer never reuses another OS's compiled output, and a Windows developer's local Releases read MISSES for build/typecheck/test even though the artifact would be valid. Fix direction: classify targets OS-sensitive vs OS-independent and namespace only the sensitive ones -- which touches CORR-01's uniform wording in PROJECT.md plus two comment-locked single sources (`releaseAssetName`, `cacheArchivePath`), so it is a design change, not a patch. Also still UNTESTED: the version-hash layer PROJECT.md cites, because the keys never collided -- a probe forcing two OSes onto one key would close that. | later milestone (maintainer decision, 2026-07-25) | 2026-07-25 | +| Value | Cross-OS cache HITs for the platform-independent targets (`build`/`typecheck`/`test`) are not achieved, so a Windows/macOS developer's local Releases read MISSES them even though the artifact would be valid. **FRAMING CORRECTED 2026-07-26 (quick 260725-w3s Step 0), read from the requirements rather than from summaries -- the deferral decision and milestone standing are UNCHANGED, but the severity framing below was too weak in the other direction.** This is not merely "compliant" or "deferred value": the MISS is v0.0.1's SPECIFIED, TESTED, and now LIVE-DEMONSTRATED behavior. ROADMAP SC2 (`v0.0.1-ROADMAP.md:277-280`, CORR-01) requires that the store is "OS-namespaced **by default** ... so a Linux-produced entry is **never served to a Windows reader**; the discriminator lives in the key/namespace, not left to chance", and TEST-05 (SC3, `:282-284`) asserts "a correct hit or a MISS -- never a wrong-OS artifact". Phase 3's proof is a deliberately NON-VACUOUS negative test (`SENSITIVE_HASH` seeded only under `OTHER_PLATFORM` -> `{kind:'miss'}`, with a comment noting a positive-only test would still pass with namespacing deleted). Step 0 then demonstrated it live on real infrastructure: a Windows local read of `14522047022641658505` and `12332927989897543193` (both published `-linux` only) returned clean 404s. So there are NO OS-agnostic cache records in v0.0.1 -- by design, not by omission -- and a cross-OS hit for these targets would VIOLATE CORR-01 as currently worded. IMPORTANT: CORR-01 is an either/or -- "OS-namespaced by default (**or** the consumer requirement to OS-discriminate non-portable outputs is documented + enforced)". v0.0.1 took the FIRST branch. Achieving cross-OS hits means taking the SECOND branch, which is a design change to a LOCKED requirement (plus CORR-01's uniform wording in PROJECT.md and two comment-locked single sources), NOT a bug fix. What IS deferred value is the second branch itself. Two independent causes, both measured in quick 260725-rk4. (1) Nx hash parity across OSes was engineered and verified pre-rebuild but is NOT re-established in the greenfield tree: probe run 30173654069 recorded `build` computing two different hashes at one commit (nx-cache-14522047022641658505 and nx-cache-13655686526929222562) -- **STALE ATTRIBUTION REMOVED 2026-07-26: this pair is NOT ubuntu-vs-windows and must never be cited as an OS measurement. Both values are reproducible on ONE Windows machine by toggling `.nx/workspace-data` freshness. See `.planning/research/v0.0.2/PROBE-RESULTS.md` for the definitive cold-vs-cold cross-OS reading** -- and of the three documented parity fixes the `typecheck.outputs` pin is absent from BOTH `package.json` `nx.targets` AND `nx.json` `targetDefaults` (all four targetDefaults have outputs:null). Because `ProjectConfiguration` is one hash node folded into EVERY task hash, one target's config divergence diverges all of them. Fix home = `nx.json` targetDefaults (D-02 keeps this project free of project.json); root-cause first via the method that worked before -- node-by-node hash comparison, native Windows vs a Linux clone -- because on a current Windows box the inference yields all 7 typecheck outputs, so the missing pin may be latent rather than the active cause. EVIDENCE FRAMING CORRECTION (quick 260725-w3s, 2026-07-25, evidence only -- the deferral decision, severity framing, TEST-05 compliance and milestone standing are unchanged): the cited hash pair does NOT isolate OS as the variable. BOTH values are reproducible on ONE Windows machine at one commit by varying only `.nx/workspace-data` freshness -- warm workspace-data -> 14522047022641658505, fresh/cold workspace-data -> 13655686526929222562 -- so workspace-data-derived state (plugin target re-inference, lockfile re-parse) is an uncontrolled variable in that evidence. The divergence is NOT disproven and this evidence is NOT retracted: CI is always cold, and cold-Windows measured locally equals the recorded cold-Windows value. But the parity investigation must control for workspace-data freshness on BOTH sides before attributing any hash difference to OS. w3s further measured that ALL FOUR cacheable targets (not just `build`) compute a different hash under freshness alone: typecheck 3381254060286801611 (cold) vs 17612203514283256006 (warm), test 5027851155743781967 vs 12332927989897543193, integration 13758457399293023985 vs 18311993323643153366. (2) Even WITH parity, `releaseAssetName` is unconditionally `-` and the reader resolves the RUNNING platform's asset, so a Windows read asks for `-windows` while ubuntu CI published `-linux`; the Windows publish leg cannot create it either (the recorded `publish-mirror cross-OS gap`), so it would require running every target on every consumer OS in CI. Deciding whether the OS suffix should apply only to OS-SENSITIVE targets is the design half, and it touches CORR-01's uniform wording plus the comment-locked `releaseAssetName` single source. Superseded framing note: OS-namespacing is uniform where it arguably should be per-target -- `build`/`typecheck` are `tsc` (output is portable JS / a pass-fail) and `test` is vitest over the same sources -- a Linux-produced result is correct on Windows, so these SHOULD hit cross-OS. Only `integration` is genuinely OS-sensitive (binds sockets, spawns processes, touches tmpdir), which is what its explicit `{"runtime":"node -p process.platform"}` discriminator is for. Today a cross-OS hit is impossible at every layer: the Releases reader resolves the RUNNING platform's `-` asset (per-OS publish matrix populates each), and even with identical Nx keys `@actions/cache` version-hashes `join(tmpdir(),...)`. Hash parity is a second thing to fix, and it is REAL -- but the evidence originally cited here (quick 260725-rk4, probe run 30173654069, a windows-11-arm `build` writing nx-cache-13655686526929222562 against ubuntu's nx-cache-14522047022641658505) did NOT establish it, because graph freshness was uncontrolled. **The claim is established instead by the 2026-07-26 pre-flight probe (`.planning/research/v0.0.2/PROBE-RESULTS.md`), which ran `nx reset` on BOTH legs: at `fe25a3f`, cold-ubuntu and cold-windows differ for every target. That probe also showed why the earlier pair was misleading -- warm local Windows equals cold ubuntu CI to the digit, and cold local Windows equals cold windows CI to the digit, for both `build` and `test`. So there are TWO independent axes and the earlier evidence conflated them.** COST: a matrix consumer never reuses another OS's compiled output, and a Windows developer's local Releases read MISSES for build/typecheck/test even though the artifact would be valid. Fix direction: classify targets OS-sensitive vs OS-independent and namespace only the sensitive ones -- which touches CORR-01's uniform wording in PROJECT.md plus two comment-locked single sources (`releaseAssetName`, `cacheArchivePath`), so it is a design change, not a patch. Also still UNTESTED: the version-hash layer PROJECT.md cites, because the keys never collided -- a probe forcing two OSes onto one key would close that. | later milestone (maintainer decision, 2026-07-25) | 2026-07-25 | | Docs | Consumer-doc follow-up PR, three real defects found while dogfooding (quick 260725-rk4/w3s): (1) the quickstart tells consumers to mint the bearer token with `openssl`, which is ABSENT from the Windows runners' Git Bash -- use `node` instead; (2) the quickstart has no readiness poll, so a consumer's first task can race the sidecar's bind; (3) no `timeout-minutes` guidance, despite omitting `cancel:` being measured to hang the job at an implicit wait-all (run 30172888579 died at its 3-min cap). A ready 112-line patch existed in a since-expired session scratchpad -- re-derive from `260725-w3s-RESULTS.md` and the rk4 SUMMARY rather than hunting for it. ALSO in scope: two dead-citation claims to verify or drop -- "max 10 concurrent background steps" and "composite cannot declare `background:`" -- both resting on an unreproducible `[VERIFIED: docs.github.com]` tag in `06-RESEARCH.md`. **CLOSED 2026-07-26 by quick 260726-gok (`e6430bf`, `3385cb7`, `5f54049`, `58c6e82`).** (1) openssl -> node at ALL FIVE sites, not the three the row names -- `README.md`, `docs/advanced.md`, `docs/examples/minimal-ci.yml`, `start-cache-server/action.yml` and `.github/workflows/ci.yml:523` (the `consumer-smoke` job, which contradicted the node-not-openssl reasoning stated 344 lines above it in the same file); the one-liner is the form already proven on both runner OSes at `ci.yml:183`. (2) Readiness poll ported into the two copy-paste surfaces with its REASONING, not just its shape -- it demands exactly 404-or-200 because accepting "any status but 000" would pass a **401**, after which every Nx request 401s, best-effort read degradation kicks in, and the job goes GREEN having cached nothing. (3) `timeout-minutes` documented as generic hang insurance, kept DISTINCT from the hang caused by omitting `cancel:`, and with no `continue-on-error` / fail-gate mechanism (rk4 measured that as unnecessary AND fail-open on drift). **The two "dead citations" were NOT dead -- both are CORROBORATED, and the framing in this row was wrong.** GitHub's workflow-syntax reference documents both verbatim (`#jobsjob_idstepsbackground`); docs PR #61978 landed 2026-06-30, three weeks BEFORE the 2026-07-20 fetch, so that citation was legitimate when written and the "unreproducible" flag was a FETCH failure (docs.github.com blocks WebFetch's UA), not a factual finding. So the composite-`background:` claim SHIPS UNCHANGED and `06-RESEARCH.md:508` is annotated as corroborated with the URL; the 10-step limit is still deliberately not propagated to any consumer doc (no consumer doc asserted it, and adopters run one background step). This was the trap-quadrant UNRESOLVED item: had `--auto` locked "drop the claim", it would have deleted an accurate, citable statement. A 5th defect surfaced by the independent verification and fixed in `58c6e82`: `docs/advanced.md`'s `&`-fallback snippet used `export` / `$(...)` / `&` / `>> "$GITHUB_ENV"` with no `shell: bash`, so it would break on the Windows runner its own new comment addresses. | CLOSED 2026-07-26 (quick 260726-gok) | 2026-07-26 | | CI hygiene | **`typecheck` can serve a stale nx cache HIT that masks a real type error in a spec file.** Surfaced (not fixed, out of scope) during quick 260726-4cc and independently reproduced by its verifier. Mechanism, confirmed from config on both halves: `nx.json`'s `targetDefaults.typecheck.inputs` starts from the `production` named input, which EXCLUDES `*.spec.ts` **and** `tsconfig.spec.json`; but the target's command is `tsc --build tsconfig.json --emitDeclarationOnly`, and `packages/github-cache/tsconfig.json` references `./tsconfig.spec.json`, which includes `src/**/*.spec.ts` -- so the command DOES compile specs while their content is absent from the hash. Live repro on a tree with a genuine `TS2353` in a spec: `npm run typecheck` exits **0** with `Cache: 2/2 hit (100%)` and prints "Successfully ran target typecheck" while the REPLAYED output itself contains "Found 1 error." and "exited with non-zero status code"; the same tree with `--skip-nx-cache` exits 1. Exit 0 is what any `&&` chain or CI gate reads. Nx's own flaky-task detector fires (one hash, two outcomes) -- the detector is the symptom, the input set is the cause. Same false-pass class as T-06-03-02 (the stale-cache false pass that already bit `governance-email.spec.ts` in 06-03). Did NOT undermine 260726-4cc's own battery claims: its two source commits changed files that ARE in `production`, and the verifier re-ran every commit with `--skip-nx-cache`. `build` is unaffected (`tsc --build tsconfig.lib.json` does not compile specs). Fix direction: add the spec fileset to `targetDefaults.typecheck.inputs` (dropping the `!tsconfig.spec.json` exclusion for that target), or stop the `typecheck` target building the spec project. **CLOSED 2026-07-26 by quick 260726-gok (`37f7d63`), first direction taken, as a ONE-TOKEN change: `typecheck.inputs[0]` `production` -> `default`.** The second direction was rejected on evidence -- vitest transpiles via esbuild and does NOT typecheck, so dropping the spec project would have silently removed spec type coverage entirely (260726-4cc's Task 1 RED depended on `typecheck` catching a spec `TS2353`). Research PROVED by executed probe that a third candidate -- keep `production` and re-add the spec globs -- is DEAD: Nx partitions a fileset's patterns into included/excluded buckets by a leading `!`, DISCARDS position, and sorts the array, so a later positive pattern can never undo an earlier negation. `tsconfig.spec.json` needed no separate entry (`default` covers it via `{projectRoot}/**/*`) -- and it was a SECOND, unreported instance of the same defect, closed by the same token. Proven by differential, not reasoning: warm cache + a real spec type error now exits **1** ("Found 2 errors.") where it previously exited 0 at `Cache: 2/2 hit (100%)`; touching `tsconfig.spec.json` now re-runs `typecheck` (`Cache: 1/2`) where it previously replayed (`2/2`). Guarded by `packages/github-cache/src/nx-target-inputs.spec.ts`, which resolves the invariant through Nx's own `splitInputsIntoSelfAndDependencies` -> `extractPatternsFromFileSets` -> `filterUsingGlobPatterns` trio (NOT `expandSingleProjectInputs`, which THROWS on this inputs array because it rejects entries carrying `dependencies: true`). MUTATION-TESTED: reverting the token makes the guard fail with exactly its two spec-hashing assertions red. The guard's own precondition -- `{workspaceRoot}/nx.json` added to `test.inputs` in the SAME commit -- was load-bearing, not tidiness: a target's `inputs` array and root `namedInputs` are NOT in the ProjectConfiguration hash, so without it the guard would have replayed a cached PASS after someone reopened the hole, i.e. the same bug class one level up. | CLOSED 2026-07-26 (quick 260726-gok, `37f7d63`) | 2026-07-26 | | Release | Cut and push the `v0` git tag | release-checklist item, NOT an action: deliberately not created during quick 260722-0od (outward-facing, hard to retract); the maintainer cuts it at publish time | 2026-07-22 | ## Session Continuity -Last session: 2026-07-26 (quick 260726-gok) -Stopped at: Quick 260726-gok EXECUTED (4/4 tasks + 1 verification-driven follow-up) and VERIFIED `passed` (0 blocking, 4 advisory). Five atomic commits on `gsd/quick-260726-gok-typecheck-inputs-consumer-docs` (forked off `origin/main` at `e56e5d2`): `37f7d63` (nx.json inputs + wiring + guard), `e6430bf` (openssl -> node, 5 sites), `3385cb7` (readiness poll + timeout-minutes), `5f54049` (citation annotation + doc-lag comment), `58c6e82` (the `shell: bash` fix the verification surfaced). 433 -> 438 tests; full 8-command battery green at EVERY commit. **This closes the LAST two open Deferred Items rows** -- that table now has none. +### Session resumed at quick 260808-wxg and RAN THE WINDOW to completion (2026-08-08) + +Resumed from `HANDOFF.json` + `.planning/.continue-here.md` (both committed at `b276bdc`). Both +one-shot artifacts are now CONSUMED and deleted. The pause was correct: the window needed a full +context budget, it opened and closed in one sitting, and it was never left open across a session. + +**The window is CLOSED and every post-window assertion passes.** `origin/main` is `fe25a3f`, CI +workflow `313666980` is `active`, PR #16 is OPEN with `mergedAt` null, the five pre-existing +`refs/backups/*` are untouched, and nothing is merged. Open 65m25s, 22:19:01Z to 23:24:26Z. + +Operator-plan item 3 is COMPLETE (commit `6708929`). Item 4 -- the maintainer's additional code +review and security review, which gate the merge -- remains NOT STARTED and is explicitly not the +agent's call to begin. **The merge prohibition is unchanged and still absolute.** Anything that +landed after the milestone audit belongs in the reviews' scope: the `260808-u2q` `ci.yml` gate and +its spec assertion, and now this window's evidence and the two source-row updates. + +Two things a next session needs that no artifact would otherwise carry: + +1. **`gh workflow disable` is DENIED to the agent by the auto-mode classifier.** Hop 3's suppression + had to be run by the maintainer. Every future window hits this -- budget for it up front rather + than discovering it mid-window with `main` forward. +2. **A job summary is invisible to `gh` and to any unauthenticated fetch.** Two of the four + observations had a component that exists ONLY in the job summary, and they were first recorded + NOT OBSERVED until the maintainer supplied a signed-in browser via `playwright-cli --extension`. + That read is what surfaced the `readMisses` finding. An observable that lives only in a summary + will otherwise be silently filed as unobservable. + +### Session resumed at quick 260804-lc3 and carried it to completion (2026-08-04) + +Resumed from `HANDOFF.json` + `.planning/.continue-here.md` (both committed at `a372b96`, the commit +that corrected the handoff its own pause had made stale). No active phase -- all 7 v0.0.2 phases are +complete and the milestone is audited. Both one-shot artifacts are now CONSUMED and deleted. + +The pause was justified. Plan-check iteration 2 -- the reason the operator stopped before executing -- +returned BLOCKERS FOUND, and its central finding was that the `SRV-05 >= 2` gate could not localize: +naming the literal twice inside PART A alone would satisfy the count, after which the escape hatch +could be "corrected" by DELETING its false clause, leaving a grammatical sentence with no replacement +reason and all 23 gates green. Had execution proceeded at the pause point, the task's own headline +defect class could have shipped inside the commit that exists to close it. Three gates added (one +site-unique literal at the escape hatch, one docstring literal not satisfiable from the `required` +array, one `forbidden: []` count), one `` overclaim rewritten, and one measurably false +execution-HALTING instruction corrected (PRE-FLIGHT expected SIX search hits; it returns FIVE). + +Executed on the MAIN TREE with no worktree isolation, per the recorded `check:action` false-drift +hazard. Commit `abb722d`, exactly two files, 26/26 gates exit 0. Verified 24/24 with 0 gaps by an +independent pass that traced every shipped claim to source and re-pulled both runs' job logs rather +than trusting RESEARCH.md. One advisory acted on: the SUMMARY's `dead sidecar` census said three where +there are four, because `rg -U -i 'dead\s+sidecar'` cannot bridge a wrap whose continuation carries a +`#` -- corrected in the SUMMARY, and it never reached the shipped prose. + +### Session resumed; v0.0.2 planning pushed to a milestone branch (2026-07-26) + +Resumed from `HANDOFF.json` + `.planning/.continue-here.md`. Both were one-shot artifacts and are +now consumed: the `wip: v0.0.2 paused` commit that carried them (`542f212`) was dropped via +`git reset --soft 83ac4fd` and the two files deleted, so they never reach the remote. Proven +lossless the same way as the pre-PR-#4 tidy -- `git diff --stat refs/backups/v0.0.2-pre-branch HEAD` +shows EXACTLY the two artifact deletions (206 lines, matching the wip commit's 206 insertions) and +nothing else. Backup ref `refs/backups/v0.0.2-pre-branch` (`542f212`) retained and unspent. + +**The 15 v0.0.2 planning commits are PUSHED, on a branch, not on `main`.** Branch +`gsd/v0.0.2-os-invariant-cross-os-sharing`, named from this project's own +`milestone_branch_template` (`gsd/{milestone}-{slug}`) and matching the +`gsd/v0.0.1-greenfield-rebuild` precedent. Local `main` was reset back to `origin/main` (`fe25a3f`) +so all v0.0.2 work lives on the branch and `main` tracks the remote cleanly -- the branch-then-PR +shape used for PRs #3/#4/#6/#7. **No PR opened yet** (not requested). + +Pre-push hygiene ran allowlist-inversion over three surfaces, never encoding the forbidden value: +author AND committer identity on all 16 commits (every one the approved public gmail), email-shaped +tokens in every ADDED content line (zero), and the commit messages (zero) -- plus no AI-attribution +trailer. The batch's ONE source change is comment-only: `actions-cache-backend.ts:88` re-points a +`ARCHITECTURE-DECISION.md` reference at `THREAT-MODEL.md`. No behavior, no bundle impact. + +**Three blocking constraints carried out of the deleted checkpoint** (they lived only there; the +third is the load-bearing one for Phase 8): + +1. `rg` over `~/.claude/gsd-core` returns a FALSE ZERO without `-L` -- it is a symlink and `rg` does + not traverse it by default. A first pass returned zero hits for EVERY term, indistinguishable + from a clean confirmation. Use `-L` or the resolved path, and run a positive control before + recording any negative result. + +2. A coverage gate with one probe term per section produces a FALSE GREEN, and it fired twice in + one plan-check in OPPOSITE directions -- once because a single term stood in for a twelve-item + list and three terms matched checklist TICKS rather than restatements, once because the negative + probes searched the source document's OWN wording, so a claim with six live homes measured as + homeless. Probe at CLAIM level, one assertion per distinctive claim, never the source's phrasing. + +3. `.nx/workspace-data` staleness changes every task hash and never self-heals. Measured at HEAD: + warm local Windows `build`/`test` equal cold ubuntu CI to the digit, and cold local Windows + equals cold windows CI to the digit. Every prior cross-OS measurement in this repo, including the + pair that was in this file, read a CONFOUNDED variable. Phase 8 must pin graph freshness before + attributing any difference to the OS. Full record: `.planning/research/v0.0.2/PROBE-RESULTS.md`. + +Required reading before Phase 7/8 work, in order: `research/v0.0.2/PROBE-RESULTS.md` (FIRST -- it +reframes Phase 8), `research/v0.0.2/SUMMARY.md`, `REQUIREMENTS.md`, `ROADMAP.md`, +`THREAT-MODEL.md`, `research/v0.0.2/PITFALLS.md` (weight SILENT-3 above everything else). + +Local graph is COLD (probing ran `nx reset`). Cold hashes at `83ac4fd`: `build +9351058897283095552`, `typecheck 4008983504000491231`, `test 7684434396554539514`. Local clones +worth reusing: `D:\projects\github\actions\cache`, `D:\projects\github\actions\toolkit` (cache +package at exactly our pinned 6.2.0), `D:\projects\github\nrwl\nx` -- NOTE its working tree is at +tag `23.0.2`, not our `23.1.0`, so read via `git show 23.1.0:`. + +One thing to settle EARLY in Phase 7 planning, flagged by research and still undecided: the +explicit-`lint`-target alternative to `@nx/eslint` would dissolve the LINT-01 -> PARITY-01 ordering +constraint entirely. Recommendation is to keep `@nx/eslint` and have CORR-03 treat `lint` as a +fourth target -- but the alternative deserves one line in the plan before it is dismissed. + +Next: `/gsd:discuss-phase 7` (or `/gsd:plan-phase 7` to skip discussion). + +### v0.0.2 roadmap created (2026-07-26) + +**Phases 7-12 defined; 43/43 v0.0.2 requirements mapped to exactly one phase each.** Numbering continues from v0.0.1 (Phases 0-6, archived). Granularity standard (6 phases). `**Mode:** mvp` is marked on 9, 10 and 12 only -- 7 is toolchain adoption, 8 is measurement/configuration, 11 is proof-only. + +The shape is driven by two hard orderings, both expressed as PHASE BOUNDARIES rather than notes. **Phase 7 before Phase 8** because `@nx/eslint` is an Nx INFERENCE plugin: an inferred `lint` target changes `hash_project_config`, which is folded into EVERY task hash, so adopting the linter after the parity root-cause work would invalidate that work. **Phase 11 before Phase 12** because enabling O4 makes Windows CI a second producer of the `build`/`typecheck`/`test` hashes and permanently destroys the attribution O1 depends on -- TEST-08 captures that evidence at O1-proof time for exactly this reason. + +One deliberate ordering choice worth knowing: **Actions cache (Phase 9) before Releases mirror (Phase 10)**, though the constraint table allows either. Actions-first puts all four TRUST requirements in ONE phase with verifiable code behind them -- TRUST-13 demands a SINGLE SECURITY.md classifying both TRUST-11 (created by CORR-02) and TRUST-12 (created by VER-01/VER-03 plus CORR-02), so reversing the order would have the auditor classifying a VER-caused threat before VER exists. + +Two requirements straddle phases and are flagged in the roadmap, not hidden. **CORR-05** is owned by Phase 10 because that is where it becomes TRUE -- one of its three violating specs goes with VER-02 in Phase 9, the other two with CORR-02 in Phase 10; Phase 9 must not close it early. **XOS-02** needs an O2 baseline measured BEFORE the CORR-02 rename, and that baseline is unrecoverable afterwards -- Phase 10 carries it as an explicit pre-condition, and the 2026-07-26 Step 0 record already contains a qualifying pre-rename baseline (local Windows `[remote cache]` HIT on `integration` from a Windows-CI asset). + +Phases 9-12 each carry a `Live-CI close` line naming what cannot be closed locally, per the v0.0.1 retrospective's top lesson (three distribution bugs passed every local gate AND the verifier; five live pushes to close them). Phase 11 is live-only end to end. + +Next: `/gsd:plan-phase 7`. + +### Prior session (2026-07-26, quick 260726-gok) + +Last session: 2026-08-02T05:08:59.991Z +Stopped at: Completed 13-04-PLAN.md THE FIX IS ONE TOKEN, and the interesting part is what made it safe. `production` -> `default` in `typecheck.inputs`. Two alternatives were killed on evidence rather than taste: dropping the spec project from typecheck would have silently removed spec type coverage entirely (vitest transpiles via esbuild and does NOT typecheck), and "keep `production`, re-add the spec globs" is structurally IMPOSSIBLE -- Nx buckets a fileset's patterns by leading `!`, discards position, and sorts the array, so a later positive can never undo an earlier negation (proven by executed probe). PROVEN BY DIFFERENTIAL, NOT BY READING THE CONFIG. Warm cache + a real spec type error: exit **1**, "Found 2 errors." Previously exit 0 at `Cache: 2/2 hit (100%)`. And touching `tsconfig.spec.json` now re-runs `typecheck` (`Cache: 1/2`) where it previously replayed (`2/2`) -- a SECOND instance of the same defect that no prior artifact had measured, closed by the same token. The verifier reproduced both independently, using the reverted config as a control so the DELTA is the evidence. THE GUARD IS MUTATION-TESTED, which no prior agent had done for any guard in this repo. Reverting the token turns `nx-target-inputs.spec.ts` red on exactly its two spec-hashing assertions (`2 failed | 436 passed`). A guard that cannot fail is worthless; this one demonstrably can. @@ -269,9 +676,77 @@ Stopped at: Executed quick 260722-0od (address the 27 upheld PR #3 multi-agent-r Task 3a (413-flush, F14) RESOLVED as a documented HTTP/1.1 limitation (lead-approved option a): a bounded raw-socket investigation proved the ECONNRESET is deterministic for a client streaming a body far over the cap (60/60 on a 100MB repro; <=16KB-over-cap gets a clean 413, >=256KB resets), and the prescribed destroy-on-finish fix does NOT resolve it (and one variant hangs). Landed a ponytail ceiling comment at both destroy sites (cb2832d), no behavior change, no bundle diff, no flaky test. The memory-bounding cap (backend.put never reached, proven by the mid-stream abort test) is intact regardless. HELD (deliberately, for the lead): the branch push and the PR-body update. The lead handles the outward-facing push after independently verifying the series. This executor did not push and did not touch the PR body. Final local battery at HEAD cb2832d: fmt / build / typecheck / typecheck:action / test (430) / fallow:ci / check:action / pack:check all exit 0. -Resume file: none. Full task->SHA mapping + deviations + battery table in ./quick/260722-0od-address-pr-3-review-findings/260722-0od-SUMMARY.md. +Resume file: None Next: lead verifies the series -> pushes gsd/v0.0.1-greenfield-rebuild + updates the PR #3 body. Then the milestone-fate decision (non-blocking) - complete/archive v0.0.1 (/gsd:complete-milestone v0.0.1 + /gsd:cleanup) and land PR #3 on main. Milestone is audit-passed. ## Operator Next Steps -- Start the next milestone with /gsd-new-milestone +*Rewritten 2026-08-02 at Phase 13 close. The previous list was stale: its first two items (plan +Phase 7; capture the pre-rename O2 baseline before Phase 10) were completed milestones ago, and the +third said `gsd/v0.0.2-os-invariant-cross-os-sharing` had no PR when PR #12 has been open since.* + +- **`publish-verify` is CLOSED, proven live on run `30807461616` (FULL GREEN, zero failed jobs).** + Both `publish` legs, BOTH `publish-verify` legs, `o3-witness` and `format-check` green, with a + fresh `nx-cache-202608` shard holding 69 assets. Closed by quick `260803-fcd`: the burned-name + skip (Phase A, proven separately on run `30803953260`) plus the `nx-cache-` prefix rotation + (Phase B). The legacy `cache-mirror-202607` release and tag were deleted afterwards, verified by + exit code against a positive control. **PR #16's blocker is gone.** Historical record of how it + was diagnosed follows. +- ~~**`publish-verify` is ROOT-CAUSED and fixed in code; one `main` window remains to prove it.**~~ + `.planning/debug/publish-verify-422-empty-shard.md`. **It was never a regression this branch + introduced** -- the earlier note here said so and was wrong. `createRelease` + (`action/index.ts:103-111`), which decides the shard's born state, is byte-identical to + `origin/main`; the publish path is the same code that produced five green pushes. What changed is + a REPOSITORY SETTING: immutable releases were enabled between 2026-07-16 and 2026-08-02, so + `cache-mirror-202608` was born `immutable: true` and rejected all 65 uploads, while + `cache-mirror-202607` (field absent) holds 155 assets. The status-only 422 classifier then + reported that total failure as a GREEN publish leg, and the failure surfaced one job later in + `publish-verify`, naming the wrong subsystem. **Fixed by `e96670e`:** only an explicit + `already_exists` 422 earns the benign skip; an unreadable body falls through to the fault branch, + because guessing benign is the defect. TDD RED then GREEN, 980/980, no bundle drift. **Maintainer + actions done 2026-08-03:** immutability disabled; the dead `cache-mirror-202608` release AND its + tag deleted (both WERE deletable -- the debug session's "undeletable" claim was wrong, corrected + in its addendum), so August is recoverable rather than written off. The design incompatibility is + DEFERRED to a later milestone -- see Deferred Items. **Remaining:** one authorised `main` window + to prove `publish` and `publish-verify` both go green -- backup ref first, PR #16 closed first, + `main` restored and the restore VERIFIED after. +- **Then run `/gsd:audit-milestone`.** All seven v0.0.2 phases are Complete (45 of 45 plans) with + every post-completion gate closed. +- **Phase 13 verification: CLOSED 2026-08-03, `status: passed` 7/7.** Re-run by `gsd-verifier` + rather than resolved by editing the frontmatter -- editing `status:` to make a gate pass is the + self-certification this project forbids. The prior `human_needed` snapshot is preserved verbatim + as a dated superseded section. The item it waited on was closed AT STEP GRANULARITY, not by the + job's colour: on run `30745558383` step 9 (`Gate on the cross-OS remote-cache label count`) is + the failure while steps 7 and 8 are green, which matters because `ci.yml:527`'s readiness poll + carries a pre-existing bare `exit 1` and could redden the same job for an unrelated reason -- the + exact vacuity trap TEST-11 exists to catch. Three non-blocking flags in the report: the prior + single-line grep evidence method now under-covers a multi-line `toMatch` added by `40e4d21`; run + `30768540898` reads `conclusion: failure` at run level while ROADMAP marks Case B PROVEN (only + `o3-witness` was red -- the documented false red -- so the row deserves one clarifying sentence); + and two concurrent agents were measuring the same working tree, whose transient reds were + collisions rather than HEAD. +- **Both live-CI residuals are now CLOSED.** Case B by quick 260802-toz (run 30768540898); **A1 by + quick 260803-0rr**, answered AFFIRMATIVELY by local measurement rather than by the sidecar + instrumentation the earlier note assumed would be needed -- `server.ts:128-133` returns the 403 + BEFORE `handlePut`, so the backend's identity cannot affect the PUT path and the existing + read-only MEMORY backend was exactly equivalent. Four PUTs observed, each refused 403, Nx silent. + Propagated to five artifacts; `ROADMAP.md`'s `**Live-CI close**` block is closed on both items. +- **Fix the O3 witness before the first post-merge no-input PR.** `o3-witness` asserts a CREATION + ordering, and on a Case-B run nothing is created because every producer HITs, so it reddens as a + false red. Found by run 30768540898. +- **PR #16 replaces #12** (closed unmerged so the temporary `main` push could not mark it merged). + Note the 5 push-gated jobs (`consumer-smoke`, `dogfood-seed`, `dogfood-verify`, `publish`, + `publish-verify`) stay `skipped` on a feature branch because `on.push` is `branches: [main]` -- + structurally unreachable on a PR, which is precisely how the `publish-verify` regression hid. +- **One follow-up sits outside Phase 13:** `packages/github-cache/src/dogfood-cross-os.spec.ts:349-352` + conflates OBS-04 with what is actually OBS-02's subject. Two executors and the verifier each + declined to edit it because the referent of "its" is ambiguous and one reading would delete a true + claim. The verifier traced it to Phase 12's `fee5fbe`, so it now has a diagnosis rather than an + ambiguity. +- **`docs/versioning.md:15-17` lists only four of the six package type exports** (`ReadableBackend` + and `WritableBackend` are missing). Pre-existing, uncovered by any guard -- `docs-adoption.spec.ts` + pins versioning.md's env-knob group only, which is why the drift survived. Logged in 13-04. +- **Regenerate `.planning/codebase/*` via `/gsd:map-codebase`** -- mapped 2026-07-22 against v0.0.1 + and already flagged stale in PROJECT.md. v0.0.2 invalidates it materially: renamed asset scheme, + new archive path, a new inferred `lint` target, ESLint in the toolchain, and now a second + Actions-cache backend behind `selectBackend`. diff --git a/.planning/THREAT-MODEL.md b/.planning/THREAT-MODEL.md new file mode 100644 index 00000000..ae0886e2 --- /dev/null +++ b/.planning/THREAT-MODEL.md @@ -0,0 +1,161 @@ +# Threat Model: CREEP-Safety Control Ledger + +**Status:** Accepted. This file holds the project-level CREEP-safety control register (C1-C18) +and the handful of notes that have no canonical home anywhere else. Nothing else. +**Date:** Controls recorded 2026-07-17; slimmed to the ledger 2026-07-26; renamed from +`ARCHITECTURE-DECISION.md` 2026-07-26, once it no longer held decisions and the old name had +become a misnomer. +**Scope:** Project-wide. These controls apply across every phase and every milestone, not to one +phase's threat model. + +## Where the rest of this record went + +This file used to restate decisions that other artifacts own. It no longer does. Each of those +artifacts is the single source of truth for its own half: + +- `.planning/PROJECT.md` `## Key Decisions` - every locked architecture and trust decision, with + its rationale and its current status, supersessions included. That status is exactly what a + second copy here kept getting wrong. +- `.planning/PROJECT.md` `## Constraints` - the Nx self-hosted-cache HTTP contract and the hard + Nx version floor. +- `.planning/research/STACK.md` section 1 - the same contract in detail: the endpoint and status + table, the PUT `202` to `200` drift between Nx 20 and Nx 21, and why watching `info.version` + cannot detect it. +- `.planning/spikes/001-005` - the FOUND-01 reader-adapter evidence and its verdict. +- `.planning/research/*` - the source corpus behind all of the above. + +**Former name.** This file was `.planning/ARCHITECTURE-DECISION.md` until 2026-07-26. Every LIVE +reference was re-pointed at the rename; the ~41 references under `.planning/milestones/` and +`.planning/quick/` were deliberately NOT rewritten, because those are sealed historical records +and editing them to match a later state would falsify them. A link to the old name in an archived +artifact is expected and resolves here. + +## Review cadence + +Audited every milestone. The `## Key Decisions` row in `.planning/PROJECT.md` that points here is +covered by the hardcoded Key Decisions audit that `/gsd:complete-milestone` runs, so this ledger +gets re-read and reconciled on the same schedule as the decisions it backs. The coupling is +deliberate: the previous arrangement scheduled no review at all, and this file drifted out of +agreement with PROJECT.md without anything noticing. + +## Why the ledger has no canonical GSD home + +GSD models security strictly per phase - a `` block in each PLAN.md and a per-phase +SECURITY.md - and provides no project-level control register. Fragmenting these controls into +per-phase threat models would orphan the cross-cutting ones: C1 applies to every future phase, so +it cannot live in any single phase's SECURITY.md. `PROJECT.md ## Constraints` is the wrong shape, +because constraints are limits and controls are mitigations. + +GSD's own artifact taxonomy sanctions the alternative: a project-scoped Standing Reference +Artifact at the `.planning/` root, the same category as GSD's own METHODOLOGY.md. That is the +category this file belongs to. + +One consequence, recorded so it is not re-litigated: this file trips `gsd health` W019 +("unrecognized `.planning/` file"). It tripped W019 before the slimming, it still trips it after, +and GSD's own METHODOLOGY.md trips it too. W019's remediation text - move it to an archive +directory or delete it if stale - does not apply here, and renaming the file would not clear the +warning either. Do not act on W019. + +## Control ledger + +CVE-2025-36852 (CVSS 9.4, CWE-829, GHSA-rrr2-jcr8-7q3x, no patched version): poison at **construction, before hashing**; **first-to-cache-wins**; any PR-privileged contributor. Fix = write-scope isolation aligned to VCS trust; **signing/integrity is ineffective** against it. Controls scale with composition — the default (Actions-cache CI-RW only) carries only C1 + C4 + docs. + +| # | Control | +|---|---------| +| C1 | Write-trust allowlist (default-deny); `pull_request`/`release` on **only where GitHub's untrusted-default-branch cache guard exists — detected from `GITHUB_SERVER_URL` (`github.com`/`*.ghe.com` → ON; all GHES → OFF, fail-closed; no caller flag)**; dangerous set refused by construction | +| C2 | Sync gate = separate predicate = `{push, schedule}` only; test-locked to reject all other events + non-default refs | +| C3 | No-overwrite/409 per adapter — **contract-mandated**, CREEP value **conditional on C1/C2** (not standalone). Actions cache native; **GHCR has no atomic create-if-absent (confirmed absent from the OCI spec and GHCR) → best-effort check-then-write**, which is low-severity: same-hash trusted writes are byte-identical under CORR-01 (idempotent overwrite), and an untrusted overwrite is C2's job, not atomicity's. Reinforced by pull-by-digest (C6) | +| C4 | Repo-wide PPE hygiene: a **shipped installable gate** (reusable workflow / composite action) running `zizmor`/`actionlint` for named patterns (no `pull_request_target`+PR-checkout; no `issue_comment`/`workflow_run` executing PR code). **Best-effort/advisory** — heuristic linters cannot verify novel/obfuscated evasions, so it is NOT load-bearing; containment is **C2 (untrusted writers kept out of the shared store) + default-branch protection** | +| C5 | No content signing for CREEP (ineffective — trusted producer signs poisoned bytes) | +| C6 | Pull-by-digest mandatory iff GHCR; the `{hash}→digest` map is **designed out** (tag == hash) or its single writer + concurrency pinned — never a mutable shared index | +| C7 | Deferred (a later milestone): asymmetric provenance attestation (cosign keyless), reader-verified — never HMAC | +| C8 | Retention: native Actions LRU + age-only RO + **no manifest** (no mutable retention state) | +| C9 | Cleanup delete path: **list phase aborts with zero deletions on any non-404 fault / incomplete pagination**; delete phase isolates per item | +| C10 | GHCR >5000-download refusal handled non-fatally; documented age-floor exception; recorded as a **poison-remediation gap** (weighs in Decision 3) | +| C11 | Cleanup credential: **prefer keeping GHCR in-repo so a job-scoped `GITHUB_TOKEN` suffices** (no long-lived PAT). Fine-grained PATs / GitHub App tokens are **unsupported for GHCR deletion**, so an org-owned/unlinked package forces a **classic PAT (`delete:packages`)** — gate it **behind an Actions Environment with required reviewers** and **document its org-wide-package-deletion blast radius** as an accepted trade-off. Never referenced in a PR-triggered workflow | +| C12 | First-party Octokit cleanup (the delete credential never enters a third-party action) | +| C13 | GHCR child-manifest cleanup gated on a reference check (fail-closed); reader degrades a missing/partial child to MISS, never truncated bytes | +| C14 | Docs: github.com-only backstop + GHES floor; **never enable fork-PR "send write tokens"/"send secrets"**; default-branch-protection + ephemeral-single-tenant-runner prerequisites | +| C15 | Docs: retention is storage-hygiene, **not** poison-containment | +| C16 | Mirror filter admits **only server-produced keys** (distinguishing namespace/prefix), not "any 1-512 hex" — **must ship before/with** enabling the mirror for any private repo (else unrelated hex-keyed CI artifacts leak); docs warn every mirrored key is world-readable | +| C17 | Observability: a whole-run sync/publish failure **fails loud** (annotation + non-zero exit); ship a "how do I know the cache is working" signal | +| C18 | (GHCR) Publish-time **package-visibility fail-closed assert**: the publish pipeline verifies package visibility matches the repo (private repo → private package) and **fails the run** on mismatch — not a docs-only step | + +## Residual notes + +Kept here because the criterion for this file is "keep only what has no canonical home", and +these do not have one. Each was probed at claim level against the live tree before the rest of +the record was deleted. + +- **Deferred by YAGNI, not designed out.** The one-backend-per-process port defers + `multiple simultaneous stores` and `synchronous write fan-out` until a real consumer needs + them. Neither is rejected; both are simply unbuilt. +- **GHES anti-spoofing cross-check (recorded, unbuilt).** The host-based write-trust gate can be + cross-checked against the absence of an `installed_version` field on `/meta`, plus the + `X-GitHub-Enterprise-Version` response header, which together catch a spoofed + `GITHUB_SERVER_URL`. The matching version-gate knob stays dormant and OFF until GitHub + publishes a GHES floor that carries the read-only-cache guard. +- **Read-time integrity for the reader (optional, unbuilt).** The defense-in-depth equivalent of + a registry digest pin is to publish a `content-sha256` in the asset metadata and verify it on + read. It is explicitly NOT `sha256(blob) == {hash}`: the Nx key hashes task INPUTS, not the + stored bytes, so that comparison could never hold. It defends nothing against CREEP (C5). +- **Scope check on the reader choice.** Choosing Releases over GHCR is `orthogonal` to CREEP: the + primary threat is defended at the write and sync gates (C1/C2/C5) whichever reader is in use. + The win is in incident remediation, not in poison prevention - which is also why the choice is + low-stakes and reversible. +- **Inherited protection in the Nx client.** The Nx client hardens tarball extraction so a + malicious cache server cannot `zip-slip` it. Verified at the pinned Nx 23.1.0: + `packages/nx/src/native/cache/http_remote_cache.rs` extracts via the `tar` crate's `unpack_in` + (`:257`), and entries that `unpack_in` skips (`..` traversal) or refuses (symlink escape) are + explicitly REJECTED rather than silently dropped (`:256-260`); the behaviour carries its own test + (`extract_rejects_parent_dir_traversal`, `:331`). This project inherits that protection rather + than implementing it. Written up for consumers at `docs/trust-and-security.md`. +- **Residual risk: containment is `single-layer`.** CREEP containment rests on the write and sync + gates plus the advisory PPE gate. The only genuine second layer would be reader-side provenance + attestation (C7), which is deferred. Gate correctness is therefore load-bearing with + `no backstop`. +- **`CACHE_READ_ONLY` is a one-way ratchet, and NO control row was added for it (deliberate; + Phase 13, 2026-08-02).** The knob is a strictly-narrowing consumer signal whose only reachable + effect is to remove `put` from the constructed backend. The guarantee comes from BRANCH ORDER in + `selectBackend`, not from validation: the check is LAST, after every existing narrowing branch, so + every path that could narrow has already returned and the knob cannot resurrect the Releases + branch, the fail-closed throw, or the memory-degrade branch. It is control flow, not validation, + and moving the check earlier would break it -- the POSITION is the invariant, not the comment + above it. Truthiness rather than `=== 'true'` is the FAIL-SAFE direction for a ratchet: a typo'd + value still narrows, whereas an exact-string comparison would let a typo silently restore the + writable backend. **No `C19` was added on purpose**, and that decision is recorded here so the + ledger's silence is not read as an omission: this phase strictly REDUCES capability, opens no + attack surface and introduces no new trust boundary, so it meets this file's criterion for a note + and not for a control. C1 is ADJACENT but not contradicted -- C1's 409 describes a write blocked + AT THE STORE, while this blocks one layer earlier, so `server.ts:124-129` answers the contract's + 403 at the protocol boundary and `saveCache` is never attempted. That 403 is the ESTABLISHED + behaviour of the two existing read-only outcomes, not a new one, and C1's 409 path on the writable + backend is untouched. C8 runs BACKWARDS from the intuition and is worth one sentence so nobody + "fixes" a non-problem: a read-only leg no longer refreshes an entry's clock by WRITING, but + GitHub's eviction policy is keyed on ACCESS ("removed if not accessed in over 7 days") and a + restore IS an access -- so there is no retention regression. Per D-08, read-only against the SAME + store CI writes contradicts none of the three stances it appears to touch: `PROJECT.md`'s "Local + read-write mode" Out of Scope line bans LOCAL WRITE, while this adds a CI-side READ-ONLY position + -- strictly less capability, in the same direction that stance points; TRUST-05 is scoped to + RW-vs-RO-by-construction and is satisfied as long as the signal can only narrow; and CORR-01 is + about OS-namespacing and is already superseded by the v0.0.2 OS-invariant decision, untouched + here. That is a RECORD, not a re-litigation. + +### Accepted as spent (2026-07-26) + +Four reasoning sentences were deleted during the slimming without a coverage row, and re-probing +found no home for them. Recorded here as a deliberate acceptance rather than an unnoticed loss: +Decision 3's caveat that the control-surface comparison was "a defensible judgment for this tool, +not a raw-count fact", the reversibility-cost detail, the GHES 3.21/3.22 version specifics, and +GHCR's "self-inflicted edge" remark. None is a control, an invariant, or an operative constraint; +the decision they hedge is recorded in three independent places plus the full spike records; and +the first is arguably SUPERSEDED rather than lost, since `spikes/004-ghcr-hazards/README.md:84-88` +makes a harder empirical claim in the same direction. Carrying four hedging sentences into +canonical artifacts would re-create precisely the duplication this slimming removed. + +## References + +CVE-2025-36852 / GHSA-rrr2-jcr8-7q3x / NVD (CVSS 9.4, CWE-829); Nx blog + HeroDevs `nx.app/files/cve-2025-06`; Nx self-hosted caching + the 2026-06-26 read-only-cache changelog; GitHub dependency-caching (scope isolation); CodeQL cache-poisoning; Adnan Khan "Cacheract"; Wiz PPE; OCI distribution spec (tag mutability); GHCR has no immutable tags; sccache/bazel-remote/Turborepo; `nixcite/nixcache-oci`. Full corpus: `.planning/research/*`. + +--- +*Controls recorded 2026-07-17. Rev after an independent Sonnet `/lz-security-review`: C1 fail-closed detection; C4 PPE gate advisory; C11 in-repo-GHCR preference; C16 sequenced before the private mirror; C18 visibility assert. Rev after targeted research: C1 detection is host-based (`GITHUB_SERVER_URL` github.com / `*.ghe.com` -> ON, all GHES -> OFF; GHES floor unpublished) and the backstop is a default-branch-poisoning guard, not a PR/release read-only; C3 GHCR no-overwrite is best-effort (atomic create-if-absent confirmed unavailable) - low-severity, C2-covered. Rev after the FOUND-01 spike (`.planning/spikes/001-005`): the GHCR-conditional controls C6/C10/C11/C13/C18 move to the later-milestone GHCR revisit trigger. Slimmed 2026-07-26 to the ledger plus the notes that have no canonical home; where the extracted material went is listed at the top of this file.* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 05b383bb..3b8c331c 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -195,7 +195,7 @@ scanned for stub/placeholder markers and found none. still poison a cache entry undetected by any signature check. - Files: N/A (feature does not exist yet); tracked as **PROV-01** in `.planning/PROJECT.md` and `.planning/milestones/v0.0.1-REQUIREMENTS.md`. -- Current mitigation: `ARCHITECTURE-DECISION.md` explicitly rejects content +- Current mitigation: `THREAT-MODEL.md` explicitly rejects content signing as a CREEP control for v0.0.1: CVE-2025-36852's poisoning precedes hashing, so signing the bytes verifies transport integrity, not correctness-for-the-key. CREEP is instead defended at the write/sync trust @@ -386,9 +386,11 @@ here. ## Deferred Later-Milestone Triggers -These are LOCKED architectural decisions (not accidental gaps), recorded in -`.planning/ARCHITECTURE-DECISION.md` and `.planning/milestones/v0.0.1-REQUIREMENTS.md`, -re-evaluated together when their shared trigger condition is met. +These are LOCKED architectural decisions (not accidental gaps), recorded as rows in +`.planning/PROJECT.md` `## Key Decisions` and in +`.planning/milestones/v0.0.1-REQUIREMENTS.md`, re-evaluated together when their shared trigger +condition is met. What `.planning/THREAT-MODEL.md` still records for them is the +control surface each trigger carries (C6/C10/C11/C13/C18, cited in the bullets below). **GHCR-01 -- GHCR/OCI as an additional synced store:** - Status: Deliberately deferred, not built. v0.0.1 locked GitHub Releases as @@ -411,7 +413,7 @@ re-evaluated together when their shared trigger condition is met. **PROV-01 -- optional reader-verified cosign keyless provenance attestation:** - Status: Deliberately deferred (one line by design per - `ARCHITECTURE-DECISION.md` control C7). Explicitly never content signing, + `THREAT-MODEL.md` control C7). Explicitly never content signing, never HMAC -- would only be clean on a GHCR/OCI backend. - Trigger to revisit: Paired with GHCR-01 and FOUND-03 (Docker) graduating together. diff --git a/.planning/config.json b/.planning/config.json index 65c2c23e..3ad50cbb 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -46,7 +46,7 @@ "security_enforcement": true, "security_asvs_level": 1, "security_block_on": "high", - "_auto_chain_active": false, + "_auto_chain_active": true, "tdd_mode": true }, "ship": { diff --git a/.planning/debug/publish-verify-422-empty-shard.md b/.planning/debug/publish-verify-422-empty-shard.md new file mode 100644 index 00000000..8a9badb2 --- /dev/null +++ b/.planning/debug/publish-verify-422-empty-shard.md @@ -0,0 +1,662 @@ +--- +slug: publish-verify-422-empty-shard +status: awaiting_human_verify +trigger: "publish-verify fails on BOTH legs at the Phase 13 tip (run 30767511870: 24 success / 2 failure, both publish-verify legs failing at the round-trip read-back). It succeeded on all five prior main pushes. The job is push-gated (on.push branches: [main]) so it is structurally invisible to every PR run -- it was only found via the temporary main push in quick 260802-toz. Measured so far: the cache-mirror-202608 shard exists with ZERO assets; every asset upload returned 422 and was swallowed as benign; the seed asset is unique per run so its 422 cannot mean already-exists. The month-boundary hypothesis is UNCONFIRMED -- the shard predates the run. Mechanism NOT established. This blocks PR #16. Maintainer has authorised whatever it takes including another temporary main push window (same close-the-PR-first sequencing as 260802-toz) if read-only investigation stalls." +goal: find_and_fix +created: 2026-08-03 +updated: 2026-08-03 +--- + +# Debug: publish-verify fails on both legs; shard has zero assets and every upload 422s + +## Symptoms + +**Expected behavior:** On a push to `main`, each `publish` leg mirrors its OS's server-produced +Actions-cache entries into the monthly shard release (`cache-mirror-`), and +`publish-verify` then reads one back through the real read path. Both legs green. This held on all +five prior `main` pushes. + +**Actual behavior:** Run `30767511870` (event `push`, head `ce197701`, 2026-08-02T21:16:28Z) is +**24 success / 2 failure**. Both `publish-verify` legs fail, both at the same step: +`Run node packages/github-cache/dist/roundtrip/read-back.js`. The `publish` legs themselves report +SUCCESS. The `cache-mirror-202608` shard exists but holds **ZERO assets**. + +**Error messages:** No error from `publish` -- that is the core of it. Every asset upload returned +**422 and was swallowed as benign**. The failure only surfaces downstream, when `publish-verify` +tries to read back an asset that was never written. + +**Timeline:** Regression introduced by this branch. Succeeded on all five prior `main` pushes; +fails at the Phase 13 tip, which changes this code by ~1888 insertions. Found ONLY because quick +`260802-toz` temporarily pushed the Phase 13 tip to `main` -- the job is push-gated +(`on.push` `branches: [main]`) and therefore structurally skipped on every pull request, so no PR +run could ever have caught it. This is the v0.0.1 retrospective lesson repeating. + +**Reproduction:** Push to `main`. Structurally unreachable from a PR. A temporary `main` push +window is authorised (see Constraints). + +## What is already MEASURED (do not re-derive; verify only if load-bearing) + +1. `cache-mirror-202608` shard EXISTS and holds **zero assets**. +2. **Every** asset upload returned 422 and was swallowed as benign. +3. The **seed asset is unique per run**, so its 422 CANNOT mean already-exists. This is the + load-bearing observation -- it breaks the benign reading of the 422. +4. The **month-boundary hypothesis is UNCONFIRMED**: the shard was created BEFORE the run, so + "first run of a new month races shard creation" does not explain it as stated. +5. Mechanism is **NOT established**. Nothing below is a confirmed cause. + +## Leading hypothesis (unconfirmed -- the 422 is being misclassified) + +`publish-mirror.ts:329-331` treats a 422 on `uploadReleaseAsset` as already-exists and continues: + +> "(our list and upload) returns 422 already_exists -- benign no-op (D-05)" + +That reading is FALSE for a unique-per-run seed name. So either the 422 carries a different +GitHub reason (and the code cannot tell them apart), or the upload target is wrong. Candidate +sub-causes, none checked: + +- The 422 is not `already_exists` at all. GitHub returns 422 from the release-asset endpoint for + several distinct reasons; the handler branches on **status only** (`statusOf(error) === 422`), + never on the error body's `errors[].code`. A status-only branch cannot distinguish benign from + fatal. +- The release id / upload URL resolved by `ensureShardRelease` (`publish-mirror.ts:112-131`, + which has its OWN 422-race branch at `:131`) points at something that rejects uploads -- + e.g. a draft release, a mismatched id, or a shard created by a different path this branch added. +- Something Phase 13 changed upstream of the upload changes the asset name, the release lookup, + or the client. + +**Anti-pattern to avoid, from the sibling session:** `publish` reporting green means nothing here. +`.planning/debug/windows-publish-one-asset.md` (root_caused, correct-by-design) established that +this job's OBS-01 summary reports only `mirrored | skipped | failed`, has no `scanned`, and folds +restore-MISS into `skipped` -- so a leg that mirrored nothing is indistinguishable from a healthy +one. Read that session before reasoning from any publish-leg summary. Its PROPOSED item 1 (report +`scanned` and `readMisses`) is directly relevant and still unapplied. + +## Constraints on the investigation + +- **MAINTAINER INSTRUCTION, mandatory for any `main` push window: BACK UP `main` BEFORE and + RESTORE IT AFTER.** Capture the pre-push SHA into a remote backup ref (the precedent is + `refs/backups/main-pre-phase13-verify`, still on the remote from `260802-toz`), verify the ref + exists on the remote before pushing, and restore `main` to its original SHA afterwards with the + restore VERIFIED, not assumed. +- **Close any PR whose head SHA equals the tip being pushed, FIRST.** From `260802-toz`: pushing + with such a PR OPEN makes its SHA reachable from `main` and GitHub marks the PR permanently + **Merged** -- a PR cannot be un-merged, and the later `main` restore would leave a public repo + showing a merged PR that `main` does not contain. The git side is restorable; the PR status is + not. Verify `state=CLOSED mergedAt=null` after the push AND after the restore. +- Prefer read-only investigation first (existing run logs, `gh api` GETs against the shard release + and Actions caches, local reproduction of the publish + read-back path). Spend the `main` window + only if read-only stalls. +- No destructive `gh api` calls: do not delete or modify mirror assets, releases, or Actions-cache + entries while diagnosing. +- `git grep` / `rg` only -- never the Grep tool or the `grep` command. Use `| rg`, never `| grep`. +- No `cd &&` prefixes. ASCII only. Temp logs to the session scratchpad, never the repo. +- `git commit -m` FAILS on this Dev Drive (ReFS, `COMMIT_EDITMSG: Invalid argument`) -- write the + message with the Write tool and use `git commit -F `. +- Editing any `serve()`-reachable source drifts `start-cache-server/index.js`; regenerate the + bundle in the SAME commit or `action-bundle-drift` fails that commit. + +## Evidence + +### E1 -- The 422 response BODY is never logged, and that is structural (MEASURED) + +`node_modules/@octokit/plugin-request-log/dist-src/index.js` is the sole source of the +`METHOD path - status with id in Nms` lines in the publish logs. It logs `requestOptions` +only -- method, path, status, request id, duration. **The response body is never read and never +logged, on any branch.** Successes go to `octokit.log.info` (a no-op in `@octokit/core`'s default +logger) and failures to `octokit.log.error`, which is why ONLY failing requests appear in the job +log at all. + +Consequences, both load-bearing: +1. `errors[].code` / `errors[].field` for these 422s **do not exist anywhere in the run + artifacts**. Investigation priority 1 is answered: the body is not recoverable read-only. +2. The ABSENCE of a `POST /repos/op-nx/github-cache/releases` line PROVES `createRelease` + SUCCEEDED (only failures print). Same for `listReleaseAssets`. + +### E2 -- Both legs 422 on every upload into a release that provably held zero assets (MEASURED) + +Job `91549048541` (`publish (ubuntu-24.04-arm)`) and `91549048531` (`publish (windows-11-arm)`), +run 30767511870: + +| leg | tag lookup | uploads attempted | 422s | +|---|---|---|---| +| ubuntu | `GET .../releases/tags/cache-mirror-202608 - 404` at 21:20:04.62 | 32 | **32** | +| windows | (no 404 logged -- shard already existed) | 33 | **33** | + +First ubuntu upload, verbatim: + +``` +2026-08-02T21:20:05.9784238Z POST /repos/op-nx/github-cache/releases/363897680/assets?name=nx-cache-feed230767511870&label=mirrored-by%3A%20linux - 422 with id 3400:1AA75C:59598D:96EF3C:6A6FB485 in 337ms +``` + +The ubuntu leg's GET 404 means the shard did NOT exist at 21:20:04; `createRelease` then made it +(no failure line) and every subsequent upload went to release id 363897680. **`already_exists` is +therefore impossible for the ubuntu leg's FIRST upload** -- the release was empty and brand new. + +### E3 -- The shard release is healthy and NOT a draft (MEASURED) + +`gh api repos/op-nx/github-cache/releases/363897680`: + +``` +id 363897680 tag_name cache-mirror-202608 draft false prerelease false +created_at 2026-08-02T12:36:20Z published_at 2026-08-02T21:20:05Z +target_commitish main assets 0 +upload_url https://uploads.github.com/repos/op-nx/github-cache/releases/363897680/assets{?name,label} +``` + +`published_at` = 21:20:05Z matches the ubuntu leg's create to the second. The tag +`refs/tags/cache-mirror-202608` exists and points at `ce197701` (the pushed head). Priority 3's +draft / missing-release / missing-tag candidates are all eliminated. + +### E4 -- The request is well-formed and reaches uploads.github.com (MEASURED) + +`node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/generated/endpoints.js:2032-2035`: + +```js +uploadReleaseAsset: [ + "POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label}", + { baseUrl: "https://uploads.github.com" } +] +``` + +`name` and `label` are consumed by the URL template (the log confirms both landed in the query +string), `data` becomes the raw body, and the endpoint's own `baseUrl` override is not overridden +by `createResilientOctokit` (which passes only `auth` + `throttle`). The logged path is +origin-stripped because request-log does `requestOptions.url.replace(options.baseUrl, '')` against +the MERGED baseUrl -- so a path-only log line is expected and is NOT evidence of a wrong origin. +Priority 4 (an octokit-side / wrong-origin 422) is eliminated: each 422 carries a real +`x-github-request-id`, so GitHub itself validated and rejected. + +### E5 -- Exactly TWO things changed on the upload call vs the last-good push (MEASURED) + +`git diff origin/main...HEAD` over the publish path. The octokit stack is UNCHANGED +(`@octokit/rest` 22.0.1, `@octokit/core` 7.0.6, `@octokit/plugin-retry` 8.1.0, +`@octokit/plugin-throttling` 11.0.3; the only `package.json` additions are eslint devDeps). +`resilient-octokit.ts` and `octokit-status.ts` are byte-identical to main. The delta is: + +1. **CORR-02 -- the asset NAME shape.** `releaseAssetName(hash)` is now + `` `${CACHE_KEY_PREFIX}${hash}` `` = `nx-cache-`, replacing the previous `-`. +2. **OBS-03 -- a NEW `label` query param.** `uploadReleaseAsset(releaseId, name, bytes, label)` + gained a 4th positional and forwards `label: 'mirrored-by: '`. + +Confirmed against the live mirror: `cache-mirror-202607` (id 354838660) holds 155 assets, ALL in +the old shapes (`-linux`, `-windows`, `.tar.gz`), the newest uploaded +2026-07-28T21:24:42Z, and **every one has an empty label**. So no upload has ever succeeded with +either the new name shape or a non-empty label. + +### E6 -- The new name shape AND the new label are both PROVEN GOOD against live GitHub (MEASURED) + +This is the observation that eliminates the entire "the request shape changed" family. The live +`cache-mirror-202607` shard already holds **24 assets in the CURRENT `nx-cache-` shape, every +one carrying a non-empty `mirrored-by: ` label**, uploaded successfully on 2026-07-29: + +``` +2026-07-29T16:44:26Z nx-cache-feed230471772954 label=[mirrored-by: linux] +2026-07-29T16:44:40Z nx-cache-cafe30400231720 label=[mirrored-by: linux] +2026-07-29T16:48:37Z nx-cache-feed030471772954 label=[mirrored-by: windows] +2026-07-29T23:43:59Z nx-cache-feed230500255530 label=[mirrored-by: linux] +2026-07-29T23:47:24Z nx-cache-feed030500255530 label=[mirrored-by: windows] + ... 24 total, 155 assets in the shard, 24 with non-empty labels, 0 in a non-`uploaded` state +``` + +Shape census of that shard: 81 legacy `-`, 50 PoC `.tar.gz`, **24 current +`nx-cache-`** = 155. The 24 labelled ones are exactly the 24 current-shape ones. + +Cross-check against run 30767511870's ubuntu leg: of its 32 attempted uploads, **exactly 24 are +these same names** and 8 are new to this run (the seed, the run-id keys and this run's task +hashes). All 32 got 422 -- including the 8 that exist nowhere. + +So CORR-02 and OBS-03 shipped to `main` in an earlier temporary push window (2026-07-29) and +**GitHub accepted both**. Neither is the trigger. + +### E7 -- ROOT CAUSE: the new shard was born IMMUTABLE (MEASURED, decisive) + +The only structural difference between the shard that accepts uploads and the shard that rejects +every one of them: + +| field | `cache-mirror-202607` (id 354838660) | `cache-mirror-202608` (id 363897680) | +|---|---|---| +| **`immutable`** | **`false`** | **`true`** | +| created_at | 2026-07-16T02:51:16Z | 2026-08-02T12:36:20Z (= `ce197701`'s commit date) | +| published_at | 2026-07-16T02:52:07Z | 2026-08-02T21:20:05Z (this run's `createRelease`) | +| draft / prerelease | false / false | false / false | +| assets | 155 (24 in the current shape) | **0** | + +`gh api repos/op-nx/github-cache/commits/ce1977016...` returns committer date +`2026-08-02T12:36:20Z`, so the shard's `created_at` is just the tag target's commit date -- there +is no hidden draft phase. The `published_at` is this run's create, to the second. + +GitHub's own specification of the feature (changelog 2025-09-18 "Immutable releases are now +generally available", and +`docs.github.com/en/code-security/.../immutable-releases`), quoted verbatim: + +> **Immutable assets**: Once you publish a release as immutable, its assets **can't be added**, +> modified, or deleted. + +> You can enable immutable releases at the repository or organization level in your settings. Once +> enabled: **All new releases are immutable**... **Existing releases remain mutable unless you +> republish them.** Disabling immutability doesn't affect releases created while it was enabled. +> They remain immutable. + +> **Release assets cannot be modified or deleted**: All files attached to the release ... are +> protected from modification or deletion. + +> Once an immutable release is published, its associated Git tag is locked to a specific commit, +> cannot be changed, and cannot be deleted while the release exists. **If you delete the immutable +> release, you can delete the tag, but you cannot reuse the same tag name.** + +> **Best practices** ... 1. Create the release as a draft. 2. Attach all associated assets to the +> draft release. 3. Publish the draft release. + +Every observation falls out of this with zero residue: + +- `cache-mirror-202607` predates the setting -> `immutable: false` -> still accepts uploads, which + is why the 2026-07-29 pushes with the identical name+label shape succeeded (E6). +- `ensureShardRelease` called `createRelease(tag)` with no `draft: true`, so the August shard was + created **already published** and therefore **already immutable**. +- From that instant no asset can ever be added to it. Both legs, every name, every label, every + payload size, unique-per-run seed included: **422, deterministically, forever**. That is exactly + 32/32 and 33/33. +- `publish` still reported SUCCESS because the engine reads only `statusOf(error) === 422` and + calls it a benign `already_exists` race. +- `publish-verify` then read back a seed asset that was never written -> MISS -> RED. + +The "month-boundary" instinct in the trigger was directionally right and mechanically wrong: this +is not a race at the rollover, it is that **the first shard created after the repo/org enabled +immutable releases is born frozen and empty**, and every future month's shard will be too. + +**Two consequences that are NOT fixable in this repo's source:** + +1. `cache-mirror-202608` is permanently dead. Immutability cannot be lifted from an existing + release; disabling the setting does not retroactively free it. +2. Per the docs above, even DELETING the release does not recover the tag: `cache-mirror-202608` + **can never be reused as a tag name in this repository**. So the August shard is unrecoverable + under the current `shardTag()` scheme. + +### E8 -- The immutable-releases CONTROL is unreadable from here (MEASURED, clean negative) + +The maintainer cannot weigh "disable the setting" against "reshape sharding" without knowing +whether this repo can opt out at all. Read-only probes, GETs only, 2026-08-03: + +| probe | result | +|---|---| +| `gh api repos/op-nx/github-cache/rulesets` | `[]` -- no repo-level rulesets at all | +| `gh api repos/op-nx/github-cache/rules/branches/main` | `[]` -- no rules apply to `main` | +| `gh api repos/op-nx/github-cache` (whole body) | no `immutable` token (`rg` exit 1; positive control on `releases_url` exit 0, so the zero is real and not a failed command) | +| `.security_and_analysis` | only the 5 dependabot / secret-scanning switches, every one `disabled` | +| `gh api orgs/op-nx` | no `immutable` and no `release` token | +| `gh api orgs/op-nx/rulesets` | **404 + `This API operation needs the "admin:org" scope`** | + +Token scopes are `gist, read:org, repo, workflow, write:packages`. **`admin:org` is absent, so the +org-level surface is unreadable from here.** As instructed, NO scope escalation, token change or +auth change was attempted -- this is where the probe stops. + +What this establishes, and what it deliberately does NOT: + +- The repo has ZERO rulesets, so the setting is not a repo ruleset, and no read-only repo surface + reachable with this token exposes an immutable-releases flag. +- It does NOT follow that the control is therefore org-level. The only positive evidence of the + setting anywhere is still the RELEASE object's own `immutable: true` (E7), which is the OUTCOME, + not the control. A plain repo Settings toggle with no REST projection is equally consistent with + every observation above. +- So D1 cannot be answered by probing at all. It needs a human to read repo Settings and the org + settings in a browser. That is a clean negative, and it is the honest result. + +## Eliminated + +- **The asset NAME shape (CORR-02, `nx-cache-`).** ELIMINATED by direct measurement: 24 + assets in exactly that shape are live in `cache-mirror-202607`, uploaded 2026-07-29. (E6) +- **The new `label` query param (OBS-03, `mirrored-by: `).** ELIMINATED by the same + measurement: all 24 of those assets carry exactly that label. (E6) +- **A shard-creation propagation race (upload issued 0.6s after `createRelease`).** ELIMINATED. + The windows leg uploaded into the same release id **3 minutes later** and got 422 on all 33. +- **Permissions.** ELIMINATED. The `publish` job declares `contents: write` + `actions: read` + (ci.yml:2050-2052) and `createRelease` SUCCEEDED with that token; a scope fault is 403, not 422. +- **Empty or corrupt restored bytes.** ELIMINATED. `dogfood-verify` passed on BOTH OS legs in this + same run, and that job asserts exact byte equality against `dogfoodBody` through the same + `actionsCache.get` -> `readFile` path the publisher uses. +- **A JSON-serialized body instead of raw binary.** ELIMINATED. + `node_modules/@octokit/request/dist-src/fetch-wrapper.js:16` only `JSONStringify`s a plain object + or an array; a `Buffer` passes through untouched. +- **The 422 is `already_exists` (the benign reading the code assumes).** ELIMINATED. The ubuntu + leg's own `GET .../tags/cache-mirror-202608 -> 404` proves the release did not exist one second + before its first upload, so release 363897680 held zero assets when `nx-cache-feed230767511870` + was rejected. Nothing could already exist. (E2) +- **Draft release / missing tag / wrong release id / upload-URL mismatch.** ELIMINATED. `draft: + false`, the tag exists at `ce197701`, and the id in the failing POSTs is the same id + `getReleaseByTag`/`createRelease` returned. (E3) +- **A 422-shaped octokit request-validation error, wrong `origin`/baseUrl, or a bad URL + template.** ELIMINATED. Every 422 carries an `x-github-request-id`, so GitHub answered; and the + installed endpoint definition pins `baseUrl: https://uploads.github.com` with `{?name,label}`, + which the logged query string confirms was expanded correctly. (E4) +- **A dependency/toolchain change under the upload.** ELIMINATED. No octokit or `@actions/*` + version moved between `origin/main` and this tip. (E5) +- **The ROBUST-05 1000-asset cap.** ELIMINATED. The target shard held 0 assets; the cap branch + logs a `core.warning` and neither leg's log contains one. + +## UNRESOLVED + +Both items below are MAINTAINER DECISIONS. Both were explicitly NOT granted at the 2026-08-03 +checkpoint, and neither was self-approved. **The GREEN fix resolves NEITHER of them.** Nothing in +this session changed a repo or org setting, and no release, tag, asset or Actions-cache entry was +created, modified or deleted -- every GitHub call made while closing this session was a GET. + +### BLOCKER D1 -- immutable releases vs the monthly-shard scheme. State: OPEN. + +Until this is decided the mirror cannot write a single asset. The monthly-shard design REQUIRES a +release that keeps accepting assets for a whole month; an immutable release accepts none after +publication (E7). The two are incompatible by construction, and this is not a one-off: every +future month rolls over into the same dead shard. + +Competing options -- none chosen, listed with the grounds against each: + +- **D1-a: disable immutable releases** at whichever level owns it, so next month's shard is born + mutable. Cheapest, zero code change. Against: it gives up a supply-chain integrity control that + someone may have enabled deliberately, and per E8 we cannot even see WHERE it is set, so we + cannot know who set it or why. It also does nothing for August (D2). +- **D1-b: create the shard as a DRAFT, upload, then publish** -- GitHub's own documented best + practice for immutable releases. Against: it is structurally hostile to this design. The shard + is written INCREMENTALLY across a month and across two OS legs, so the draft would have to stay + unpublished all month, and a draft release's assets are not reachable by the anonymous consumer + read path. It turns "publish at month end" into a new scheduled job with its own failure modes. +- **D1-c: reshape sharding** so each publish run creates its OWN release under a per-run or + per-day tag and publishes it once, closed -- compatible with immutability by construction. + Against: it changes `shardTag()`, the retention-window arithmetic, the cleanup engine's shard + enumeration AND the consumer READ contract. All four are explicitly out of scope at this + checkpoint, and together they are milestone-scale. + +Why confidence is not high enough to auto-decide: the real choice is between weakening a security +posture (D1-a) and a milestone-scale redesign that reaches the consumer read contract (D1-c), with +D1-b sitting between them and fighting the incremental-write model. E8 additionally proves the +control cannot be located read-only, so even recommending D1-a would be advice about a setting +whose owner and rationale are unknown. HIGH IMPACT plus NOT-HIGH CONFIDENCE -- the one quadrant +that must not be auto-decided. + +### BLOCKER D2 -- the dead `cache-mirror-202608` shard and its burned tag name. State: OPEN. + +No action taken. `cache-mirror-202608` (id 363897680) is frozen at zero assets and cannot be +un-frozen: GitHub states that disabling immutability does not affect releases created while it was +enabled. Worse, per the same docs, deleting an immutable release lets you delete its tag but the +tag name **can never be reused** -- so `cache-mirror-202608` is permanently unavailable under the +current `shardTag()` scheme no matter what is decided for D1. + +Competing options -- none chosen: + +- **D2-a: leave it.** August has no mirror; the scheme resumes in September, but only if D1-a + lands first. Cost: one month of mirror coverage. +- **D2-b: delete the empty release.** A destructive call, forbidden this session. It does NOT + recover the tag name, so it buys cosmetics only. +- **D2-c: change the tag scheme** so August gets a reachable name -- but that IS D1-c and cannot + be decided independently of D1. + +Why confidence is not high enough: D2 is strictly downstream of D1 (a tag-scheme change subsumes +it), and the only unilateral action available is destructive and irreversible on a public repo. + +## Resolution + +root_cause: | + TWO defects, one environmental and one in this repo's source. They compound, and only the + second is fixable here. + + (1) TRIGGER (environmental, NOT a code regression). The repository or its organization has + GitHub's **immutable releases** enabled. `ensureShardRelease` creates a missing month shard with + `createRelease(tag)` and NO `draft: true`, so the release is born PUBLISHED and therefore born + IMMUTABLE. GitHub then refuses every `POST .../releases/{id}/assets` on it with 422, forever. + `cache-mirror-202608` (id 363897680, `immutable: true`) was created that way by run + 30767511870's own ubuntu publish leg at 21:20:05Z and is frozen at ZERO assets. The previous + shard `cache-mirror-202607` (id 354838660, `immutable: false`) predates the setting, which is + why the identical code shipped 24 current-shape, labelled assets into it on 2026-07-29 (E6/E7). + This is a design incompatibility, not a one-off: the monthly-shard scheme REQUIRES a release + that accepts assets over the whole month, and an immutable release accepts none after + publication. Every future month rolls over into the same dead shard. + + (2) WHY IT SHIPPED SILENT (the source defect). `publish-mirror.ts:328-336` classifies the 422 + on `uploadReleaseAsset` as a benign `already_exists` duplicate-upload race using + `statusOf(error) === 422` -- STATUS ALONE. It never reads `error.response.data.errors[].code`, + even though the injected-client seam and its own spec fake already carry that body. So 32 of 32 + (ubuntu) and 33 of 33 (windows) permanent, fatal upload rejections were counted as `skipped`, + `failed` stayed 0, the aggregate `core.setFailed` never fired, and both publish legs exited + GREEN with `mirrored: 0`. Nothing in the run artifacts named the cause: octokit's request-log + plugin logs method/path/status/request-id only and never the response body (E1). The failure + surfaced one job later as publish-verify's "cache MISS ... suspect the month-shard tag", which + points at the wrong subsystem. + +fix: | + APPLIED (source defect #2 only). `publish-mirror.ts` now reads the 422 body before deciding. + A new module-local `uploadFaultCode(error)` extracts `response.data.errors[].code`, and the + upload catch takes the benign first-write-wins skip ONLY on + `statusOf(error) === 422 && code === 'already_exists'` -- the sole 422 GitHub documents for + this endpoint. Every other 422 falls through to the existing per-item fault branch: `failed++` + plus a `core.warning` that now prints the asset name, the numeric status AND GitHub's own + reason code, routing into the pre-existing aggregate `failed > 0 -> core.setFailed`. + + Defensive shape, deliberately: the 422 body is NOT guaranteed, and the exact code GitHub + returns for an immutable-release upload was never measured (the body is never logged -- E1). + A missing `response`, a missing or non-array `errors`, and a missing or non-string `code` all + yield `undefined`, and `undefined` is counted as `failed`, never as benign. The warning prints + `code unknown` in that case. Guessing benign is the entire defect; it is not reintroduced one + level down. + + Also corrected: the engine doc block's stale "a duplicate-upload race returning 422 is likewise + benign" prose, and the now-wrong "discriminated on status alone, never on body text" comment at + the catch. The `ensureShardRelease` 422 branch is UNTOUCHED -- that one is a genuine + create-race and is out of scope. + + WHAT THE FIX DOES NOT COVER -- state this plainly, it is easy to overstate: + The fix makes the publish leg go RED instead of silently green while D1 is unresolved. That is + the INTENDED signal, not a side effect: a loudly-broken mirror beats a silently-broken one. But + it means the fix alone does NOT unblock PR #16 and does NOT repair the mirror. It converts a + confusing downstream read-back failure in the wrong subsystem (publish-verify's "cache MISS ... + suspect the month-shard tag") into a clear upstream one that names GitHub's own reason at the + point of failure. Both publish legs will now FAIL on the next `main` push until D1 is decided + and acted on. Nothing here resolves D1 or D2 -- see UNRESOLVED above. + +verification: | + TDD red -> green, then the full package suite, all in the main tree. + - The RED test "counts a 422 that is NOT already_exists as a real fault, never a benign skip" + now PASSES: `failed: 1, skipped: 0` with one `core.warning` and one `core.setFailed`. + - Its positive twin "treats a 422 already_exists upload race as a benign skip" still PASSES, + unchanged -- it already supplied `{ errors: [{ code: 'already_exists' }] }`, so the benign + path is still exercised and is now exercised for the right reason. + - `npx nx test github-cache`: 42 files, 980 tests, ALL PASS. NO existing test asserted the old + swallow-as-skipped behaviour, so nothing had to be edited to accommodate the fix -- the bug + was never encoded in a test, which is exactly why it shipped. + - `npx nx run-many -t typecheck lint --projects=github-cache`: clean. + - `npm run check:action` (rebuild + `git diff --exit-code -- start-cache-server/index.js`) in + the MAIN tree: no drift. publish-mirror.ts is confirmed NOT `serve()`-reachable by RUNNING + the guard, not by reading it, so no bundle regeneration was needed in this commit. + + NOT verified, and unverifiable read-only: that GitHub returns any particular `errors[].code` + for an immutable-release rejection. The fix does not depend on that value -- every + non-already_exists 422 is a fault regardless -- and the next failing run will now PRINT the + real code, which is what finally measures it. + +files_changed: + - packages/github-cache/src/publish/publish-mirror.ts: 422 classifier reads errors[].code; only + an explicit already_exists is a benign skip; warning now names the code; two stale comments + corrected. + - packages/github-cache/src/publish/publish-mirror.spec.ts: the RED-then-GREEN negative-twin + test for a non-already_exists 422. + +## Current Focus + +hypothesis: CONFIRMED (E7) and the source half is now FIXED. The August shard was created + already-published under GitHub's immutable-releases setting, so it is permanently closed to new + assets and every upload 422s; the status-only 422 classifier in publish-mirror.ts then reported + that total failure as green. The classifier now reads the body and only an explicit + `already_exists` is benign. +test: TDD red -> green COMPLETE. Full package suite, typecheck, lint and the action-bundle-drift + guard all run in the main tree and all clean. +expecting: n/a -- verification done, see Resolution.verification. +next_action: NOTHING further in this repo without a maintainer decision. Both remaining items are + BLOCKERs recorded under UNRESOLVED above (D1 immutable-releases setting, D2 the dead + `cache-mirror-202608` tag) and neither may be self-approved. The next `main` push will now show + the publish legs FAILING with GitHub's own reason code printed -- that is the fix working as + intended, NOT a new regression, and it is also the measurement that finally captures the + unmeasured `errors[].code` for an immutable-release rejection (E1/E8 blind spot). PR #16 stays + blocked on D1. + +tdd_checkpoint: + test_file: "packages/github-cache/src/publish/publish-mirror.spec.ts" + test_name: "counts a 422 that is NOT already_exists as a real fault, never a benign skip" + status: "green" + failure_output: | + RED (before the fix), failing for exactly the misclassification the root cause names: + - "failed": 1, + + "failed": 0, + "mirrored": 0, + "readMisses": 0, + "scanned": 1, + - "skipped": 0, + + "skipped": 1, + Test Files 1 failed (1) | Tests 1 failed | 28 passed (29) + green_output: | + GREEN (after the fix): + Test Files 1 passed (1) | Tests 29 passed (29) + Full package suite: Test Files 42 passed (42) | Tests 980 passed (980) + +reasoning_checkpoint: + hypothesis: "cache-mirror-202608 was created already-published while GitHub's immutable-releases + setting was on, so it is permanently closed to new assets and every upload 422s; the + status-only 422 classifier then reported that total failure as a green publish leg." + confirming_evidence: + - "Direct GET: the failing shard has immutable=true, the working shard immutable=false (E7)" + - "24 assets in the exact new name shape WITH the exact new label uploaded successfully into + the mutable shard on 2026-07-29, so neither CORR-02 nor OBS-03 is the trigger (E6)" + - "GitHub's own spec: assets cannot be ADDED to a published immutable release; all new + releases are immutable once the setting is on (E7, quoted verbatim)" + - "The ubuntu leg's own tag GET returned 404 one second before its first 422, so the release + was empty and already_exists was impossible (E2)" + falsification_test: "An upload into a release with immutable=false, using the same name shape + and label, would have to also 422. It measurably does NOT -- that is exactly the 2026-07-29 + upload set in E6. Conversely, if a future upload into an immutable=true release SUCCEEDS, the + hypothesis is dead." + fix_rationale: "The source fix targets the defect the source actually owns -- a benign-by-default + 422 classifier that converts fatal, permanent upload rejections into `skipped` and exits 0. It + is not a workaround for immutability; it is what makes immutability (and every other + non-already_exists 422) fail loud at the point of failure instead of surfacing one job later + in the wrong subsystem." + blind_spots: "The exact errors[].code GitHub returns for an immutable-release upload is NOT + measured -- the body is never logged (E1) and reproducing it needs a write. The fix does not + depend on that value (it treats every non-already_exists 422 as a fault), but the spec's + `code: 'immutable'` literal is illustrative, not measured. Whether the setting is enabled at + repo or org level is also unmeasured: /orgs/op-nx/rulesets needs admin:org, and the plain repo + object does not expose the flag." + +--- + +## ADDENDUM -- maintainer actions 2026-08-03, and two corrections to this session + +### The branch is NOT the cause. This session's own trigger text is wrong on that point. + +Recorded because both `STATE.md` and this file's `trigger` call it "a regression this branch +introduced" and cite "1888 insertions" in the publish path. **That inference does not hold.** + +`createRelease` -- the call that decides the shard's born state -- is in +`action/index.ts:103-111` and is **byte-identical to `origin/main`** +(`git diff origin/main...HEAD -- packages/github-cache/src/action/index.ts` shows no change to it). +The publish path that creates the shard is the same code that produced five green `main` pushes. + +What actually changed is a **repository setting**, not the branch. The two shards side by side: + +| Shard | `immutable` | Assets | Created | +|-------|-------------|--------|---------| +| `cache-mirror-202607` | field **absent** | **155** | 2026-07-16 | +| `cache-mirror-202608` | **`true`** | **0** | 2026-08-02 | + +Immutable releases were turned on somewhere between those two dates. The branch's only relationship +to the failure is that it happened to be the tip pushed when the first post-change shard was born. + +### Correction: the immutable release WAS deletable + +This session concluded "immutability cannot be lifted, and deleting the release does not free the +name". The first half is **wrong, measured**: `DELETE /releases/363897680` returned success and the +release is gone (`GET` now 404s). The leftover tag ref was then deleted too, verified by exit code +with a positive control (`cache-mirror-202607` still resolves; `cache-mirror-202608` does not; the +only remaining `cache-mirror` tag is `202607`). + +Checked before deleting the tag: `ce197701` is an ancestor of the branch HEAD and present on +origin, so removing the ref could not orphan it. Confirmed still reachable afterwards. + +Net: August is recoverable after all. A fresh `cache-mirror-202608` will be created by the next +`main` push, now under the corrected setting, rather than the month being written off. + +### Maintainer actions taken + +1. **Immutable releases DISABLED** by the maintainer in repo settings (browser; not API-reachable + with the available token scopes). +2. **`cache-mirror-202608` release and tag DELETED.** Zero assets, so nothing was lost. +3. **The design limitation is DEFERRED to a later milestone**, by maintainer decision. Immutable + releases and the monthly-shard mirror are structurally incompatible -- a shard must accept + assets all month, an immutable release accepts none after publication, and the draft -> + attach -> publish workaround is closed off because a draft release is not anonymously readable + and anonymous read is the mirror's contract. This is a real limitation of the design, not a + bug, and it is now a standing exposure: anyone re-enabling the setting silently kills the + mirror again. Recorded in `STATE.md` Deferred Items. + +The classifier fix in `e96670e` stands on its own merit regardless of the setting: it is what makes +this class of failure fail LOUD at the point of failure instead of surfacing one job later in the +wrong subsystem. It is not a workaround for immutability. + +### Verification window RESULT -- run 30773689490. The fix WORKS. A SECOND defect is now the blocker. + +Window executed and closed cleanly (see the log at the end of this section). Outcome: + +| | Before (`30767511870`) | After (`30773689490`) | +|---|---|---| +| `publish` | **green**, having mirrored nothing | **RED, at the point of failure** | +| `publish-verify` | **red**, naming the wrong subsystem | `skipped` (never reached) | + +**That inversion is exactly what `e96670e` was for** and it is the fix's proof: the failure stopped +laundering itself through a downstream job. The run is still red, but for an honest reason now. + +### E10 -- the SAME defect class exists at a SECOND site, and this session wrongly cleared it + +`publish` now dies with an UNCAUGHT `Not Found` on `get-a-release-by-tag-name`, on both legs +(ubuntu 00:11:07, windows 00:14:10 -- three minutes apart, so not a race). + +`ensureShardRelease` has exactly one unguarded `getReleaseByTag`: the one INSIDE its 422 branch. +So the measured sequence is forced: + +1. `getReleaseByTag('cache-mirror-202608')` -> **404** -> caught, falls through (correct) +2. `createRelease('cache-mirror-202608')` -> **422** -> caught, assumed "another leg won the race" +3. `getReleaseByTag('cache-mirror-202608')` -> **404 again** -> **UNCAUGHT** -> job dies + +**Step 2's assumption is falsified by direct measurement.** After the run, all four probes agree +that nothing was created by anyone: no `cache-mirror-202608` release, no `cache-mirror-202608` tag +ref (exit 1, with `cache-mirror-202607` as a passing positive control), no draft release, and the +only `cache-mirror` tag on the remote is `202607`. A 422 meaning `already_exists` is impossible +when the resource provably does not exist -- the identical logic that broke the upload path. + +This session explicitly cleared that branch in `e96670e`'s own commit body: *"The +`ensureShardRelease` 422 branch is untouched -- that one is a genuine [race]"*. **That reasoning +was wrong**, and it was wrong in precisely the way the session had just finished diagnosing one +level down: `statusOf(error) === 422` tested alone, never the body's `errors[].code`. The fix +closed one instance of the defect and left its sibling standing, then that sibling became the +blocker on the very next run. + +**Why `createRelease` is rejected is NOT established.** The body is still unreadable for the same +structural reason as E1 (the octokit request-log plugin logs no response body), and `createRelease` +has no `uploadFaultCode` equivalent. Candidates, none measured: the immutable-releases setting not +actually taking effect on this path, an org-level policy the repo toggle does not cover +(`orgs/op-nx/rulesets` still needs `admin:org`), or a tag/ruleset restriction on `cache-mirror-*`. + +**Next move, and it is the only one that can see the body:** apply the same `errors[].code` +discrimination to `ensureShardRelease` -- reuse `uploadFaultCode` (rename it to something +site-neutral), require an explicit `already_exists` before the re-GET, and log the real code on +every other 422. Then one more `main` window makes GitHub name its own reason. + +### Window log -- every step verified, not assumed + +| Step | Result | +|------|--------| +| Backup `main` | `refs/backups/main-pre-publish-verify-window` -> `fe25a3f`, confirmed present on the remote BEFORE the push | +| Close PR #16 first | `state=CLOSED mergedAt=null` (its head `1162a01` WAS the tip being pushed, so this was mandatory) | +| Push | `fe25a3f..1162a01` -> `refs/heads/main` | +| PR #16 immediately after push | still `CLOSED mergedAt=null` -- the permanent-merge trap did not fire | +| Restore `main` | `--force-with-lease` back to `fe25a3f`, re-read from the remote and confirmed | +| PR #16 after restore | `CLOSED mergedAt=null`, then reopened -> `OPEN`, `mergedAt=null` | + +Also cleaned up beforehand and worth noting for the next window: the dead `cache-mirror-202608` +release and tag were both deleted, so this run exercised the create-from-scratch path for the +first time this month. That is WHY the second defect surfaced now rather than staying latent -- +before the deletion, `getReleaseByTag` always succeeded and steps 2 and 3 never ran. diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-PLAN.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-PLAN.md new file mode 100644 index 00000000..98cefeb4 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-PLAN.md @@ -0,0 +1,609 @@ +--- +phase: 07-lint-toolchain-and-the-ambient-platform-read-ban +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - package.json + - package-lock.json + - .fallowrc.jsonc + - eslint.config.mjs + - nx.json + - packages/github-cache/src/pinned-deps.spec.ts + - packages/github-cache/src/nx-target-inputs.spec.ts + - packages/github-cache/src/lint-rules.spec.ts + - start-cache-server/index.js + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +autonomous: true +requirements: [LINT-01, LINT-04, LINT-05, LINT-06] + +must_haves: + truths: + - "The five ESLint devDependencies are installed at exact versions and a range specifier on any of them fails the unit suite (LINT-01, D-02, D-04)." + - "A bare `eslint-disable-next-line` is an ESLint ERROR, and a bare TypeScript suppression comment is an ESLint ERROR (LINT-05, D-29, D-30)." + - "A described disable sitting over a line that violates nothing is an ESLint ERROR (LINT-06, D-28)." + - "`eslint .` from `packages/github-cache` lints the same file count whether or not `build`/`typecheck` have run (RESEARCH G3, C6)." + - "Editing `eslint.config.mjs` invalidates the `test` task hash (D-25, SC1)." + - "The full eight-command battery is green at this plan's single commit." + artifacts: + - "eslint.config.mjs (workspace root, flat config, no ban rules yet)" + - "packages/github-cache/src/lint-rules.spec.ts (ESLint Node-API harness plus the LINT-05/LINT-06/CORR-06 assertions)" + - "packages/github-cache/src/pinned-deps.spec.ts (five new `it()` blocks)" + - ".planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md (D-12 baseline recorded)" + key_links: + - "`nx.json` `targetDefaults.test.inputs` -> `{workspaceRoot}/eslint.config.mjs` plus the four ESLint `externalDependencies` (D-25; without it every later LINT-03 result is a replayed cached PASS)." + - "`.fallowrc.jsonc` `ignoreDependencies` -> `@nx/eslint` (fallow:ci is battery command 7 and the plugin is referenced only from `nx.json`)." + - "`packages/github-cache/src/lint-rules.spec.ts` -> `import { ESLint } from 'eslint'` (the only importer of the `eslint` package; it is what credits the devDependency to fallow)." + prohibitions: + - "D-06: `packages/github-cache/package.json` is UNTOUCHED for the whole phase. No runtime dependency change, no new export, no new action input, no new env knob (D2-02, PARITY-05). All five ESLint packages are ROOT devDependencies and the `lint` script is a ROOT script -- the package manifest is not where either belongs. Verified by `git diff --exit-code -- packages/github-cache/package.json` returning clean at every commit in phase 7." + - "D-06: `packages/github-cache/src/public-surface.spec.ts` passes UNCHANGED. That it passes unchanged is itself a v0.0.2 requirement, so the honest check is both halves: the file is byte-identical (`git diff --exit-code -- packages/github-cache/src/public-surface.spec.ts` clean) AND green in the `test` battery command. A passing suite over an edited guard would satisfy neither." +--- + + +Adopt the ESLint 9 flat-config toolchain and prove the opt-out discipline, WITHOUT yet enabling +the ambient-platform ban and WITHOUT yet creating a `lint` target. + +Purpose: this is the ordering the mechanism forces. `@nx/eslint@23.1.0`'s `createNodes` returns +`[]` when no `eslint.config.*` exists anywhere (RESEARCH F16/C8), so the config file is a hard +precondition of the target, and `nx.json` `plugins[]` cannot name an uninstalled package without +failing every battery command at once. Install -> config -> registration, never any other order. +Splitting the ban rules out into plan 07-02 is what buys a genuine RED-before-GREEN observation +there (LINT-03) while keeping every commit in this phase green. + +Output: five exact-pinned devDependencies with a name guard, a root `eslint.config.mjs` carrying +the recommended sets and the LINT-05/LINT-06 opt-out rules, an ESLint Node-API guard spec, the +D-25 `test.inputs` wiring, and the recorded D-12 baseline. + +**The explicit-target alternative, dismissed in one line as REQUIREMENTS and research require:** +an explicitly declared `command: 'eslint .'` target beside the existing `integration` target in +`packages/github-cache/project.json` would need no inference plugin at all, but D-01 is +USER-SELECTED and CLOSED -- `@nx/eslint` is the ecosystem norm, the ordering constraint holds +either way because any declared target mutates `hash_project_config`, and the accepted +OS-divergence cost is carried by D-35. + +**COMMIT BOUNDARY -- read this before starting.** Tasks 1, 2 and 3 land as ONE commit. This is a +deliberate deviation from the repo's task-per-commit habit and it is forced, not stylistic: +`fallow dead-code --fail-on-issues` is battery command 7, and five new devDependencies with zero +importers is an unused-dependency finding. `@eslint/js`, `typescript-eslint` and the comments +plugin are credited only by `eslint.config.mjs` (task 2); the `eslint` package itself is credited +only by `import { ESLint } from 'eslint'` in the guard spec (task 3). A commit after task 1 or +task 2 alone would be RED. Run the battery once, after task 3, then commit. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-PATTERNS.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-VALIDATION.md + + + + + + Task 1: Install the five exact-pinned ESLint devDependencies and guard their names + + +package.json +package-lock.json +.fallowrc.jsonc +packages/github-cache/src/pinned-deps.spec.ts +start-cache-server/index.js + + + +- `package.json` (root) -- the alphabetical `devDependencies` block and the script list. +- `packages/github-cache/src/pinned-deps.spec.ts` -- the `'pinned build tooling (ROBUST-03)'` + describe at lines 63-87, its `workspaceManifest` read idiom, the `EXACT_SEMVER` const, and the + rationale-block voice at lines 45-49. +- `.fallowrc.jsonc` -- the `ignoreDependencies` array at lines 59-67 and its `@nx/vitest` entry. +- `07-RESEARCH.md` sections `## Security Domain` (the Package Legitimacy Audit and the executor + obligation), `G6` (the fallow verdict table and its three one-line contingencies), `G7` (SC7, + SC8, SC9) and `Q10`. +- `07-PATTERNS.md` sections `packages/github-cache/src/pinned-deps.spec.ts` and `.fallowrc.jsonc`. + + + +BEFORE any install, run the supply-chain pre-check that the security contribution makes an +executor obligation: `npm view eslint scripts.postinstall`, then the same for `@eslint/js`, +`typescript-eslint`, `@eslint-community/eslint-plugin-eslint-comments` and `@nx/eslint`. A +NON-EMPTY result on any one of the five is a STOP condition (T-07-04): do not install, record the +package name and the script body in `07-EVIDENCE.md`, and escalate to the maintainer. In the same +pass re-confirm that `@eslint-community/eslint-plugin-eslint-comments@4.7.2` resolves on the +registry -- RESEARCH's audit approved it from STACK.md's 2026-07-26 pass and did NOT re-fetch it +this session, so it is the one package whose metadata is second-hand. + +Install exactly five packages, exact-pinned, nothing else (D-02): +`eslint@9.39.5`, `@eslint/js@9.39.5`, `typescript-eslint@8.65.0`, +`@eslint-community/eslint-plugin-eslint-comments@4.7.2`, `@nx/eslint@23.1.0`. +Use `npm i -D -E`. ESLint stays at 9.39.5 and NOT 10.x per D-03: `@nx/eslint`'s +`resolveESLintClass` calls `eslintModule.loadESLint({ useFlatConfig })`, whose survival under +v10's eslintrc-loader removal is unchecked. `@eslint/js` stays in lockstep at 9.39.5. +Do NOT add `jiti`, `globals`, `@vitest/eslint-plugin`, `eslint-plugin-n`, `eslint-plugin-import`, +`eslint-plugin-unicorn`, `eslint-config-prettier` or `@nx/jest` -- STACK.md section 4 rejects each +by name. + +`package-lock.json` MUST be regenerated in a linux/arm64 `node:24` container (D-05), never by a +bare Windows `npm install`: a Windows install prunes the Linux-only optional subtrees, breaks CI +`npm ci`, and the damage is invisible locally. It is doubly load-bearing this milestone, because +lockfile asymmetry is the leading external-instruction hypothesis for the Phase 8 parity bug. + +Immediately after `npm ci` on the regenerated lockfile, run `npm run check:action` (RESEARCH Q10 / +SC9). `@actions/cache` and `@actions/core` are exact-pinned but their own dependencies carry +ranges, so a regeneration can re-resolve a transitive runtime dep and change the bytes esbuild +inlines into the committed bundle. If it drifts, run `npm run build:action` and stage +`start-cache-server/index.js` IN THIS COMMIT -- never as a follow-up, or the action-bundle-drift +gate fails this commit and every later one. This is a contingency, not a prediction; RESEARCH +verified no `serve()`-reachable source is touched by this phase. + +Keep the root `devDependencies` block alphabetical with bare `x.y.z` specifiers, matching +`"@nx/js": "23.1.0"` and `"esbuild": "0.28.1"`. + +All five packages go in the ROOT manifest. D-06: `packages/github-cache/package.json` stays +UNTOUCHED -- no runtime dependency, no new export, no new action input, no new env knob -- and this +is the task where that is most at risk of being confused, because it is the one task installing +anything. `npm i -D -E` run from the repo root writes the root manifest; if it writes the package +manifest instead, the working directory was wrong. Confirm with a clean +`git diff --exit-code -- packages/github-cache/package.json` before moving on. + +Extend `packages/github-cache/src/pinned-deps.spec.ts` with FIVE sibling `it()` blocks inside the +existing `'pinned build tooling (ROBUST-03)'` describe -- one per package, never an `it.each` or a +loop over `Object.keys(devDependencies)` (D-04 turns on this being a hard-coded NAME list; the +workspace deliberately carries ranges for `typescript`, `vitest`, `prettier` and `@types/node`). +Reuse the existing `workspaceManifest` const and `EXACT_SEMVER` regex. Title shape: +`' is pinned to an exact version in the workspace devDependencies, never a range (LINT-01)'`. +Above the group, add a rationale block in the file's own voice recording the D-04 ROBUST-03-class +call, because the precedent is genuinely ambiguous and the reasoning has to be written down, not +just the outcome: ESLint deps join the class because `lint` is a build GATE whose behaviour a +silent minor bump can change -- the same argument that put `esbuild` in the list -- unlike +`prettier`, which is formatting-only and is deliberately out. End the block with the file's +established closing sentence form, "This spec fails the build the moment ...". + +Add `"@nx/eslint"` to `.fallowrc.jsonc` `ignoreDependencies`, FIRST (the array is alphabetical +today), with a trailing one-line comment naming it as the Nx plugin that INFERS the `lint` target +via `nx.json` and citing D-01. This is the identical case to the existing `@nx/vitest` entry and +is the only fallow change to make pre-emptively. Do NOT add `entry` lines or further +`ignoreDependencies` speculatively -- the file's own header warns that speculative entries +suppress real future findings. Contingencies, applied only against a measured `npm run fallow:ci` +finding at the end of task 3, are enumerated in RESEARCH G6's three-row table. + +Do NOT commit at the end of this task. See the plan objective's COMMIT BOUNDARY note. + + + + cd packages/github-cache && npx vitest run src/pinned-deps.spec.ts + + + +- `npm view scripts.postinstall` was run for all five packages BEFORE `npm i`, every result + was empty, and the five results are recorded in `07-EVIDENCE.md`. +- Root `package.json` `devDependencies` contains exactly these five new keys with these exact bare + specifiers: `eslint` = `9.39.5`, `@eslint/js` = `9.39.5`, `typescript-eslint` = `8.65.0`, + `@eslint-community/eslint-plugin-eslint-comments` = `4.7.2`, `@nx/eslint` = `23.1.0`. No sixth + package was added. +- `packages/github-cache/package.json` is byte-identical to its pre-task state (D-06). +- `npx vitest run src/pinned-deps.spec.ts` passes with five more tests than the pre-task count, and + the file contains no `it.each` and no iteration over `devDependencies` keys. +- `npm run check:action` exits 0. If it did not on first run, `npm run build:action` was run and + `start-cache-server/index.js` is staged in this same commit. +- `.fallowrc.jsonc` `ignoreDependencies[0]` is `"@nx/eslint"` and carries a trailing comment + naming the inference mechanism and D-01. +- The lockfile was produced in a linux/arm64 `node:24` container; the container invocation is + recorded in `07-EVIDENCE.md`. + + + +Five exact-pinned devDependencies installed from a Linux-regenerated lockfile, name-guarded by +five new `it()` blocks, with `@nx/eslint` declared to fallow and the action bundle proven +un-drifted. + + + + + Task 2: Author eslint.config.mjs and measure the D-12 baseline before fixing anything + + +eslint.config.mjs +.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md + + + +- `07-RESEARCH.md` sections `G1(b)` (the glob base path and the wrong-pattern table), `G3` (the + file-by-file lint scope, the blocking `dist`/`out-tsc` finding and the required global `ignores` + block, the `.cjs` override with its ORDERING constraint), `G4` (the predicted baseline table, + the `no-unused-vars` underscore fix and the recording command), and corrections `C4`, `C5`, `C6`. +- `07-PATTERNS.md` section `eslint.config.mjs (CREATE, ...)` -- conventions 1 through 5, including + the `// ponytail:` three-part ceiling form at `with-hash-lock.ts:1-3` and the single-quote rule. +- `packages/github-cache/vitest.config.mts` -- the closest same-file-type analog for a + decision-carrying tool config. +- `packages/github-cache/pack-check.cjs` -- the one non-TypeScript file inside the lint scope. +- `.prettierrc` and `.prettierignore` -- `eslint.config.mjs` is NOT ignored, so `format:check` + (battery command 1) fails on double quotes. + + + +Create ONE root `eslint.config.mjs` (D-10: `.mjs`, not `.ts` -- a TypeScript config needs `jiti`, +an extra install and a transpile step in the `lint` critical path). Import no helper module: a +helper outside `{projectRoot}` or `tools/eslint-rules/**/*` would not be a declared input, which +is exactly the LINT-04 hole. Do NOT create `packages/github-cache/.eslintignore` (D-09) and do NOT +create a `tools/eslint-rules/` project. + +Config-object order is Claude's discretion under CONTEXT.md, but ONE ordering is forced by a +mechanism (RESEARCH C5) and must be honoured. Emit the objects in this order: + +1. A STANDALONE `ignores`-only object (global ignores, deliberately NOT the D-17 shape). + Entries: `**/dist/`, `**/out-tsc/`, `**/test-output/`, `**/.nx/`, `**/coverage/`. Do not restate + `**/node_modules/` or `.git/` -- ESLint's default config already ignores both. This block is + REQUIRED, not hygiene: `eslint .` walks the real filesystem via `hfs.walk` and does not consult + git, so the 96 generated files under `dist/` and `out-tsc/` -- gitignored, therefore never in + Nx's file map and never hashed -- WOULD be linted, making `lint`'s result depend on whether + `build` ran at an unchanged Nx hash. That is a stale-cache false PASS by construction and it is + recorded in no other project artifact. +2. `@eslint/js` `configs.recommended`. +3. The `typescript-eslint` `configs.recommended` spread (the NON-type-checked variant). Do NOT + enable `recommendedTypeChecked` and do NOT set `parserOptions.projectService` or + `parserOptions.project` (D-11): no mandated rule is type-aware, and type-aware linting would + make `lint` sensitive to every file in the TypeScript program plus the tsconfigs -- a much wider + input set to declare correctly and a much bigger stale-cache blast radius. +4. The `**/*.cjs` override, which MUST come AFTER the spread. `typescript-eslint/base` carries no + `files` key, so it applies the TS parser and `sourceType: 'module'` to EVERY linted file and + overrides ESLint's own default `.cjs` handling; an override placed before the spread is itself + overridden and silently does nothing. Set `languageOptions.sourceType` to `commonjs` and declare + four globals inline -- `require` and `__dirname` readonly, `module` writable, `process` readonly + -- rather than installing the `globals` package (it is not one of the five approved + devDependencies, is not in the LINT-04 `externalDependencies` list, and would need a fallow + entry; four names beat a sixth dependency). Turn `@typescript-eslint/no-require-imports` off for + this glob only. Keep `no-undef` LIVE here, which is what still catches a typo in the guard + script; record the lazier one-line alternative (switching `no-undef` off for `**/*.cjs`) and why + it was not taken. +5. A repo-wide `rules` object carrying: `linterOptions.reportUnusedDisableDirectives` set + explicitly to `error` (D-28 -- v9's default is a non-failing `warn`, and setting it explicitly + makes the default irrelevant for one line; skip `reportUnusedInlineConfigs`, no requirement + needs it); `@typescript-eslint/ban-ts-comment` configured with `'ts-expect-error'` set to + `allow-with-description` and `'ts-ignore'` set to `true` (D-30); the comments plugin's + `require-description` rule at `error`, imported from the plugin's `./configs` SUBPATH export + (D-29) and referenced under its flat-config prefix `@eslint-community/eslint-comments/` -- NOT + the legacy bare `eslint-comments/` prefix that LINT-05's requirement text uses, which is the + same rule under a different prefix and must not be copied verbatim; and + `@typescript-eslint/no-unused-vars` with `argsIgnorePattern`, `varsIgnorePattern` and + `caughtErrorsIgnorePattern` all set to the leading-underscore pattern, which codifies the + convention the repo already follows at six sites rather than editing working code or spending a + described disable. + +Do NOT add the two ambient-platform ban rules in this task. They land in plan 07-02, together with +the four described disables and after their assertions have been observed RED. + +Comment-lock three facts in the file header, in the house style (what invariant holds, why the +alternative was rejected, which decision ID it satisfies): +- Every `files`/`ignores` glob is matched against a path relative to the WORKSPACE ROOT, because + the base path is `dirname(eslint.config.mjs)` -- even though the target's `cwd` is the package + directory. This is invisible until someone "tidies" a `**/`-prefixed pattern into a + project-relative form, at which point the ban silently matches nothing. Also record that + `--config ` must never be added, because that branch sets the base path to `cwd` instead + and rewrites the frame every pattern in the file is built on (D-34 already forbids overriding + the inferred command; this is a second, independent reason). +- D-08's lock, which has no vehicle at its stated home: `nx.json` is strict JSON with zero + comments today, so the lock lives HERE. Never create a root `src/` or `lib/` directory during + v0.0.2 -- it would flip `getProjectUsingESLintConfig` for the root project and silently add a + SECOND lint target, changing `hash_project_config` and rotating every task hash in the middle of + the parity investigation. Plan 07-02 task 2 adds the mechanical half of this lock. +- D-07's recorded consequence: `lint` is project-scoped (`eslint .` with `cwd` at + `packages/github-cache`), the workspace root gets NO lint target because it has neither a `src/` + nor a `lib/` directory, and `esbuild.action.mjs`, `start-cache-server/entry.ts`, + `vitest.workspace.ts` and the spike scripts are therefore NOT linted. This is an intentional, + recorded deviation from LINT-01 SC1's literal "across the workspace" -- flag it for the verifier + as a deviation, not a gap. All 32 spec files and all four CORR-05 sites are inside the scope. + +Do NOT add `eslint-config-prettier` and do NOT add `eslint-plugin-prettier` (D-14). `nx format:check` +runs Prettier directly and already owns formatting, and neither enabled recommended set turns on a +stylistic rule -- there is no conflict to bridge, so a bridge package would be a sixth dependency +buying nothing. + +Do NOT add a `.mts` override block for `vitest.config.mts` / `vitest.integration.config.mts`. +CONTEXT.md D-13 is wrong about them (RESEARCH C4): `typescript-eslint/eslint-recommended` scopes +`no-undef` off to the `ts,tsx,mts,cts` set, which includes `.mts`, and both files use ESM imports +so `no-require-imports` cannot fire. An override there would be dead configuration. + +Then MEASURE the D-12 baseline, before applying the two remediation blocks above. Concretely: +author the config through step 3 plus the header locks, run RESEARCH G4's recording command from +`packages/github-cache` (`npx eslint . --format json` piped into the per-rule counter), and record +in `07-EVIDENCE.md` the `files linted` count, the total finding count, and the per-rule breakdown. +The prediction to check against is 10 findings (+0 to 2 uncertain from the regex-class rules) and +64 files linted; nine of the ten come from `pack-check.cjs` alone. THEN add the `.cjs` override and +the `no-unused-vars` underscore options, re-run, and record the residual -- predicted zero. Record +the D-12 call with its number: predicted outcome is ZERO rules turned off and ZERO code edits, two +configuration blocks. If the real count comes in materially higher, check FIRST that the global +`ignores` block is present and correct -- 96 generated files being linted is the most likely cause +-- before reaching for any rule disable. If a genuine broad sweep does appear, turn that single +rule OFF with a one-line recorded reason plus a deferred-ideas entry, never a blanket file-level or +directory-level disable and never an open-ended cleanup. + +Also run RESEARCH G5's negative control 2 in this task and record both numbers: count linted files, +`rm -rf dist out-tsc`, count again. The two counts MUST be identical. Two different numbers means +`lint`'s result depends on whether `build` ran while its Nx hash does not, and the `ignores` block +is wrong. Restore the build output afterwards with `npm run build` and `npm run typecheck`. + +Write the file with SINGLE quotes -- `.prettierrc` sets `singleQuote: true` and `eslint.config.mjs` +is not in `.prettierignore`, so `npm run format:check` fails otherwise. + +Do NOT commit at the end of this task. + + + + npm run format:check + + + +- `eslint.config.mjs` exists at the workspace root, is `.mjs`, imports no local helper module, and + uses single quotes throughout. +- The FIRST config object in the exported array is a standalone `ignores`-only object whose entries + are exactly the five listed stems. +- The `**/*.cjs` override object appears AFTER the `typescript-eslint` recommended spread in + document order. Verifiable as a source assertion: the index of the `.cjs` override in the array + is greater than the index of the spread. +- `linterOptions.reportUnusedDisableDirectives` is set explicitly to the string `error`. +- The comments-plugin rule is referenced under its scoped flat-config prefix, not the legacy bare + prefix used in LINT-05's requirement text. +- `parserOptions.projectService` and `parserOptions.project` appear nowhere in the file, and the + type-checked variant of the `typescript-eslint` config is not imported (D-11 / LINT-04 clause c). +- No config object targets `**/*.mts` (RESEARCH C4). +- `cd packages/github-cache && npx eslint . --format json` reports the SAME `files linted` count + before and after `rm -rf dist out-tsc` (G5 negative control 2). Both numbers are recorded in + `07-EVIDENCE.md`. +- `07-EVIDENCE.md` records: the pre-remediation `files linted` count, the pre-remediation total + finding count and per-rule breakdown, the post-remediation residual, and the explicit D-12 call + (how many rules were turned off, how many code edits were made, and why). +- The file header comment-locks all three facts named in the action: the workspace-root-relative + glob frame, D-08's never-create-a-root-`src`-or-`lib` rule, and D-07's recorded scope deviation. +- `npm run format:check` exits 0. + + + +A root flat config carrying the recommended sets, the required global ignores, the correctly +ordered CommonJS override and the LINT-05/LINT-06 opt-out rules -- with the D-12 baseline measured +before any remediation and recorded with its number. + + + + + Task 3: Land the ESLint Node-API guard harness, wire test.inputs, and commit + + +packages/github-cache/src/lint-rules.spec.ts +nx.json +packages/github-cache/src/nx-target-inputs.spec.ts + + + +- `07-RESEARCH.md` sections `G1(c)` (the `lintText` vacuity trap, the three `configStatus` + outcomes, and the exact constructor options block with its three verbatim notes), `G7` (SC1), + and `## Validation Architecture` -> `Non-vacuous assertions`. +- `07-PATTERNS.md` sections `The D-20/D-21/D-22 RED-proof spec` (sub-sections a through e) and + `packages/github-cache/src/nx-target-inputs.spec.ts` (the literal-pinning `{workspaceRoot}` + assertion at `:114-125` and its "this one DOES pin a literal, deliberately" comment framing). +- `packages/github-cache/src/nx-target-inputs.spec.ts` in full -- the header, the resolver trio, + and the recorded `expandSingleProjectInputs` warning at `:28-43` which must NOT be touched. +- `packages/github-cache/src/public-surface.spec.ts:18-29` -- the explicit-assertion-list house + rule the site table in plan 07-02 inherits. +- `nx.json` lines 42-71 -- the `test` target block being extended. + + + +- A source string containing a bare `eslint-disable-next-line` for some rule, linted at a path + under `packages/github-cache/src/` ending in `.spec.ts`, produces a `require-description` error. +- The same source with a described disable (a `--` followed by non-empty reason text) produces no + `require-description` error. +- A described disable placed over a line that violates nothing produces a + `reportUnusedDisableDirectives` error (LINT-06). +- A bare TypeScript suppression comment produces a `ban-ts-comment` error; the same comment + carrying a description does not. +- `const x: any = 1;` linted at a path ending in `.integration.spec.ts` DOES produce a + `@typescript-eslint/no-explicit-any` error. This is the CORR-06 control that proves integration + specs are LINTED and merely exempt from one block, rather than globally un-linted -- and it is + what makes plan 07-02's "no ban error at the integration path" assertions non-vacuous. +- EVERY assertion above additionally asserts that the result carries no ignore-warning (a message + with severity 1 and a null rule id). Without it, a path typo lands in `unconfigured`, ESLint + returns zero messages, and a "no error here" assertion passes for entirely the wrong reason. + + + +Create `packages/github-cache/src/lint-rules.spec.ts`. Open with a `/** ... */` header stating, +in the repo's guard-spec voice: the requirement IDs (LINT-05, LINT-06, CORR-06; LINT-02 and +LINT-03 arrive in plan 07-02), the failure mode being closed, why the obvious alternative was +rejected, and the guard's own honest limitation. + +Instantiate the ESLint Node API against the REAL root config. Use RESEARCH G1's constructor block +verbatim in shape: +- `import { ESLint } from 'eslint'` -- a genuine import of the root devDependency, and the thing + that credits `eslint` to fallow. +- Resolve the workspace root from `import.meta.url`, never `__dirname` and never `process.cwd()` + (the house convention at five existing sites, and doubly load-bearing here because + `process.cwd()` in a unit spec is precisely what LINT-02 exists to ban). From `src/*.spec.ts` + the root is three levels up; record the level count in a comment as `docs-trust.spec.ts` and + `ppe-action.spec.ts` do. +- Pass `cwd` set to that workspace root, so the resolved file path lands in the same frame the + config's globs are matched in, and so the upward config search terminates on hop zero. +- Pass `warnIgnored: true` explicitly. It is already the default; setting it explicitly is what + makes the non-vacuity control meaningful to a later reader. +- Do NOT pass `overrideConfigFile` (the point is to load the REAL config) and do NOT pass + `overrideConfig` (it would add a config object the product run does not have, and the guard would + stop testing the shipped rule set). +- Use fixture paths INSIDE the real project tree, e.g. under `packages/github-cache/src/`, not a + bare `foo.spec.ts`. The path need not exist -- the config-status check is a pure path match -- but + keeping it inside the tree also proves the path SHAPE the ban is scoped to. + +Write ONE shared non-vacuity helper that every assertion routes through: given a lint result, +assert that the messages contain no entry with severity 1 and a null rule id. That is exactly what +ESLint's ignore-result carries, and a real rule violation always has a non-null rule id, so the +control cannot mask a finding. Put the three-line explanation from RESEARCH G1(c) beside it. + +Then write the assertions listed in this task's `` block. Assert on rule ids and counts, +never on message prose. Keep the fixture sources as short as the shape allows. + +`workflow.tdd_mode` is on, so run `npm run test` BEFORE the config's opt-out rules are trusted to +fire and record which assertions fail and which pass. A RED in which the CORR-06 control assertion +ALSO fails means the config is not being loaded at all -- the `unconfigured` trap -- not that a +rule is missing. That distinction is the whole reason the control exists; record it in the task +notes either way. + +In the SAME commit (SC1, forced by D-25), extend `nx.json` `targetDefaults.test.inputs`: +- add the string `{workspaceRoot}/eslint.config.mjs` among the other `{workspaceRoot}` string + entries, following this file's ordering convention (bare named inputs, then `{workspaceRoot}` + strings, then object entries); +- append `eslint`, `@eslint/js`, `typescript-eslint` and + `@eslint-community/eslint-plugin-eslint-comments` to the existing `externalDependencies` entry + that currently lists only `vitest`. +This is not tidiness. Without it, editing a rule replays a cached `test` PASS -- and since LINT-03 +IS the activity that edits rules, the false PASS surfaces during LINT-03 itself and reads as "the +rule does not fire". This repo has shipped that exact defect twice, in `governance-email.spec.ts` +and in `typecheck`'s spec-excluding inputs. Do not make it three. + +Also in the same commit, add ONE assertion to the existing `'the guard cannot replay a stale pass'` +describe in `packages/github-cache/src/nx-target-inputs.spec.ts`, asserting that +`targetDefaults.test.inputs` contains `{workspaceRoot}/eslint.config.mjs`. Copy the neighbouring +"this one DOES pin a literal, deliberately" comment framing -- it is how that file pre-empts the +"why isn't this delegated to the resolver?" review question. Do not touch the recorded +`expandSingleProjectInputs` warning, and do not "restore" that function: it THROWS on this inputs +array because it rejects entries carrying dependency filesets. + +Now run the FULL EIGHT-command battery -- `format:check`, `build`, `typecheck`, `typecheck:action`, +`test`, `fallow:ci`, `check:action`, `pack:check` -- and commit tasks 1 through 3 as one commit. +There is no `lint` command yet; it becomes the ninth in plan 07-03. If `fallow:ci` reports a +finding, apply the matching one-line remedy from RESEARCH G6's contingency table (an `entry` line +for the config file, or an `ignoreDependencies` entry for the comments plugin if its subpath +specifier is unresolved) in THIS commit, and add nothing beyond what the finding names. + + + + npm run test + + + +- `packages/github-cache/src/lint-rules.spec.ts` exists, imports `ESLint` from `eslint`, resolves + the workspace root through `import.meta.url`, and contains no reference to `__dirname` or + `process.cwd()`. +- The ESLint constructor call passes `cwd` and `warnIgnored`, and passes neither + `overrideConfigFile` nor `overrideConfig`. +- Every assertion in the file routes through the shared no-ignore-warning control; no assertion + reads a lint result without it. +- The CORR-06 control assertion is present and asserts that a non-ban rule DOES fire at a path + ending in `.integration.spec.ts`. +- `nx.json` `targetDefaults.test.inputs` contains the string `{workspaceRoot}/eslint.config.mjs`, + and its `externalDependencies` entry lists five names. +- `npx vitest run src/nx-target-inputs.spec.ts` passes and the file has exactly one more test than + before, inside the existing `'the guard cannot replay a stale pass'` describe. +- A RED was observed before GREEN and the failing/passing assertion split is recorded in the task + notes, together with the explicit statement of whether the CORR-06 control passed on both sides. +- All EIGHT battery commands exit 0 at the commit: `npm run format:check`, `npm run build`, + `npm run typecheck`, `npm run typecheck:action`, `npm run test`, `npm run fallow:ci`, + `npm run check:action`, `npm run pack:check`. +- Tasks 1, 2 and 3 are ONE commit. + + + +The ESLint Node-API harness proves the opt-out discipline is live rather than merely configured, +`test` re-runs when a rule changes, and the eight-command battery is green at a single commit. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| npm registry -> workspace `node_modules` | Five new devDependencies and a regenerated lockfile cross this boundary. It is the only untrusted input this plan admits. | +| contributor -> lint gate | An opt-out annotation is a contributor-authored instruction to suppress a build gate. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-07-01 | Tampering | the five new devDependencies | high | mitigate | Names taken only from RESEARCH's completed Package Legitimacy Audit (five OK, zero SLOP, zero SUS). Exact-pinned via `npm i -D -E`. `@eslint-community/eslint-plugin-eslint-comments@4.7.2` is re-confirmed against the registry at install time because it is the one entry approved from a prior session's pass rather than re-fetched. | +| T-07-02 | Tampering | `npm view scripts.postinstall` for all five | high | mitigate | Run BEFORE `npm i` (task 1). A non-empty result on any package is a STOP condition: do not install, record it, escalate to the maintainer. This gate is never auto-approvable. | +| T-07-03 | Tampering | root `package.json` devDependency specifiers | medium | mitigate | Five new `it()` blocks in `pinned-deps.spec.ts` fail the build the moment any of the five widens to a range, closing the "a later `npm install eslint@latest` passes every check" path. | +| T-07-04 | Tampering | `package-lock.json` regeneration environment | high | mitigate | Regenerated in a linux/arm64 `node:24` container (D-05). A bare Windows install prunes Linux-only optional subtrees, breaks CI `npm ci`, and is invisible locally. | +| T-07-05 | Tampering | committed `start-cache-server/index.js` bundle | medium | mitigate | `npm run check:action` immediately after `npm ci` on the regenerated lockfile; on drift, rebuild and stage the bundle in the same commit so the action-bundle-drift gate cannot fail a later commit. | +| T-07-06 | Repudiation | ESLint disable annotations | medium | mitigate | `require-description` at `error` plus `reportUnusedDisableDirectives` at `error` (this plan's task 2), proven live rather than merely configured by task 3's assertions. | +| T-07-07 | Tampering | `.fallowrc.jsonc` suppression entries | low | accept | A speculative `ignoreDependencies` entry would suppress a real future finding. Mitigated by policy rather than mechanism: only `@nx/eslint` is added pre-emptively; every other entry requires a measured `fallow:ci` finding first. | + + + +- `npm run format:check`, `npm run build`, `npm run typecheck`, `npm run typecheck:action`, + `npm run test`, `npm run fallow:ci`, `npm run check:action`, `npm run pack:check` all exit 0 at + the single commit this plan produces. +- `packages/github-cache/src/public-surface.spec.ts` passes unchanged and + `packages/github-cache/package.json` is untouched (D-06 / PARITY-05 / D2-02). +- `cd packages/github-cache && npx eslint .` runs and reports a finding count matching the recorded + post-remediation residual. +- The linted-file count is invariant across `rm -rf dist out-tsc`. + + + +- Five exact-pinned ESLint devDependencies present, name-guarded, installed from a + Linux-regenerated lockfile. +- A root `eslint.config.mjs` exists with the global ignores block, the recommended sets, the + correctly ordered CommonJS override, and the LINT-05/LINT-06 opt-out rules. +- The D-12 baseline is measured and recorded with its number and its call. +- `test` re-runs when `eslint.config.mjs` changes, wired and asserted in the same commit. +- No `lint` target exists yet, and the eight-command battery is green. + + + +## Artifacts this phase produces + +Every symbol below is CREATED by phase 7 and did not exist before it. Drift verification must not +flag any of them as an unresolved reference. + +**New files** +- `eslint.config.mjs` (plan 07-01) +- `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01, extended by plan 07-02) +- `packages/github-cache/src/lint-scope-drift.spec.ts` (plan 07-02) +- `.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md` (plan + 07-01, appended by plans 07-02, 07-03, 07-04) + +**New exported / module-level constants** (all inside the files above) +- `BAN_MESSAGE` in `eslint.config.mjs` (plan 07-02) -- the single source shared by both ban rules +- `CORR_05_SITES` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-02) -- the D-22 site + table, keyed on file plus expression text +- `WORKSPACE_ROOT` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01) + +**New npm script** +- `lint` in the root `package.json`, defined as `nx run-many -t lint` (plan 07-03) + +**New Nx graph entities** +- the inferred `lint` target on `packages/github-cache` (plan 07-03, from `@nx/eslint/plugin`) +- `nx.json` `plugins[]` entry for `@nx/eslint` (plan 07-03) +- `nx.json` `targetDefaults.lint` (plan 07-03) + +**New CI job** +- `lint` in `.github/workflows/ci.yml` (plan 07-03) + +**ESLint rule ids configured by this phase** (none existed before; the repo had no linter) +- `no-restricted-imports`, `no-restricted-syntax` (plan 07-02) +- `@typescript-eslint/ban-ts-comment`, `@typescript-eslint/no-unused-vars`, + `@typescript-eslint/no-require-imports`, `no-undef`, + `@eslint-community/eslint-comments/require-description` (plan 07-01) +- `linterOptions.reportUnusedDisableDirectives` (plan 07-01) + +**New spec describe-block subjects** (titles are the executor's, the subjects are fixed) +- the opt-out discipline assertions (LINT-05/LINT-06) and the CORR-06 integration-path control + (plan 07-01) +- the D-21 evasion-shape verdicts and the D-22 four-site table (plan 07-02) +- the D-19 glob/vitest superset agreement and the D-08 root-directory lock (plan 07-02) +- the `lint` input probes and their negative control (plan 07-03) + + + +Create +`.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-SUMMARY.md` +when done. + diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-SUMMARY.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-SUMMARY.md new file mode 100644 index 00000000..540236cd --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-SUMMARY.md @@ -0,0 +1,249 @@ +--- +phase: 07 +plan: 01 +subsystem: lint-toolchain +tags: [eslint, flat-config, nx-inputs, stale-cache, supply-chain, tdd] +status: complete +requires: + - the eight-command pre-commit battery + - packages/github-cache/src/pinned-deps.spec.ts (ROBUST-03 name-list guard) + - packages/github-cache/src/nx-target-inputs.spec.ts (Nx resolver-trio inputs guard) +provides: + - eslint.config.mjs (root flat config; plan 07-02 adds the ban rules to it) + - packages/github-cache/src/lint-rules.spec.ts (ESLint Node-API harness; 07-02 extends it) + - WORKSPACE_ROOT module const in lint-rules.spec.ts + - the five exact-pinned ESLint devDependencies (07-03 registers @nx/eslint as a plugin) + - nx.json targetDefaults.test.inputs carrying {workspaceRoot}/eslint.config.mjs + - 07-EVIDENCE.md (appended to by 07-02, 07-03, 07-04) +affects: + - package.json + - package-lock.json + - nx.json + - .fallowrc.jsonc + - start-cache-server/index.js +tech-stack: + added: + - eslint@9.39.5 + - "@eslint/js@9.39.5" + - typescript-eslint@8.65.0 + - "@eslint-community/eslint-plugin-eslint-comments@4.7.2" + - "@nx/eslint@23.1.0" + patterns: + - ESLint 9 flat config, single root file, no helper module (D-10) + - non-type-checked typescript-eslint recommended (D-11) + - standalone global `ignores` object distinct from D-17 per-object ignores + - ESLint Node-API (`lintText`) as a permanent guard rather than a one-time observation +key-files: + created: + - eslint.config.mjs + - packages/github-cache/src/lint-rules.spec.ts + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md + modified: + - package.json + - package-lock.json + - nx.json + - .fallowrc.jsonc + - packages/github-cache/src/pinned-deps.spec.ts + - packages/github-cache/src/nx-target-inputs.spec.ts + - start-cache-server/index.js +decisions: + - "D-12 call: ZERO rules turned off repo-wide, ZERO code edits, TWO configuration blocks. Baseline 10 findings -> residual 0." + - "Q2 resolved: fallow auto-credits eslint.config.mjs and its three imports; the contingency `entry` line was NOT needed." + - "Q10 contingency FIRED: undici 6.27.0 -> 6.28.0 drifted the action bundle; rebuilt and staged in the same commit per SC9." + - "The shared non-vacuity control needed a position check, not just severity+ruleId; the RED observation is what exposed it." + - "comments.recommended is spread (the ./configs subpath D-29 names), bringing five aligned opt-out-hygiene rules at zero measured cost." +metrics: + duration: ~40 min + tasks: 3 + files: 10 + tests: 438 -> 453 + completed: 2026-07-27 +--- + +# Phase 7 Plan 01: Lint Toolchain Adoption Summary + +ESLint 9 flat config adopted with five exact-pinned devDependencies, the opt-out discipline +proven live through the ESLint Node API, and the D-25 `test.inputs` hole closed in the same +commit -- with no `lint` target yet, because the mechanism forbids one before the config +exists. + +## What Shipped + +**Five exact-pinned devDependencies** in the ROOT manifest, each name-guarded by its own +`it()` block in `pinned-deps.spec.ts` (never an `it.each`, because D-04 turns on the guard +being a hard-coded NAME list -- the workspace deliberately carries ranges for `typescript`, +`vitest`, `prettier` and `@types/node`). The lockfile was regenerated in a linux/arm64 +`node:24` container per D-05, and the Linux-only WASM-fallback subtrees a Windows install +would have pruned are verified present. + +**`eslint.config.mjs`** at the workspace root: a standalone global `ignores` object, the two +recommended sets (non-type-checked), the CommonJS override ordered after the +`typescript-eslint` spread, the comments plugin, and the LINT-05/LINT-06 opt-out rules. Three +facts are comment-locked in the header -- the workspace-root glob frame, D-08's +never-create-a-root-`src`-or-`lib` rule (which has no vehicle at its stated home, because +`nx.json` is strict JSON), and D-07's recorded scope deviation. + +**`lint-rules.spec.ts`**: nine assertions running real ESLint against the real root config via +`lintText`, all routed through a shared non-vacuity control, plus a self-test proving that +control can itself fail. + +**The D-25 wiring**: `{workspaceRoot}/eslint.config.mjs` plus the four ESLint +`externalDependencies` in `test.inputs`, with the guard assertion in the same commit. + +## Task Commits + +All three tasks land as ONE commit, per the plan's declared commit boundary. This is forced, +not stylistic: `fallow dead-code --fail-on-issues` is a battery command, and five new +devDependencies with zero importers is an unused-dependency finding. `@eslint/js`, +`typescript-eslint` and the comments plugin are credited only by `eslint.config.mjs` (task 2); +`eslint` itself is credited only by `import { ESLint } from 'eslint'` in the guard spec (task +3). A commit after task 1 or task 2 alone would have been RED. + +## Key Results + +**The D-12 baseline reproduced RESEARCH G4's analytic prediction exactly** -- 64 files linted, +10 findings, file-for-file and line-for-line, with the low-confidence regex class producing +zero. Post-remediation residual is zero. The call: **zero rules turned off repo-wide, zero +code edits, two configuration blocks.** One scoped rule-off exists and is recorded honestly +rather than claimed away -- `@typescript-eslint/no-require-imports: 'off'` limited to +`**/*.cjs`, which D-13 mandates for `pack-check.cjs`. `no-undef` was deliberately kept LIVE +for that glob via a four-name inline globals map, so the one file in the repo where the rule +still applies keeps its typo check. + +**The G3 finding is closed and the closure is proven.** The linted-file count is invariant +across `rm -rf dist out-tsc` (64 / 64). M7 shows that control can fail: without the global +`ignores` block the count is **155**, so 91 generated files would be linted at an Nx hash that +never moves. + +**Q10's contingency fired.** The container lockfile regeneration re-resolved `undici` +6.27.0 -> 6.28.0 through `@actions/*`'s ranged dependencies, drifting the committed action +bundle by 88 lines. Rebuilt and staged in this same commit per SC9 -- never as a follow-up, +which would leave the `action-bundle-drift` gate failing on this commit and every later one. + +**Q2 resolved favourably.** `fallow:ci` is green with the new config file and its three +imports, so fallow does auto-credit `eslint.config.mjs` (F20's binary-strings inference was +right). No speculative `.fallowrc.jsonc` entry was added; the only fallow change is the +`@nx/eslint` `ignoreDependencies` line, which was the one certain case. + +## TDD + +The RED was produced deliberately by stripping the opt-out rules object from the config: +**2 failed, 7 passed of 9**, restored to 9/9. The two failures were exactly the assertions +depending on rules only that object provides. Critically, **the CORR-06 controls passed on +both sides** -- had they failed too, the RED would have meant the config was not loading at +all (the `unconfigured` trap) rather than that a rule was missing. + +Recorded honestly: three `ban-ts-comment` assertions also passed in RED, because +`typescript-eslint`'s recommended set already enables that rule and its plugin defaults +coincide with D-30's configuration. Those three do not discriminate D-30's explicit block. +The block still earns its four lines -- it pins the behaviour against a future default change, +and the `@ts-ignore`-described-form assertion would fail if `'ts-ignore': true` were weakened +-- but the stronger claim is not made. + +Mutations M5 and M7 applied, observed, and reverted before the commit. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] The shared non-vacuity control was itself vacuous** + +- **Found during:** Task 3, by the RED observation +- **Issue:** The control filtered on `severity === 1 && ruleId === null`, taken straight from + RESEARCH G1(c). Under RED that also matches the LINT-06 unused-directive report, because + with `reportUnusedDisableDirectives` back at v9's default `warn` such a report is likewise + severity 1 with a null rule id. The control therefore reported "this file was not linted" + about a file that had just been linted correctly -- the exact misdiagnosis it exists to + prevent, reproduced inside itself. It passed in GREEN only because `'error'` moves those + reports to severity 2, meaning its correctness depended on the very setting it was supposed + to be independent of. +- **Fix:** Added a position check. Measured: an ignore/unconfigured result describes the whole + FILE and carries no `line`; every rule report and every directive report carries one. The + filter is now `severity === 1 && ruleId === null && line === undefined`. The spec carries a + self-test asserting the control still detects a genuinely ignored path. +- **Files modified:** `packages/github-cache/src/lint-rules.spec.ts` +- **Note:** This is the gok lesson ("a guard's own non-vacuity control can itself be vacuous") + recurring, and it is the concrete return on running the RED rather than assuming green. + +**2. [Rule 3 - Blocking] `MSYS_NO_PATHCONV=1` required on the container invocation** + +- **Found during:** Task 1 +- **Issue:** Git Bash rewrote the container-side `-w /app` into `C:/Program Files/Git/app` and + docker rejected it. +- **Fix:** Prefixed the invocation with `MSYS_NO_PATHCONV=1`. Recorded in `07-EVIDENCE.md` so + the next executor does not rediscover it. + +### Procedural Deviation + +**The manifest was edited directly instead of via `npm i -D -E`.** The plan names `npm i -D -E`, +but that command is itself a bare Windows `npm install` and writes `package-lock.json` -- the +exact thing D-05 forbids. Editing the manifest produces byte-identical devDependency entries +(bare exact specifiers, alphabetical) and then hands the whole resolve to the container, which +is the more faithful reading of the two instructions taken together. All five versions were +independently confirmed to resolve on the registry beforehand, and the container resolve would +have failed loudly on a bad specifier. + +### Requirement Checkboxes Deliberately NOT Ticked + +`REQUIREMENTS.md` is left **unchanged**. The plan frontmatter lists +`[LINT-01, LINT-04, LINT-05, LINT-06]`, and running `requirements mark-complete` over that +list ticks four boxes this plan does not close: + +- **LINT-01**'s own text requires "a `lint` target wired into the CI battery". This plan + deliberately ships NO `lint` target -- that is the C8 ordering constraint, not an oversight. + Ticking it would put a factual falsehood in the ledger the milestone audit reads. +- **LINT-04** is closed "by differential, not by reading the config" (D-27/SC4). The + differential needs the `lint` target to exist. +- **LINT-05 / LINT-06** have their rules configured and proven live here, but plan 07-02 still + owes the four described disables and 07-04 owes the recorded evidence. + +All four recur in the frontmatter of 07-03 and 07-04, which are the plans that actually +complete them. The ticks belong there. (The mechanical run also introduced cosmetic blank +lines into unrelated CORR rows, which the revert removes.) + +### Discretionary Call + +**`comments.recommended` is spread rather than the plugin being hand-registered.** D-29 says +`require-description` is "imported from the plugin's `./configs` subpath export", and spreading +that export is what the subpath is for -- hand-registering `plugins` would require importing +the package's MAIN entry instead, contradicting D-29. The cost is five additional rules +(`disable-enable-pair`, `no-aggregating-enable`, `no-duplicate-disable`, +`no-unlimited-disable`, `no-unused-enable`). They are the same opt-out-hygiene family as +LINT-05/LINT-06 and measure ZERO findings, since the tree has no `eslint-disable` comments +yet. + +## Prohibitions Verified + +Both D-06 halves are clean at the commit: +`git diff --exit-code -- packages/github-cache/package.json` and +`git diff --exit-code -- packages/github-cache/src/public-surface.spec.ts` both return zero. +The package manifest is byte-identical and the public-surface guard passes unchanged, which is +itself a v0.0.2 requirement. + +## For the Verifier + +**D-07's scope narrowing is an intentional, recorded DEVIATION, not a gap.** `lint` is +project-scoped, so `esbuild.action.mjs`, `start-cache-server/entry.ts`, `vitest.workspace.ts` +and the spike scripts are not linted. That narrows LINT-01 SC1's literal "across the +workspace" to "across the project that has specs". All 32 spec files and all four CORR-05 +sites are inside the scope, so LINT-02, LINT-03 and CORR-06 are fully covered. The reasoning +is comment-locked in `eslint.config.mjs`'s header. + +**`.planning/codebase/CONVENTIONS.md` still says "ESLint is NOT configured in this +repository".** This plan falsifies that sentence. Regenerating `.planning/codebase/*` is a +Deferred Idea, not a Phase 7 deliverable -- do not treat the stale line as a contradiction. + +**No `lint` target exists yet, deliberately.** `@nx/eslint@23.1.0`'s `createNodes` returns +`[]` when no `eslint.config.*` exists anywhere, so the config file is a hard precondition of +the target. Registration is plan 07-03's job. The battery is eight commands at this commit and +becomes nine there. + +## Battery at the Commit + +Eight commands, all exit 0: `format:check`, `build`, `typecheck`, `typecheck:action`, `test`, +`fallow:ci`, `check:action`, `pack:check`. + +Unit suite: **453 tests across 32 files**, up from 438. The +15 are 5 pin guards, 9 lint-rule +assertions and 1 input assertion. + +## Self-Check: PASSED diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-PLAN.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-PLAN.md new file mode 100644 index 00000000..a837a1fa --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-PLAN.md @@ -0,0 +1,483 @@ +--- +phase: 07-lint-toolchain-and-the-ambient-platform-read-ban +plan: 02 +type: execute +wave: 2 +depends_on: ["07-01"] +files_modified: + - eslint.config.mjs + - packages/github-cache/src/lint-rules.spec.ts + - packages/github-cache/src/lint-scope-drift.spec.ts + - packages/github-cache/src/lib/cache-archive-path.spec.ts + - packages/github-cache/src/backend/releases-backend.spec.ts + - packages/github-cache/src/lib/release-asset-name.spec.ts + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +autonomous: true +requirements: [LINT-02, LINT-03, LINT-05, LINT-06, CORR-06] + +must_haves: + truths: + - "A unit spec that reads ambient platform state fails ESLint with a message naming the rule (LINT-02, CORR-06)." + - "The identical source at an `*.integration.spec.ts` path does NOT fail the ban, while a different rule still fires there -- proving exemption, not global un-linting (D-17, CORR-06)." + - "Every D-21 evasion shape has an explicitly asserted verdict, and any shape the matcher cannot reach is recorded as a named ceiling with its upgrade path (LINT-03, D-21)." + - "Each of the FOUR extant CORR-05 error positions is proven CAUGHT while it still exists (LINT-03, D-22)." + - "Each of the four sites carries a described disable whose reason states why the assertion cannot move to integration (LINT-05, LINT-06, D-31)." + - "The ESLint `files` and `ignores` extension sets are identical to each other and are a superset of the integration vitest include set (D-19)." + artifacts: + - "eslint.config.mjs -- the two ban rules and the shared ban message" + - "packages/github-cache/src/lint-rules.spec.ts -- the D-21 evasion table and the D-22 site table" + - "packages/github-cache/src/lint-scope-drift.spec.ts -- the D-19 superset guard and the D-08 root-directory lock" + - "Four described `eslint-disable-next-line` annotations across three existing spec files" + key_links: + - "The shared ban message constant -> every `paths[].message` and every `no-restricted-syntax` message, so the two rules can never give contradictory advice." + - "The D-22 site table -> file plus expression text, never line numbers (inserting the disables shifts every later line in the same commit)." + - "ESLint `files`/`ignores` globs -> `vitest.integration.config.mts`'s include extension set, asserted by reading both, never by restating either." +--- + + +Turn the ambient-platform-read convention into a build failure, prove it RED before GREEN over the +evasion shapes AND the four extant violation sites, and land the four described disables that keep +the build green until Phases 9 and 10 remove the violations themselves. + +Purpose: LINT-02 and LINT-03 are the phase's actual subject. Everything in plan 07-01 was the +scaffolding that makes a genuine RED observable here without a deliberately-red commit. + +Output: two core ESLint rules with a shared message, a permanent programmatic RED proof, a +scope-drift guard, and four described opt-outs. + +**Do NOT remove any CORR-05 violation.** Phases 9 and 10 own that. Phase 7 must land described +disables and LEAVE the violations in place, or LINT-03 has nothing to catch. A planner or executor +who does not know this will either leave the build red or delete the violations early and destroy +the evidence -- both are failures. `reportUnusedDisableDirectives` then forces each disable out +together with its violation later. That is the design working, not a leak. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-PATTERNS.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-VALIDATION.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-01-SUMMARY.md + + + + + + Task 1: Prove the ban RED, then enable the two rules and land the four described disables + + +packages/github-cache/src/lint-rules.spec.ts +eslint.config.mjs +packages/github-cache/src/lib/cache-archive-path.spec.ts +packages/github-cache/src/backend/releases-backend.spec.ts +packages/github-cache/src/lib/release-asset-name.spec.ts + + + +- `07-RESEARCH.md` section `G2` in full -- the four real violation sites read from disk with their + exact expressions and error positions, the measured selector set P1 through P7, the shape-by-shape + verdict table, the eight measured false-positive controls, the two ceilings and their recorded + forms, the `no-restricted-imports` four-entry `paths` block, and the shared message wording. +- `07-RESEARCH.md` corrections `C1`, `C2` and `C3`, and `## Open Questions` `Q5`. +- `07-PATTERNS.md` section `The four CORR-05 sites` -- each site's verbatim source, the reason-text + pattern, and the line-number-rot warning; and section `The D-20/D-21/D-22 RED-proof spec` + sub-section (b) for the explicit-assertion-list house rule. +- `packages/github-cache/src/lint-rules.spec.ts` as it stands after plan 07-01 -- the constructor, + the shared no-ignore-warning control, and the header. +- The three violation-site spec files, at least the regions named in the site table below. +- `packages/github-cache/src/lib/with-hash-lock.ts:1-3` -- the only `ponytail:` comment in the tree + and therefore the exact three-part ceiling form to copy. + + + +Assertions to write BEFORE the rules exist, each with an explicit expected verdict: + +D-21 evasion shapes, at a unit-spec path: +- `process.platform` -- CAUGHT by the primary member-expression selector. +- destructuring the platform binding out of the process object -- CAUGHT at the declarator. +- aliasing the process object into a local and reading the member off the alias -- CAUGHT at the + declarator, which is the better error location than the read. +- a named import of a banned accessor from the os module -- CAUGHT by the imports rule. +- a namespace import of the os module -- CAUGHT TWICE (the imports rule reports the namespace + specifier regardless of the local binding name, and the namespace-member selector also fires). +- computed access to the process object through a variable key -- CAUGHT by the deliberately broad + computed-access selector. +- a dynamic import of the os module and of the path module -- CAUGHT by the import-expression + selector ONLY. The imports rule has no import-expression visitor and cannot see either. + +False-positive controls, all of which must produce ZERO ban errors (RESEARCH measured all eight +clean; assert at least these five): the canonical allowed shape (the single-argument platform +helper called with an explicit literal), a plain object literal carrying a platform-named property +read back off it, an environment variable read through the dotted form on the process object, a +join call on the path module, and a named import of two non-banned path accessors. + +Direction pair (CORR-06): every ban assertion above, re-run at a path ending in +`.integration.spec.ts`, produces NO ban error -- paired with the plan 07-01 control that a +different rule DOES still fire at that same path. + +D-22, the four extant sites: for each row of the site table, read the REAL file, locate the +violating expression by searching the file content, blank out the disable line (do not delete it, +so numbering is preserved), lint the resulting text at the real file's path, and assert exactly one +ban error is reported at that expression's position. Separately assert that the file as committed +carries a described disable at that position and that the reason text is non-empty and mentions +integration. + + + +STEP 1 -- write the assertions first and observe the RED. Extend +`packages/github-cache/src/lint-rules.spec.ts` with everything in this task's `` block, +run `npm run test`, and RECORD which assertions failed and which passed. Expect a partial RED: the +"error at the unit-spec path" assertions fail while the direction-control assertions pass on both +sides by design. A RED in which the direction controls ALSO fail means the config is not being +loaded at all, not that the rules are missing -- that is the ignored/unconfigured trap, and it is +the single most likely way this phase ships a vacuous guard. Every new assertion routes through +plan 07-01's shared no-ignore-warning control; none may read a lint result without it. + +Encode the D-22 site table as a SCREAMING_SNAKE_CASE module-level constant of objects, in the +explicit-assertion-list house style (never a snapshot -- an intentional change must land as a +reviewable diff). Key every row on FILE plus EXPRESSION TEXT, NEVER on a line number: inserting the +disables shifts every later line in the very commit that creates the table. Comment-lock the +removal schedule on the table so a Phase 9 or Phase 10 executor deletes the ROW together with the +SITE: + +| Site (file + expression) | Removed by | +|---|---| +| `src/lib/cache-archive-path.spec.ts` -- the named import of the temp-directory accessor from the os module, at line 1 | VER-02, Phase 9 | +| `src/backend/releases-backend.spec.ts` -- the wrong-OS fixture derived from the running platform, at line 38 | CORR-02, Phase 10 | +| `src/lib/release-asset-name.spec.ts` -- the asset-name call taking the running platform, at line 39 | CORR-02, Phase 10 | +| `src/lib/release-asset-name.spec.ts` -- the platform-helper default-argument assertion, at line 60 | NOTHING in this milestone. Phase 10 makes an explicit call; the recommendation on record is moving it to `src/server/public-server.integration.spec.ts`, where LINT-02 allows it | + +FOUR sites and FOUR error positions -- at lines 1, 38, 39 and 60 as the files stand today. There is +NO fifth error position. `cache-archive-path.spec.ts:26` calls a binding whose import is already +the error, and RESEARCH measured it against every selector: zero matches, correctly so, because in +strict ESM that binding cannot exist without the import and the import is the chokepoint. A disable +placed above line 26 would be an UNUSED directive, and `reportUnusedDisableDirectives` at `error` +would fail the build -- the phase would ship red through its own opt-out discipline. CONTEXT.md +D-22's table and REQUIREMENTS.md CORR-05's table both read "line 1 AND line 26"; that is correct as +a SITE (both lines leave together in Phase 9) and wrong as an error position. Note the same +correction for ROADMAP SC3, which says "three CORR-05 violations" where REQUIREMENTS, CONTEXT and +RESEARCH all say FOUR -- use FOUR, and record the correction so the verifier does not read it as a +miss. + +STEP 2 -- enable the two rules in `eslint.config.mjs`. TWO rules, both required, no plugin (D-15): +one rule alone proves RED for one shape and silently misses the other -- `no-restricted-imports` is +the only rule that can see a destructured named import, and `no-restricted-syntax` is the only one +that can ban a member of a namespace import or reach a dynamic import. Both are ESLint core; no new +dependency. +The scope block is `files` set to `**/*.spec.{ts,mts,cts}` with `ignores` set to +`**/*.integration.spec.{ts,mts,cts}` -- the FULL three-extension set in BOTH globs (D-16). The +`.ts`-only form INVERTS the rule: the integration vitest config includes the full set, so an +integration spec written as `.mts` would be linted as a unit spec and its LEGITIMATE platform read +would fail lint, while a `.mts` unit spec would slip the ban entirely. The `ignores` key sits +ALONGSIDE `files` in the SAME config object (D-17), never as a standalone `ignores`-only object: an +`ignores` beside `files` removes those paths from THIS object only, so integration specs keep every +other rule and lose only the ban -- which is precisely CORR-06's "the same APIs stay ALLOWED in +integration". A standalone form would globally un-lint them, and plan 07-01's different-rule +control is what proves this did not happen. + +Rule 1, `no-restricted-imports`, with FOUR `paths` entries, not two: the source match is an exact +string lookup, so the prefixed and bare module specifiers are independent keys and both must be +listed. Entries: the prefixed os specifier and the bare os specifier, each with `importNames` set +to the seven accessors `tmpdir`, `EOL`, `platform`, `arch`, `homedir`, `type`, `release`; and the +prefixed path specifier and the bare path specifier, each with `importNames` set to `sep`, +`delimiter`, `win32`, `posix`. This is the only rule that can see a destructured named import, and +one of the four real sites is exactly that shape. + +Rule 2, `no-restricted-syntax`, with RESEARCH G2's measured selector set. Use these strings: +- P1: `MemberExpression[computed=false][object.name='process'][property.name=/^(platform|arch)$/]` +- P2: `MemberExpression[computed=true][object.name='process']` +- P3: `VariableDeclarator[init.name='process']` +- P4: `MemberExpression[computed=false][object.name=/^(os|nodeOs)$/][property.name=/^(tmpdir|EOL|platform|arch|homedir|type|release)$/]` +- P5: `MemberExpression[computed=false][object.name=/^(path|nodePath)$/][property.name=/^(sep|delimiter|win32|posix)$/]` +- P6: `ImportExpression[source.value=/^(node:)?(os|path)$/]` +- P7: `MemberExpression[computed=false][object.property.name='process'][property.name=/^(platform|arch)$/]` + +P6 is MANDATORY, not optional. `no-restricted-imports` at 9.39.5 has visitors for static import and +export declarations only and has no import-expression visitor, so it closes the STATIC import family +and not "the whole import family" as STACK.md and PITFALLS.md state. Without P6, D-21's dynamic +import assertion fails and the executor will look in the wrong rule. + +P1 carries the non-computed constraint precisely so that P2 can be a separate, deliberately BROAD +ban on ALL computed access to the process object -- which is the only way to reach a read through a +runtime key. P2's blast radius includes computed access to the environment bag; that is intended, +because within a unit spec computed indexing of the process object has no legitimate use, and the +dotted environment read is measured clean. Say so in P2's message. + +P7 is RECOMMENDED and additive: it matches the globalThis-qualified form and, measured, does not +double-report the plain form. If P7 is declined, that shape MUST be recorded as a named +`// ponytail:` ceiling with the globalThis qualifier as the evasion and P7 as the upgrade path. +Record the P4/P5 ceiling either way, in the three-part form the repo's single existing ceiling +comment uses -- what the simplification is, the named limit, and the upgrade path with its trigger: +P4 and P5 hardcode the conventional namespace binding names, so a namespace bound to any other name +is invisible to THEM and is still an error because the imports rule reports the namespace import +itself regardless of the local name. Do NOT drop the object-name constraint without adding an +allowlist of legitimate objects -- measured, the unconstrained form false-positives on the +canonical allowed shape and on a plain property read. + +Declare ONE shared message constant and reference it from every `paths[].message` and every +`no-restricted-syntax` message, so the two rules can never give contradictory advice. Its wording +is Claude's discretion; it must cite CORR-06, state that a unit spec must not derive an expectation +from the RUNNING machine, name the canonical allowed shape as the single-argument platform helper +called with an explicit literal, offer moving the assertion to an integration spec as the +alternative, and say that opting out needs a described disable stating why the assertion cannot +move. It MUST NOT name the two-argument form of the asset-name helper (D-18): CORR-02 deletes that +parameter in Phase 10, three phases after this rule is written, and `fallow` would then flag the +example. Note that ROADMAP SC2 uses exactly that forbidden example -- do not copy it. + +STEP 3 -- in the SAME commit, add the four described `eslint-disable-next-line` annotations, one at +each of the four error positions and nowhere else. Form: +`// eslint-disable-next-line -- `. Site 1 disables the imports rule; sites 2, 3 +and 4 disable the syntax rule. Each reason must state WHY the assertion cannot move to integration, +must contain the word integration (the guard asserts this), and must name its removal owner from +the site table. Write them in the file-local comment voice -- the density model is +`release-asset-name.spec.ts:45-49`. At `releases-backend.spec.ts` the violating expression already +carries a three-line rationale block above it; the disable joins that block. + +Then re-run `npm run test` and confirm GREEN. Run the full EIGHT-command battery and commit STEPS +1 through 3 as ONE commit -- D-31 requires the disables in the same commit as the rules, and +independently any commit where the rules are enforced and the disables are absent is red, which +this repo's bisect-safety standard forbids. + +Record in `07-EVIDENCE.md`: the RED assertion split from step 1, and whether P7 was included or +declined with a ceiling. + + + + npm run test + + + +- `eslint.config.mjs` contains exactly one config object whose `files` value is the three-extension + unit-spec glob and whose `ignores` value is the three-extension integration-spec glob, with + `ignores` a sibling key of `files` in that same object. +- That object configures both `no-restricted-imports` and `no-restricted-syntax` at `error`. +- `no-restricted-imports` has FOUR `paths` entries; the two os entries each list seven + `importNames` and the two path entries each list four. +- `no-restricted-syntax` includes an `ImportExpression` selector (P6). A source assertion suffices: + the selector list contains an entry whose selector string begins with `ImportExpression`. +- A single shared message constant is declared once and referenced by every `paths[].message` and + every `no-restricted-syntax` message. The message names the single-argument platform helper as + the allowed shape and does not name the two-argument asset-name form. +- `npx vitest run src/lint-rules.spec.ts` passes, and the file contains an explicitly asserted + verdict for every shape listed in this task's `` block, including all seven evasion + shapes and at least five false-positive controls. +- The D-22 site table constant contains exactly four rows, each keyed on a file path and an + expression string, with the removal owner comment-locked. No row contains a line number as its + key. +- Exactly FOUR `eslint-disable-next-line` annotations exist across the three violation-site files. + `git grep -c "eslint-disable-next-line" -- packages/github-cache/src` returns 4 across three + files, and `packages/github-cache/src/lib/cache-archive-path.spec.ts` accounts for exactly one of + them. +- Every one of the four reason texts is non-empty and contains the word `integration`, asserted by + the guard, not merely present by inspection. +- No CORR-05 violating expression was deleted, moved, or rewritten. `git diff` over the three + violation-site files shows ONLY added comment lines. +- All EIGHT battery commands exit 0 at the commit. + + + +The two ban rules are live with a shared message, every evasion shape and every extant site has an +asserted verdict, and four described disables keep the build green while leaving the violations in +place for Phases 9 and 10. + + + + + Task 2: Guard the scope split against drift and lock the root-directory rule + + +packages/github-cache/src/lint-scope-drift.spec.ts + + + +- `07-CONTEXT.md` D-19 (the two load-bearing invariants) and D-08. +- `07-RESEARCH.md` `## Open Questions` `Q6` and `Q7` -- how to read `eslint.config.mjs` from a + `.ts` spec under `nodenext`, and why importing the vitest configs probably fails. +- `07-PATTERNS.md` section `The D-19 drift guard` -- the `docs-trust.spec.ts` header form, the + assert-agreement loop with a per-item failure message, and the two real single sources with their + verbatim include/exclude lines; and sub-section (d) of the RED-proof-spec section for the + read-the-file-and-strip-its-own-comments idiom. +- `packages/github-cache/src/docs-trust.spec.ts` and + `packages/github-cache/src/cleanup-workflow.spec.ts` -- the two shapes being combined. +- `packages/github-cache/vitest.config.mts` and `vitest.integration.config.mts` -- the two include + lines the guard reads. + + + +- The extension set in the ESLint `files` glob is IDENTICAL to the extension set in the ESLint + `ignores` glob. Asymmetry here is the inversion bug: an exemption narrower than the ban silently + bans a legitimate integration platform read. +- That shared extension set is a SUPERSET of the extension set in + `vitest.integration.config.mts`'s include glob, so no integration spec can ever be linted as a + unit spec. +- "Agree" is deliberately NOT set equality. `vitest.config.mts`'s unit include is wider on purpose, + so an equality assertion against it would be permanently red. +- No `src` or `lib` directory exists at the workspace root (D-08's mechanical half). + + + +Create `packages/github-cache/src/lint-scope-drift.spec.ts`. Open with the `docs-trust.spec.ts` +header form: the requirement IDs (LINT-02, D-19, D-08), the drift failure mode being closed, why +the obvious alternative was rejected, and the guard's honest limitation. + +Read the REAL configs; never restate a glob in the assertion, which would make the guard a copy of +the thing it is guarding. For the ESLint side, the recommended route is a dynamic import through a +NON-LITERAL specifier built from `import.meta.url` -- TypeScript types that as `any` and does not +attempt to resolve it, which a static import would fail to do under `nodenext` because there is no +declaration file and `allowJs` is unset. If that route misbehaves, fall back to the house +disk-read idiom: read the file text, strip comment lines FIRST, then match. The strip is mandatory +on the fallback path and the reason is not cosmetic -- `eslint.config.mjs`'s own header comments +quote the very globs being asserted, so a naive substring match would pass even if the real config +value had drifted. Restate that reason in the spec, as `cleanup-workflow.spec.ts` and +`ppe-action.spec.ts` both do for their own files. + +For the vitest side use the disk-read idiom directly and do not burn time on importing them: those +configs export a function that evaluates a CommonJS directory global when called, which the ESM +transform does not inject for a `.mts` module imported from a spec. + +Assert the two D-19 invariants from this task's `` block. Use the `expect(value, message)` +second-argument form inside any loop so a failure names the offending extension, matching +`docs-trust.spec.ts:49-55`. + +Add the D-08 mechanical lock: assert that neither a `src` nor a `lib` directory exists at the +workspace root. This is the carrier decision the plan owes: D-08 says "comment-lock this at the +plugin registration in `nx.json`", but `nx.json` is strict JSON with zero comments today and has no +vehicle. The lock therefore lives in two places -- the prose half in `eslint.config.mjs`'s header +(plan 07-01 task 2) and this two-line mechanical half, which actually fails the build the moment +someone creates one of those directories and silently adds a second lint target mid-parity- +investigation. The lazier alternative was the header comment alone; it was not taken because a +comment in a file nobody opens while creating a root directory is not a lock, and the mechanical +half costs two lines. Comment the reasoning inline so a future reader does not delete it as +paranoia. + +TDD ordering: write the assertions and run `npm run test` before the guard reads anything real, or +mutate one glob to observe the failure -- the M4 mutation in plan 07-04 is the formal version of +this and must go red for the RIGHT assertion. A guard that stays green under M4 is either asserting +set equality of the wrong pair or restating the globs instead of reading them. + +Run the full EIGHT-command battery and commit. + +One-file alternative, named rather than taken: this could have been a second `describe` inside +`lint-rules.spec.ts`. Two files were chosen because the two guards have genuinely different +mechanisms (the ESLint Node API versus config text reads) and different failure modes, and the +RED-proof file is already the largest spec in the package. If the reviewer prefers one file, the +merge is mechanical. + + + + cd packages/github-cache && npx vitest run src/lint-scope-drift.spec.ts + + + +- `packages/github-cache/src/lint-scope-drift.spec.ts` exists and resolves every path through + `import.meta.url`; it contains no reference to `__dirname` and none to `process.cwd()`. +- The spec obtains the ESLint globs and the vitest include globs by READING the real files. A + source assertion suffices: no glob string is written as a literal inside an `expect` call. +- If the disk-read fallback is used for either side, comment lines are stripped before matching, + and the reason is restated in the spec. +- The spec asserts extension-set IDENTITY between the ESLint `files` and `ignores` globs, and + extension-set SUPERSET against the integration vitest include -- not equality against the unit + vitest include. +- The spec asserts that no `src` and no `lib` directory exists at the workspace root, with the + D-08 reasoning inline. +- `npx vitest run src/lint-scope-drift.spec.ts` passes. +- All EIGHT battery commands exit 0 at the commit. + + + +The ESLint scope split cannot silently diverge from the vitest partition, and the second-lint-target +hazard has a mechanical lock rather than only a comment. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| contributor -> unit spec | A spec author can introduce an ambient platform read that makes a cross-OS-shared cache entry machine-dependent. This is the boundary the whole phase exists to close. | +| contributor -> lint gate | An opt-out annotation suppresses that closure for one line. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-07-08 | Tampering | the two ban rules in `eslint.config.mjs` | high | mitigate | The rules are proven live rather than configured: a permanent programmatic spec asserts a verdict for every evasion shape and for all four extant sites, and plan 07-04's M1/M2/M3 mutations prove each selector family is individually load-bearing. | +| T-07-09 | Repudiation | the four described disables | high | mitigate | `require-description` makes a bare disable an error, `reportUnusedDisableDirectives` makes a stale one an error, and the guard asserts each reason is non-empty and mentions integration. An opt-out can never be silent and can never outlive its violation. | +| T-07-10 | Tampering | the `files`/`ignores` extension sets | medium | mitigate | The D-19 drift guard asserts identity between the two sets and superset over the integration include, reading both from the real configs. Mutation M4 proves it can fail. | +| T-07-11 | Spoofing | a namespace import bound to an unconventional name | low | accept | P4 and P5 hardcode the conventional binding names and miss other names. Accepted because the imports rule reports the namespace import itself regardless of local name, and the dynamic form is closed by P6. Recorded as a named ceiling with its upgrade path rather than left implicit. | +| T-07-12 | Tampering | a platform read hidden behind a helper in another module | low | accept | Out of reach for any non-type-aware rule, and type-aware linting is excluded by D-11 for a stated stale-cache-blast-radius reason. Recorded as a residual ceiling, not silently ignored. | + + + +- All EIGHT battery commands green at each of this plan's two commits. +- `packages/github-cache/src/public-surface.spec.ts` passes unchanged (D-06). +- No violating expression removed: the diff over the three CORR-05 spec files is comment-only. +- The RED observation from task 1 step 1 is recorded with its failing/passing assertion split. + + + +- A unit spec reading ambient platform state fails ESLint; the same source at an integration path + does not, while a different rule still fires there. +- Seven evasion shapes and four extant sites each carry an explicitly asserted verdict. +- Four described disables exist, at four positions, with no fifth. +- The scope split is guarded against drift and the root-directory hazard is mechanically locked. + + + +## Artifacts this phase produces + +Every symbol below is CREATED by phase 7 and did not exist before it. Drift verification must not +flag any of them as an unresolved reference. + +**New files** +- `eslint.config.mjs` (plan 07-01) +- `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01, extended by plan 07-02) +- `packages/github-cache/src/lint-scope-drift.spec.ts` (plan 07-02) +- `.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md` (plan + 07-01, appended by plans 07-02, 07-03, 07-04) + +**New exported / module-level constants** +- `BAN_MESSAGE` in `eslint.config.mjs` (plan 07-02) +- `CORR_05_SITES` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-02) +- `WORKSPACE_ROOT` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01) + +**New npm script** +- `lint` in the root `package.json`, defined as `nx run-many -t lint` (plan 07-03) + +**New Nx graph entities** +- the inferred `lint` target on `packages/github-cache` (plan 07-03) +- `nx.json` `plugins[]` entry for `@nx/eslint` (plan 07-03) +- `nx.json` `targetDefaults.lint` (plan 07-03) + +**New CI job** +- `lint` in `.github/workflows/ci.yml` (plan 07-03) + +**ESLint rule ids configured by this phase** (none existed before; the repo had no linter) +- `no-restricted-imports`, `no-restricted-syntax` (plan 07-02) +- `@typescript-eslint/ban-ts-comment`, `@typescript-eslint/no-unused-vars`, + `@typescript-eslint/no-require-imports`, `no-undef`, + `@eslint-community/eslint-comments/require-description` (plan 07-01) +- `linterOptions.reportUnusedDisableDirectives` (plan 07-01) + +**New spec describe-block subjects** +- the opt-out discipline assertions and the CORR-06 integration-path control (plan 07-01) +- the D-21 evasion-shape verdicts and the D-22 four-site table (plan 07-02) +- the D-19 glob/vitest superset agreement and the D-08 root-directory lock (plan 07-02) +- the `lint` input probes and their negative control (plan 07-03) + + + +Create +`.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-SUMMARY.md` +when done. + diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-SUMMARY.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-SUMMARY.md new file mode 100644 index 00000000..8da81d7a --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-SUMMARY.md @@ -0,0 +1,315 @@ +--- +phase: 07 +plan: 02 +subsystem: lint-toolchain +tags: [eslint, no-restricted-syntax, no-restricted-imports, ambient-platform-ban, tdd, drift-guard, flaky-test] +status: complete +requires: + - eslint.config.mjs (plan 07-01) + - packages/github-cache/src/lint-rules.spec.ts ESLint Node-API harness (plan 07-01) + - the shared non-vacuity control with its position check (plan 07-01) + - the eight-command pre-commit battery +provides: + - the two ban rules and BAN_MESSAGE in eslint.config.mjs (LINT-02) + - CORR_05_SITES, the four-row D-22 site table in lint-rules.spec.ts + - the D-21 evasion-shape verdicts and the six false-positive controls + - the CORR-06 direction pair at an *.integration.spec.ts path + - four described eslint-disable-next-line directives at four error positions + - packages/github-cache/src/lint-scope-drift.spec.ts (D-19 guard, D-08 mechanical lock) +affects: + - packages/github-cache/src/lib/cache-archive-path.spec.ts + - packages/github-cache/src/backend/releases-backend.spec.ts + - packages/github-cache/src/lib/release-asset-name.spec.ts + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +tech-stack: + added: [] + patterns: + - two core ESLint rules behind ONE shared message constant (D-15, no plugin, no new dependency) + - ignores as a SIBLING of files, so integration specs keep every other rule (D-17) + - a site table keyed on FILE + EXPRESSION TEXT, never a line number (D-22) + - blank-do-not-delete when stripping a directive, so line numbering survives the strip + - importing eslint.config.mjs through a NON-LITERAL specifier to read real config values +key-files: + created: + - packages/github-cache/src/lint-scope-drift.spec.ts + modified: + - eslint.config.mjs + - packages/github-cache/src/lint-rules.spec.ts + - packages/github-cache/src/lib/cache-archive-path.spec.ts + - packages/github-cache/src/backend/releases-backend.spec.ts + - packages/github-cache/src/lib/release-asset-name.spec.ts + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +decisions: + - "P7 INCLUDED, not declined -- so globalThis.process.platform needs no ceiling comment." + - "Q6 closed affirmatively: the non-literal dynamic import of eslint.config.mjs works under both vitest and typecheck, so the disk-read fallback was not needed on the ESLint side." + - "Q5 closed affirmatively: every esquery-measured verdict reproduced under real ESLint with the real parser." + - "The path.join false-positive control uses a LOCAL object, because a namespace import of node:path is itself an error and belongs in the evasion table." + - "M4 run in a third variant not in VALIDATION.md (narrow BOTH globs), which is the only one that isolates the superset invariant." +metrics: + duration: ~25 min + tasks: 2 + files: 6 + post_merge_fixes: 1 + tests: 453 -> 486 + completed: 2026-07-27 +--- + +# Phase 7 Plan 02: The Ambient-Platform-Read Ban Summary + +The convention is now a build failure: two core ESLint rules behind one shared message, +proven RED before GREEN over seven evasion shapes and all four extant violations, with +four described opt-outs that keep the build green while leaving the violations in place +for Phases 9 and 10. + +## What Shipped + +**The two ban rules** in `eslint.config.mjs`, scoped by `files: ['**/*.spec.{ts,mts,cts}']` +with `ignores: ['**/*.integration.spec.{ts,mts,cts}']` as a SIBLING key in the same object. +`no-restricted-imports` carries FOUR `paths` entries -- the prefixed and bare `os` and `path` +specifiers are independent exact-string keys -- with the seven os accessors and the four path +accessors declared once each and shared across the pair. `no-restricted-syntax` carries +RESEARCH G2's measured P1..P7. One `BAN_MESSAGE` constant is referenced by every +`paths[].message` and every selector message, so the two rules cannot give contradictory +advice; P2's message extends it with its own deliberate blast radius rather than replacing it. + +**28 new assertions** in `lint-rules.spec.ts`: 7 evasion shapes, 6 false-positive controls, +7 integration-path direction assertions, and 8 site assertions (4 sites x 2). Every one routes +through plan 07-01's shared non-vacuity control. + +**`lint-scope-drift.spec.ts`**, 5 assertions: the two D-19 invariants read from the real +configs, a structural proof that `ignores` is a sibling of `files`, and D-08's mechanical +root-directory lock. + +**Four described disables** at four error positions across three files, each stating why the +assertion cannot move to integration and naming its removal owner. + +## Task Commits + +| Task | Commit | What | +|---|---|---| +| 1 | `1454404` | the two rules, the RED proof, the four disables | +| 2 | `5adde9b` | the D-19 drift guard and the D-08 lock | +| post-merge fix | `e683c92` | hoist the toolchain boot out of the per-test budget | + +Task 1 is necessarily one commit. D-31 requires the disables alongside the rules, and +independently any commit where the rules are enforced and the disables are absent is RED -- +which this repo's bisect-safety standard forbids. + +## The RED, Observed + +Written and RUN before either rule existed. **15 failed, 22 passed of 37.** + +| Group | Count | Verdict | +|---|---|---| +| plan 07-01's nine assertions | 9 | passed, untouched | +| D-21 evasion shapes at a unit path | 7 | **FAILED** | +| false-positive controls | 6 | passed (vacuously, by design) | +| CORR-06 direction pair at the integration path | 7 | passed | +| D-22 "is CAUGHT once the disable is stripped" | 4 | **FAILED** | +| D-22 "carries a described disable" | 4 | **FAILED** | + +Actual failure text, site 2: + +``` +AssertionError: expected 'const OTHER_PLATFORM: NodeJS.Platform...' to contain + 'eslint-disable-next-line no-restricte...' +Expected: "eslint-disable-next-line no-restricted-syntax" +Received: "const OTHER_PLATFORM: NodeJS.Platform =" +``` + +and for an evasion shape, `expected [] to deeply equal [ 'no-restricted-syntax' ]`. + +**The direction controls passing on BOTH sides is what makes the RED interpretable.** Had the +seven integration-path assertions failed too, the meaning would have been "the config never +loaded" -- the ignored/unconfigured trap, and the single most likely way this phase could +have shipped a vacuous guard. Intermediate after STEP 2: **6 failed, 31 passed**. After +STEP 3: **37 of 37**. + +## Key Results + +**P6 earned its mandatory status empirically.** With the full selector set live, the dynamic +import shape is caught by `no-restricted-syntax` and by nothing else -- `no-restricted-imports` +reports neither `await import('node:os')` nor `await import('node:path')`. RESEARCH C1 is +confirmed rather than assumed. + +**Q5 is closed affirmatively.** Every shape in RESEARCH G2's esquery-measured verdict table +reproduced exactly under real ESLint with `@typescript-eslint/parser`, including the two-rule +double report on a namespace import. No selector that measured MATCH came back clean. + +**Q6 is closed affirmatively.** The non-literal dynamic import of `eslint.config.mjs` works +under both `vitest` and `typecheck` on the first attempt, so the drift guard reads the REAL +evaluated array rather than matching source text. The disk-read fallback was needed only on +the vitest side, per Q7, which was not retested. + +**M4 in three variants, each failing exactly one assertion.** The third -- narrowing BOTH +globs to `{ts,mts}` -- is not in VALIDATION.md's table and is the one that matters: identity +still holds, so only the SUPERSET invariant can catch it. That is what proves the two D-19 +invariants are independently load-bearing rather than one thing asserted twice. +`eslint.config.mjs` was restored byte-identical before the commit. + +**`npx eslint .` exits 0 with zero findings.** That is the measurement proving all four +disables are USED -- an unused one is an error under `reportUnusedDisableDirectives: 'error'`. + +## Post-Merge Fix: A Flaky Timeout in Both New Guards + +The post-merge gate caught `lint-scope-drift.spec.ts` intermittently failing under +`nx run-many -t typecheck,test --skip-nx-cache` (exit 0, 1, 0 across three runs; Nx's own +flaky-task detector fired on one hash with two outcomes). Fixed in `e683c92`. + +**Root cause, measured rather than reasoned.** The module-level `import()` of +`eslint.config.mjs` pulls in the whole ESLint toolchain -- `@eslint/js`, typescript-eslint's +parser and plugin, the comments plugin. Measured in isolation on an IDLE workstation: + +| Cost | Measured | +|---|---| +| bare `import('eslint.config.mjs')` in a cold node | **981 ms** | +| first test in `lint-scope-drift.spec.ts` (pays the resolve) | 731-883 ms | +| every subsequent test in that file | 0-1 ms | +| first test in `lint-rules.spec.ts` (first `lintText` loads config + TS parser) | 592-913 ms | + +Against vitest's DEFAULT 5000 ms per-test budget that is ~5.7x headroom on an idle box. +Under CPU contention it is not enough, and because the cost falls on whichever test happens +to run FIRST, the failure location is arbitrary -- which is why it presents as flakiness +rather than as "the import is slow". CI is strictly worse: slower runners, and the four +dogfooded jobs each run a background sidecar alongside the target. + +**The fix.** Hoist the one-time cost out of the per-test budget with a `beforeAll(fn, 30_000)` +in each file, not a bigger per-test timeout. `lint-scope-drift.spec.ts` resolves the config +in the hook and stores it, so `banConfigObject()` and all three tests become synchronous; +`lint-rules.spec.ts` gets a discarded warm-up `lintText`. **`testTimeout` in +`vitest.config.mts` was deliberately NOT raised** -- that would mask this class for all 486 +tests, which is the opposite of what this phase is for. + +`banConfigObject()`'s "exactly ONE object configures `no-restricted-syntax`" assertion stayed +in the assertion layer and did NOT move into the hook, so it still fails the suite. It also +became strictly harder to fool: `flatConfig` initialises to `[]`, so a hook that never ran +now fails that length assertion instead of passing on nothing. + +**Proof, by controlled experiment rather than by repeat-running until green.** Three green +runs is what the BROKEN version already produced, so repeat runs alone prove nothing. The +decisive measurement pins the per-test budget just above the observed cost: + +| Version | `--testTimeout=500` | `--testTimeout=50` | +|---|---|---| +| pre-fix | **2 failed** / 40 passed -- `Test timed out in 500ms` on `configures the ban in ONE object...` (the exact reported failure) AND on `lint-rules.spec.ts`'s first test | not run | +| post-fix | 42 passed | **42 passed** | + +The post-fix suites pass at a budget **100x tighter** than the default, at the point where +the pre-fix version already failed. First-test duration went 731/883 ms -> 2/3 ms; headroom +against the real 5000 ms budget went from ~5.7x to >2500x. That is structural, not +statistical: no amount of contention times out a 2 ms test whose expensive dependency +resolved in a 30 s hook. + +The pre-fix run also reproduced the timeout in `lint-rules.spec.ts`, which had NOT failed in +the gate's three runs. Giving that file the same hook was therefore closing a measured +exposure, not a speculative one. + +Repeat evidence on the final state: `npm exec -- nx run-many -t typecheck,test +--skip-nx-cache` run **8 times, exit 0 every time** (`0 0 0 0 0 0 0 0`), zero `Test timed +out` across all eight logs. M4b was re-run against the refactored guard and still fails on +exactly the right assertion, so the hoist did not cost the guard its teeth. + +## Deviations from Plan + +### Discretionary Calls + +**1. P7 was INCLUDED.** RESEARCH recommends it and leaves the call to the planner. Including +it means `globalThis.process.platform` is caught rather than accepted, so D-21 needs no +ceiling comment for it. Two ceilings ARE recorded in the three-part `with-hash-lock.ts:1-3` +form: P4/P5's hardcoded namespace binding names (upgrade path: drop the `object.name` +constraint IN FAVOUR OF an allowlist, never without one), and T-07-12's helper-in-another- +module read (upgrade path: type-aware linting, which D-11 excludes for a stated reason, so +it is accepted rather than scheduled). + +**2. The `path.join` false-positive control uses a LOCAL object, not a namespace import.** +RESEARCH's control list writes it as a bare `path.join('a','b')` expression, which is correct +for an esquery run over a standalone snippet but not reproducible under real ESLint, where +`path` must come from somewhere. `import * as path from 'node:path'` IS an error -- the +imports rule reports a namespace specifier whenever the entry lists `importNames` -- so using +it here would have turned a false-positive control into a failing assertion. The namespace +form is asserted as an EVASION shape instead, which is where it belongs, and the local object +isolates exactly what the control is for: P5's property-name list. + +**3. `releaseAssetName(hash, 'win32')` was dropped from the control set.** RESEARCH lists it +as one of eight measured controls, but D-18 forbids that form anywhere, and the plan's +critical notes repeat the prohibition. Six controls shipped instead of RESEARCH's eight; the +plan required at least five. + +**4. A third M4 variant was added.** See Key Results. + +### Corrections Recorded, Not Silently Absorbed + +- **ROADMAP SC3 says "three CORR-05 violations".** REQUIREMENTS, CONTEXT and RESEARCH all say + FOUR, and FOUR is what shipped. Not scope creep. +- **CONTEXT D-22 and REQUIREMENTS CORR-05 both list `cache-archive-path.spec.ts:26` beside + `:1`.** Correct as a SITE -- both lines leave together under VER-02 in Phase 9 -- and wrong + as an error POSITION. Verified against the live rule set: the bare `tmpdir()` call produces + zero ban errors, because in strict ESM that binding cannot exist without the import and the + import is the chokepoint. A fifth directive there would have been an UNUSED directive and + would have failed the build through the phase's own opt-out discipline. Both corrections are + comment-locked on `CORR_05_SITES` so the next reader does not re-derive them. + +### Requirement Checkboxes + +The frontmatter lists `[LINT-02, LINT-03, LINT-05, LINT-06, CORR-06]`. **Three were ticked, +two deliberately were not.** + +**LINT-02 and LINT-03 are unambiguously closed.** Every clause of both requirement texts is +implemented and asserted -- the two rules, the full three-extension scope pair, the drift +spec, the evasion fixture, and all four sites confirmed CAUGHT while they still exist. + +**LINT-05 and LINT-06 are NOT ticked.** Their rules were configured in 07-01 and their four +described disables land here, but 07-04 still owes the recorded M8/M9 mutation evidence that +both are live rather than merely configured. 07-04's frontmatter carries them. + +**CORR-06 IS ticked, with one nuance the verifier should hold.** Its text says "a guard fails +the `test` target when a non-integration spec reads AMBIENT platform state", and read +literally that is only half-true today: a NEWLY written violation in some other spec fails +`lint`, which does not exist until 07-03. What fails `test` today is the guard-integrity +half -- D-25 wired `{workspaceRoot}/eslint.config.mjs` into `targetDefaults.test.inputs`, so +a rule change re-runs `test` and `lint-rules.spec.ts` goes red if the ban stops firing. The +tick was taken anyway on two grounds: the requirement's own closing line delegates +enforcement to "the lint rules in LINT-02", which are complete; and CORR-06 appears in NO +later plan's frontmatter, so leaving it would orphan it against a phase-level traceability +row that 07-03 will satisfy regardless. + +`requirements mark-complete` inserts a spurious blank line before nearly every OTHER bullet +in the file as a side effect (the same cosmetic corruption 07-01 recorded). The mechanical +run was reverted and the six intended edits -- three checkboxes, three traceability rows -- +reapplied exactly. `git diff` over `REQUIREMENTS.md` is 6 insertions and 6 deletions, nothing +else. + +## Prohibitions Verified + +**No CORR-05 violating expression was deleted, moved, or rewritten.** `git show --numstat` on +the task-1 commit reports `1 0`, `6 0` and `2 0` for the three violation-site files -- pure +additions, zero deletions, and every added line is a comment. Phases 9 and 10 own the +removals; removing one early would destroy LINT-03's evidence. + +Both D-06 halves clean at both commits: `git diff --exit-code` returns zero for +`packages/github-cache/package.json` and for +`packages/github-cache/src/public-surface.spec.ts`. + +## For the Verifier + +**Four `eslint-disable-next-line` DIRECTIVES exist, across three files** (1 + 1 + 2). Note +that a naive `git grep -c "eslint-disable-next-line" -- packages/github-cache/src` also +returns 7 hits in `lint-rules.spec.ts`; every one of those is a fixture STRING, a test name, +or a matcher argument, none is a comment token, and `npx eslint .` at exit 0 confirms none is +a live directive. The acceptance criterion's literal grep count was already unreachable in +07-01 for the same reason. + +**The `lint` target still does not exist.** The battery is eight commands at both of this +plan's commits and becomes nine in 07-03. `npx eslint .` was run directly instead, from +`packages/github-cache`, and exits 0. + +## Battery at Both Commits + +Eight commands, all exit 0: `format:check`, `build`, `typecheck`, `typecheck:action`, `test`, +`fallow:ci`, `check:action`, `pack:check`. Plus `npx eslint .` at exit 0. + +Unit suite: **486 tests across 33 files**, up from 453. The +33 are 28 in `lint-rules.spec.ts` +and 5 in the new `lint-scope-drift.spec.ts`. Measured with `--skip-nx-cache`, not a cached +replay. + +## Self-Check: PASSED diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-PLAN.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-PLAN.md new file mode 100644 index 00000000..3d9d9f7b --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-PLAN.md @@ -0,0 +1,362 @@ +--- +phase: 07-lint-toolchain-and-the-ambient-platform-read-ban +plan: 03 +type: execute +wave: 3 +depends_on: ["07-02"] +files_modified: + - nx.json + - package.json + - .github/workflows/ci.yml + - packages/github-cache/src/nx-target-inputs.spec.ts + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +autonomous: true +requirements: [LINT-01, LINT-04] + +must_haves: + truths: + - "A cacheable `lint` target exists on `packages/github-cache`, inferred by `@nx/eslint/plugin` (LINT-01, D-01)." + - "`npm run lint` is a battery command and a named CI job, making the battery nine commands (LINT-01, D-32)." + - "`lint` declares the full input list rather than inheriting the inferred one-entry external-dependency set, which IS the LINT-04 hole (D-24)." + - "The `lint` input probes carry an honest NEGATIVE control, so a resolver that resolves nothing cannot pass them all together (LINT-04)." + - "`integration` remains the ONLY target declaring a platform runtime input (CORR-04)." + artifacts: + - "nx.json `plugins[]` entry for `@nx/eslint` with the target name set to `lint`" + - "nx.json `targetDefaults.lint` with the full input list and empty outputs" + - "root `package.json` `lint` script" + - ".github/workflows/ci.yml `lint` job" + - "packages/github-cache/src/nx-target-inputs.spec.ts `lint` probes plus negative control" + key_links: + - "`@nx/eslint/plugin` -> the existence of `eslint.config.mjs` (the plugin returns no targets at all when no config file exists, silently)." + - "`nx.json` `targetDefaults.lint.inputs` -> `{workspaceRoot}/eslint.config.mjs` plus the four ESLint external dependencies (the inferred single-entry set is the stale-cache hole)." + - "`.github/workflows/ci.yml` `lint` job -> the root `lint` script -> the `lint` target (a break at any link is a red CI leg or a red battery command)." +--- + + +Wire the `lint` target into the graph, the battery and CI. + +Purpose: this is the commit that rotates EVERY task hash. Registering an Nx inference plugin +changes `hash_project_config`, which is folded into every task hash, and `test` rotates twice over +because `{workspaceRoot}/nx.json` is already an explicit `test` input. Isolating that rotation in +one commit is what makes it attributable, which Phase 8 needs. + +Output: the plugin registration, the declared input block that closes LINT-04, the input guard's +`lint` probes with an honest negative control, the root script, and the CI job. + +Ordering is forced and non-negotiable: install, then config, then registration. `@nx/eslint`'s +`createNodes` short-circuits and returns no targets when no `eslint.config.*` exists anywhere -- +silently, not as an error, which reads as "the plugin does not work". The config file has existed +since plan 07-01, so this plan is now unblocked. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-PATTERNS.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-02-SUMMARY.md + + + + + + Task 1: Declare the lint input probes RED, then register the plugin and the input block + + +packages/github-cache/src/nx-target-inputs.spec.ts +nx.json + + + +- `packages/github-cache/src/nx-target-inputs.spec.ts` in full -- the header, `PROBE_FILES`, + `hashedFilesFor`, the existing negative control at `:100-111` with its comment, and the + `'the guard cannot replay a stale pass'` describe. +- `07-CONTEXT.md` D-24, D-26, D-08, and the `nx.json` note under Canonical References (CORR-04: + `integration` must stay the only target declaring a platform discriminator). +- `07-RESEARCH.md` `F16`, `F17`, `F18`, corrections `C7` and `C8`, and `G7` couplings SC4 and SC5. +- `07-PATTERNS.md` sections `nx.json targetDefaults.lint` (the verbatim `test` block to copy the + ordering convention from, the `plugins[]` analog, and the CORR-04 constraint) and + `packages/github-cache/src/nx-target-inputs.spec.ts`. +- `nx.json` lines 12-33 (the two existing plugin registrations) and 41-131 (the four + `targetDefaults` blocks). + + + +- `hashedFilesFor('lint')` includes the project's lib source probe and its spec source probe -- + `lint` lints both, and its inputs start from `default`. +- `hashedFilesFor('lint')` does NOT include a probe path that lives OUTSIDE the project root. This + is the negative control, and it is the honest one for this target. +- `targetDefaults.lint.inputs` contains the workspace-root ESLint config entry, and its external + dependency entry lists all four ESLint packages, not just the linter itself. +- `targetDefaults.lint.outputs` is an empty array. + + + +STEP 1 -- write the probes and observe the RED. Extend +`packages/github-cache/src/nx-target-inputs.spec.ts` with the assertions in this task's +`` block, run `npm run test`, and record the failure. Before `targetDefaults.lint` exists +the helper indexes an undefined target and throws at that point, which is a loud, unambiguous RED +and the same coupling quick 260726-gok used for `test`. + +The NEGATIVE control needs a decision the plan owes you, because the obvious one does not work. +`build` is the existing discriminator for `typecheck` because `build`'s inputs genuinely exclude +specs. It is UNUSABLE for `lint`: `lint`'s inputs start from `default`, which is +`{projectRoot}/**/*` and hashes specs too, so a `build`-shaped negative would be asserting +something `lint` is not supposed to satisfy. Use instead: add ONE new entry to `PROBE_FILES` for a +workspace-root path OUTSIDE the project root -- `start-cache-server/entry.ts` is the natural choice +because it is a real file, it is genuinely not linted (D-07's recorded scope deviation), and it +already appears in `test.inputs` as a `{workspaceRoot}` string so a reader can see the two frames +side by side. Then assert `lint` does NOT hash it. This discriminates for the exact reason the +existing control does: the glob filter returns the WHOLE probe list untouched when the resolved +pattern list is empty, so a positive-only set would pass together on a resolver that resolved +nothing. A path outside `{projectRoot}` is filtered out only if the filter genuinely filtered. + +Write the reasoning above into the comment beside the new control, in the voice of the existing +one at `:100-111`. RESEARCH's non-vacuity table explicitly instructs that if no clean negative +exists for `lint`, the comment must SAY SO rather than ship a positive-only set -- a clean negative +does exist, so state which one it is and why `build` was not reused. + +Adding an entry to `PROBE_FILES` is safe for the existing assertions: every one of them is a +`toContain` or `not.toContain` on a named path, and none is a whole-array comparison. Confirm that +by running the file before and after. + +Also add the literal `{workspaceRoot}` assertions for `lint` to the existing +`'the guard cannot replay a stale pass'` describe, matching the "this one DOES pin a literal, +deliberately" framing already there. + +Do NOT touch the recorded warning about the rejected single-project input expander at `:28-43`, and +do not "restore" that function: it THROWS on this inputs array because it rejects entries carrying +dependency filesets. It looks like a cleanup and it is a regression. + +STEP 2 -- register the plugin and declare the inputs, both in `nx.json`, in the SAME commit as the +probes. + +Add to `plugins[]`, after the two existing entries and following their shape exactly: plugin +`@nx/eslint/plugin`, with `options.targetName` set to `lint`. There is no auto-registration. + +Add `targetDefaults.lint`, following this file's ordering convention -- bare named inputs first, +then `{workspaceRoot}` string entries, then object entries last: +- `default` +- `^default` +- `{workspaceRoot}/eslint.config.mjs` +- `{workspaceRoot}/tools/eslint-rules/**/*` +- an `externalDependencies` object listing `eslint`, `@eslint/js`, `typescript-eslint` and + `@eslint-community/eslint-plugin-eslint-comments` + +plus `outputs` set to an empty array. + +`targetDefaults..inputs` REPLACES the inferred list rather than merging -- verified +empirically on this repo for `test` -- so the block must restate everything it keeps. The inferred +list's single-entry external dependency set, naming only the linter itself, IS the LINT-04 hole: a +bump of the TypeScript plugin or the comments plugin would not invalidate the `lint` cache. Empty +outputs is honest, because `eslint .` with no output-file flag writes nothing, and it removes the +output-file token from `hash_project_config` entirely. + +One entry deliberately NOT restated, checked rather than assumed: at 23.1.0 the inferred inputs +also map the tsconfig chain that lives outside the project root into workspace-root entries. +STACK.md's quoted shape omits it. For this repo that chain resolves to exactly `tsconfig.base.json`, +which the shared-globals named input already folds into `default`, so the replacement list needs no +change. Record in `07-EVIDENCE.md` that this was checked, because D-24's whole premise is "restate +everything it keeps" and a planner comparing against STACK's quote would conclude the list was +complete when the real inferred list is one entry longer. + +Do NOT add a `cache` key: the inferred target is already cacheable and restating it is noise during +a milestone that is trying to hold `hash_project_config` still. Do NOT override `options.command` +and do NOT add a max-warnings flag (D-34): every mandated rule is at `error`, so warnings-as-errors +is redundant, and leaving the inferred command literal keeps one more hashed field untouched. Do +NOT give `lint` a runtime or platform input -- `integration`'s platform discriminator must remain +the ONLY one in the file (CORR-04). + +Re-run `npm run test` to GREEN, run the EIGHT-command battery, and commit. `lint` is not yet a +battery command; it becomes the ninth in task 2. + +Note for the record, and do not treat it as a defect: this commit legitimately rotates every task +hash, so the first default-branch push carrying it is an all-MISS push. Plan 07-04 writes that +pre-record. + + + + npm run test + + + +- `nx.json` `plugins[]` contains a third entry naming `@nx/eslint/plugin` with `options.targetName` + equal to `lint`. +- `nx.json` `targetDefaults.lint.inputs` has exactly the five entries listed in the action, in that + order, and `targetDefaults.lint.outputs` is an empty array. +- `nx.json` contains exactly ONE runtime input across the whole file, and it belongs to + `integration` (CORR-04). Verifiable as a source assertion over the `targetDefaults` block. +- `npx nx show project github-cache --json` lists a `lint` target. Its presence is the proof the + config-existence gate was satisfied; an absent `lint` target means `eslint.config.mjs` was not + found, not that the plugin is broken. +- `npx vitest run src/nx-target-inputs.spec.ts` passes, and the file now contains a NEGATIVE + assertion for `lint` against a probe path outside the project root, with a comment stating why + `build` was not reused as the discriminator. +- `PROBE_FILES` gained exactly one entry and every pre-existing assertion in the file still passes. +- The RED from step 1 was observed and recorded before step 2 was applied. +- All EIGHT battery commands exit 0 at the commit. + + + +The `lint` target exists in the graph with a fully declared, guarded input set, and the guard has a +negative control that genuinely discriminates. + + + + + Task 2: Add the lint battery command and the CI job + + +package.json +.github/workflows/ci.yml + + + +- `package.json` lines 5-19 -- the scripts block, grouped by concern rather than alphabetised. +- `.github/workflows/ci.yml` lines 13-24 (`format-check`), 26-42 (`fallow` with its rationale + comment), and 68-83 (`pack-check`) -- the three non-dogfooded job shapes. +- `07-CONTEXT.md` D-32, D-33, D-34. +- `07-PATTERNS.md` section `.github/workflows/ci.yml - new lint job` -- the verbatim five-line + boilerplate and the rule that every non-dogfooded job carries a rationale comment above its key. + + + +Add `"lint": "nx run-many -t lint"` to the root `package.json` scripts, placed with `build`, +`typecheck`, `test` and `integration` rather than alphabetised -- this block is grouped by concern. + +Add a `lint` job to `.github/workflows/ci.yml` beside `format-check`, `fallow` and `pack-check`. +Copy the `fallow` job's five-line boilerplate exactly: the arm64 ubuntu runner, the checkout +action, the setup-node action with the node-version file and npm cache, `npm ci`, then the single +`npm run lint`. Put a rationale comment above the job key, as all three of its siblings have, +stating what it gates and why it is a distinct named job. + +The `lint` job gets NO sidecar dogfood block (D-33). Do NOT copy from the `build`, `typecheck`, +`test` or `integration` jobs -- those carry the background-sidecar wiring, and adding a fifth cache +producer and a fifth mirrored hash family in the middle of the milestone whose entire job is +stabilising hashes buys nothing (lint runs in seconds) and adds surface to the Phase 8 +investigation. It is purely additive later and is already carried as a deferred idea, not a gap. + +The job needs no build step before it. `pack-check` runs one because its guard needs the packed +output; `lint` has no `dependsOn` and the global ignores block exists precisely so lint output does +not depend on whether `build` ran. + +Do not add `lint` to any other job's `needs:` chain, and do not assert on `ci.yml` from any spec -- +it is not an Nx input yet, and PARITY-06 registers it in Phase 9. + +Then run the full NINE-command battery for the first time -- `format:check`, `build`, `typecheck`, +`typecheck:action`, `test`, `lint`, `fallow:ci`, `check:action`, `pack:check` -- and commit. From +this commit onward the battery is nine, and the repo standard set by quick 260726-4cc and +260726-gok is green at EVERY commit, not just the last. + + + + npm run lint + + + +- `npm run lint` exits 0 and its output reports the `lint` target ran successfully. +- Root `package.json` scripts contain `lint` defined as `nx run-many -t lint`, positioned with the + four sibling target scripts. +- `.github/workflows/ci.yml` contains a `lint` job whose steps are exactly checkout, setup-node + with the node-version file and npm cache, `npm ci`, and `npm run lint` -- with no background + sidecar step, no `cancel:` step, and no build step. +- The `lint` job carries a rationale comment above its key. +- All NINE battery commands exit 0 at the commit. + + + +`lint` is a battery command and a named CI leg; the battery is nine commands from this commit +onward. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Nx cache -> gate result | A cached `lint` result is trusted as if the command ran. An under-declared input set lets a stale PASS stand in for an unrun gate. | +| CI workflow -> merge decision | The `lint` job's verdict gates the branch. A job that silently does not run reads as a pass. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-07-13 | Tampering | `nx.json` `targetDefaults.lint.inputs` | high | mitigate | The full input list replaces the inferred single-entry external dependency set, closing the path where a TypeScript-plugin or comments-plugin bump leaves the `lint` cache valid. Guarded by the input probes and proven by plan 07-04's differential measurement plus mutation M6. | +| T-07-14 | Tampering | the `lint` target's filesystem scope versus its hashed inputs | high | mitigate | `eslint .` walks the filesystem while Nx hashes a git-derived file map, so build output is in scope but unhashed. Closed by plan 07-01's global ignores block; proven by the linted-file-count control and mutation M7. | +| T-07-15 | Spoofing | the `lint` CI job | medium | mitigate | The job is a distinct named leg using the same boilerplate as the three existing non-dogfooded jobs, and `npm run lint` is simultaneously a local battery command, so a workflow-only regression still fails locally. | +| T-07-16 | Tampering | `hash_project_config` rotation from plugin registration | medium | accept | The rotation is legitimate and unavoidable under D-01, which was user-selected with this cost stated. Isolated in one commit so it stays attributable; pre-recorded in plan 07-04 so Phase 9's tripwire is authored to tolerate three legitimate rotation windows rather than firing on correct work. | +| T-07-17 | Tampering | OS-divergent target inference | medium | transfer | Whether `@nx/eslint` infers `lint` identically on both operating systems is UNVERIFIED BY DESIGN; the existence gate does run for this project layout, so the risk is live rather than hypothetical. Transferred to Phase 8's CORR-03 two-leg measurement, which treats `lint` as a fourth target. Phase 7's obligation is to record the hashed node values as the baseline (plan 07-04) and NOT to reason it closed here. | + + + +- All NINE battery commands green at this plan's final commit; all EIGHT at its first. +- `npx nx show project github-cache --json` lists exactly one new target, `lint`. +- `packages/github-cache/project.json` is untouched -- no explicit lint target was declared + (D-01/D-02 keep this project free of a hand-written lint target). +- `packages/github-cache/package.json` is untouched (D-06). + + + +- A cacheable `lint` target exists, inferred, with a fully declared input set and empty outputs. +- `npm run lint` is battery command six of nine and a named CI job. +- The input guard's `lint` probes carry a negative control that genuinely discriminates. +- `integration` remains the only target with a platform discriminator. + + + +## Artifacts this phase produces + +Every symbol below is CREATED by phase 7 and did not exist before it. Drift verification must not +flag any of them as an unresolved reference. + +**New files** +- `eslint.config.mjs` (plan 07-01) +- `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01, extended by plan 07-02) +- `packages/github-cache/src/lint-scope-drift.spec.ts` (plan 07-02) +- `.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md` (plan + 07-01, appended by plans 07-02, 07-03, 07-04) + +**New exported / module-level constants** +- `BAN_MESSAGE` in `eslint.config.mjs` (plan 07-02) +- `CORR_05_SITES` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-02) +- `WORKSPACE_ROOT` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01) + +**New npm script** +- `lint` in the root `package.json`, defined as `nx run-many -t lint` (plan 07-03) + +**New Nx graph entities** +- the inferred `lint` target on `packages/github-cache` (plan 07-03) +- `nx.json` `plugins[]` entry for `@nx/eslint` (plan 07-03) +- `nx.json` `targetDefaults.lint` (plan 07-03) + +**New CI job** +- `lint` in `.github/workflows/ci.yml` (plan 07-03) + +**ESLint rule ids configured by this phase** (none existed before; the repo had no linter) +- `no-restricted-imports`, `no-restricted-syntax` (plan 07-02) +- `@typescript-eslint/ban-ts-comment`, `@typescript-eslint/no-unused-vars`, + `@typescript-eslint/no-require-imports`, `no-undef`, + `@eslint-community/eslint-comments/require-description` (plan 07-01) +- `linterOptions.reportUnusedDisableDirectives` (plan 07-01) + +**New spec describe-block subjects** +- the opt-out discipline assertions and the CORR-06 integration-path control (plan 07-01) +- the D-21 evasion-shape verdicts and the D-22 four-site table (plan 07-02) +- the D-19 glob/vitest superset agreement and the D-08 root-directory lock (plan 07-02) +- the `lint` input probes and their negative control (plan 07-03) + + + +Create +`.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-SUMMARY.md` +when done. + diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-SUMMARY.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-SUMMARY.md new file mode 100644 index 00000000..6f85d4d6 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-SUMMARY.md @@ -0,0 +1,217 @@ +--- +phase: 07 +plan: 03 +subsystem: lint-toolchain +tags: [nx-plugin, target-inference, lint, target-inputs, stale-cache, hash-rotation, ci, tdd] +status: complete +requires: + - eslint.config.mjs (plan 07-01) -- the plugin returns NO target without it, silently + - "@nx/eslint 23.1.0 as a root devDependency (plan 07-01)" + - packages/github-cache/src/nx-target-inputs.spec.ts and its hashedFilesFor helper (quick 260726-gok) + - the eight-command pre-commit battery +provides: + - the inferred cacheable lint target on packages/github-cache (LINT-01) + - nx.json plugins[] entry for @nx/eslint/plugin with targetName lint + - nx.json targetDefaults.lint -- full input list, four ESLint external dependencies, empty outputs (LINT-04) + - the lint input probes plus an out-of-project negative control in nx-target-inputs.spec.ts + - a CORR-04 assertion that integration is the only target with a platform runtime input + - root package.json lint script, making the battery NINE commands + - the lint CI job + - the D-35 hashed-node baseline Phase 8's CORR-03 compares against +affects: + - every task hash in the workspace (hash_project_config rotation, D-36) + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +tech-stack: + added: [] + patterns: + - targetDefaults..inputs REPLACES the inferred list, so the block restates everything it keeps (D-24) + - prove an inferred target EXISTS via nx show project, never assume registration worked (C8) + - read the inferred input list from the installed plugin, not from a doc's quote of it (C7) + - a negative control chosen for the target under test, not copied from a sibling target +key-files: + created: [] + modified: + - nx.json + - package.json + - .github/workflows/ci.yml + - packages/github-cache/src/nx-target-inputs.spec.ts + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +decisions: + - "C8 verified, not assumed: nx show project lists lint, so the config-existence gate was satisfied. An absent target would have meant no eslint.config.* was found, not a broken plugin." + - "C7 checked against the installed plugin source. The real inferred list is one entry longer than STACK.md quotes; the extra entry resolves to tsconfig.base.json, which sharedGlobals already folds into default, so the replacement needs no addition -- a checked conclusion, not an inherited one." + - "build is UNUSABLE as lint's negative control, because lint's inputs start from default and hashing a spec is what lint is supposed to do. The chosen discriminator is a probe path outside {projectRoot}." + - "A vacuity mutation (empty self pattern list) proved BOTH positive assertions still pass while only the negative control catches it -- the trap made visible rather than argued." + - "outputs: [] rather than the inferred ['{options.outputFile}'], and no cache key, no command override, no --max-warnings: three hash_project_config fields deliberately left alone during a milestone about holding hashes still." +metrics: + duration: ~20 min + tasks: 2 + files: 5 + tests: 486 -> 494 + completed: 2026-07-27 +--- + +# Phase 7 Plan 03: Wiring the lint Target into the Graph, the Battery and CI Summary + +`lint` is now a real, cacheable, fully-declared Nx target: inferred by `@nx/eslint`, guarded +against the stale-cache hole its own inferred input list would have left open, and wired into +both the local battery and CI as a distinct named leg. + +## What Shipped + +**Task 1 (`b3fdf6d`) -- the plugin, the input block, and the probes.** + +`nx.json` gains a third `plugins[]` entry (`@nx/eslint/plugin`, `targetName: lint`) and a +`targetDefaults.lint` block declaring `default`, `^default`, +`{workspaceRoot}/eslint.config.mjs`, `{workspaceRoot}/tools/eslint-rules/**/*`, and an +`externalDependencies` entry naming all four ESLint packages -- plus `outputs: []`. + +`packages/github-cache/src/nx-target-inputs.spec.ts` gains eight assertions in three groups: +the `lint` glob probes with their negative control, the LINT-04 declaration pins +(four external dependencies, empty outputs, and the CORR-04 single-runtime-input check), and +two `{workspaceRoot}` literal pins in the existing stale-pass describe. + +**Task 2 (`372ed35`) -- the battery command and the CI job.** + +`"lint": "nx run-many -t lint"` in the root scripts, grouped with `build` / `typecheck` / +`test` / `integration`. A `lint` job in `ci.yml` on the `fallow` boilerplate, with a rationale +comment above its key. + +## The Three Things That Were Proven Rather Than Assumed + +**1. The target exists.** `@nx/eslint`'s `createNodes` short-circuits with +`if (eslintConfigFiles.length === 0) return [];` and produces nothing, silently. So after +registering, `nx show project @op-nx/github-cache --json` was run and `lint` confirmed present +in the target list -- exactly one new target. An absent `lint` would have meant the config file +was not found, not that the plugin was broken, and the two failure modes are indistinguishable +from the outside. + +**2. The inferred input list, read from the plugin rather than from a doc.** D-24's premise is +"restate everything it keeps", so the real list was read at +`node_modules/@nx/eslint/dist/src/plugins/plugin.js:288-302`. It is one entry longer than +`STACK.md` quotes: `...tsconfigChainOutsideProjectRoot.map(...)`. All three of this project's +tsconfigs extend `../../tsconfig.base.json` and nothing else, so that entry resolves to exactly +one file which `sharedGlobals` already folds into `default`. The replacement list needs no +addition -- but a planner comparing against STACK's quote would have concluded it was complete +without ever knowing the entry existed. The `.eslintignore` entry is also conditional on +`existsSync`; no such file exists here. + +**3. The negative control genuinely discriminates.** `build` -- the discriminator the +`typecheck` probes use -- is unusable for `lint`, because `lint`'s inputs start from `default` +and hashing a spec is precisely what `lint` is supposed to do. A `build`-shaped negative would +assert something false. The chosen control is a probe path outside `{projectRoot}` +(`start-cache-server/entry.ts` -- a real file, genuinely not linted, and already visible in +`test.inputs` as a `{workspaceRoot}` string). A mutation reducing `lint.inputs` to an empty +self pattern list then proved the point: `filterUsingGlobPatterns` returns the whole probe list +when the pattern list is empty, so **both positive assertions still passed** and the negative +was the only glob-resolution assertion that caught it. + +## TDD + +RED observed and recorded before any `nx.json` edit: **7 failed / 7 passed of 14**, every new +assertion throwing `TypeError: Cannot read properties of undefined (reading 'inputs')` on the +absent target default. GREEN after: **14 / 14**. + +The CORR-04 assertion passed on BOTH sides by design -- the same direction-control device plans +07-01 and 07-02 used. Had it failed in RED, the meaning would have been "the spec cannot read +`nx.json` at all", not "`lint` is missing". + +All four pre-existing assertions kept passing after the fourth `PROBE_FILES` entry was added, +confirming empirically that no assertion in the file is a whole-array comparison. + +## Mutations (D-23) + +| # | Mutation | Result | +|---|---|---| +| M6a | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.lint.inputs` | 1 failed / 13 passed -- the expected assertion, zero collateral | +| MV | reduce `lint.inputs` so the self pattern list resolves EMPTY | 3 failed / 11 passed -- both positives still passed; only the negative control caught it | + +Both applied, observed, and REVERTED before the commit. `nx.json` restored byte-identical and +re-verified at 14 / 14. M6's second half (the stale-cache HIT differential) is plan 07-04's and +is not claimed here. + +## D-35 -- the Phase 8 baseline (a deliverable, not a note) + +The inferred `lint` target's HASHED node values, recorded in `07-EVIDENCE.md` as the baseline +CORR-03 compares against: `targetName: lint`, `executor: nx:run-commands`, `outputs: []`, +`options.cwd: packages/github-cache`, `options.command: eslint .`, `configurations: {}`, +`parallelism: true`. `metadata` is NOT hashed and is omitted, so the `${pmc.exec}` it carries is +a non-issue. + +**This is a baseline, not a closure.** Whether `@nx/eslint` infers the same node on Linux is +UNVERIFIED BY DESIGN (T-07-17): the existence gate runs +`eslint.isPathIgnored(join(workspaceRoot, file))` with a POSIX `join` over an absolute Windows +root. `options.cwd` is the row most likely to diverge, and it is hashed. Phase 8's CORR-03 two-leg +measurement treats `lint` as a fourth target and settles it empirically. + +## D-36 -- this is a legitimate all-MISS push + +Registering an inference plugin changes `hash_project_config`, which is folded into EVERY task +hash, and `test` rotates twice over because `{workspaceRoot}/nx.json` is already an explicit +`test` input. The rotation is isolated in `b3fdf6d` so it stays attributable. + +**Consequence for Phase 9:** Phase 7's first default-branch push carrying these commits is a +legitimate all-MISS push. OBS-04's tripwire must therefore be authored as *"two consecutive +all-miss pushes with NO version-affecting change in between"* -- there are three legitimate +rotation windows in this milestone, and a tripwire that fires on correct work gets disabled. +Plan 07-04 writes the pre-record. + +## Deviations from Plan + +**None on substance.** Two additive judgements inside the plan's own acceptance criteria: + +1. **[Rule 2 - missing verification] The CORR-04 acceptance criterion was made executable.** The + plan's acceptance criteria require "`nx.json` contains exactly ONE runtime input across the + whole file, and it belongs to `integration`... verifiable as a source assertion over the + `targetDefaults` block", but the `` block did not list it. Shipped as an `it()` + that walks every `targetDefaults` entry and asserts the set of targets carrying a `runtime` + input equals `['integration']`. A criterion nobody executes is a comment. +2. **[Rule 2 - missing verification] MV, the vacuity mutation, was run in addition to M6a.** D-23 + is the repo standard and M6a alone only proves a literal pin can fail. MV is what proves the + *negative control* is load-bearing, which is the assertion this plan actually had to invent. + +## Battery + +| Commit | Battery | Result | +|---|---|---| +| `b3fdf6d` | EIGHT | all exit 0 | +| `372ed35` | **NINE** (adds `lint`) | all exit 0 | + +Green at EVERY commit, not just the last -- the standard set by quick 260726-4cc and 260726-gok. +Unit suite 486 -> **494 tests across 33 files**; the +8 are all this plan's, all in +`nx-target-inputs.spec.ts` (6 assertions -> 14). First `lint` run: 1.7 s, `Cache: 0/1 hit (0%)`, +cold by construction. + +## Verification + +| Check | Result | +|---|---| +| `nx show project` lists exactly ONE new target, `lint` | yes | +| `packages/github-cache/project.json` untouched (D-01/D-02) | `git diff --exit-code` clean | +| `packages/github-cache/package.json` untouched (D-06) | `git diff --exit-code` clean | +| `ci.yml` `lint` job has exactly checkout, setup-node, `npm ci`, `npm run lint` | parsed with the `yaml` package; confirmed | +| no job's `needs:` references `lint`; no spec asserts on `ci.yml` | confirmed | +| `integration` is still the only target with a platform runtime input (CORR-04) | asserted and passing | + +## What This Leaves Open + +- **T-07-17 (transferred, not closed).** OS-divergent inference. Phase 8 CORR-03. +- **The `lint` sidecar dogfood block** (D-33). Purely additive, deferred by decision. +- **LINT-04's differential proof** (D-27 / G5 measurements A, B, C and M6's cache half) is plan + 07-04's. This plan closed the *hole*; 07-04 measures that it is closed. +- **`.planning/codebase/CONVENTIONS.md` still says "ESLint is NOT configured in this + repository".** Falsified since plan 07-01. Regenerating `.planning/codebase/*` is a deferred + idea, not a Phase 7 deliverable. + +## Known Stubs + +None. + +## Threat Flags + +None. No new network endpoint, auth path, file-access pattern or trust-boundary schema change: +this plan modifies build-tool configuration, a package script, and a CI job. + +## Self-Check: PASSED + +All five modified files and the summary exist on disk; both commit hashes (`b3fdf6d`, +`372ed35`) resolve in `git log`. diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-PLAN.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-PLAN.md new file mode 100644 index 00000000..97db7999 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-PLAN.md @@ -0,0 +1,394 @@ +--- +phase: 07-lint-toolchain-and-the-ambient-platform-read-ban +plan: 04 +type: execute +wave: 4 +depends_on: ["07-03"] +files_modified: + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md +autonomous: true +requirements: [LINT-01, LINT-03, LINT-04, LINT-05, LINT-06] + +must_haves: + truths: + - "LINT-04 is closed BY DIFFERENTIAL, not by reading the config: editing a rule and editing a linted source each re-run `lint` instead of replaying, with the cache line recorded on both sides (D-27, SC4)." + - "The declared input block is proven load-bearing: with the config entry removed, the stale-cache HIT reproduces (G5 negative control 1)." + - "Every guard shipped by this phase demonstrably CAN fail -- M1 through M9 applied, observed, recorded, and reverted (D-23)." + - "The inferred `lint` target's HASHED node values are recorded as the Phase 8 CORR-03 baseline (D-35)." + - "The legitimate all-MISS push is pre-recorded so Phase 9's tripwire is authored to tolerate it rather than fire on correct work (D-36)." + artifacts: + - ".planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md -- complete" + key_links: + - "The recorded `lint` hashed node values -> Phase 8's CORR-03 two-leg measurement, which treats `lint` as a fourth target and settles the OS-inference question empirically." + - "The all-MISS pre-record -> Phase 9's OBS-04 tripwire wording (three legitimate rotation windows exist this milestone)." +--- + + +Close LINT-04 by measurement rather than by reading the config, prove every guard this phase ships +can actually fail, and hand Phase 8 and Phase 9 the two records they cannot reconstruct later. + +Purpose: `nx reset` is not a substitute for a differential, and a guard that cannot fail is +worthless. Both standards were set on this repo by quick 260726-gok, which proved its one-token fix +by running the FAILING case on both sides of the change and then mutation-tested the guard that +protects it. This phase inherits both. + +Output: `07-EVIDENCE.md`, complete -- differential measurements with their cache lines and SHAs, +the M1-M9 mutation results, the D-35 hashed-node baseline, and the D-36 pre-record. + +Every task in this plan is measurement and recording. NO source, config, workflow, or spec file may +be left modified. Mutations are applied, observed, recorded, and REVERTED before any commit; +mutation runs are never committed. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-VALIDATION.md +@.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-03-SUMMARY.md + + + + + + Task 1: Run and record the LINT-04 differential and both negative controls + + +.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md + + + +- `07-RESEARCH.md` section `G5` in full -- Measurements A, B and C, negative controls 1 and 2, the + exact expected cache lines, and the "what to record" note. +- `07-RESEARCH.md` `## Open Questions` `Q8` -- the fallback if the run-many cache summary line is + absent under `lint`. +- `07-CONTEXT.md` D-27. +- `.planning/quick/260726-gok-resolve-typecheck-stale-cache-false-pass/260726-gok-SUMMARY.md` -- + the evidence discipline being inherited and the measured wording of the cache summary line. + + + +Run all five measurements from the repo root, in Git Bash, and record for EACH the exact command, +the cache summary line on BOTH sides, and the git SHA. A single cache reading carries no +information -- it is the BEFORE/AFTER PAIR that is the proof. That is not pedantry: a zero-hit line +was measured on this repo to appear identically on a run that never consulted a remote at all. + +Measurement A, editing a rule re-runs `lint`. Run `npm run lint` twice to warm (first executes, +second replays), then perturb ONE rule with a real severity toggle -- not a comment edit. A +comment-only edit still changes the file hash and therefore still proves the file is an input, but +it does NOT prove the rule set is what the command reads. Toggling the globalThis-qualified +selector off is the suggested perturbation. Run `npm run lint` again: it must EXECUTE. Restore the +file and run once more: it must REPLAY, confirming the pre-edit hash is still in the cache. A hit +at the perturbed step is the LINT-04 defect, unambiguously. + +Measurement B, editing a linted source re-runs `lint`. Same shape, appending a trailing comment to +a linted tracked source file that is NOT a spec, so the ban rules stay out of the measurement. +Restore afterwards. + +Measurement C, the second-order hole. The guard specs run under `test` and read +`eslint.config.mjs`, so `test` must re-run too. Warm `npm run test`, toggle the same rule, run +`npm run test`: it must EXECUTE. A hit here means the workspace-root config entry is missing from +`test.inputs` and every LINT-03 result from that moment on is untrustworthy. Run this BEFORE +trusting any mutation result in task 2. + +Negative control 1, the mutation that proves the declared input block is load-bearing. Measurements +A and B both PASS on a `lint` target with no declared input block at all, because the inferred +inputs already contain the default named input and the workspace-root config entry -- so A and B +alone do NOT prove the block did anything. Temporarily delete ONLY the workspace-root config entry +from `targetDefaults.lint.inputs`, leaving the rest of the block in place (the declared list +replaces the inferred one, so removing that entry genuinely removes it rather than falling back). +Re-warm, then toggle the same rule: it must now HIT. That reproduced stale-cache hit is the proof. +If it MISSES instead, something ELSE is invalidating the hash -- most likely the project-root +fileset catching a stray edit -- and the measurement is confounded: stop and find it before +recording anything. Restore both files afterwards. + +Negative control 2, `lint`'s scope must not depend on gitignored build output. Re-run plan 07-01's +file-count control now that the target exists: count linted files from `packages/github-cache`, +remove the build output directories, count again. The two numbers MUST be identical. Two different +numbers means `lint`'s result depends on whether `build` ran while its Nx hash does not, which is a +stale-cache false PASS by construction and means the global ignores block is wrong. Restore the +build output afterwards with `npm run build` and `npm run typecheck`. + +If the run-many cache summary line does not appear for `lint`, fall back to running the single +target with the verbose flag and record the local-cache label and execution evidence instead -- +noting the substitution explicitly rather than silently. + +Confirm `git status` is clean before committing the evidence file. + + + + npm run lint + + + +- `07-EVIDENCE.md` records Measurements A, B and C, each with the command, the cache summary line + on BOTH sides, and the git SHA. +- Measurement A shows an EXECUTE after the rule toggle and a REPLAY after the restore. +- Measurement B shows an EXECUTE after the source edit. +- Measurement C shows an EXECUTE for `test` after the rule toggle. +- Negative control 1 records a reproduced stale-cache HIT with the config entry removed, and the + record states explicitly that A and B alone would not have proven the block load-bearing. +- Negative control 2 records two IDENTICAL linted-file counts across removal of the build output. +- `git status` is clean apart from `07-EVIDENCE.md` at the commit; `nx.json`, `eslint.config.mjs` + and every source file are byte-identical to their plan 07-03 state. +- All NINE battery commands exit 0 at the commit. + + + +LINT-04 is closed by differential with both negative controls recorded, and the second-order `test` +hole is proven closed before any mutation result is trusted. + + + + + Task 2: Apply, observe, record and revert mutations M1 through M9 + + +.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md + + + +- `07-VALIDATION.md` `## Mutation Testing` -- the M1-M9 table with each mutation's expected result. +- `07-RESEARCH.md` `## Validation Architecture` -> `Mutation testing`, and the + `Non-vacuous assertions` table immediately above it. +- `07-CONTEXT.md` D-23. + + + +Run each of the nine mutations in turn. For each: apply it, run the command that should detect it, +record the OBSERVED failure set (counts and the identity of the failing assertions, not just +"red"), and REVERT before moving to the next. Never commit a mutated tree. Confirm `git status` is +clean between mutations. + +The expected failure sets are the point of the exercise -- a mutation that goes red on the WRONG +assertions is as informative as one that stays green, and both must be recorded as observed rather +than as expected. + +- M1: delete the primary member-expression selector from the syntax rule. Expect the three + running-platform site assertions and the matching evasion assertion RED; every import-shape + assertion GREEN. +- M2: delete the prefixed os entry from the imports rule's paths. Expect the first site assertion + and the named-import and namespace-import evasion assertions RED; every running-platform + assertion GREEN. +- M3: delete the import-expression selector. Expect ONLY the two dynamic-import assertions RED. If + nothing goes red, that selector is untested and the dynamic-import shape is a silent gap -- treat + that as a finding, not a pass. +- M4: narrow the `ignores` glob to the single-extension form. Expect the scope-drift guard RED. If + it stays green it is asserting equality of the wrong pair, or restating the globs instead of + reading them. +- M5: remove the workspace-root config entry from `targetDefaults.test.inputs`. Expect the input + guard's `test` assertion RED. This is the D-25 guard's own mutation test. +- M6: remove the workspace-root config entry from `targetDefaults.lint.inputs`. Expect the `lint` + probe assertion RED. Task 1's negative control 1 is the behavioural half of this same mutation; + cross-reference the two records rather than re-running the differential. +- M7: remove the global ignores block from the flat config. Expect task 1's negative control 2 to + report two DIFFERENT linted-file counts across removal of the build output. This is the only + control for that finding, so it must be run, not reasoned. +- M8: replace one described disable with a bare one. Expect a require-description error, proving + LINT-05 is live and not merely configured. +- M9: move one described disable one line off its violation. Expect BOTH an unused-directive error + and the underlying ban error, proving LINT-06 is live. + +Record every observed result in `07-EVIDENCE.md` as a table with the mutation, the command run, the +observed failure set, and whether it matched the expectation. Any divergence is a finding to +resolve before the phase gate, not a footnote. + + + + npm run test + + + +- `07-EVIDENCE.md` contains a nine-row table with the observed failure set for each of M1 through + M9 and an explicit match/divergence verdict per row. +- Each recorded failure set names the failing assertions, not only a count. +- M3's row states explicitly whether anything went red; a green M3 is recorded as a finding. +- M7's row records two linted-file counts, and they differ. +- `git status` is clean at the commit; `git diff` against the plan 07-03 tree shows only + `07-EVIDENCE.md`. +- All NINE battery commands exit 0 at the commit, on the REVERTED tree. + + + +Every guard this phase ships has been shown to fail for the right reason and then restored; the +evidence is recorded rather than asserted. + + + + + Task 3: Record the Phase 8 and Phase 9 hand-offs and close the phase record + + +.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md + + + +- `07-CONTEXT.md` D-35, D-36, D-01, D-07, D-12. +- `07-RESEARCH.md` `F18`, `## Open Questions` `Q1`, and the `Also noted` block at the end of + `## Corrections to Existing Artifacts`. +- `.planning/research/v0.0.2/PROBE-RESULTS.md` -- what a hash rotation does and does not prove, + and the two axes it establishes. + + + +Record the D-35 baseline. Capture the inferred `lint` target's node from +`npx nx show project github-cache --json` and write down exactly the fields that +`hash_project_config` folds in: the target name, the executor, the outputs, the options including +the RESOLVED working directory, the configurations, and the parallelism flag. Record explicitly +that `metadata` is NOT hashed, so the package-manager-exec token it carries is a non-issue for the +hash -- a future reader will otherwise re-derive that. + +State the hand-off in the terms Phase 8 needs: whether `@nx/eslint` infers `lint` identically on +both operating systems is UNVERIFIED BY DESIGN and must NOT be reasoned closed here. The risk is +live rather than hypothetical -- the plugin's existence gate genuinely runs for this project layout +because the config directory differs from the project root, and that gate joins a POSIX-style path +onto an absolute Windows root, producing mixed separators. It reads clean at source and Windows +tolerates it, but "should" is exactly what a two-leg measurement is for. Phase 8's CORR-03 treats +`lint` as a FOURTH target and settles it empirically. This baseline is what it compares against and +it is the recorded mitigation for D-01's accepted, user-selected risk. + +Record the D-36 pre-record, in advance and NOT as a gate. Registering the plugin rotates EVERY task +hash, and rotates `test` twice over because the workspace `nx.json` is already an explicit `test` +input. Phase 7's first default-branch push is therefore a legitimate all-MISS push, and Phase 9's +VER-01 produces a second one. Write that down NOW so Phase 9's OBS-04 tripwire is authored as "two +consecutive all-miss pushes with NO version-affecting change in between" -- there are three +legitimate rotation windows in this milestone, and a tripwire that fires on correct work gets +disabled. Note also, from the pre-flight probe record, what the rotation does NOT prove: a hash +difference is only attributable to the OS once graph freshness is controlled on both sides, and +every prior cross-OS measurement in this repo read a confounded variable. + +Consolidate the phase record. `07-EVIDENCE.md` must, by the end of this task, also carry: +- the D-12 call as finally measured -- the baseline count and per-rule breakdown from plan 07-01, + the post-remediation residual, how many rules were turned off (predicted: zero) and how many code + edits were made (predicted: zero), with the two configuration blocks named; +- the D-07 recorded deviation -- `lint` is project-scoped, so the root-level files are not linted; + this narrows LINT-01 SC1's literal "across the workspace" to "across the project that has specs", + and it is an INTENTIONAL, RECORDED deviation for the verifier, not a gap. All 32 spec files and + all four violation sites are inside the scope; +- the D-01 one-line dismissal of the explicit-target alternative, which REQUIREMENTS and research + both demand appear in the phase record; +- the three received-wording corrections that must not propagate: ROADMAP SC3 says "three CORR-05 + violations" where REQUIREMENTS, CONTEXT and RESEARCH all say FOUR; CORR-06's example uses the + two-argument asset-name form, which CORR-02 deletes in Phase 10; and LINT-05's requirement text + uses the legacy bare comments-plugin rule prefix rather than the scoped flat-config one; +- the note that `.planning/codebase/CONVENTIONS.md` still states this repository has no linter + configured, which this phase falsifies. Regenerating the codebase map is a deferred idea, not a + Phase 7 deliverable -- the verifier should not read the stale sentence as a contradiction. + +Do not modify any file outside `07-EVIDENCE.md`. + + + + npm run test + + + +- `07-EVIDENCE.md` records all six hashed node fields for the inferred `lint` target, including the + resolved working directory, and states that metadata is not hashed. +- The record states in as many words that the OS-inference question is UNVERIFIED BY DESIGN and is + transferred to Phase 8's CORR-03, and does not claim it closed. +- The all-MISS pre-record is present and phrases the Phase 9 tripwire condition as two consecutive + all-miss pushes with no version-affecting change in between, naming the three legitimate rotation + windows. +- The D-12 call, the D-07 deviation, the D-01 dismissal, the three wording corrections, and the + stale codebase-map note are all present. +- `git diff` against the plan 07-03 tree shows changes ONLY under + `.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/`. +- All NINE battery commands exit 0 at the commit. + + + +Phase 8 has its hashed-node baseline, Phase 9 has its rotation pre-record, and every deviation and +correction this phase relied on is written down where the verifier will find it. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| recorded evidence -> future decisions | Phase 8's root-cause work and Phase 9's tripwire are authored against this record. A wrong or missing entry propagates silently into two later phases. | +| mutated working tree -> git history | A mutation left un-reverted ships a deliberately broken guard. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-07-18 | Repudiation | the M1-M9 mutation record | high | mitigate | Each mutation is applied, observed and recorded with its OBSERVED failure set, not its expected one, and reverted before any commit. `git status` is confirmed clean between mutations and `git diff` against the prior plan's tree is asserted at each commit. | +| T-07-19 | Tampering | the working tree during mutation runs | high | mitigate | Every task in this plan modifies exactly one file. The acceptance criteria assert a clean tree and a diff limited to the phase directory, so an un-reverted mutation cannot reach a commit unnoticed. | +| T-07-20 | Repudiation | the LINT-04 differential | high | mitigate | Both sides of every pair are recorded with the git SHA, and negative control 1 is mandatory because the two positive measurements pass even on an undeclared input block. A confounded control is an explicit stop condition rather than a footnote. | +| T-07-21 | Information disclosure | the recorded evidence file | low | accept | The file records command output, cache lines and configuration field values from a public repository. No credential, token or private path is captured; the sidecar bearer token is not involved in any measurement here. | +| T-07-22 | Repudiation | the D-35 baseline as a Phase 8 input | medium | transfer | The OS-inference question is explicitly transferred to Phase 8's CORR-03 rather than reasoned closed. The record must say so, and the acceptance criteria assert it does. | + + + +- All NINE battery commands green at each of this plan's commits. +- The working tree is byte-identical to the plan 07-03 tree apart from + `.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/`. +- `07-VALIDATION.md`'s sign-off checklist can be ticked from this file alone. + + + +- LINT-04 closed by differential with both negative controls, not by reading the config. +- M1 through M9 applied, observed, recorded and reverted, with observed failure sets. +- The D-35 hashed-node baseline and the D-36 all-MISS pre-record written down. +- Every deviation, correction and discretionary call this phase made is in one recorded place. + + + +## Artifacts this phase produces + +Every symbol below is CREATED by phase 7 and did not exist before it. Drift verification must not +flag any of them as an unresolved reference. + +**New files** +- `eslint.config.mjs` (plan 07-01) +- `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01, extended by plan 07-02) +- `packages/github-cache/src/lint-scope-drift.spec.ts` (plan 07-02) +- `.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md` (plan + 07-01, appended by plans 07-02, 07-03, 07-04) + +**New exported / module-level constants** +- `BAN_MESSAGE` in `eslint.config.mjs` (plan 07-02) +- `CORR_05_SITES` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-02) +- `WORKSPACE_ROOT` in `packages/github-cache/src/lint-rules.spec.ts` (plan 07-01) + +**New npm script** +- `lint` in the root `package.json`, defined as `nx run-many -t lint` (plan 07-03) + +**New Nx graph entities** +- the inferred `lint` target on `packages/github-cache` (plan 07-03) +- `nx.json` `plugins[]` entry for `@nx/eslint` (plan 07-03) +- `nx.json` `targetDefaults.lint` (plan 07-03) + +**New CI job** +- `lint` in `.github/workflows/ci.yml` (plan 07-03) + +**ESLint rule ids configured by this phase** (none existed before; the repo had no linter) +- `no-restricted-imports`, `no-restricted-syntax` (plan 07-02) +- `@typescript-eslint/ban-ts-comment`, `@typescript-eslint/no-unused-vars`, + `@typescript-eslint/no-require-imports`, `no-undef`, + `@eslint-community/eslint-comments/require-description` (plan 07-01) +- `linterOptions.reportUnusedDisableDirectives` (plan 07-01) + +**New spec describe-block subjects** +- the opt-out discipline assertions and the CORR-06 integration-path control (plan 07-01) +- the D-21 evasion-shape verdicts and the D-22 four-site table (plan 07-02) +- the D-19 glob/vitest superset agreement and the D-08 root-directory lock (plan 07-02) +- the `lint` input probes and their negative control (plan 07-03) + + + +Create +`.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-SUMMARY.md` +when done. + diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-SUMMARY.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-SUMMARY.md new file mode 100644 index 00000000..724f2829 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-SUMMARY.md @@ -0,0 +1,251 @@ +--- +phase: 07 +plan: 04 +subsystem: lint-toolchain +tags: + [ + evidence, + differential, + mutation-testing, + stale-cache, + nx-hash, + hand-off, + lint, + ] +status: complete +requires: + - the inferred lint target and the root lint script (plan 07-03) + - nx.json targetDefaults.lint and targetDefaults.test.inputs (plans 07-01, 07-03) + - eslint.config.mjs with both ban rules and the four described disables (plans 07-01, 07-02) + - the three guard specs -- lint-rules, lint-scope-drift, nx-target-inputs +provides: + - LINT-04 closed BY DIFFERENTIAL with both negative controls and the cache line on both sides of every pair + - proof that the declared lint input block is load-bearing, via a reproduced stale-cache HIT + - proof the D-25 second-order test hole is closed, measured BEFORE any mutation was trusted + - the M1-M9 mutation record with OBSERVED failure sets and a per-row match verdict + - the D-35 hashed-node baseline re-verified as the Phase 8 CORR-03 input + - the D-36 all-MISS pre-record that authors Phase 9's OBS-04 tripwire condition + - the consolidated D-12 call, D-07 deviation, D-01 dismissal and the four wording corrections + - LINT-05 and LINT-06 ticked against the measurements that make their text true +affects: + - Phase 8 CORR-03 (compares its two-leg lint measurement against the D-35 baseline) + - Phase 9 OBS-04 (its tripwire is authored from the D-36 pre-record) + - .planning/REQUIREMENTS.md (LINT-05, LINT-06) +tech-stack: + added: [] + patterns: + - a differential's perturbed side must be run exactly ONCE; a repeat with the same edit text caches the perturbed hash and reads as the defect + - a Cache line is a statement about a HASH, never about correctness -- only the BEFORE/AFTER pair carries proof + - mutation-test the guard AND record the observed failure set, because a mutation that goes red on the wrong assertions is as informative as one that stays green +key-files: + created: + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-04-SUMMARY.md + modified: + - .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md + - .planning/REQUIREMENTS.md +decisions: + - "The confounded first attempt at Measurement B was recorded rather than quietly discarded: running the perturbed side twice caches the perturbed hash, so a later repeat of the same edit reads Cache 1/1 -- indistinguishable at a glance from the LINT-04 defect the measurement exists to exclude." + - "All nine mutations were re-run first-hand, including the five earlier waves had already measured, so the table is one executor's direct observation rather than a stitched-together citation." + - "M3 went RED, so P6 is covered -- but its granularity diverges from VALIDATION.md: the shipped spec folds both dynamic-import shapes into ONE it() row, so the observed count is 1 failing assertion and not 2." + - "M1 and M2 produce DISJOINT red sets. That is the measured form of D-15's claim that neither ban rule is sufficient alone." + - "requirements.mark-complete was skipped and REQUIREMENTS.md edited by hand, because the tool corrupted that file in both prior waves of this phase." +metrics: + duration: ~55 min + tasks: 3 + files: 2 + tests: 494 (unchanged -- this plan adds no assertion) + completed: 2026-07-27 +--- + +# Phase 7 Plan 04: Evidence Summary + +LINT-04 is closed by MEASUREMENT rather than by reading the config, every guard this phase ships +has been shown to fail for the right reason and then restored, and Phases 8 and 9 have the two +records they could not reconstruct later. + +## What Shipped + +Three commits, each modifying exactly the evidence file (the third also ticks two requirements). +**No source, config, workflow or spec file is modified by this plan.** Every perturbation was +applied, observed and reverted, with a `git diff --exit-code` confirmation before the next one. + +| Commit | Content | +| ------ | ------- | +| `9ea224f` | G5 measurements A, B and C plus both negative controls | +| `be895a6` | the M1-M9 table with observed failure sets | +| `8300b58` | the D-35 / D-36 hand-offs, the consolidated phase record, LINT-05 and LINT-06 ticked | + +## LINT-04, Closed By Differential + +Base SHA `81048ca` for every reading. Q8 closed affirmatively: `nx run-many -t lint` prints +`Cache: n/m hit (p%)` in exactly the form `test` and `typecheck` do, so the verbose fallback +RESEARCH held in reserve was never substituted. + +| Measurement | Baseline | Perturbed | Restored | +| ----------- | -------- | --------- | -------- | +| **A** rule edit re-runs `lint` | `1/1 hit (100%)` | **`0/1 hit (0%)`** | `1/1 hit (100%)` | +| **B** source edit re-runs `lint` | `1/1 hit (100%)` | **`0/1 hit (0%)`** | `1/1 hit (100%)` | +| **C** rule edit re-runs `test` (D-25) | `1/1 hit (100%)` | **`0/1 hit (0%)`** | n/a | +| **NC1** same rule edit, config entry REMOVED from `lint.inputs` | `1/1 hit (100%)` | **`1/1 hit (100%)`** -- THE BUG | n/a | +| **NC2** linted-file count across `rm -rf dist out-tsc` | **66** | **66** -- identical | n/a | + +Measurement C was run **before** any mutation result was trusted, which is the ordering D-25 +requires: every M1-M9 verdict below is read off a `test` target proven to re-run when the rule +set moves. + +**Negative control 1 is the one that matters, and the record says so explicitly.** A and B both +pass on a `lint` target with no declared input block at all, because `@nx/eslint`'s inferred +inputs already contain `default` and the workspace-root config entry. A and B alone would NOT +have proven D-24's block did anything. Deleting one entry and watching a real rule change serve a +cached PASS is what does. + +### The methodological trap, recorded because it manufactures a false defect + +The first attempt at Measurement B was CONFOUNDED and discarded. The perturbed side was run +TWICE; the second run legitimately replayed the *perturbed* hash, so re-applying that identical +edit later read `Cache: 1/1 hit (100%)` -- indistinguishable, at a glance, from exactly the +stale-cache bug the measurement exists to exclude. The rule this yields: **a differential's +perturbed side must be run exactly ONCE, and any repeat needs perturbation text that has never +been hashed.** Same class as PITFALLS D1 -- a `Cache:` reading is a statement about a HASH, not +about correctness. + +## M1-M9: Every Guard Can Fail + +All nine re-run first-hand against the plan 07-03 tree, including the five earlier waves had +already measured, so the table is one executor's direct observation. Baseline across the three +guard specs before any mutation: 56 passed of 56. **All nine MATCHED their expectation.** + +| # | Observed | Verdict | +| - | -------- | ------- | +| M1 | 4 failed / 33 passed -- the P1 evasion row plus the `is CAUGHT` assertion at all three syntax sites; every import-shape assertion GREEN | match | +| M2 | 3 failed / 34 passed -- named-import and namespace-import evasion rows plus site 1; every `process.*` assertion GREEN | match | +| M3 | 1 failed / 36 passed -- the dynamic-import row, `expected [] to deeply equal [ 'no-restricted-syntax', 'no-restricted-syntax' ]` | match, with a granularity divergence | +| M4 | 1 failed / 4 passed -- caught by the parser's OWN non-vacuity guard before the comparison ran | match | +| M5 | 1 failed / 13 passed -- the `test` input assertion, zero collateral | match | +| M6 | 1 failed / 13 passed on the assertion half; `1/1 hit` on the behavioural half | match, both halves | +| M7 | **66 -> 159** linted files | match | +| M8 | 1 error: `require-description` at severity 2 | match -- LINT-05 is LIVE | +| M9 | 2 errors: the unsuppressed ban AND a severity-2 unused-directive report | match -- LINT-06 is LIVE | + +### M3 is not a silent gap, but VALIDATION.md's count is off by one + +The plan flagged M3 as the mutation most likely to pass vacuously, which would mean P6 was +untested and D-21's dynamic-import shape a silent hole. **It went red**, so P6 is genuinely +load-bearing and genuinely covered. + +The divergence, recorded because it is a real difference: VALIDATION.md predicts "ONLY the two +dynamic-import assertions RED". The shipped spec folds BOTH dynamic shapes into a SINGLE `it()` +row whose expected value is a two-element list, so the observed count is **one** failing +assertion covering two shapes. Coverage is identical; the granularity is not. A reader checking +"2 red" against the table would otherwise conclude the mutation had under-fired. + +### M1 and M2 produce disjoint red sets + +M1 leaves every import-shape assertion GREEN; M2 leaves every `process.*` assertion GREEN. That +is the measured form of D-15's claim that neither rule is sufficient alone -- two mutations, two +disjoint red sets, no overlap. The guard is not asserting one thing twice. + +## The Two Hand-Offs + +**D-35, Phase 8's CORR-03 baseline.** Present and complete from plan 07-03; re-read at this plan +and byte-for-byte unchanged. All six hashed fields recorded including the resolved `options.cwd`, +with `metadata` explicitly named as NOT hashed so a future reader does not re-derive whether its +package-manager-exec token matters. **The record states in as many words that whether `@nx/eslint` +infers the same node on Linux is UNVERIFIED BY DESIGN and is transferred to Phase 8, not reasoned +closed here.** The risk is live: the plugin's existence gate genuinely runs for this layout, +because the config directory differs from the project root, and that gate joins a POSIX-style +path onto an absolute Windows root. + +**D-36, the legitimate all-MISS push.** Written down in advance and NOT as a gate. Three +legitimate rotation windows exist in this milestone -- Phase 7's plugin registration, Phase 8's +parity fix, Phase 9's VER-01 -- so Phase 9's OBS-04 tripwire must read *"two consecutive all-miss +pushes with NO version-affecting change in between"*. A tripwire that fires on correct work gets +disabled. The record also carries what a rotation does NOT prove: a hash difference is +attributable to the OS only once graph freshness is controlled on both sides, and every prior +cross-OS measurement in this repo read a confounded variable. + +## The Consolidated Phase Record + +`07-EVIDENCE.md` now also carries, in one place a verifier will find: + +- **the D-12 call** with a predicted-vs-measured table -- ZERO rules turned off repo-wide, ZERO + code edits to satisfy a linter, TWO configuration blocks, both named, with the one scoped + rule-off recorded honestly rather than claimed as zero; +- **the D-07 deviation** -- `lint` is project-scoped, so the four root-level files are not linted. + This narrows LINT-01 SC1's literal "across the workspace" to "across the project that has + specs" and is INTENTIONAL and RECORDED, not a gap: all 32 spec files and all four CORR-05 sites + are inside the scope; +- **the D-01 one-line dismissal** of the explicit-target alternative, which REQUIREMENTS and + research both demand appear in the phase record; +- **four received-wording corrections** that must not propagate -- ROADMAP SC3's "three CORR-05 + violations" against the four everything else says; CORR-06's example using the two-argument + asset-name form CORR-02 deletes in Phase 10; LINT-05's legacy bare comments-plugin prefix; and + `cache-archive-path.spec.ts:26` being a valid SITE but not an error POSITION; +- **the stale codebase-map note** -- `CONVENTIONS.md:316` still says this repo has no ESLint + configured. Regenerating the map is a deferred idea, so the verifier should read that sentence + as a dated artefact rather than a contradiction. + +## Requirement Ticks + +LINT-05 and LINT-06 ticked, each against the measurement that makes its text true (M8 and M9). +Plans 07-01 and 07-02 left both unticked on purpose rather than write a falsehood into the ledger +the milestone audit reads -- at the time both were configured but their liveness was unproven, +and the proof was this plan's work. LINT-01, LINT-02, LINT-03 and LINT-04 were already ticked by +earlier plans; LINT-04's tick is now backed by the differential rather than by the declaration +probes alone. + +## Deviations from Plan + +**None on substance.** Three judgement calls inside the plan's own acceptance criteria: + +1. **[Rule 2 - missing verification] All nine mutations were re-run rather than five cited.** The + plan permits cross-referencing M6 and treats M4, M5, M7 and the wave-2/3 records as already + measured. Re-running each takes seconds and turns a citation chain into one executor's direct + observation, which is what D-23 is actually for. M7 in particular had to be re-run because its + absolute counts moved with the tree (64/155 in wave 1, 66/159 now). +2. **[Rule 3 - blocking] Measurement B was re-run with novel perturbation text** after the first + attempt cached the perturbed hash and produced a false HIT. Recorded as a finding rather than + silently redone, because the false reading is indistinguishable from the LINT-04 defect. +3. **[Rule 1 - avoid a known bug] `requirements.mark-complete` was skipped** and `REQUIREMENTS.md` + hand-edited, because the tool corrupted that same file in both prior waves of this phase. The + diff is exactly four lines -- two checkbox flips and two traceability rows -- and was inspected + line by line. + +The optional tidy the brief offered (three no-op `await` expressions in `lint-scope-drift.spec.ts`) +was NOT taken. It is not adjacent to any file this plan touches, and this plan's whole contract is +that no source file is modified. + +## Battery + +All NINE commands exit 0 at all three commits: `format:check`, `build`, `typecheck`, +`typecheck:action`, `test`, `lint`, `fallow:ci`, `check:action`, `pack:check`. Unit suite +**494 tests across 33 files**, unchanged -- this plan adds no assertion, only recorded +measurement. Post-restore `npx eslint .` from `packages/github-cache`: 66 files linted, 0 +findings, exit 0. + +`git diff` against the plan 07-03 tree (`81048ca`) touches only +`.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md` and +`.planning/REQUIREMENTS.md`. No mutation residue reached any commit. + +## What This Leaves Open + +- **T-07-17 / D-35 (transferred, not closed).** OS-divergent `lint` inference. Phase 8 CORR-03. +- **`lint-scope-drift.spec.ts`'s three no-op `await` expressions.** Harmless; not taken. +- **`.planning/codebase/CONVENTIONS.md` regeneration.** Deferred idea. +- **The `lint` sidecar dogfood block** (D-33). Purely additive, deferred by decision. + +## Known Stubs + +None. This plan produces recorded measurement only. + +## Threat Flags + +None. No network endpoint, auth path, file-access pattern or trust-boundary schema change: this +plan modifies two planning documents and nothing else. + +## Self-Check: PASSED + +`07-EVIDENCE.md` and `07-04-SUMMARY.md` both exist on disk; `07-EVIDENCE.md` carries the +`## Plan 07-04` heading, the five-row differential set, the nine-row mutation table and the +hand-off sections. All three commit hashes (`9ea224f`, `be895a6`, `8300b58`) resolve in +`git log`. diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md new file mode 100644 index 00000000..b0c52ca7 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md @@ -0,0 +1,600 @@ +# Phase 7: Lint Toolchain and the Ambient-Platform-Read Ban - Context + +**Gathered:** 2026-07-27 +**Status:** Ready for planning + + +## Phase Boundary + +Adopt a linter this repository has never had (no ESLint, no Biome, verified), and convert one +specific convention -- "a unit spec must not derive an expectation from the running machine" -- +from documented prose into a build failure that names the rule and cannot be silenced without +writing down why. + +Delivers: ESLint 9 flat config + a cacheable `lint` target in the CI battery (LINT-01); the +two-rule ambient-platform ban scoped to unit specs and exempted in integration specs (LINT-02, +CORR-06); a permanent RED-before-GREEN proof covering evasion shapes AND the four extant +violation sites (LINT-03); `lint` inputs that cannot serve a stale-cache false PASS (LINT-04); +described-only opt-outs with stale directives failing the build (LINT-05, LINT-06). + +Does NOT deliver: removal of any CORR-05 violation (Phases 9 and 10 own that -- Phase 7 must +land described disables and leave the violations in place so LINT-03 has something to catch); +any hash-parity measurement or root-cause work (Phase 8); any general code-quality or ecosystem +hygiene rule sweep. + +**Mode:** not `mvp` (toolchain adoption, no vertical user-facing slice). TDD is globally on +(`workflow.tdd_mode: true`). + + + + +## Implementation Decisions + +### Toolchain adoption + +- **D-01:** The `lint` target comes from the **`@nx/eslint/plugin` INFERENCE plugin**, registered + in `nx.json` `plugins` as `{ "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } }`. + There is no auto-registration. **USER-SELECTED at discuss time** over the explicit-target + alternative, which was presented in full and is now CLOSED -- do not re-open it. The one-line + dismissal REQUIREMENTS and research both demand still goes in the plan: an explicitly declared + `command: 'eslint .'` target beside the existing `integration` target in + `packages/github-cache/project.json` would need no inference plugin at all (`@nx/eslint`'s value + is target inference plus the `@nx/eslint:lint` executor, neither of which LINT-01..06 requires), + but `@nx/eslint` is the ecosystem norm, the generator does the wiring, and ROADMAP/REQUIREMENTS + already cite inference as the LINT-01 -> PARITY-01 ordering mechanism. **The ordering constraint + holds either way** -- any declared target mutates `hash_project_config` -- so nothing in the + roadmap shape depends on this choice. The accepted cost is carried by D-35. + +- **D-02:** Exactly **five** root devDependencies, exact-pinned via `npm i -D -E`: + `eslint@9.39.5`, `@eslint/js@9.39.5`, `typescript-eslint@8.65.0`, + `@eslint-community/eslint-plugin-eslint-comments@4.7.2`, `@nx/eslint@23.1.0`. Nothing else. + `STACK.md` section 4 enumerates every rejected addition with its reason: `jiti`, + `@vitest/eslint-plugin`, `eslint-plugin-n` / `-import` / `-unicorn`, `eslint-config-prettier`, + a `tools/eslint-rules/` workspace-rules project, and `@nx/jest` (declared as an optional peer; + npm will neither install nor warn -- do not add it). + +- **D-03:** **ESLint 9.39.5, not 10.x.** Every peer range already admits v10 and we would be + flat-config-only anyway, so v10 is unblocked -- take 9 regardless. LINT-01 says v9; nothing in + the milestone needs a v10 feature; `eslint@10.8.0` was days old at research time; and + `@nx/eslint`'s `resolveESLintClass` calls `eslintModule.loadESLint({ useFlatConfig })`, whose + survival under v10's eslintrc-loader removal is UNCHECKED. On 9.39.5 that call is documented and + present. `@eslint/js` stays in lockstep at 9.39.5. + +- **D-04:** All five names are added to `packages/github-cache/src/pinned-deps.spec.ts` as sibling + `it()` blocks in the existing `describe`. **Pinning and guarding are two separate tasks.** That + spec is a hard-coded NAME list with one `it()` per package, NOT a blanket "every dependency is + exact" rule -- the workspace deliberately carries ranges (`typescript ~6.0.3`, `vitest ~4.1.0`, + `prettier ^3.8.1`, `@types/node ^24.0.0`). Pinning without adding the names leaves them + unguarded and a later `npm install eslint@latest` passes every check. Record the ROBUST-03-class + call in the spec's comment, where every other such decision lives: ESLint deps join the class + because `lint` is a build gate whose behaviour a silent minor bump can change -- the same + argument that put `esbuild` in the list -- unlike `prettier`, which is formatting-only and is + deliberately out. The precedent is genuinely ambiguous, so the reasoning must be written down, + not just the outcome. + +- **D-05:** Regenerate `package-lock.json` in a **linux/arm64 `node:24` container**, never with a + bare Windows `npm install`. A Windows install prunes the Linux-only optional subtrees, breaks CI + `npm ci`, and is invisible locally. Doubly load-bearing in this milestone: lockfile asymmetry is + the leading `External`-instruction hypothesis for the Phase 8 parity bug, so a Windows-pruned + lockfile would inject the very variable Phase 8 exists to isolate. + +- **D-06:** `packages/github-cache/package.json` is **untouched**. No runtime dependency changes, + no new export, no new action input, no new env knob (D2-02, PARITY-05). `public-surface.spec.ts` + must pass unchanged, and that it passes unchanged is itself a v0.0.2 requirement. + +### Lint scope and blast radius + +- **D-07:** `lint` is **project-scoped**: `eslint .` with `cwd = packages/github-cache`. The + workspace root gets NO lint target -- `@nx/eslint`'s `getProjectUsingESLintConfig` returns + `null` for `.` because the root has neither a `src/` nor a `lib/` directory (verified). Recorded + consequence rather than papered over: `esbuild.action.mjs`, `start-cache-server/entry.ts`, + `vitest.workspace.ts` and `.planning/spikes/*.mjs` are **not linted** by this phase. This + narrows LINT-01 SC1's literal "across the workspace" to "across the project that has specs" -- + **flag it for the verifier as an intentional, recorded deviation, not a gap.** It costs nothing + against this phase's goal: all 32 spec files and all four CORR-05 sites live inside the scope, + so LINT-02, LINT-03 and CORR-06 are fully covered. It also keeps the LINT-04 input set matched + to the actual lint scope, which is the direction that closes the stale-PASS class rather than + widening it. + +- **D-08:** **Never create a root `src/` or `lib/` directory during v0.0.2.** It would flip + `getProjectUsingESLintConfig` for the root project and silently add a SECOND lint target, + changing `hash_project_config` and rotating every task hash in the middle of the parity + investigation. Comment-lock this at the plugin registration in `nx.json`. + +- **D-09:** Do **not** create `packages/github-cache/.eslintignore`. Its mere existence makes the + plugin construct a per-project `ESLint` instance instead of the shared one and appends an + OS-touching `existsSync` branch, for zero benefit. Ignores live in the flat config's `ignores` + key. + +- **D-10:** A **single root `eslint.config.mjs`** -- `.mjs`, not `.ts` (a TypeScript config needs + `jiti`, an extra install and a transpile step in the `lint` critical path). No helper module + imported by it unless that helper lives under `tools/eslint-rules/**/*` or inside + `{projectRoot}`; an imported helper anywhere else would not be a declared input, which is + exactly the LINT-04 hole. Prefer one file and add no `tools/eslint-rules/` project. + +### Rule set composition + +- **D-11:** Enable `@eslint/js` `recommended` plus `typescript-eslint` `recommended` (the + **non-type-checked** variant), on top of the LINT-02/05/06 rules. **Do NOT enable + `recommendedTypeChecked`, and do NOT set `parserOptions.projectService` or `project`.** No + mandated rule is type-aware (`no-restricted-syntax`/`no-restricted-imports` are syntactic; + `ban-ts-comment` and `require-description` are comment/AST-level), and type-aware linting would + make `lint` sensitive to every file in the TypeScript program plus the tsconfigs -- a much wider + input set to declare correctly and a much bigger stale-cache blast radius (LINT-04 clause c). + +- **D-12:** **Bounded-cleanup rule.** Measure the baseline finding count from the recommended sets + BEFORE fixing anything, and record it. Findings that are few and mechanical get fixed in-phase. + Any single rule producing a broad sweep is turned OFF in `eslint.config.mjs` with a one-line + recorded reason plus a deferred-ideas entry -- **never** a blanket file-level or directory-level + disable, and never an open-ended codebase cleanup. LINT-01's scope is "a `lint` target exists + and the platform ban is enforced"; ecosystem hygiene is a separate, later decision. + +- **D-13:** Known-in-advance scoping needs, so the planner does not discover them as surprises: + `packages/github-cache/pack-check.cjs` is CommonJS and will trip + `@typescript-eslint/no-require-imports` -- scope that rule off for `**/*.cjs` with + `sourceType: 'commonjs'` and node globals, rather than rewriting a working guard script. + `vitest.config.mts` and `vitest.integration.config.mts` use `__dirname` and fall in the same + treatment class. + + > **NOTE ADDED 2026-07-27 (D-16 amendment, finding ME-01).** Widening the ban's `files` to + > `{js,mjs,cjs,ts,mts,cts,jsx,tsx}` means a hypothetical `foo.spec.cjs` now matches BOTH the + > `**/*.cjs` override here AND the ban block. **Measured: the ban still fires** -- P1 + > (`process.platform`) is reported at `x.spec.cjs`. No config-object reordering was needed, + > and C5's original ordering reason is intact: the `**/*.cjs` override still sits AFTER the + > `tseslint.configs.recommended` spread (so it still wins on `languageOptions`), and the ban + > block sits after BOTH and only sets `rules`, so the two compose rather than collide. + > Confirmed live by `x.spec.cjs` reporting no `no-undef` on `process` -- i.e. the override's + > inline globals map is still active -- while the ban error is present. + > + > Secondary, recorded not fixed: a hypothetical `*.spec.{js,mjs,jsx}` WOULD additionally + > trip `no-undef` on `process`, because `typescript-eslint/eslint-recommended` scopes + > `no-undef: 'off'` to `{ts,tsx,mts,cts}` only and the override above covers `.cjs` only. + > That is noise on a file that does not exist, not a hole -- the ban fires regardless. Adding + > a globals map for those three extensions would be speculative config for files nobody has + > written; do it if and when the first one appears. + +- **D-14:** No `eslint-config-prettier` and no `eslint-plugin-prettier`. `nx format:check` + (Prettier directly, `.prettierrc` = `{ "singleQuote": true }`) already owns formatting, and + neither enabled recommended set turns on stylistic rules -- there is no conflict to bridge. + +### The ban itself (LINT-02, CORR-06) + +- **D-15:** **Two core rules, both required, no plugin.** One rule alone proves RED for one shape + and silently misses the other: + - `no-restricted-imports` with `paths` entries for `node:os` / `os` (`importNames`: `tmpdir`, + `EOL`, `platform`, `arch`, `homedir`, `type`, `release`) and `node:path` / `path` + (`importNames`: `sep`, `delimiter`, `win32`, `posix`). This is the only rule that can see a + **destructured named import** -- and `cache-archive-path.spec.ts:1` is exactly that shape. + - `no-restricted-syntax` with `MemberExpression` selectors for `process.platform|arch`, the + `node:os` accessor set off an `os`-style namespace object, and `path.{sep,delimiter,win32,posix}`. + This is the only rule that can ban a **member of a namespace import**. + Both are core ESLint rules -- no new dependency. `STACK.md` 1.5 carries a selector sketch; treat + it as a starting point and validate it against the real expressions, not as final text. + +- **D-16:** ~~Scope block is `files: ['**/*.spec.{ts,mts,cts}']` with + `ignores: ['**/*.integration.spec.{ts,mts,cts}']` -- **the full `{ts,mts,cts}` set in BOTH + globs.**~~ The `.ts`-only form INVERTS the rule: `vitest.integration.config.mts` includes + `{src,tests}/**/*.integration.spec.{ts,mts,cts}`, so an `*.integration.spec.mts` would be linted + as a unit spec and its LEGITIMATE platform read would fail lint, while a `*.spec.mts` unit spec + would slip the ban entirely. + + > **AMENDED 2026-07-27 -- origin: code-review finding ME-01 (`07-REVIEW.md`).** + > The struck text above is superseded. The scope block is now: + > + > ```js + > files: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + > ignores: ['**/*.integration.spec.{ts,mts,cts}'], + > ``` + > + > **What was wrong.** "The full `{ts,mts,cts}` set in BOTH globs" was right about the E5 + > inversion it was aimed at, and wrong about the FRAME. Symmetry between the two ESLint + > globs says nothing about what the RUNNER collects, so both could sit narrower than it in + > lockstep and still read as correct. They did: the unit runner collects + > `{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}`, so `*.test.ts`, + > `*.spec.tsx` and `*.spec.cjs` ran as unit tests with the ambient-platform ban silently + > OFF. Zero such files existed, which is exactly why nothing would have noticed the first + > one. + > + > **The two globs are now ASYMMETRIC ON PURPOSE.** Each mirrors a DIFFERENT vitest key: + > `files` mirrors the unit `include`, `ignores` mirrors the unit `exclude`. Naively widening + > `ignores` to match `files` opens a new hole in the other direction -- the integration + > runner collects only `{ts,mts,cts}`, so `foo.integration.spec.tsx` is not an integration + > spec, the unit runner's exclude misses it too, and it runs as a UNIT test that a widened + > `ignores` would exempt. Measured: under that widening `x.integration.spec.tsx` goes from + > BANNED to clean. + > + > **Blast radius checked before amending:** repo-only. The npm tarball carries 53 files and + > zero eslint/lint/spec/vitest artifacts; none of the three files touched here ship. + > `packages/github-cache/package.json` and `public-surface.spec.ts` remain byte-identical. + > + > **Verified after amending:** the ban fires at all eight extensions plus the `{test,spec}` + > name family at a unit path, stays exempt at `*.integration.spec.{ts,mts,cts}`, and fires + > at the `.tsx` corner. See the `.cjs` note in D-13. + +- **D-17:** `ignores` sits **alongside `files` in the same config object**, never as a bare + `ignores`-only object. An `ignores` beside `files` removes those paths from THIS object only, so + integration specs keep every other rule and lose only the platform ban -- which is precisely + CORR-06's "the same APIs stay ALLOWED in `integration`". A standalone `ignores` object would + globally un-lint them. + +- **D-18:** The canonical ALLOWED shape, used in every rule `message` and anywhere the rule is + documented, is **`cachePlatform('win32')`**. Do **not** use `releaseAssetName(hash, 'win32')`: + CORR-02 deletes that parameter in Phase 10, three phases after Phase 7 writes the rule, and + `fallow` will then flag it. OBS-03 deliberately keeps `cachePlatform`, so it is the stable + substitute. Injected or explicit platform values are never banned -- only deriving an + expectation from the RUNNING machine is. + +### Drift guard for the scope split + +- **D-19:** A drift spec asserts the ESLint globs and the two vitest configs agree, in the repo's + existing drift-guard style (`docs-trust.spec.ts`, `trust.generated.spec.ts`): read/import the + REAL configs, never restate the globs in the assertion. ~~**"Agree" is NOT set equality** -- + `vitest.config.mts`'s include is deliberately wider (`{js,mjs,cjs,ts,mts,cts,jsx,tsx}`), so an + equality assertion would be permanently red. The two load-bearing invariants to assert are:~~ + 1. ~~the ESLint `files` and `ignores` extension sets are **identical to each other**, so the + exemption can never be narrower than the ban (that asymmetry IS the E5 inversion); and~~ + 2. ~~that shared set is a **superset of `vitest.integration.config.mts`'s include extension + set**, so no integration spec can ever be linted as a unit spec.~~ + + > **AMENDED 2026-07-27 -- origin: code-review finding ME-01 (`07-REVIEW.md`).** + > The two struck invariants are superseded. Read-the-real-configs and never-restate-the-globs + > are UNCHANGED and still the point. + > + > **The invariant is: a file runs as a unit test IF AND ONLY IF the ban applies to it.** + > Asserted as three legs, all against the real vitest configs: + > + > 1. ESLint `files` **==** `vitest.config.mts` `include` + > 2. ESLint `ignores` **==** `vitest.config.mts` `exclude` (its quoted spec-name entry) + > 3. `vitest.config.mts` `exclude` **==** `vitest.integration.config.mts` `include` + > + > **Why (a) had to go.** It compared the two ESLint globs only to EACH OTHER, so it had no + > term for the runner and could not express "the ban is narrower than what runs". Legs 1+2 + > subsume the E5 inversion (a) existed to catch, and additionally reach the `.tsx` corner + > (a) cannot express. Measured contrast: against a naive symmetric widening of `ignores`, + > (a) PASSES while leg 2 fails. Leg 3 is old (b) stated exactly -- equality rather than + > superset, because a superset in that direction means a file runs twice or not at all. + > + > **"NOT set equality" was the part that misdiagnosed the problem.** The unit include IS + > wider, but that is not a reason to avoid equality -- it is a reason to compare the ban + > against the CORRECT key. What must not be compared literally is the PATH ANCHOR: ESLint + > matches relative to the config file's directory (`**/`), vitest relative to the package + > root (`{src,tests}/**/`). The guard therefore compares the BASENAME pattern -- name family + > and extension set, both as sorted sets -- which is neither vacuous nor permanently red. + > That limitation is recorded in the spec header. + +### RED before GREEN (LINT-03) + +- **D-20:** The RED proof is a **permanent programmatic spec**, not a one-time observation and not + a deliberately-red intermediate commit. Instantiate ESLint's Node API against the real root + `eslint.config.mjs` and use `lintText(code, { filePath })` -- it applies flat-config + `files`/`ignores` matching to the supplied path, so ONE mechanism proves the rule fires AND + proves the scoping in both directions (a synthetic `...spec.ts` path errors, the same source at + a `...integration.spec.ts` path does not). This preserves the repo's bisect-safety discipline + (full battery green at EVERY commit): the rules and the D-31 disables land in one commit, and + the evidence lives in a test rather than in a red build nobody can re-run later. + +- **D-21:** The fixture covers the **evasion shapes**, not only what exists today: `const + { platform } = process`, `const p = process; p.platform`, `import { platform } from 'node:os'`, + `import * as os from 'node:os'`, `const k = 'platform'; process[k]`, and + `await import('node:os')`. Each shape's expected verdict is asserted explicitly. Any shape the + AST matcher genuinely cannot reach is recorded as a **known ceiling** in a `// ponytail:`-style + comment naming the ceiling and its upgrade path -- never left as an untested silent gap. A rule + proven only against the cases that already exist is proven against the easy half, and a rule + that matches nothing is indistinguishable from a rule that is not wired up. + +- **D-22:** A second, deliberately coupled assertion proves each of the **four extant CORR-05 + sites** is caught while it still exists: a declared site table (file + violating expression), + each linted with its `eslint-disable-next-line` stripped, asserting an error at that position. + Comment-lock the table with the removal schedule so Phases 9/10 delete the row together with the + site: + + | Site | Removed by | + |------|-----------| + | `src/lib/cache-archive-path.spec.ts:1` (`import { tmpdir }`) and `:26` (`tmpdir()`) | VER-02, Phase 9 | + | `src/backend/releases-backend.spec.ts:38` (`cachePlatform(process.platform)`) | CORR-02, Phase 10 | + | `src/lib/release-asset-name.spec.ts:39` (`releaseAssetName(hash, process.platform)`) | CORR-02, Phase 10 | + | `src/lib/release-asset-name.spec.ts:60` (`cachePlatform(process.platform)`) | **NOTHING** in this milestone -- Phase 10 makes an explicit call; recommended is moving it to `src/server/public-server.integration.spec.ts`, where LINT-02 allows it | + +- **D-23:** **Mutation-test the guard before declaring it done.** Revert one selector and confirm + the spec goes red on exactly the expected assertions, then restore it. Precedent and standard: + quick 260726-gok mutation-tested `nx-target-inputs.spec.ts` and it is the reason that guard is + trusted. A guard that cannot fail is worthless. + +### Stale-cache closure (LINT-04) + +- **D-24:** `nx.json` `targetDefaults.lint` declares the full input list plus `outputs: []`. + `targetDefaults..inputs` **REPLACES** the inferred list rather than merging (verified + empirically on this repo for `test`), so the block must restate everything it keeps: `default`, + `^default`, `{workspaceRoot}/eslint.config.mjs`, `{workspaceRoot}/tools/eslint-rules/**/*`, and + `{ externalDependencies: ['eslint', '@eslint/js', 'typescript-eslint', + '@eslint-community/eslint-plugin-eslint-comments'] }`. The inferred + `{ externalDependencies: ['eslint'] }` alone IS the LINT-04 hole -- a `typescript-eslint` or + comments-plugin bump would not invalidate the `lint` cache. `outputs: []` is honest (`eslint .` + with no `--output-file` writes nothing) and removes the `{options.outputFile}` token from + `hash_project_config` entirely. + +- **D-25:** **Second-order hole, and the one most likely to be missed.** The D-20/D-22 guard specs + run under the `test` target, so `test.inputs` must ALSO gain `{workspaceRoot}/eslint.config.mjs` + and the ESLint entries in its `externalDependencies` -- **in the same commit** as the guard. + Without it, editing a rule replays a cached `test` PASS, and since LINT-03 IS the activity that + edits rules, the false PASS surfaces during LINT-03 itself and reads as "the rule does not + fire". This repo has shipped that exact defect twice: `governance-email.spec.ts` (T-06-03-02) + and `typecheck`'s spec-excluding inputs (quick 260726-gok). Do not make it three. + +- **D-26:** Extend `packages/github-cache/src/nx-target-inputs.spec.ts`; do **not** build a new + mechanism. It already delegates every glob decision to Nx's own resolver trio + (`splitInputsIntoSelfAndDependencies` -> `extractPatternsFromFileSets` -> + `filterUsingGlobPatterns`, mirroring Nx's `getTargetInputs`) so it cannot drift from Nx's + behaviour. **Do not "restore" `expandSingleProjectInputs`** -- it looks like a cleanup and it + THROWS on this inputs array, because it rejects entries carrying `dependencies: true` and + `nx.json` has one. Honour the spec's own recorded caveat: reading `nx.json` from a spec is safe + only because `{workspaceRoot}/nx.json` is a `test` input, and **only `test` declares it**. + +- **D-27:** LINT-04 is closed **by differential, not by reading the config** -- SC4 says so in as + many words. Two measurements, both with the before/after `Cache: n/m` line recorded: editing a + rule in `eslint.config.mjs` re-runs `lint` instead of replaying, and editing a linted source + file does the same. Same evidence discipline as quick 260726-gok, which proved its one-token fix + by running the failing case on both sides of the change. + +### Opt-out discipline (LINT-05, LINT-06) + +- **D-28:** Set `linterOptions.reportUnusedDisableDirectives: 'error'` **explicitly** in the flat + config. v9's default is a non-failing `warn`; setting it explicitly makes the default + irrelevant and costs one line. Skip `reportUnusedInlineConfigs` -- no v0.0.2 requirement needs + it. + +- **D-29:** `@eslint-community/eslint-comments/require-description` at `error`, imported from the + plugin's **`./configs` subpath export**. Note the flat-config rule prefix is the scoped + `@eslint-community/eslint-comments/`, **not** the legacy bare `eslint-comments/` that LINT-05's + requirement text uses. Same rule, different prefix -- do not copy the requirement text + verbatim into the config. + +- **D-30:** `@typescript-eslint/ban-ts-comment` configured + `{ 'ts-expect-error': 'allow-with-description', 'ts-ignore': true }`, so a bare + `@ts-expect-error` or `@ts-ignore` is also an error. Free with `typescript-eslint`; no extra + package. + +- **D-31:** Each of the four CORR-05 sites gets a described + `// eslint-disable-next-line -- ` **in the same commit as the rules**, so Phase 7 + lands GREEN. Per LINT-06 the reason text must state WHY the assertion cannot move to + `integration`. LINT-06's unused-directive error is then the mechanism that forces each disable + out together with its violation in Phases 9 and 10. **That is the design working, not a leak.** + A planner who does not know this will either leave the build red or delete the violations early + and destroy LINT-03's evidence -- both are failures. + +### CI wiring + +- **D-32:** Add a `lint` job to `.github/workflows/ci.yml` beside `format-check` / `fallow` / + `pack-check`, and a root `"lint": "nx run-many -t lint"` package script mirroring the existing + `build` / `typecheck` / `test` / `integration` scripts. + +- **D-33:** The `lint` job does **not** get the sidecar dogfood block in Phase 7. The four + dogfooded targets stay `build` / `typecheck` / `test` / `integration`. Adding a fifth cache + producer and a fifth mirrored hash family in the middle of the milestone whose entire job is + stabilising hashes buys nothing (lint runs in seconds) and adds surface to the Phase 8 + investigation. Purely additive later -- carried as a deferred idea, not a gap. + +- **D-34:** No `--max-warnings` flag and no override of the inferred `command`. Every mandated + rule is `error`, so warnings-as-errors is redundant; and leaving `options.command` at the + literal `eslint .` keeps one more `hash_project_config` field untouched during a milestone that + is trying to hold hashes still. + +### Phase 8 hand-off (the recorded mitigation for D-01's inherited risk) + +- **D-35:** Phase 7 **must record the inferred `lint` target's HASHED node values** as the + baseline CORR-03 compares against: `targetName`, `executor`, `outputs`, `options` (including the + resolved `cwd`), `configurations`, `parallelism`. Those are exactly the fields + `hash_project_config` folds in -- `metadata` is NOT hashed, so the `${pmc.exec}` it contains is + a non-issue for the hash. This is the accepted-risk mitigation for D-01: `STACK.md` section 7 + leaves "does `@nx/eslint` infer `lint` identically on both OSes?" **UNVERIFIED BY DESIGN**, + because the existence gate runs `eslint.isPathIgnored(join(workspaceRoot, file))` with a POSIX + `join` over an absolute Windows root, producing mixed separators. It reads clean at source and + Windows tolerates it, but "should" is exactly what a two-leg measurement is for. **Phase 8's + CORR-03 treats `lint` as a FOURTH target and settles it empirically. Do not reason it closed + here.** + +- **D-36:** **Record in advance, do not gate.** Registering the plugin rotates EVERY task hash, + and rotates `test` twice over (`{workspaceRoot}/nx.json` is already an explicit `test` input). + Phase 7's first default-branch push is therefore a legitimate **all-MISS push**; Phase 9's + VER-01 produces a second one. Write this down now so Phase 9's OBS-04 tripwire is authored as + "two consecutive all-miss pushes with NO version-affecting change in between" -- there are three + legitimate rotation windows in this milestone, and a tripwire that fires on correct work gets + disabled. + +### Claude's Discretion + +- Exact esquery selector strings, rule `message` wording, flat-config file layout and config-object + ordering, and the per-site disable reason prose. +- Which recommended-set rules (if any) end up scoped off under D-12, and where the fix-vs-disable + line falls. Decide on the measured count and record the call with its number. +- Whether the D-21 evasion fixtures live inline in the spec or as exported string constants in a + sibling module. +- Whether the D-19 drift guard and the D-20/D-22 RED proof are one spec file or two. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Required reading, in this order (carried from STATE.md's Session Continuity block) + +- `.planning/research/v0.0.2/PROBE-RESULTS.md` -- **FIRST.** Establishes the two axes (a real OS + axis and a `.nx/workspace-data` freshness axis that perfectly masquerades as it) and reframes + Phase 8. Phase 7 needs it to understand what D-36's hash rotation does and does not prove. +- `.planning/research/v0.0.2/SUMMARY.md` -- section 3.1 findings B-4 (pinned-deps is name-scoped), + B-6 (four CORR-05 sites and the Phase 7 described-disable consequence), B-7 (the `.mts` glob + inversion), B-8 (one rule cannot enforce the ban list); section 3.3 "Phase 7"; section 4 item 5 + (the `@nx/eslint` adjudication); section 6 open gaps. +- `.planning/REQUIREMENTS.md` -- lines 115-127 (CORR-06), 129-188 (LINT-01..06), 85-114 (CORR-05, + the four-site table and the sequencing consequence), 534-536 (the LINT sequencing rows), 40-64 + (locked decisions D2-01..D2-06). +- `.planning/ROADMAP.md` -- "### Phase 7" (goal, five success criteria, and the hashing reason + Phase 7 comes first), the Traceability rows for LINT-01..06 / CORR-06, and + "### Every sequencing-constraint row, and where it is honoured". +- `.planning/THREAT-MODEL.md` -- the C1-C18 CREEP control ledger. Phase 7 does not touch a + control, but the register is the project-level security context. +- `.planning/research/v0.0.2/PITFALLS.md` -- **weight SILENT-3 above everything else** (STATE.md + instruction). Section E is the ESLint-adoption block: E1 verified facts, E2 the explicit-target + alternative and the inference-plugin OS-divergence class, E3 `lint`'s own stale-cache false + PASS, E4 the evasion shapes, E5 the glob inversion, E6 the name-scoped pin guard. + +### ESLint toolchain specifics + +- `.planning/research/v0.0.2/STACK.md` sections 0 through 1.7 -- the exact dependency table and + versions, the v9-not-v10 call, the inferred target's literal shape read from + `packages/eslint/src/plugins/plugin.ts` @ 23.1.0, the `hash_project_config` confirmation, the + field-by-field OS-variance audit and its two caveats, the `.eslintignore` advice, the LINT-04 + input block, the two-rule requirement plus a selector sketch, LINT-05/06 wiring, and the pinning + obligation. Section 4 "What NOT to add" (every rejected dependency with its reason), section 5 + (install command and the lockfile-container rule), section 7 (open items, labelled). +- `.planning/research/v0.0.2/ARCHITECTURE.md` -- section 2.2 Gap 1 (the four CORR-05 sites), + section 5.1, section 6.4. + +### Repository single sources this phase edits or must not break + +- `nx.json` -- `plugins[]` (D-01), `targetDefaults.lint` (D-24), `targetDefaults.test.inputs` + (D-25). Note the existing `integration` target's `{ runtime: 'node -p process.platform' }` + discriminator: CORR-04 requires `integration` stays the ONLY target declaring one, so `lint` + must not acquire a platform input. +- `packages/github-cache/project.json` -- the existing explicit `integration` target + (`command` + `options.cwd`); the shape precedent for the alternative rejected in D-01. +- `packages/github-cache/vitest.config.mts` and `vitest.integration.config.mts` -- the partition + LINT-02 mirrors and D-19 guards. Note the unit config's include is wider than `{ts,mts,cts}`. +- `packages/github-cache/src/pinned-deps.spec.ts` -- the name-scoped guard to extend (D-04). +- `packages/github-cache/src/nx-target-inputs.spec.ts` -- the Nx-resolver-delegating inputs guard + to extend (D-26), including its recorded `expandSingleProjectInputs` warning. +- `packages/github-cache/src/public-surface.spec.ts` -- must pass unchanged (D-06). +- The four CORR-05 sites: `packages/github-cache/src/lib/cache-archive-path.spec.ts:1,26`; + `src/backend/releases-backend.spec.ts:38`; `src/lib/release-asset-name.spec.ts:39,60`. +- `.github/workflows/ci.yml` -- the job battery `lint` joins (D-32). It is **not** currently an + Nx input; PARITY-06 registers it in Phase 9, so a Phase 7 spec must not assert on it. +- `.fallowrc.jsonc` -- `entry` / `ignorePatterns` / `ignoreDependencies`. A new root + `eslint.config.mjs` is not import-reachable and may need an `entry` declaration; the four new + ESLint devDependencies are consumed only by the config file, so check the unused-dependency + verdict before assuming `fallow:ci` stays green. +- `.prettierignore` and `.prettierrc` -- the Prettier/ESLint boundary D-14 relies on. +- `.planning/codebase/CONVENTIONS.md` -- the "single source of truth + drift guard" pattern every + guard in this phase should follow, plus the two style rules currently "enforced by convention, + not by a lint config". **Stale as of 2026-07-22**: it states "ESLint is NOT configured in this + repository", which this phase falsifies. + +### Prior art on the exact failure class this phase must not repeat + +- `.planning/quick/260726-gok-resolve-typecheck-stale-cache-false-pass/` -- the `typecheck` + stale-cache false PASS and its one-token fix. Source of the differential-proof discipline + (D-27), the mutation-test standard (D-23), the `expandSingleProjectInputs` correction (D-26), + and the "a guard's own non-vacuity control can itself be vacuous" lesson. +- `.planning/quick/260726-4cc-audit-and-triage-proposals-1-4-then-appl/` -- where the same + false-pass class was first surfaced. + + + + +## Existing Code Insights + +### Reusable assets + +- **`pinned-deps.spec.ts`** -- exact-semver guard reading the ROOT manifest via + `new URL('../../../package.json', import.meta.url)`. Adding five `it()` blocks to the existing + `describe` satisfies D-04 with no new file and no new mechanism. +- **`nx-target-inputs.spec.ts`** -- resolves Nx target inputs through Nx's own resolver trio and + is already mutation-tested. Extend it with `lint` probe files (D-26). +- **`docs-trust.spec.ts` / `trust.generated.spec.ts`** -- the drift-guard shape D-19 copies: + import the real single source, assert the derived copies agree, fail the build on divergence. +- **`cleanup-workflow.spec.ts` / `ppe-action.spec.ts`** -- config-assertion specs that read a file + off disk via `import.meta.url` (never `__dirname`, never `process.cwd()`) and strip + `#`-comment lines first so the spec's own prose cannot make an assertion vacuously pass. The + pattern to reach for when asserting on `eslint.config.mjs` content. +- **`src/test/` fixtures** (`octokit-fault.ts`, `consumer-contract.ts`) -- spec-only helpers with + no product imports; the right home for shared LINT-03 fixture strings if D-21's discretion goes + that way. + +### Established patterns that constrain this phase + +- **Single source + drift guard is the dominant convention.** Author a fact once, then add a spec + that fails the moment a second copy drifts. Every new cross-cutting fact in this phase (the glob + extension set, the site table, the input list) should follow it rather than being hand-synced. +- **Strict ESM, `nodenext`.** Every relative import carries an explicit `.js` extension even from + a `.ts` source; `import type` for type-only imports. Non-negotiable under the current + `tsconfig.base.json`. +- **Explicit assertion lists, never `toMatchSnapshot()`.** `public-surface.spec.ts` is the + precedent: an intentional change must show up as a reviewable diff, not a rubber-stampable + `.snap` regen. +- **Comment density carries decisions.** Module and function headers state the invariant, why the + alternative was rejected, and the requirement ID. A stale rationale comment is treated as a + defect. Every comment-lock this phase asks for follows that house style. +- **`// ponytail:` marks a deliberate, scoped simplification with its ceiling and upgrade path + named inline** -- the right form for D-21's known-ceiling notes. + +### Integration points + +- `nx.json` `plugins[]` and `targetDefaults` -- where the target enters the graph and where its + inputs are pinned. +- The root `package.json` `scripts` block and `.github/workflows/ci.yml` job list -- where `lint` + becomes a gate. +- `packages/github-cache/src/**/*.spec.ts` -- the 32 files the ban applies to; exactly one + integration spec exists today (`src/server/public-server.integration.spec.ts`), which is also + Phase 10's recommended destination for CORR-05 site 4. + + + + +## Specific Ideas + +- The user chose `@nx/eslint` over the explicit-target alternative **with the inference-plugin + OS-divergence risk stated in full**. That risk is therefore ACCEPTED, not overlooked, and D-35 + is its recorded mitigation. Do not re-litigate the choice; do not silently drop the mitigation. +- Three pieces of received wording are known-wrong and must not be copied verbatim: + LINT-05's bare `eslint-comments/` rule prefix (D-29), CORR-06's + `releaseAssetName(hash, 'win32')` example (D-18), and LINT-01's "covered by the `pinned-deps` + guard" phrasing (D-04). Each has a corrected form above. +- Two claims that read like conclusions but are open questions, to be carried as open: + whether `@nx/eslint` infers `lint` identically on both OSes (D-35), and how many findings the + recommended rule sets produce on this tree (D-12). Neither is answerable by reading. + + + + +## Deferred Ideas + +- **Mechanize the two conventions `CONVENTIONS.md` records as "enforced by convention, not by a + lint config"** -- `curly` (a core rule, zero-dep, and the tree already complies) and + blank-lines-around-control-flow (needs `@stylistic`, a new dependency). Genuinely tempting given + a linter is now present, but outside LINT-01..06; research parks ecosystem hygiene rules as "a + later, separate decision". A follow-on, not this phase. +- **Sidecar dogfood block for the `lint` job** (D-33). Purely additive. Revisit once Phase 8's + parity work has settled and adding a fifth cache producer no longer muddies the investigation. +- **Lint the root-level files the project-scoped target misses** -- `esbuild.action.mjs`, + `start-cache-server/entry.ts`, `vitest.workspace.ts`. Needs a second lint scope, and the + `@nx/eslint` route to it is closed by D-08 (creating a root `src/`). A later, deliberate change. +- **`eslint@10` bump.** Blocked on checking that `loadESLint` survives v10's eslintrc-loader + removal, which `@nx/eslint`'s `resolveESLintClass` depends on (`STACK.md` section 7). One-line + change once checked. +- **Regenerate `.planning/codebase/*` via `/gsd:map-codebase`.** Mapped 2026-07-22 against v0.0.1 + and already flagged stale in PROJECT.md and STATE.md's Operator Next Steps. This phase falsifies + `CONVENTIONS.md`'s "ESLint is NOT configured in this repository" outright. Not a Phase 7 + deliverable. + +### Surfaced, and NOT owned by this phase + +- **Whether `gsd/v0.0.2-os-invariant-cross-os-sharing` gets a PR per phase or one at milestone + end** is an open operator decision already carried in STATE.md's Operator Next Steps. It is out + of Phase 7's scope, but it determines WHEN D-36's legitimate all-MISS push lands on `main`, and + Phase 10's live-CI close and Phase 11's proofs both depend on a warm mirror on the default + branch. Flagged so it is not discovered late. + +### Closed at discuss time, not deferred + +- **The explicit-`lint`-target alternative** (a `command: 'eslint .'` target in the existing + `project.json`). Presented in full with its case for and against, and rejected by the user in + favour of `@nx/eslint` (D-01). Record the one-line dismissal in the plan as REQUIREMENTS and + research require, then move on. + + + +--- + +*Phase: 7-Lint Toolchain and the Ambient-Platform-Read Ban* +*Context gathered: 2026-07-27* diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-DISCUSSION-LOG.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-DISCUSSION-LOG.md new file mode 100644 index 00000000..7f574ad6 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-DISCUSSION-LOG.md @@ -0,0 +1,214 @@ +# Phase 7: Lint Toolchain and the Ambient-Platform-Read Ban - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md -- this log preserves the alternatives considered. + +**Date:** 2026-07-27 +**Phase:** 7-Lint Toolchain and the Ambient-Platform-Read Ban +**Mode:** `--analyze --auto --chain` (trade-off analysis per area; Claude auto-selected the +recommended option for every area except the one escalated below) +**Areas discussed:** Lint target provenance, Lint scope and blast radius, Rule set composition, +The ban rules, Scope-glob drift guard, RED-before-GREEN evidence, Stale-cache closure, Opt-out +discipline and the four CORR-05 sites, CI wiring, Phase 8 hand-off + +--- + +## Lint target provenance (ESCALATED -- not auto-decided) + +**Why this one was escalated rather than auto-locked.** It sat in the trap quadrant: HIGH impact +(the phase's central toolchain choice; it feeds Phase 8's clean-room parity investigation, and +unwinding it after Phase 8's root-cause record would invalidate that record -- the same argument +that put Phase 7 first) combined with NOT-HIGH confidence in the "recommended" option. STATE.md +called it "still undecided" and "deserves one line in the plan before it is dismissed"; research +SUMMARY section 4 item 5 called the dismissal "close"; and the stated reason for the +recommendation was a convention argument ("ecosystem norm, the generator does the wiring") against +an unrebutted mechanism argument (a new inference plugin is a new OS-divergence surface in the +milestone whose purpose is removing unverified cross-OS variance). + +| Option | Description | Selected | +|--------|-------------|----------| +| Explicit target in `project.json` | `command: 'eslint .'` beside the existing `integration` target, same shape. No inference plugin, four deps instead of five, closes STACK.md's "does `@nx/eslint` infer `lint` identically on both OSes? UNVERIFIED BY DESIGN" open item. Diverges from the research recommendation. | | +| `@nx/eslint` inference plugin | Research SUMMARY section 4 item 5 (three lenses converged on KEEP); ROADMAP and REQUIREMENTS already cite inference as the LINT-01 -> PARITY-01 mechanism; ecosystem norm. Costs a fifth exact-pinned dep in version lockstep with nx 23.1.0, and carries one unverified cross-OS inference. | Yes | + +**User's choice:** `@nx/eslint` inference plugin. + +**Notes:** Chosen with the OS-divergence cost stated in full, so the risk is ACCEPTED rather than +overlooked. Consequences recorded in CONTEXT.md: D-01 (the decision plus the one-line dismissal +the requirement demands), D-08 (never create a root `src/`or `lib/` this milestone, or a second +lint target appears silently), and D-35 (Phase 7 records the inferred target's hashed node values +as the baseline Phase 8's CORR-03 compares against, treating `lint` as a fourth target). The +Phase 7 -> Phase 8 ordering was NOT a differentiator: any declared target mutates +`hash_project_config`, so the constraint holds under either option. + +--- + +## Lint scope and blast radius + +| Option | Description | Selected | +|--------|-------------|----------| +| Project-scoped (`eslint .`, cwd `packages/github-cache`) | What `@nx/eslint` infers. Covers all 32 specs and all four CORR-05 sites. Leaves root-level files unlinted. | Yes | +| Workspace-wide | Matches LINT-01 SC1's literal "across the workspace" wording, but needs a root lint scope, which the `@nx/eslint` route can only reach by creating a root `src/` -- forbidden by the plugin's own caveat. | | + +**Auto-selected:** project-scoped. Follows mechanically from the target-provenance choice, and is +the same answer under either option. **Recorded as an intentional deviation, not a gap** (D-07): +`esbuild.action.mjs`, `start-cache-server/entry.ts`, `vitest.workspace.ts` and +`.planning/spikes/*.mjs` are not linted by this phase. Widening later is additive and is carried +as a deferred idea. + +--- + +## Rule set composition + +| Option | Description | Selected | +|--------|-------------|----------| +| Mandated rules only | Only LINT-02/05/06's rules. Smallest surface, zero cleanup risk. Makes `@eslint/js` an unused dependency. | | +| Recommended sets, non-type-checked | `@eslint/js` recommended + `typescript-eslint` recommended, plus the mandated rules. What the research dependency table budgets for. | Yes | +| Recommended type-checked | Adds `parserOptions.projectService`. | | + +**Auto-selected:** recommended, non-type-checked, with the D-12 bounded-cleanup rule attached. +Type-checked was rejected outright by LINT-04 clause (c) -- no mandated rule is type-aware, and it +would make `lint` sensitive to the whole TypeScript program plus tsconfigs, widening the input set +and the stale-cache blast radius. The bounded-cleanup rule is what keeps "adopt a linter" from +turning into an open-ended sweep: measure the baseline count first, fix what is few and +mechanical, scope off any single broad rule with a recorded reason -- never a blanket file or +directory disable. + +--- + +## The ban rules (LINT-02, CORR-06) + +| Option | Description | Selected | +|--------|-------------|----------| +| `no-restricted-syntax` alone | The rule the requirement names first. | | +| `no-restricted-imports` alone | Catches the import family in one line. | | +| Both core rules | Neither is sufficient alone; both are core, no new dependency. | Yes | + +**Auto-selected:** both. Not a preference -- a structural necessity. `no-restricted-syntax` is an +AST-selector matcher and cannot see a destructured named import, and +`cache-archive-path.spec.ts:1` (`import { tmpdir } from 'node:os'`) is exactly that shape; +conversely `no-restricted-imports` cannot ban a member of a namespace import. Wiring one would +make LINT-03's RED proof pass for one shape and silently miss the other. + +Two received-wording corrections locked at the same time: the allowed-shape example is +`cachePlatform('win32')`, NOT CORR-06's `releaseAssetName(hash, 'win32')` (that parameter is +deleted by CORR-02 in Phase 10 and `fallow` would then flag the example); and the glob set is the +full `{ts,mts,cts}` in BOTH `files` and `ignores`, because the `.ts`-only form inverts the rule +against `.mts` integration specs. + +--- + +## Scope-glob drift guard + +| Option | Description | Selected | +|--------|-------------|----------| +| Assert set equality with both vitest configs | Simplest to state. | | +| Assert the two load-bearing invariants | `files` and `ignores` extension sets identical to each other; that set a superset of the integration config's includes. | Yes | + +**Auto-selected:** the two invariants. Set equality is not achievable -- `vitest.config.mts`'s +include is deliberately wider (`{js,mjs,cjs,ts,mts,cts,jsx,tsx}`) than the `{ts,mts,cts}` the +requirement mandates for the lint globs, so an equality assertion would be permanently red. +Discovered by reading the real config during the codebase scout; the requirement text does not +mention it. + +--- + +## RED-before-GREEN evidence (LINT-03) + +| Option | Description | Selected | +|--------|-------------|----------| +| Deliberately-red intermediate commit | Land the rules, observe four failures, then land the disables. | | +| A committed violating fixture file | Permanent, but permanently reds the `lint` target. | | +| Programmatic ESLint spec via `lintText` | One mechanism proves the rule fires AND proves the scoping in both directions; permanent and mutation-testable; rules and disables land in one green commit. | Yes | + +**Auto-selected:** the programmatic spec. The intermediate-red option breaks the repo's +bisect-safety discipline (full battery green at every commit), and a one-time observation is not +a regression guard. `lintText(code, { filePath })` applies flat-config `files`/`ignores` matching +to the supplied path, so a synthetic `...spec.ts` path and the identical source at a +`...integration.spec.ts` path prove the exemption too. + +Two halves locked (D-21, D-22): inline evasion-shape fixtures for the rule set's completeness, and +a comment-locked table of the four real sites, each linted with its disable stripped. The table +carries its own removal schedule so Phases 9 and 10 delete the row with the site. Guard must be +mutation-tested before it counts (D-23). + +--- + +## Stale-cache closure (LINT-04) + +| Option | Description | Selected | +|--------|-------------|----------| +| Rely on the inferred inputs | `{ externalDependencies: ['eslint'] }` plus `default`. | | +| Full `targetDefaults.lint` override, plus the `test` second-order fix | Restates every input, adds the three missing external deps, and closes the hole in `test` too. | Yes | + +**Auto-selected:** the full override. The inferred `externalDependencies` list names `eslint` +only, so a `typescript-eslint` bump would not invalidate the `lint` cache -- precisely LINT-04's +named failure class. And `targetDefaults..inputs` REPLACES rather than merges, so the +block must restate everything it keeps. + +**The second-order hole is the one most likely to be missed (D-25):** the RED-proof spec runs +under the `test` target, so `test.inputs` needs `{workspaceRoot}/eslint.config.mjs` and the ESLint +external deps in the SAME commit. Without it, editing a rule replays a cached `test` PASS -- and +since LINT-03 IS the activity that edits rules, the false PASS surfaces during LINT-03 and reads +as "the rule does not fire". This repo has shipped that exact defect twice already. + +--- + +## Opt-out discipline and the four CORR-05 sites + +No competing options -- REQUIREMENTS already dictates described-disable-only, and the four sites +each get one in the same commit as the rules so Phase 7 lands green. What was decided here is the +mechanism narrative that the requirement leaves implicit and that a planner will otherwise get +wrong in one of two ways: leave the build red, or delete the violations early and destroy LINT-03's +evidence. LINT-06's `reportUnusedDisableDirectives: 'error'` is what forces each disable out +together with its violation in Phases 9 and 10 -- the design working, not a leak. + +One received-wording correction: the flat-config rule prefix is the scoped +`@eslint-community/eslint-comments/`, not the bare `eslint-comments/` LINT-05's text uses. + +--- + +## CI wiring + +| Option | Description | Selected | +|--------|-------------|----------| +| `lint` job with the sidecar dogfood block | Consistent with the four cacheable targets already dogfooded. | | +| Plain `lint` job, no sidecar | Lint runs in seconds; no fifth cache producer during the parity milestone. | Yes | + +**Auto-selected:** plain job. Adding a fifth cache producer and a fifth mirrored hash family in +the middle of the milestone whose job is stabilising hashes adds surface to the Phase 8 +investigation for no gain. Additive later -- carried as a deferred idea. Also locked: no +`--max-warnings` flag and no override of the inferred `command`, so one more +`hash_project_config` field stays untouched. + +--- + +## Phase 8 hand-off + +Not a fork -- an obligation created by the target-provenance choice. Phase 7 records the inferred +`lint` target's hashed node values as CORR-03's comparison baseline (D-35), and records in advance +that registering the plugin makes Phase 7's first default-branch push a legitimate all-MISS push +(D-36), so Phase 9's OBS-04 tripwire is authored as "two consecutive all-miss pushes with no +version-affecting change in between" rather than firing on correct work. + +--- + +## Claude's Discretion + +- Exact esquery selector strings, rule `message` wording, flat-config layout and object ordering, + and the per-site disable reason prose. +- Which recommended-set rules end up scoped off under the bounded-cleanup rule, and where the + fix-vs-disable line falls -- decided on the measured count, with the number recorded. +- Whether the evasion fixtures live inline or as exported constants in a sibling module. +- Whether the drift guard and the RED proof are one spec file or two. + +## Deferred Ideas + +- Mechanize `curly` and blank-lines-around-control-flow, the two style rules CONVENTIONS.md + records as enforced by convention only. Outside LINT-01..06. +- Sidecar dogfood block for the `lint` job. +- Lint the root-level files the project-scoped target misses. +- `eslint@10` bump, blocked on checking `loadESLint` survives v10. +- Regenerate `.planning/codebase/*`; this phase falsifies CONVENTIONS.md's "ESLint is NOT + configured in this repository". +- Surfaced but not owned here: whether the v0.0.2 branch gets a PR per phase or one at milestone + end. It determines when the all-MISS push lands on `main`, which Phases 10 and 11 depend on. diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md new file mode 100644 index 00000000..9fa67987 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-EVIDENCE.md @@ -0,0 +1,1006 @@ +# Phase 7 - Recorded Evidence + +Measurements taken during phase 7 execution. Appended to by plans 07-01, 07-02, 07-03 and +07-04. Every number here is MEASURED on this repo, never predicted; where a prediction from +`07-RESEARCH.md` exists it is quoted alongside so the divergence (or its absence) is visible. + +--- + +## Plan 07-01 + +Recorded: 2026-07-27. Host: Windows 11 arm64, node v24.13.0, npm 11.6.2. +Base commit: `7b451ca`. + +### T-07-02 supply-chain pre-check (run BEFORE `npm i`) + +`npm view scripts.postinstall` for all five packages. A non-empty result on any one is +a STOP condition. All five returned EMPTY, so no stop condition fired and the install +proceeded. + +| Package | `scripts.postinstall` | Result | +|---|---|---| +| `eslint` | (empty) | pass | +| `@eslint/js` | (empty) | pass | +| `typescript-eslint` | (empty) | pass | +| `@eslint-community/eslint-plugin-eslint-comments` | (empty) | pass | +| `@nx/eslint` | (empty) | pass | + +A second pass over the full `scripts` object confirmed none of the five carries a +`preinstall`, `install` or `postinstall` hook. The comments plugin's `preversion` / +`version` / `postversion` entries are publish-time hooks in its own repo and never run for a +consumer install. + +**Registry re-confirmation of the one second-hand entry.** +`07-RESEARCH.md`'s Package Legitimacy Audit approved +`@eslint-community/eslint-plugin-eslint-comments@4.7.2` from `STACK.md`'s 2026-07-26 pass +without re-fetching it. Re-fetched here: version `4.7.2` resolves, repository is +`git+https://github.com/eslint-community/eslint-plugin-eslint-comments.git`, tarball +`https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz`. +The other four also re-resolved at their exact requested versions against the expected +official orgs (`eslint/eslint`, `typescript-eslint/typescript-eslint`, `nrwl/nx`). + +### D-05 lockfile regeneration (linux/arm64 `node:24` container) + +Invocation, run from the repo root under Git Bash. `MSYS_NO_PATHCONV=1` is required on this +host: without it Git Bash rewrites the container-side `-w /app` into +`C:/Program Files/Git/app` and docker rejects it. + +```bash +MSYS_NO_PATHCONV=1 docker run --rm --platform linux/arm64 \ + -v "D:/projects/github/op-nx/github-cache:/app" \ + -v /app/node_modules \ + -w /app node:24 \ + sh -c "rm -f package-lock.json && npm install --package-lock-only" +``` + +Container: `node:24` at node `v24.18.0`, npm `11.16.0`. `-v /app/node_modules` masks the host +`node_modules` with an anonymous volume so the Windows tree cannot bias the resolve, which is +the whole point of the procedure. + +**Result:** `package-lock.json` regenerated, +1450 / -104 lines, 621 entries, +`lockfileVersion 3`. + +| Check | Result | +|---|---| +| all five packages present at the exact requested versions | yes (`eslint 9.39.5`, `@eslint/js 9.39.5`, `typescript-eslint 8.65.0`, `@eslint-community/eslint-plugin-eslint-comments 4.7.2`, `@nx/eslint 23.1.0`) | +| Linux-only WASM-fallback optional subtrees survived | yes -- 15 `@emnapi` / `wasm32-wasi` entries, including the nested `@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/*` trio that a bare Windows install prunes | +| entries that appear as removals in the diff | `@typespec/ts-http-runtime`, `get-proto`, `agent-base`, `https-proxy-agent` -- all verified still PRESENT in the regenerated lockfile; the `-` lines are re-ordering, not drops | +| `npm ci` on the host afterwards | exit 0, 516 packages added | + +### Q10 / SC9 -- the action bundle DID drift + +`npm run check:action` immediately after `npm ci` on the regenerated lockfile. RESEARCH +carried this as a contingency, not a prediction. **The contingency fired.** + +- **Cause:** `undici` re-resolved `6.27.0` -> `6.28.0`. It is a transitive runtime dependency + reached through `@actions/*`, which are themselves exact-pinned but carry ranged + dependencies of their own -- exactly the mechanism Q10 named. +- **Bundle delta:** `start-cache-server/index.js`, +88 / -6 lines. The changed bytes are + undici's cookie parser gaining `validateCookieName` / `validateCookieValue` calls. +- **Action taken:** `npm run build:action` (already invoked by `check:action` itself), and + `start-cache-server/index.js` staged IN THIS SAME COMMIT per SC9. Never as a follow-up: a + later commit would leave the `action-bundle-drift` gate failing on this commit and every + one after it. +- **Re-verified:** `npm run check:action` exits 0 with the rebuilt bundle staged. + +No `serve()`-reachable SOURCE was edited by this plan, which is what RESEARCH verified. The +drift came from the dependency graph underneath it, which is the residual RESEARCH G7 flagged +as real. + +### D-06 prohibition check + +`git diff --exit-code -- packages/github-cache/package.json` -> clean. The package manifest is +byte-identical; all five ESLint packages are ROOT devDependencies. + +### D-12 bounded-cleanup baseline + +Measured with `npx eslint . --format json` from `packages/github-cache`, using G4's per-rule +counter. + +**Pre-remediation** -- config carrying the global `ignores` block, `@eslint/js` recommended and +the `typescript-eslint` recommended spread, and nothing else. Taken BEFORE either remediation +block was written. + +``` +files linted: 64 +total findings: 10 +{ + "no-undef": 7, + "@typescript-eslint/no-require-imports": 2, + "@typescript-eslint/no-unused-vars": 1 +} +``` + +Per-file, in full: + +| File | Line:Col | Rule | +|---|---|---| +| `pack-check.cjs` | 32:26 | `@typescript-eslint/no-require-imports` | +| `pack-check.cjs` | 32:26 | `no-undef` (`require`) | +| `pack-check.cjs` | 33:14 | `@typescript-eslint/no-require-imports` | +| `pack-check.cjs` | 33:14 | `no-undef` (`require`) | +| `pack-check.cjs` | 36:29 | `no-undef` (`__dirname`) | +| `pack-check.cjs` | 151:5 | `no-undef` (`process`) | +| `pack-check.cjs` | 160:5 | `no-undef` (`process`) | +| `pack-check.cjs` | 163:3 | `no-undef` (`process`) | +| `pack-check.cjs` | 168:3 | `no-undef` (`process`) | +| `src/serve.spec.ts` | 89:23 | `@typescript-eslint/no-unused-vars` (`_bytes`) | + +**Against RESEARCH G4's prediction: an exact match.** Predicted 64 files linted, 10 findings +(7 `no-undef` + 2 `no-require-imports` from `pack-check.cjs`, 1 `no-unused-vars` at +`serve.spec.ts:89` for `_bytes`), plus 0-2 uncertain from the low-confidence regex class +(`no-useless-escape`, `no-control-regex`, `no-irregular-whitespace`, +`no-misleading-character-class`). The uncertain class produced **zero**. G4's analytic +baseline reproduced file-for-file and line-for-line. + +**Post-remediation residual:** + +``` +files linted: 64 +total findings: 0 +``` + +### The D-12 call, with its number + +**Rules turned OFF repo-wide: ZERO. Code edits to fix a finding: ZERO. Configuration blocks +added: TWO.** That is the outcome RESEARCH G4 predicted, and it is a strong result for the +bounded-cleanup rule: nothing was swept, nothing was disabled to make a number go away, and no +working code was edited to satisfy a linter. + +The two blocks, and why neither is a "disable": + +1. **The `**/*.cjs` override** closes 9 of the 10 findings and is telling ESLint the truth + about a CommonJS file rather than suppressing anything. It carries one scoped rule-off, + `@typescript-eslint/no-require-imports: 'off'`, limited to that one glob: `pack-check.cjs` + is a deliberately dependency-free CommonJS guard CI runs straight after `npm ci`, and + rewriting it to ESM to satisfy a TypeScript-oriented rule would be the tail wagging the dog + (D-13). Recorded honestly as a scoped rule-off rather than claimed as zero. `no-undef` was + deliberately kept LIVE for the glob via a four-name inline globals map, so the one file in + the repo where that rule still applies keeps its typo check. +2. **`@typescript-eslint/no-unused-vars` with the three leading-underscore patterns** closes + the tenth. It codifies a convention the repo already follows at six sites. The alternative + -- a described disable at `serve.spec.ts:89` -- would need reason prose under LINT-05 and + would then be one more thing to keep true. + +No deferred-ideas entry was needed: D-12's escape hatch is for a single rule producing a broad +sweep, and no rule did. + +### G5 negative control 2 -- lint scope must not depend on gitignored build output + +The only control for the G3 finding. Run from `packages/github-cache`. + +| State | `dist/` files | `out-tsc/` files | Linted | +|---|---|---|---| +| with build output | 91 | 73 | **64** | +| after `rm -rf dist out-tsc` | 0 | 0 | **64** | + +**Identical.** `lint`'s scope does not depend on whether `build` ran, so the result cannot +diverge from the Nx hash. Build output restored afterwards with `npm run build` and +`npm run typecheck`. + +### M7 mutation -- proving negative control 2 can fail + +A control that reports the same number twice is worth nothing until it is shown capable of +reporting two. The global `ignores` block was removed, the count re-taken with build output on +disk, and the block restored. + +| Config | Linted | +|---|---| +| with the global `ignores` block | **64** | +| `ignores` block REMOVED (M7) | **155** | + +91 extra files -- the generated `dist/` and `out-tsc/` trees -- would be linted without the +block, at an Nx hash that never moves. That is the stale-cache false PASS G3 predicted, made +visible. RESEARCH predicted 160 against a 96-file snapshot; the tree now holds 91 generated +files, so 155 is the same finding at a moved snapshot, not a divergence. + +The mutation was applied, observed, and REVERTED before the commit. Post-restore re-measure: +64 files linted, 0 findings, and the `.cjs` override still ordered after the +`typescript-eslint` spread. + +### TDD: the RED-before-GREEN split for `lint-rules.spec.ts` + +`workflow.tdd_mode` is on. The opt-out rules were authored in this plan's task 2, so the RED +was produced deliberately: the entire `linterOptions` + `rules` config object carrying +`reportUnusedDisableDirectives`, `require-description`, `ban-ts-comment` and +`no-unused-vars` was stripped from `eslint.config.mjs`, the spec run, and the object +restored. + +**RED -- 2 failed, 7 passed of 9.** + +| Assertion | RED | Why | +|---|---|---| +| the non-vacuity control can itself fail | PASS | the control's own self-test; independent of the stripped rules | +| LINT-05 bare `eslint-disable-next-line` errors | **FAIL** | `require-description` comes ONLY from the stripped object | +| LINT-05 described disable accepted | PASS | a direction control -- passes on both sides by design | +| LINT-05 bare `@ts-expect-error` errors | PASS | see the note below | +| LINT-05 described `@ts-expect-error` accepted | PASS | direction control | +| LINT-05 `@ts-ignore` errors described or not | PASS | see the note below | +| LINT-06 stale described disable errors | **FAIL** | with the object stripped, `reportUnusedDisableDirectives` falls back to v9's default `warn` (severity 1), so the assertion on severity 2 fails | +| CORR-06 non-ban rule errors at the integration path | PASS | **the load-bearing observation -- see below** | +| CORR-06 same rule errors at the unit path | PASS | same | + +**The CORR-06 controls passed on BOTH sides.** That is the distinction the control exists to +draw, and it is the one that had to be checked: a RED in which the CORR-06 assertions ALSO +failed would mean the config was not being loaded at all (the `unconfigured` trap), not that +a rule was missing. They passed, so the RED is attributable to the stripped rules and to +nothing else. + +**Why the three `ban-ts-comment` assertions passed in RED, recorded honestly.** +`@typescript-eslint/ban-ts-comment` is already enabled by `typescript-eslint`'s recommended +set, and its plugin DEFAULT options happen to coincide with D-30's requested configuration. +So those three assertions do not discriminate the presence of D-30's explicit block; they +would stay green if it were deleted today. The explicit block is still worth its four lines +-- it pins the behaviour against a future change to the plugin's defaults, and the +`@ts-ignore`-described-form assertion WOULD fail if someone weakened `'ts-ignore': true` to +`'allow-with-description'` -- but "these three assertions prove D-30's block is present" would +be a false claim and is not made. + +**GREEN after restore: 9 passed of 9.** + +### A vacuity bug in the non-vacuity control, found BY the RED + +The first version of the shared control filtered on `severity === 1 && ruleId === null`, +straight from RESEARCH G1(c). Under RED that filter matched the LINT-06 unused-directive +report itself -- because with `reportUnusedDisableDirectives` back at v9's default `warn`, an +unused-directive report is ALSO severity 1 with a null rule id. The control therefore +reported "this file was not linted" about a file that had just been linted correctly, which +is precisely the misdiagnosis it exists to prevent, reproduced inside itself. It passed in +GREEN only because `'error'` moves those reports to severity 2 -- meaning the control's +correctness depended on the very setting it was supposed to be independent of. + +Fixed by adding the position check. Measured message shapes: + +| Message | `ruleId` | `severity` | `line` | +|---|---|---|---| +| ignored path (`dist/...`) | `null` | 1 | **absent** | +| unconfigured path (unmatched extension) | `null` | 1 | **absent** | +| unused directive (at `warn`) | `null` | 1 | `1` | +| unused directive (at `error`) | `null` | 2 | `1` | + +An ignore/unconfigured result describes the whole FILE and carries no position; every rule +report and every directive report carries one. The control now filters on +`severity === 1 && ruleId === null && line === undefined`, which separates them cleanly and +no longer depends on the LINT-06 severity. The spec carries its own self-test asserting the +control still detects a genuinely ignored path, so the control cannot silently become +inert -- the lesson quick 260726-gok recorded as "a guard's own non-vacuity control can +itself be vacuous". + +### M5 mutation -- the D-25 input assertion can fail + +`{workspaceRoot}/eslint.config.mjs` was removed from `nx.json` `targetDefaults.test.inputs` +and `nx-target-inputs.spec.ts` re-run. + +| State | Result | +|---|---| +| input present | 6 passed of 6 | +| input REMOVED (M5) | **1 failed** (`eslint.config.mjs is a test input, ...`), 5 passed | + +Exactly the one expected assertion, and no collateral. Applied, observed, REVERTED before the +commit. + +### Q2 resolved -- fallow auto-credits `eslint.config.mjs` + +RESEARCH carried this at MEDIUM confidence off a binary-strings inference (F20), with a +one-line `entry` contingency ready. `npm run fallow:ci` exits 0 with the new config file and +its three imports in place, so the contingency was NOT needed and no speculative +`.fallowrc.jsonc` entry was added. The single fallow change is the `@nx/eslint` +`ignoreDependencies` line, which was the one certain case (`@nx/eslint` is referenced only +from `nx.json`, which fallow does not read). The comments plugin's `./configs` subpath +resolved cleanly and needed no entry either. + +### Battery at the commit + +Eight commands, all exit 0: `format:check`, `build`, `typecheck`, `typecheck:action`, `test`, +`fallow:ci`, `check:action`, `pack:check`. There is no `lint` command yet -- it becomes the +ninth in plan 07-03. + +Unit suite: **453 tests across 32 files, all passing.** Baseline before this plan was 438 +(quick 260726-gok); the +15 are 5 `pinned-deps.spec.ts` pins, 9 `lint-rules.spec.ts` +assertions and 1 `nx-target-inputs.spec.ts` input assertion. + +D-06 prohibitions, both verified clean at the commit: +`git diff --exit-code -- packages/github-cache/package.json` and +`git diff --exit-code -- packages/github-cache/src/public-surface.spec.ts`. + +--- + +## Plan 07-02 + +Recorded: 2026-07-27. Host: Windows 11 arm64, node v24.13.0, npm 11.6.2. + +### The RED observation (LINT-03), assertion by assertion + +The assertions were written and RUN before either ban rule existed, per D-20. This is the +measured split, not a predicted one -- `npx vitest run src/lint-rules.spec.ts` at the moment +`eslint.config.mjs` still carried only plan 07-01's rules: + +**15 failed, 22 passed of 37.** + +| Group | Count | RED verdict | Why | +|---|---|---|---| +| plan 07-01's opt-out + CORR-06 assertions | 9 | PASSED | untouched by this plan | +| D-21 evasion shapes at a unit path | 7 | **FAILED** | the rules did not exist; `banRuleIdsOf` returned `[]` against every expected id | +| false-positive controls at a unit path | 6 | PASSED | vacuously, by design -- they assert ZERO ban errors and there were no ban rules | +| CORR-06 direction pair at the integration path | 7 | PASSED | **the critical control** | +| D-22 four sites, "is CAUGHT once the disable is stripped" | 4 | **FAILED** | no rules, so zero ban errors at each real expression | +| D-22 four sites, "carries a described disable" | 4 | **FAILED** | the disables did not exist yet | + +Representative failure text, site 2: + +``` +AssertionError: expected 'const OTHER_PLATFORM: NodeJS.Platform...' to contain + 'eslint-disable-next-line no-restricte...' +Expected: "eslint-disable-next-line no-restricted-syntax" +Received: "const OTHER_PLATFORM: NodeJS.Platform =" +``` + +and, for an evasion shape, `expected [] to deeply equal [ 'no-restricted-syntax' ]`. + +**The direction controls passing on BOTH sides is what makes this RED interpretable.** Had the +seven integration-path assertions failed too, the meaning would have been "the config is not +being loaded at all" (the ignored/unconfigured trap) rather than "the rules are missing" -- +and that trap is the single most likely way this phase could have shipped a vacuous guard. +The false-positive controls passing vacuously in RED is expected and is not evidence; their +value is entirely in GREEN, where they discriminate against an over-broad selector. + +Intermediate measurement after STEP 2 (rules added, disables not yet): **6 failed, 31 passed** +-- all seven evasion shapes flipped to CAUGHT, and the six residual failures were the four +"carries a described disable" assertions plus the two `release-asset-name.spec.ts` position +assertions, which report TWO ban errors until each site's sibling disable exists to suppress +the other. After STEP 3: **37 passed of 37.** + +### P7 was INCLUDED, not declined + +RESEARCH G2 recommends P7 +(`MemberExpression[computed=false][object.property.name='process'][property.name=/^(platform|arch)$/]`) +and leaves it to the planner. It is IN the shipped selector set. Consequence for D-21: +`globalThis.process.platform` needs no `// ponytail:` ceiling comment, because it is caught +rather than accepted. + +Two ceilings ARE recorded in `eslint.config.mjs`, both in the three-part form +(`with-hash-lock.ts:1-3`): P4/P5's hardcoded namespace binding names, whose upgrade path is +dropping the `object.name` constraint IN FAVOUR OF an allowlist and never without one; and +the residual T-07-12 ceiling -- a platform read hidden behind a helper in another module -- +whose only upgrade path is type-aware linting, which D-11 excludes for a stated reason and +which is therefore accepted rather than scheduled. + +### Selector-set behaviour, measured against REAL ESLint (Q5 closed) + +RESEARCH G2's verdict table was produced with `@babel/parser` + `esquery`, and Q5 asked +whether babel-estree and `@typescript-eslint/parser` agree on every node shape those +selectors depend on. Every shape in the table reproduced exactly under real ESLint with the +real parser, including the two-rule double report on a namespace import. **Q5 is closed +affirmatively; no selector that measured MATCH under esquery came back clean under ESLint.** + +One measured fact worth recording because it shaped a false-positive control: +`import * as path from 'node:path'` IS an error, because `no-restricted-imports` reports a +namespace specifier whenever the entry lists `importNames`. The "path.join is legitimate" +control therefore uses a LOCAL object rather than a namespace import -- the namespace form is +asserted as an evasion shape instead, which is where it belongs. + +### The four disables, and the fifth position that does not exist + +Four `eslint-disable-next-line` directives across three files +(`cache-archive-path.spec.ts` 1, `releases-backend.spec.ts` 1, `release-asset-name.spec.ts` +2). `npx eslint .` from `packages/github-cache` exits 0 with ZERO findings, which is the +measurement that proves all four are USED -- an unused one would be an error under +`reportUnusedDisableDirectives: 'error'`. + +RESEARCH C2 confirmed against the live rule set: `cache-archive-path.spec.ts`'s bare +`tmpdir()` call produces NO ban error, so it correctly carries no disable. A fifth directive +there would have failed the build through the phase's own opt-out discipline. + +### Corrections recorded for the verifier + +- **ROADMAP SC3 says "three CORR-05 violations".** REQUIREMENTS, CONTEXT and RESEARCH all say + FOUR, and FOUR is what the shipped `CORR_05_SITES` table and the four directives implement. + Do not read the extra site as scope creep. +- **CONTEXT D-22 and REQUIREMENTS CORR-05 both list `cache-archive-path.spec.ts:26` alongside + `:1`.** Correct as a SITE (both lines leave together under VER-02 in Phase 9), wrong as an + error POSITION. The site table keys on the import only. + +### Battery at the commit + +Eight commands, all exit 0: `format:check`, `build`, `typecheck`, `typecheck:action`, `test`, +`fallow:ci`, `check:action`, `pack:check`. Plus `npx eslint .` at exit 0, run directly because +no `lint` target exists until plan 07-03. + +Unit suite: **481 tests across 32 files**, up from 453. The +28 are 7 evasion shapes, 6 +false-positive controls, 7 integration-path direction assertions and 8 site assertions. + +D-06 prohibitions verified clean again at this commit: +`git diff --exit-code -- packages/github-cache/package.json` and +`git diff --exit-code -- packages/github-cache/src/public-surface.spec.ts`. + +### M4 applied early, observed, and reverted (plan 07-02 task 2) + +M4 is formally plan 07-04's, but the D-19 guard is worthless unless it can fail, so it was +run against the guard as it was written. Three variants, each producing exactly ONE failing +assertion and no collateral: + +| Variant | Mutation to `eslint.config.mjs` | Assertion that went RED | Failure text | +|---|---|---|---| +| M4a (the VALIDATION.md form) | `ignores` -> `['**/*.integration.spec.ts']` | "applies the ban to exactly the extension set it exempts" | the parser's own non-vacuity guard fires first: `expected the glob "**/*.integration.spec.ts" to END in a {ext,ext} group` | +| M4b | `ignores` -> `['**/*.integration.spec.{ts,mts}']` | same | `expected [ 'mts', 'ts' ] to deeply equal [ 'cts', 'mts', 'ts' ]` | +| M4c | BOTH globs -> `{ts,mts}` | "covers every extension the integration vitest config includes" | `an *.integration.spec.cts would be linted as a UNIT spec: the ESLint scope covers mts, ts and the integration vitest config includes cts` | + +M4c is the variant that matters most and is NOT in the VALIDATION.md table: it narrows both +globs together, so IDENTITY still holds and only the SUPERSET invariant can catch it. Both +D-19 invariants are therefore independently load-bearing -- the guard is not asserting one +thing twice. `eslint.config.mjs` was restored byte-identical +(`git diff --exit-code` clean) before the commit; no mutation is committed. + +The ESLint side is read by IMPORTING `eslint.config.mjs` through a non-literal specifier +(Q6's recommended route). It worked on the first attempt under both `vitest` and `typecheck`, +so the disk-read-plus-comment-strip fallback was NOT needed there. Q6 is closed +affirmatively. The vitest side still uses the disk-read idiom, per Q7, which was not retested. + +### Post-merge flake: the toolchain boot was inside the per-test budget + +The post-merge gate caught `lint-scope-drift.spec.ts` failing intermittently under +`nx run-many -t typecheck,test --skip-nx-cache` (exit 0, 1, 0 over three runs; Nx's flaky-task +detector fired on one hash with two outcomes). Both new guard files were affected. Measured +costs on an IDLE workstation, against vitest's DEFAULT 5000 ms per-test budget: + +| What | Measured | +|---|---| +| bare `import('eslint.config.mjs')` in a cold node | **981 ms** | +| first test in `lint-scope-drift.spec.ts` (pays the resolve) | 731-883 ms | +| every subsequent test in that file | 0-1 ms | +| first test in `lint-rules.spec.ts` (first `lintText` boots config + TS parser) | 592-913 ms | + +~5.7x headroom on an idle box, and the cost falls on whichever test runs FIRST -- so the +failure location is arbitrary and the symptom is flakiness rather than "the import is slow". + +Fixed by hoisting the one-time cost into `beforeAll(fn, 30_000)` in each file, NOT by raising +`testTimeout` (which would mask the class for all 486 tests). Controlled experiment, pinning +the per-test budget just above the observed cost, because repeat-running until green proves +nothing when the broken version already produced three green runs: + +| Version | `--testTimeout=500` | `--testTimeout=50` | +|---|---|---| +| pre-fix | **2 failed** / 40 passed, `Test timed out in 500ms` on the exact reported test AND on `lint-rules.spec.ts`'s first test | not run | +| post-fix | 42 passed | **42 passed** | + +Post-fix passes at a budget 100x TIGHTER than the default, where pre-fix already failed. +First-test duration 731/883 ms -> 2/3 ms; headroom ~5.7x -> >2500x. Structural, not +statistical. + +Note the pre-fix run reproduced the timeout in `lint-rules.spec.ts` too, which had NOT failed +in the gate's three runs -- so treating that file was closing a measured exposure rather than +a speculative one. + +Final state: `npm exec -- nx run-many -t typecheck,test --skip-nx-cache` run 8 times, exit 0 +every time, zero `Test timed out` in any log. M4b re-run against the refactored guard still +fails on exactly the right assertion. + +--- + +## Plan 07-03 + +Recorded: 2026-07-27. Host: Windows 11 arm64, node v24.13.0, npm 11.6.2. + +### C8 satisfied, and PROVEN rather than assumed + +`@nx/eslint@23.1.0`'s `createNodes` short-circuits with +`if (eslintConfigFiles.length === 0) { return []; }` and produces NO target, silently. So the +registration was verified by asking the graph, not by reading the config back: + +``` +npm exec -- nx show project @op-nx/github-cache --json +targets: typecheck, build, build-deps, watch-deps, test, lint, integration, nx-release-publish +``` + +`lint` is present, so the config-existence gate was satisfied by plan 07-01's +`eslint.config.mjs`. Exactly ONE new target appeared. + +### D-35 -- the inferred `lint` target's HASHED node values (the Phase 8 CORR-03 baseline) + +Read from `nx show project @op-nx/github-cache --json` on Windows, AFTER `targetDefaults.lint` +was applied, so this is the effective node Nx folds into `hash_project_config`. `metadata` is +NOT hashed and is therefore omitted -- the `${pmc.exec}` it contains (`npx eslint --help` on +this host) is a non-issue for the hash. + +| Hashed field | Value on Windows | +|---|---| +| `targetName` | `lint` | +| `executor` | `nx:run-commands` | +| `outputs` | `[]` | +| `options.cwd` | `packages/github-cache` | +| `options.command` | `eslint .` | +| `configurations` | `{}` | +| `parallelism` | `true` | + +`cache` is `true`, inferred; it is deliberately NOT restated in `targetDefaults` (D-24). + +**This is a BASELINE, not a closure.** Whether `@nx/eslint` infers the same node on Linux is +UNVERIFIED BY DESIGN (D-35, T-07-17): the existence gate runs +`eslint.isPathIgnored(join(workspaceRoot, file))` with a POSIX `join` over an absolute Windows +root, and F18 confirms that gate genuinely runs for this project layout. Phase 8's CORR-03 +two-leg measurement treats `lint` as a FOURTH target and settles it empirically. The +`options.cwd` row is the one most likely to diverge, and it is hashed. + +### C7 -- the real inferred input list, checked against the replacement rather than assumed + +D-24's premise is "the block must restate everything it keeps", so the inferred list was read +from the installed plugin (`node_modules/@nx/eslint/dist/src/plugins/plugin.js:288-302`, +`buildEslintTargets`) rather than from `STACK.md`'s quote, which is one entry short. + +| # | Inferred entry | Resolves to, here | Restated? | +|---|---|---|---| +| 1 | `default` | -- | yes | +| 2 | `^default` | -- | yes | +| 3 | `{workspaceRoot}/` | `{workspaceRoot}/eslint.config.mjs` | yes | +| 4 | `/.eslintignore`, only `existsSync` | ABSENT -- no such file | n/a, not emitted | +| 5 | `...tsconfigChainOutsideProjectRoot` | `{workspaceRoot}/tsconfig.base.json` | **no, deliberately** | +| 6 | `{workspaceRoot}/tools/eslint-rules/**/*` | -- | yes | +| 7 | `{ externalDependencies: ['eslint'] }` | -- | **widened to four** | + +Entry 5 is the one STACK.md omits. All three of `tsconfig.json`, `tsconfig.lib.json` and +`tsconfig.spec.json` extend `../../tsconfig.base.json` and nothing else, so the chain resolves +to exactly that one file -- which `sharedGlobals` already folds into `default`, entry 1. The +replacement list therefore needs no extra entry, and that is a checked conclusion rather than +an inherited one. + +Entry 7 is the LINT-04 hole. `outputs` also changed, from the inferred `['{options.outputFile}']` +to `[]`, which removes that token from `hash_project_config` entirely (D-24). + +### TDD: the RED, assertion by assertion + +Probes written and run BEFORE `targetDefaults.lint` existed. The helper indexes +`nxJson.targetDefaults['lint']`, so every new assertion throws +`TypeError: Cannot read properties of undefined (reading 'inputs')` -- loud and unambiguous. + +**7 failed, 7 passed of 14.** + +| Assertion | RED | Why | +|---|---|---| +| the four pre-existing `typecheck` / `build` probes | PASS | untouched; confirms the fourth `PROBE_FILES` entry broke nothing | +| `lint` hashes the lib sources | **FAIL** | no `lint` target default | +| `lint` hashes the spec sources | **FAIL** | same | +| `lint` does NOT hash a path outside the project root | **FAIL** | same | +| `lint` lists all four ESLint external dependencies | **FAIL** | same | +| `lint` declares no outputs | **FAIL** | same | +| CORR-04: `integration` is the only runtime input | PASS | **a direction control -- passes on BOTH sides by design** | +| the two pre-existing `test.inputs` literal pins | PASS | untouched | +| `eslint.config.mjs` is a `lint` input | **FAIL** | no `lint` target default | +| the custom rule directory is a `lint` input | **FAIL** | same | + +**GREEN after step 2: 14 passed of 14.** The CORR-04 control passing on both sides is the same +device plan 07-02 used for its integration-path pair: had it failed in RED, the failure would +have meant the spec could not read `nx.json` at all rather than that `lint` was missing. + +### The negative control, and the mutation proving it is the ONLY one that catches vacuity + +`build` is the discriminator the `typecheck` probes use, and it is UNUSABLE for `lint`: +`lint`'s inputs start from `default` (`{projectRoot}/**/*`), so hashing a spec is exactly what +`lint` is supposed to do. The chosen negative is a probe path OUTSIDE `{projectRoot}` -- +`start-cache-server/entry.ts`, a real file, genuinely not linted, and already present in +`test.inputs` as a `{workspaceRoot}` string so both frames are visible side by side. It is a +fourth entry in `PROBE_FILES`; every pre-existing assertion is a `toContain` / `not.toContain` +on a named path and none is a whole-array comparison, which the RED run confirmed empirically +(all four pre-existing probes still passed). + +Two mutations, each applied, observed and REVERTED before the commit. + +| # | Mutation to `nx.json` | Result | +|---|---|---| +| M6a | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.lint.inputs` | **1 failed / 13 passed** -- exactly the expected assertion, zero collateral | +| MV | reduce `lint.inputs` to `^default` + the `externalDependencies` object, so the SELF pattern list resolves EMPTY | **3 failed / 11 passed** | + +MV is the one that matters. With an empty pattern list `filterUsingGlobPatterns` returns the +WHOLE probe list untouched, so **both positive `toContain` assertions still PASSED** -- a +resolver that resolved nothing looked exactly like a working one. The out-of-project negative +was the only glob-resolution assertion that caught it. (The two literal-pinning assertions also +fired, but they pin strings the same mutation deleted; they are not evidence about the filter.) +That is the vacuity trap this control exists for, reproduced rather than argued. + +M6's second half -- the stale-cache HIT differential -- is plan 07-04's, and is not claimed +here. + +`nx.json` restored byte-identical after each mutation, re-verified at 14 passed of 14. + +### The battery is NINE commands from this plan's second commit + +`npm run lint` was added as `nx run-many -t lint` and joins the battery between `test` and +`fallow:ci`. First run, cold by construction (the plugin registration rotated every hash): + +``` +Successfully ran target lint for project @op-nx/github-cache +Run duration: 1.7s +Cache: 0/1 hit (0%) +``` + +1.7 s is the number behind D-33's "lint runs in seconds, a fifth cache producer buys nothing". + +| Commit | Battery | Result | +|---|---|---| +| `b3fdf6d` (plugin + inputs + probes) | EIGHT: `format:check`, `build`, `typecheck`, `typecheck:action`, `test`, `fallow:ci`, `check:action`, `pack:check` | all exit 0 | +| this plan's second commit (script + CI job) | **NINE** -- the above plus `lint` | all exit 0 | + +Unit suite at both commits: **494 tests across 33 files**. The delta is +8, all of them this +plan's, and all in `nx-target-inputs.spec.ts` (6 assertions -> 14). + +### The CI job, verified structurally rather than by eye + +`.github/workflows/ci.yml` parsed with the `yaml` package after the edit: + +``` +jobs: format-check, lint, fallow, action-bundle-drift, pack-check, ppe, build, typecheck, + test, integration, dogfood-seed, dogfood-verify, consumer-smoke, publish, publish-verify + +lint steps: [{"uses":"actions/checkout@v7"}, + {"uses":"actions/setup-node@v6","with":{"node-version-file":".node-version","cache":"npm"}}, + {"run":"npm ci"}, + {"run":"npm run lint"}] +``` + +Exactly the four steps of the `fallow` boilerplate: no background sidecar, no `cancel:`, no +build step. `git grep 'needs:' -- .github/workflows/ci.yml` returns no job depending on `lint`, +and no spec asserts on `ci.yml` -- it is not an Nx input yet, and PARITY-06 registers it in +Phase 9. + +### Verification items from the plan, all checked at the final commit + +| Check | Result | +|---|---| +| `nx show project` lists exactly ONE new target, `lint` | yes | +| `packages/github-cache/project.json` untouched (D-01/D-02) | `git diff --exit-code` clean | +| `packages/github-cache/package.json` untouched (D-06) | `git diff --exit-code` clean | +| `integration` is still the only target with a platform input (CORR-04) | asserted in the guard, passing | + +--- + +## Plan 07-04 + +Recorded: 2026-07-27. Host: Windows 11 arm64, node v24.13.0, npm 11.6.2. +Base commit for every measurement below: **`81048ca`** (the plan 07-03 tree). Every command was +run from the REPO ROOT in Git Bash unless a `cd` is shown. + +### G5 -- the LINT-04 differential, closed BY MEASUREMENT (D-27, ROADMAP SC4) + +SC4 requires LINT-04 be "proven by differential rather than by reading the config", so +`nx-target-inputs.spec.ts`'s declaration probes are necessary and explicitly NOT sufficient. +These are the behavioural measurements. **A single cache reading carries no information** -- +PITFALLS D1 -- so every row below is one side of a recorded PAIR. + +**Q8 closed affirmatively.** `nx run-many -t lint` prints the cache summary line in exactly the +same form `test` and `typecheck` do (`Cache: n/m hit (p%)`). The `nx run :lint --verbose` +fallback RESEARCH held in reserve was NOT needed, and no substitution was made. + +#### Measurement A -- editing a RULE re-runs `lint` + +Perturbation: the **P7 selector object deleted** from `no-restricted-syntax` -- a real rule-set +change, not a comment edit. A comment-only edit moves the file hash and so proves the file is an +input, but it says nothing about whether the command reads the rule SET. + +| Step | Command | `Cache:` line | Verdict | +|---|---|---|---| +| A0 warm 1 | `npm run lint` | `Cache: 1/1 hit (100%)` | already warm from plan 07-03 | +| A0 warm 2 (baseline) | `npm run lint` | `Cache: 1/1 hit (100%)` | replay, as expected | +| **A1 P7 deleted** | `npm run lint` | **`Cache: 0/1 hit (0%)`** | **EXECUTED** -- 1.7 s critical path | +| A2 restored | `git checkout -- eslint.config.mjs; npm run lint` | `Cache: 1/1 hit (100%)` | pre-edit hash still cached | + +A `1/1 hit` at A1 would have been the LINT-04 defect. It was `0/1`. + +#### Measurement B -- editing a linted SOURCE file re-runs `lint` + +Perturbation: one trailing comment appended to `packages/github-cache/src/index.ts` -- a linted, +tracked, NON-spec file, so the ban rules stay out of the measurement. + +| Step | `Cache:` line | Verdict | +|---|---|---| +| B0 baseline | `Cache: 1/1 hit (100%)` | replay | +| **B1 source edited** | **`Cache: 0/1 hit (0%)`** | **EXECUTED** | +| B2 restored | `Cache: 1/1 hit (100%)` | pre-edit hash still cached | + +**A methodological trap hit and recorded, because it produces a false LINT-04 defect reading.** +The first attempt at B was CONFOUNDED and discarded. The perturbed side was run TWICE with the +same edit text; the second run legitimately replayed the *perturbed* hash, so re-applying that +identical edit later read `Cache: 1/1 hit (100%)` -- indistinguishable, at a glance, from the +stale-cache bug the measurement exists to exclude. The rule this yields: **a differential's +perturbed side must be run exactly ONCE, and any repeat needs perturbation text that has never +been hashed before.** The table above uses a novel string for that reason. This is the same class +as PITFALLS D1 -- a `Cache:` reading is a statement about a HASH, not about correctness. + +#### Measurement C -- the D-25 second-order hole: `test` re-runs after a rule edit + +The guard specs run under `test` and load `eslint.config.mjs` through the ESLint Node API, so a +`test` target that replayed across a rule edit would make every LINT-03 verdict untrustworthy. +Same P7 perturbation. + +| Step | `Cache:` line | Verdict | +|---|---|---| +| C0 warm 1 | `Cache: 1/1 hit (100%)` | | +| C0 warm 2 (baseline) | `Cache: 1/1 hit (100%)` | replay | +| **C1 P7 deleted** | **`Cache: 0/1 hit (0%)`** | **EXECUTED**, and still `Successfully ran target test` | + +`{workspaceRoot}/eslint.config.mjs` is doing its job in `targetDefaults.test.inputs`. **This was +measured BEFORE any mutation in task 2 was trusted**, which is the ordering D-25 requires: every +M1-M9 result below is read off a `test` target proven to re-run when the rule set moves. + +C1 also settles what Measurement A alone cannot. A cache miss proves the FILE is an input; C1's +executed `test` run additionally exercises the rule set through `lintText`, and task 2's M1/M2/M3 +show that same suite going red when individual selectors are deleted. Together those are the +proof that the rule set -- not merely the file's bytes -- is what the toolchain reads. + +#### Negative control 1 -- the declared input block is LOAD-BEARING + +**A and B pass even on a `lint` target with no declared input block at all**, because +`@nx/eslint`'s INFERRED inputs already contain `default` and `{workspaceRoot}/` +(F17). Stated plainly: **measurements A and B alone would NOT have proven that D-24's block did +anything.** This control is what does. + +`{workspaceRoot}/eslint.config.mjs` was temporarily deleted from `targetDefaults.lint.inputs`, +leaving the rest of the block in place -- `targetDefaults` REPLACES the inferred list, so removing +that one entry genuinely removes it rather than falling back to the inferred one. + +| Step | `Cache:` line | Verdict | +|---|---|---| +| C1 re-warm 1 (after the `nx.json` edit) | `Cache: 0/1 hit (0%)` | expected: the `targetDefaults` change rotates `hash_project_config` | +| C1 re-warm 2 (baseline) | `Cache: 1/1 hit (100%)` | replay | +| **C2 same P7 deletion applied** | **`Cache: 1/1 hit (100%)`** | **THE BUG, REPRODUCED** | + +A real rule change served a cached PASS. That is the stale-cache false PASS LINT-04 exists to +prevent, made visible on this repo rather than argued. A MISS at C2 would have meant something +ELSE was invalidating the hash and the measurement was confounded -- the plan's explicit stop +condition. It did not fire. + +Both files restored with `git checkout -- nx.json eslint.config.mjs`; `git diff --exit-code` +clean on both. + +This is also the behavioural half of mutation **M6**; the assertion half (M6a) was measured in +plan 07-03 and is cross-referenced rather than re-run. + +#### Negative control 2 -- `lint`'s scope must not depend on gitignored build output (G3) + +Re-run now that the `lint` target exists. Command, from `packages/github-cache`: +`npx eslint . --format json | node -e "...console.log('linted:', JSON.parse(s).length)"`. + +| State | `dist/` files | `out-tsc/` files | Linted | +|---|---|---|---| +| with build output | 88 | 71 | **66** | +| after `rm -rf dist out-tsc` | 0 | 0 | **66** | + +**IDENTICAL.** `lint`'s result does not depend on whether `build` ran, so it cannot diverge from +its Nx hash. Two different numbers would have been a stale-cache false PASS by construction. + +The absolute count moved 64 -> 66 since plan 07-01: `lint-scope-drift.spec.ts` (plan 07-02) and +`nx-target-inputs.spec.ts`'s growth are tracked files ESLint now walks. The count that matters is +that the PAIR agrees, not its absolute value. Build output restored afterwards with +`npm run build` and `npm run typecheck` (both replayed from cache: 88 and 71 files back). + +### D-23 -- M1 through M9, applied, OBSERVED, and reverted + +Every row below was re-run first-hand in this plan against the plan 07-03 tree, including the +five that earlier waves had already measured -- so this table is one executor's direct +observation rather than a stitched-together citation. Each mutation was applied, the detecting +command run, the failure set recorded, and the file restored with `git checkout --` plus a +`git diff --exit-code` confirmation before the next mutation. **No mutation was ever committed.** + +Baseline for the three guard specs before any mutation: **56 passed of 56** across +`lint-rules.spec.ts` (37), `nx-target-inputs.spec.ts` (14) and `lint-scope-drift.spec.ts` (5). + +| # | Mutation | Command | OBSERVED failure set | Verdict | +|---|---|---|---|---| +| M1 | delete the P1 member-expression selector | `npx vitest run src/lint-rules.spec.ts` | **4 failed / 33 passed.** `catches process.platform, the primary member-expression form (P1)`; and the `is CAUGHT ...` assertion at all THREE `no-restricted-syntax` sites -- `releases-backend.spec.ts`, and both `release-asset-name.spec.ts` rows. Every import-shape assertion GREEN. | **MATCH** | +| M2 | delete the `node:os` entry from `no-restricted-imports.paths` | same | **3 failed / 34 passed.** `catches a named import of a banned accessor from the os module`; `catches a namespace import of the os module, caught by BOTH rules`; and the `is CAUGHT ...` assertion at site 1, `cache-archive-path.spec.ts`. Every `process.*` assertion GREEN. | **MATCH** | +| M3 | delete the P6 `ImportExpression` selector | same | **1 failed / 36 passed.** `catches a dynamic import of the os module and of the path module (P6 only)`, failing `expected [] to deeply equal [ 'no-restricted-syntax', 'no-restricted-syntax' ]`. Nothing else moved. | **MATCH** (see the note below on granularity) | +| M4 | narrow `ignores` to `['**/*.integration.spec.ts']` | `npx vitest run src/lint-scope-drift.spec.ts` | **1 failed / 4 passed.** `applies the ban to exactly the extension set it exempts`, failing through the parser's OWN non-vacuity guard: `expected the glob "**/*.integration.spec.ts" to END in a {ext,ext} group`. | **MATCH** | +| M5 | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.test.inputs` | `npx vitest run src/nx-target-inputs.spec.ts` | **1 failed / 13 passed.** `eslint.config.mjs is a test input, so editing a rule re-runs the lint guard`. Zero collateral. | **MATCH** | +| M6 | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.lint.inputs` | same, **plus** the G5 negative-control-1 differential above | **1 failed / 13 passed** -- `eslint.config.mjs is a lint input, so editing a rule re-runs lint`, zero collateral. The BEHAVIOURAL half is negative control 1 above: `Cache: 1/1 hit (100%)` across a real rule edit. | **MATCH, both halves** | +| M7 | remove the global `ignores` block | the negative-control-2 file count, with build output ON disk | **66 -> 159 linted files.** The two counts DIFFER by 93 -- the generated `dist/` (88) and `out-tsc/` (71) trees minus overlap, all at an Nx hash that never moves. | **MATCH** | +| M8 | replace the site-1 described disable with a bare `// eslint-disable-next-line no-restricted-imports` | `npx eslint .` from `packages/github-cache` | **1 error.** `cache-archive-path.spec.ts:6:0` severity 2, `@eslint-community/eslint-comments/require-description`, "Unexpected undescribed directive comment." Guard-spec collateral, also observed: `carries a described disable stating why the assertion cannot move to integration` went RED (1 failed / 36 passed). | **MATCH -- LINT-05 is LIVE** | +| M9 | move the site-1 described disable one line PAST its violation | same | **2 errors, both expected.** `cache-archive-path.spec.ts:6:10` severity 2 `no-restricted-imports` (the ban itself, now unsuppressed) AND `:7:1` severity 2 `ruleId: null` -- "Unused eslint-disable directive (no problems were reported from 'no-restricted-imports')". | **MATCH -- LINT-06 is LIVE** | + +Post-restore re-measure: `npx eslint .` from `packages/github-cache` reports **66 files linted, 0 +findings, exit 0**, and `git diff` against the task-1 commit shows no source, config or spec file +touched. + +#### M3 is NOT a silent gap -- but its granularity diverges from VALIDATION.md + +The plan flagged M3 as the mutation most likely to pass vacuously, in which case P6 would be +untested and D-21's dynamic-import shape a silent hole. **It went red.** P6 is genuinely load- +bearing and genuinely covered. + +One divergence from VALIDATION.md's prediction, recorded because it is a real difference and not +a rounding of the same thing. The table predicts "ONLY the two dynamic-import assertions RED". +The shipped spec folds BOTH dynamic shapes -- `await import('node:os')` and +`await import('node:path')` -- into a SINGLE `it()` row whose expected value is the two-element +list `['no-restricted-syntax', 'no-restricted-syntax']`. So the observed count is **one** failing +assertion covering two shapes, not two failing assertions. Coverage is identical; the granularity +is not. A reader checking "2 red" against this table would otherwise conclude the mutation had +under-fired. + +#### What M1 and M2 prove jointly, and why D-15 needed both rules + +M1 leaves every import-shape assertion GREEN and M2 leaves every `process.*` assertion GREEN. The +failure sets are DISJOINT. That is the measured form of D-15's claim that neither rule is +sufficient alone: `no-restricted-syntax` cannot see a destructured named import and +`no-restricted-imports` cannot ban a member of a namespace object or reach a dynamic import. +Two mutations, two disjoint red sets, no overlap -- the guard is not asserting one thing twice. + +M2's namespace row is the one partial: `import * as os from 'node:os'` is caught by BOTH rules, +so with the `node:os` path entry gone it still reports `['no-restricted-syntax']` from P4 and +fails only on the missing `no-restricted-imports` half. That is the defence-in-depth the spec +comment claims, observed rather than asserted. + +#### Mutations that produce a FALSE reading if run carelessly + +Recorded so a later reader does not repeat them: + +- **M6 through the `lint` cache.** The behavioural half must be measured with a re-warm between + the `nx.json` edit and the rule edit. Removing the entry rotates `hash_project_config`, so the + FIRST post-mutation `lint` run misses for a reason that has nothing to do with the input list. + Reading that first miss as "the entry was not load-bearing after all" is the trap; the + measurement above re-warms twice before perturbing. +- **M8 and M9 mutate a REAL spec file**, not a fixture, so both also perturb + `lint-rules.spec.ts`'s site table. The guard-spec collateral is expected and is recorded above + rather than treated as a second finding. + +--- + +## Phase 7 hand-offs and the consolidated phase record (plan 07-04 task 3) + +### D-35 -- the Phase 8 CORR-03 baseline: PRESENT, re-verified, not duplicated + +The inferred `lint` target's HASHED node values were recorded in the **Plan 07-03** section +above. They were re-read from `npm exec -- nx show project @op-nx/github-cache --json` at this +plan's task 3 and are **byte-for-byte unchanged**: `targetName: lint`, `executor: +nx:run-commands`, `outputs: []`, `options.cwd: packages/github-cache`, `options.command: +eslint .`, `configurations: {}`, `parallelism: true`. All six `hash_project_config` fields are +present, including the RESOLVED working directory. + +`metadata` is **NOT hashed** and is deliberately omitted from the baseline. It carries +`help.command: "npx eslint --help"` -- a package-manager-exec token that WOULD differ if the +resolved package manager ever changed -- and a future reader will otherwise re-derive whether +that matters. It does not: `hash_project_config` never sees it. `cache: true` is inferred and is +deliberately NOT restated in `targetDefaults` (D-24). + +**The OS-inference question is UNVERIFIED BY DESIGN and is NOT closed here.** Whether +`@nx/eslint` infers this same node on Linux is an open question transferred to Phase 8's CORR-03, +which treats `lint` as a FOURTH target and settles it empirically against this baseline. The risk +is live rather than hypothetical: the plugin's existence gate genuinely runs for this project +layout, because the config directory (the workspace root) differs from the project root, and that +gate evaluates `eslint.isPathIgnored(join(workspaceRoot, file))` -- a POSIX-style `join` over an +absolute Windows root, producing mixed separators. It reads clean at source and Windows tolerates +it, but "should" is exactly what a two-leg measurement is for. `options.cwd` is the row most +likely to diverge, and it is hashed. + +This record is the accepted-risk mitigation for D-01 (T-07-17, T-07-22). Do not reason it closed. + +### D-36 -- the legitimate all-MISS push, pre-recorded and NOT a gate + +Registering an inference plugin changes `hash_project_config`, which is folded into EVERY task +hash. `test` rotates twice over, because `{workspaceRoot}/nx.json` is already an explicit `test` +input. **Phase 7's first default-branch push is therefore a legitimate all-MISS push, and it is +correct work rather than a regression.** + +There are **three legitimate rotation windows in this milestone**: + +1. **Phase 7** -- this one. `@nx/eslint` registration rotates every hash; the rotation is + isolated in `b3fdf6d` so it stays attributable. +2. **Phase 8's PARITY root-cause fix** -- any change that makes the hash OS-invariant necessarily + moves it on at least one leg. +3. **Phase 9's VER-01** -- produces the second consecutive all-miss push. + +**Consequence for Phase 9's OBS-04 tripwire, stated now so it is authored correctly the first +time:** the tripwire condition must be *"two consecutive all-miss pushes with NO version-affecting +change in between"*. A tripwire that fires on correct work gets disabled, and a disabled tripwire +is worse than none. + +**What a rotation does NOT prove, from the pre-flight probe record** +(`.planning/research/v0.0.2/PROBE-RESULTS.md`). A hash difference is attributable to the OS only +once graph freshness is controlled on BOTH sides. The probe established two independent axes: a +real OS axis (cold-ubuntu differs from cold-windows for every target) and a FRESHNESS axis that +perfectly masquerades as it (a stale `.nx/workspace-data` on Windows reproduces the Linux result +exactly). **Every prior cross-OS measurement in this repo read a confounded variable**, including +the pair `STATE.md` once attributed to "ubuntu CI" versus "windows CI". An all-MISS push after +this phase is an expected consequence of a config rotation and is evidence about NOTHING else. + +### The D-12 call, restated in one place with its numbers + +Full per-file breakdown is in the **Plan 07-01** section above. The call itself, consolidated: + +| Quantity | Predicted (RESEARCH G4) | MEASURED | +|---|---|---| +| files linted at baseline | 64 | **64** | +| total findings at baseline | 10 | **10** | +| `no-undef` | 7 | **7** | +| `@typescript-eslint/no-require-imports` | 2 | **2** | +| `@typescript-eslint/no-unused-vars` | 1 | **1** | +| low-confidence regex class | 0-2 uncertain | **0** | +| post-remediation residual | 0 | **0** | +| **rules turned OFF repo-wide** | zero | **ZERO** | +| **code edits made to satisfy a linter** | zero | **ZERO** | +| configuration blocks added | two | **TWO** | + +G4's analytic baseline reproduced file-for-file and line-for-line. The two configuration blocks, +named: (1) the **`**/*.cjs` override** -- `sourceType: 'commonjs'` plus a four-name inline globals +map, closing 9 of the 10 findings by telling ESLint the truth about a CommonJS file, and carrying +exactly one SCOPED rule-off (`@typescript-eslint/no-require-imports: 'off'`, limited to that one +glob, recorded honestly rather than claimed as zero); and (2) +**`@typescript-eslint/no-unused-vars` with the three leading-underscore patterns**, closing the +tenth by codifying a convention the repo already followed at six sites. `no-undef` was kept LIVE +for the `.cjs` glob, so the one file in the repo where that rule still applies keeps its typo +check. No deferred-ideas entry was needed: D-12's escape hatch is for a single rule producing a +broad sweep, and no rule did. + +### D-07 -- the recorded scope deviation, flagged FOR the verifier + +`lint` is **project-scoped**: `eslint .` with `cwd = packages/github-cache`. The workspace root +gets NO lint target, because `@nx/eslint`'s `getProjectUsingESLintConfig` returns `null` for `.` +when the root has neither a `src/` nor a `lib/` directory. + +**Not linted by this phase:** `esbuild.action.mjs`, `start-cache-server/entry.ts`, +`vitest.workspace.ts`, and the `.planning/spikes/*.mjs` scripts. + +This narrows ROADMAP SC1 / LINT-01's literal "across the workspace" to "across the project that +has specs". **It is an INTENTIONAL, RECORDED DEVIATION, not a gap.** All 32 spec files and all +four CORR-05 sites are inside the scope, so LINT-02, LINT-03, LINT-05, LINT-06 and CORR-06 are +fully covered. It also keeps the LINT-04 input set matched to the real lint scope rather than +widened past it, which is the direction that CLOSES the stale-PASS class. Linting the root-level +files needs a second scope and is carried as a deferred idea. The same note is comment-locked at +the head of `eslint.config.mjs`, where an editor will meet it. + +### D-01 -- the one-line dismissal of the explicit-target alternative + +REQUIREMENTS and RESEARCH both require this appear in the phase record: an explicitly declared +`command: 'eslint .'` target beside the existing `integration` target in +`packages/github-cache/project.json` would need no inference plugin at all -- `@nx/eslint`'s value +is target inference plus the `@nx/eslint:lint` executor, and LINT-01 through LINT-06 require +neither. It was presented in full at discuss time, the user selected `@nx/eslint` (ecosystem norm, +generator does the wiring, and ROADMAP/REQUIREMENTS already cite inference as the +LINT-01 -> PARITY-01 ordering mechanism), and the alternative is CLOSED -- do not re-open it. The +ordering constraint holds either way, since ANY declared target mutates `hash_project_config`, so +nothing in the roadmap shape depended on the choice. The accepted cost is carried by D-35 above. + +### Three received-wording corrections that must NOT propagate + +| # | Where | The received wording | What is actually true | +|---|---|---|---| +| 1 | ROADMAP SC3 (and lines 310, 498, 623) | "the **three** CORR-05 violations" | **FOUR.** REQUIREMENTS, 07-CONTEXT D-22 and 07-RESEARCH all say four, across three files -- `release-asset-name.spec.ts` carries two. Four error POSITIONS, four described disables, four rows in `CORR_05_SITES`. Do not read the extra site as scope creep. (ROADMAP already carries this correction inline at its line 134.) | +| 2 | REQUIREMENTS CORR-06 | its example uses the **two-argument** asset-name form | CORR-02 DELETES that parameter in Phase 10. `BAN_MESSAGE` in `eslint.config.mjs` deliberately uses `cachePlatform('win32')` instead (D-18), which OBS-03 keeps -- so the ban's own prose does not become a `fallow` finding three phases from now. | +| 3 | REQUIREMENTS LINT-05 | the legacy bare `eslint-comments/require-description` prefix | The flat-config registration is SCOPED: `@eslint-community/eslint-comments/require-description`. Same rule, different prefix. Copying the requirement text verbatim into `eslint.config.mjs` would not resolve (D-29). | + +A fourth, recorded in plan 07-02 and repeated here because it is the same class: **CONTEXT D-22 +and REQUIREMENTS CORR-05 both list `cache-archive-path.spec.ts:26` alongside `:1`.** Correct as a +SITE -- both lines leave together under VER-02 in Phase 9 -- but wrong as an error POSITION. In +strict ESM the `tmpdir` binding cannot exist without the import, the import is already the error, +and a disable over the bare call would FAIL the build through this phase's own opt-out +discipline. The site table keys on the import only. **There is no fifth position.** + +### The stale codebase map, so the verifier does not read it as a contradiction + +`.planning/codebase/CONVENTIONS.md:316` still states **"ESLint is NOT configured in this +repository -- there is no `eslint.config.*`"**. Plan 07-01 falsified that sentence. + +Regenerating `.planning/codebase/*` is a **deferred idea, not a Phase 7 deliverable**. The map is +a generated snapshot of the tree at map time; editing one sentence by hand would leave the rest of +the snapshot equally stale while looking current, which is worse. The verifier should read the +sentence as a dated artefact, not as a contradiction of this phase's work. + +### The requirement ledger, and which ticks this plan owed + +Plans 07-01 and 07-02 deliberately LEFT LINT-05 and LINT-06 unticked rather than write a +falsehood into the ledger the milestone audit reads: at the time, both were configured but their +liveness was unproven, and the proof was M8/M9 -- this plan's work. Both are ticked here, each +against the measurement that makes its text true: + +| Requirement | The clause that had to be TRUE | The measurement that makes it true | +|---|---|---| +| LINT-05 | "a bare disable is itself a lint error", and the same for `@ts-expect-error` / `@ts-ignore` | **M8**: a bare directive at a real site produces `@eslint-community/eslint-comments/require-description` at severity 2. The `ban-ts-comment` half is pinned by five `lintText` assertions in `lint-rules.spec.ts`, including `@ts-ignore` erroring in BOTH the bare and the described form. | +| LINT-06 | "a disable left behind after its violation is removed must FAIL, not linger", and the reason must say why the assertion cannot move to integration | **M9**: displacing a directive one line produces BOTH the unsuppressed ban error AND a severity-2 unused-directive report. The reason-text clause is asserted per site (`reason` non-empty AND contains `integration`) across all four rows of `CORR_05_SITES`. | + +LINT-01, LINT-02, LINT-03 and LINT-04 were already ticked by earlier plans in this phase. +LINT-01's tick was correct only from plan 07-03, because its text requires "a `lint` target wired +into the CI battery" and that is when the target, the root script and the CI job landed. +LINT-04's tick is now backed by the differential above rather than by the declaration probes +alone, which is what D-27 and ROADMAP SC4 demand. + +**Ledger hygiene note.** `gsd-tools query requirements.mark-complete` has corrupted this file in +both prior waves of this phase by inserting spurious blank lines before unrelated bullets, and +both executors reverted and hand-applied the intended edits. This plan skipped the tool and +edited by hand for that reason; the resulting `git diff` on `REQUIREMENTS.md` is exactly four +lines -- two checkbox flips and two traceability rows -- and nothing else. diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-LEARNINGS.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-LEARNINGS.md new file mode 100644 index 00000000..a422e8c8 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-LEARNINGS.md @@ -0,0 +1,315 @@ +--- +phase: 7 +phase_name: "Lint Toolchain and the Ambient-Platform-Read Ban" +project: "@op-nx/github-cache - GitHub-backed Nx Remote Cache" +generated: "2026-07-28" +counts: + decisions: 6 + lessons: 7 + patterns: 6 + surprises: 6 +missing_artifacts: + - "07-UAT.md" +--- + +# Phase 7 Learnings: Lint Toolchain and the Ambient-Platform-Read Ban + +## Decisions + +### `@nx/eslint` inference plugin over an explicit project.json target + +The `lint` target comes from `@nx/eslint/plugin` registered in `nx.json`, not from a +`command: 'eslint .'` target declared beside the existing `integration` target. + +**Rationale:** Chosen by the maintainer at discuss time with the cost stated in full. The +alternative needed no inference plugin at all and would have removed an unverified cross-OS +inference, but `@nx/eslint` is the ecosystem norm and ROADMAP/REQUIREMENTS already cite inference +as the LINT-01 -> PARITY-01 ordering mechanism. The ordering constraint holds either way, since +any declared target mutates `hash_project_config`, so nothing in the roadmap shape depended on the +choice. The accepted cost is carried forward as D-35: Phase 7 records the inferred target's hashed +node values as the baseline Phase 8's CORR-03 compares against. +**Source:** 07-CONTEXT.md D-01, 07-DISCUSSION-LOG.md + +### `lint` is project-scoped, and that narrows a stated success criterion + +`eslint .` runs with `cwd = packages/github-cache`. The workspace root gets no lint target. + +**Rationale:** `@nx/eslint`'s `getProjectUsingESLintConfig` returns `null` for the root because it +has neither `src/` nor `lib/`, and creating one would silently add a second lint target and rotate +every task hash mid-parity-investigation. The consequence was recorded rather than papered over: +`esbuild.action.mjs`, `start-cache-server/entry.ts` and `vitest.workspace.ts` are not linted, which +narrows LINT-01 SC1's literal "across the workspace" to "across the project that has specs". +**Source:** 07-CONTEXT.md D-07/D-08, 07-VERIFICATION.md + +### Two core ESLint rules, not one + +The ban is enforced by `no-restricted-imports` AND `no-restricted-syntax` together. + +**Rationale:** Structural necessity, not preference. `no-restricted-syntax` is an AST-selector +matcher and cannot see a destructured named import, which is exactly the shape at +`cache-archive-path.spec.ts:1`. `no-restricted-imports` cannot ban a member of a namespace import. +Wiring one would have made the RED proof pass for one shape and silently miss the other. M1 and M2 +later produced disjoint red sets, which is the measured form of this claim. +**Source:** 07-CONTEXT.md D-15, 07-EVIDENCE.md + +### Bounded-cleanup rule for the recommended rule sets + +Enable `@eslint/js` recommended plus `typescript-eslint` recommended (non-type-checked); measure +the baseline finding count BEFORE fixing anything; scope off any single broad rule with a recorded +reason rather than starting a codebase sweep. + +**Rationale:** Keeps "adopt a linter" from becoming an open-ended cleanup while still adopting a +real baseline. Type-checked variants were rejected outright: no mandated rule is type-aware and +`projectService` would make `lint` sensitive to the whole TypeScript program plus tsconfigs, +widening the stale-cache blast radius. In the event the baseline closed to zero residual with zero +rules disabled and zero code edits. +**Source:** 07-CONTEXT.md D-11/D-12, 07-01-SUMMARY.md + +### The RED proof is a permanent programmatic spec, not a red commit + +LINT-03's evidence is a spec that drives ESLint's Node API via `lintText(code, { filePath })`, +not a deliberately-red intermediate commit and not a one-time observation. + +**Rationale:** The repo's standard is bisect-safe atomic commits with the full battery green at +every commit, so a red intermediate was not available. `lintText` applies flat-config +`files`/`ignores` matching to the supplied path, so one mechanism proves the rule fires AND proves +the scoping in both directions. The rules and the four described disables therefore land in one +green commit. +**Source:** 07-CONTEXT.md D-20, 07-02-SUMMARY.md + +### ME-01 amended two locked decisions rather than deferring the gap + +D-16's glob text and D-19's invariants were rewritten so both globs derive from the real runner +configs: ESLint `files` == unit-runner include, ESLint `ignores` == unit-runner exclude, unit +exclude == integration include. + +**Rationale:** The gap was latent (zero matching files existed), but measurement showed it was +repo-only with no consumer surface, so the cost of amending was purely procedural. The naive +"widen both globs symmetrically" fix was rejected because it opens a new hole: an +`integration.spec.tsx` is not collected by the integration runner but IS collected by the unit +runner, so a symmetric `ignores` would exempt a file that runs as a unit test. +**Source:** 07-REVIEW.md ME-01, 07-REVIEW-FIX.md, 07-CONTEXT.md D-16/D-19 amendment + +--- + +## Lessons + +### A cached Nx PASS hid a genuinely flaky test + +`npm run test` reported `Cache: 1/1 hit (100%)` and exit 0. The same command with +`--skip-nx-cache` exited 1. The failure was real: `lint-scope-drift.spec.ts` timed out at 5000 ms +under CPU contention, reproducing 1 time in 3. + +**Context:** This is the third appearance of the cache-masking-a-failure class in this repo, after +`governance-email.spec.ts` (T-06-03-02) and the `typecheck` inputs defect (quick 260726-gok). The +earlier two were stale INPUT SETS; this one was a genuine flake that the cache merely replayed. Nx's +own flaky-task detector fired, which is the symptom rather than the cause. Any test signal intended +to be trusted needs `--skip-nx-cache`. +**Source:** 07-02-SUMMARY.md, orchestrator post-merge gate + +### Repeat-running until green does not prove a flake is fixed + +The broken version produced three consecutive green runs before the flake was found, and three more +during the fix attempt. The fix was proven instead by pinning `--testTimeout=500` and then `50`, +where the pre-fix version already failed and the post-fix version passes. + +**Context:** A controlled experiment that forces the failure deterministically beats a sample of +passing runs. The same reasoning applies to any intermittent defect: find the axis that makes it +deterministic rather than increasing N. +**Source:** 07-02-SUMMARY.md + +### Verification against the guards' own claims cannot find a claim the guards never made + +`07-VERIFICATION.md` returned `passed` at 22/22 must-haves, having re-derived the load-bearing +facts live rather than ratifying them. Code review then found 1 CRITICAL and 2 HIGH defects in the +same code. + +**Context:** The verifier checked that each guard passes and that each recorded mutation fails as +recorded. Every review finding was a property no guard asserted: that a missing `lint` target exits +0, that a default import evades both rules, that a disable reason can satisfy `toContain('integration')` +via a filename substring. Verification and review are not redundant, and a `passed` verification is +not evidence that the must-haves are complete. +**Source:** 07-VERIFICATION.md, 07-REVIEW.md, 07-REVIEW-FIX.md + +### `nx run-many -t ` exits 0, so an inferred target is a silently deletable gate + +Deleting the four-line `plugins[]` entry from `nx.json` left every guard green while the `lint` +gate stopped existing. `nx run-many` printed `NX No tasks were run` and returned 0. + +**Context:** Load-bearing precisely because `@nx/eslint` returns `[]` silently when it finds no +config, and D-35 leaves cross-OS inference unverified. The correct discriminator is requiring the +run to PRINT `Successfully ran target lint`, not merely to exit 0. `NO_COLOR: '1'` is required on +that CI step because Nx bolds the target name mid-phrase. +**Source:** 07-REVIEW.md CR-01, 07-REVIEW-FIX.md `5e662a7` + +### A ceiling comment that misstates its ceiling is worse than no comment + +The shipped `// ponytail:` note claimed a namespace import is caught "regardless of the local +name". True for `import * as X`, false for `import X` -- and the default-import form evaded both +rules entirely. + +**Context:** `importNames` maps a namespace specifier to `"*"` but a default specifier to +`"default"`, which was not in the lists. The comment would have actively steered a future reader +away from the hole it sat next to. +**Source:** 07-REVIEW.md HI-01, 07-REVIEW-FIX.md `5b9f5ca` + +### A lexical guard can be satisfied by the wrong token + +LINT-06 requires a disable reason to say why the assertion cannot move to `integration`. The guard +was `expect(reason).toContain('integration')`, which the substring inside +`public-server.integration.spec.ts` satisfies. Site 4's reason argued why the assertion SHOULD +move, and passed. + +**Context:** The fix strips the filename token before the containment check. The residual is +recorded honestly: the control still enforces the WORD, not the ARGUMENT, and that half is not +automatable. +**Source:** 07-REVIEW.md HI-02, 07-SECURITY.md residual N-1 + +### The IDE's TypeScript diagnostics were wrong 17 times and right zero times + +Across the phase the LSP feed flagged `comments`, `readFileSync`, three `eslint.config.mjs` +constants, `beforeAll` in two files, `statSync`, `extensionsOf` at six sites, and three more as +undefined or unused. Every one was genuinely used or genuinely absent from the current file. + +**Context:** Several were stale snapshots of a mid-edit state; `extensionsOf` had been deleted by a +refactor and the feed still reported six references to it. `npm run typecheck` returned 0 at every +check. Confirms the standing rule that the compiler is authoritative and the diagnostics feed is +not. +**Source:** orchestrator verification at each wave boundary + +--- + +## Patterns + +### Prove a guard can fail before trusting it + +Every guard added in this phase was mutation-tested: apply a mutation, observe the exact failure +set, revert, verify byte-identity. Nine mutations M1-M9 plus several ad-hoc ones. + +**When to use:** Any time a spec's purpose is to prevent a class of defect rather than to test a +behaviour. A guard that cannot fail is worthless, and this repo has shipped one before. +**Source:** 07-VALIDATION.md, 07-EVIDENCE.md, 07-04-SUMMARY.md + +### The vacuity mutation, distinct from the deletion mutation + +Beyond "delete the thing and watch the assertion go red", run a mutation that makes the assertion's +INPUT empty. When `lint.inputs`' self pattern list resolved empty, both positive `toContain` +assertions still passed and only the outside-project-root negative control caught it. + +**When to use:** Whenever a helper returns a collection that a filter narrows. +`filterUsingGlobPatterns` returns the WHOLE list when the pattern list is empty, so every +`toContain` passes together on a resolver that resolved nothing. +**Source:** 07-03-SUMMARY.md, 07-EVIDENCE.md + +### Pair a negative assertion with a positive control at the same path + +"The ban does not fire at an integration path" passes trivially if the config never loaded, the +path is misspelled, or the rules were never added. It is only meaningful paired with an assertion +that a DIFFERENT rule DOES fire at that same path. + +**When to use:** Any assertion whose expected result is "nothing happened". The pairing is what +distinguishes "correctly exempt" from "never evaluated". +**Source:** 07-CONTEXT.md D-17, 07-VALIDATION.md, 07-02-SUMMARY.md + +### Close a stale-cache hole in the same commit as the guard that depends on it + +`{workspaceRoot}/eslint.config.mjs` entered `targetDefaults.test.inputs` in the same commit as the +first guard spec that reads it. + +**When to use:** Whenever a spec asserts on a file outside its own project. Without the input +wiring the guard replays a cached PASS, and because the RED-before-GREEN activity IS the thing that +edits the file, the false pass surfaces during that activity and reads as "the rule does not fire". +**Source:** 07-CONTEXT.md D-25, 07-01-SUMMARY.md + +### Derive a guard's expectations from the real config, but compare the part that matters + +The drift guard reads both vitest configs off disk rather than restating their globs, but compares +the basename pattern (name family plus extension set as sorted sets) rather than the literal +string, because the path anchor legitimately differs between ESLint (`**/`) and vitest +(`{src,tests}/**/`). + +**When to use:** Cross-tool invariants where the two sides express the same rule in different +coordinate systems. Comparing literals makes the guard permanently red; comparing nothing makes it +vacuous. +**Source:** 07-CONTEXT.md D-19 amendment, 07-REVIEW-FIX.md `a6af663` + +### Behavioural coverage over text comparison for per-file-type rules + +Glob-text comparison structurally cannot see a per-extension config interaction. The validation +audit added 25 literal path-shape rows because `*.spec.cjs` matches both the `**/*.cjs` override +and the ban block, and their composing rather than colliding is a property of array order. + +**When to use:** Whenever config objects are order-sensitive and a rule's applicability varies by +file extension. Derive the row list literally, not from the config, or the guard agrees with +whatever the config says. +**Source:** 07-VALIDATION.md GAP-1 + +--- + +## Surprises + +### Only four error positions exist for four violation sites + +REQUIREMENTS.md and CONTEXT.md D-22 both describe `cache-archive-path.spec.ts` as having +violations at `:1` and `:26`. Measurement showed `:26` produces zero errors: in strict ESM the +`tmpdir` binding cannot exist without the import, and the import is already the error. + +**Impact:** A disable placed above line 26 would have been an UNUSED directive, and +`reportUnusedDisableDirectives: 'error'` would have failed the build. The phase would have shipped +red through its own opt-out discipline. Caught by research before any code was written. +**Source:** 07-RESEARCH.md C2 + +### `eslint .` walks the filesystem while Nx hashes from git + +`dist/` (60 files) and `out-tsc/` (36) are gitignored, so Nx never hashes them, but ESLint would +lint them. + +**Impact:** `lint`'s result would depend on whether `build` had run, at an unchanged Nx hash -- a +stale-cache defect with no input change to detect it. Required a global `ignores` block. Recorded +nowhere in the project's prior artifacts; M7 later measured 66 linted files with the block and 159 +without. +**Source:** 07-RESEARCH.md C3, 07-EVIDENCE.md M7 + +### The container lockfile regeneration changed a shipped consumer artifact + +Regenerating `package-lock.json` in a linux/arm64 container re-resolved `undici` 6.27.0 -> 6.28.0 +through `@actions/*`'s ranged transitive deps, drifting the committed action bundle by 88 lines. + +**Impact:** The only consumer-facing change in an otherwise dev-tooling-only phase. Assessed twice: +the code reviewer found the new throw-on-invalid-header path unreached with safe inputs; the +security auditor found it stronger than that -- the bundled undici's `ProxyAgent` is dereferenced +once, inside `getAgentDispatcher()`, which has zero call sites in the bundle. Unreachable, not +merely un-hit. +**Source:** 07-01-SUMMARY.md, 07-REVIEW.md ME-05, 07-SECURITY.md + +### The prescribed non-vacuity filter was itself vacuous under the setting it tested + +RESEARCH G1(c) prescribed detecting an ESLint "file was ignored" result via +`severity === 1 && ruleId === null`. That also matches a LINT-06 unused-directive report at v9's +default `warn` severity. + +**Impact:** The control misread a correctly-linted file as never-linted, and passed only because +`'error'` moves those reports to severity 2 -- meaning its correctness depended on the very setting +it existed to be independent of. Found during the RED phase and fixed with a position check. +**Source:** 07-01-SUMMARY.md + +### A research-supplied false-positive control was itself a failing assertion + +RESEARCH listed `path.join('a','b')` as a control that must NOT error. Valid for a standalone +esquery snippet, but under real ESLint `path` has to come from somewhere, and +`import * as path from 'node:path'` IS an error. + +**Impact:** Using it verbatim would have turned a false-positive control into a failing assertion. +Replaced with a local object; the namespace form was reclassified as an evasion shape. +**Source:** 07-02-SUMMARY.md + +### Registering the plugin rotates every task hash, and `test` twice + +Adding a target changes `hash_project_config`, which is folded into every task hash; and +`{workspaceRoot}/nx.json` is already an explicit `test` input, so `test` rotates a second time. + +**Impact:** Phase 7's first default-branch push is a legitimate all-MISS push. Recorded in advance +so Phase 9's OBS-04 tripwire is authored as "two consecutive all-miss pushes with NO +version-affecting change in between" -- there are three legitimate rotation windows in this +milestone, and a tripwire that fires on correct work gets disabled. +**Source:** 07-CONTEXT.md D-36, 07-03-SUMMARY.md, 07-EVIDENCE.md diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-PATTERNS.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-PATTERNS.md new file mode 100644 index 00000000..0905c58e --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-PATTERNS.md @@ -0,0 +1,772 @@ +# Phase 7: Lint Toolchain and the Ambient-Platform-Read Ban - Pattern Map + +**Mapped:** 2026-07-27 +**Files analyzed:** 11 (2 create, 9 modify) +**Analogs found:** 9 / 11 + +Every excerpt below is verbatim from the working tree at the mapped date, with real +`file:line` references. Nothing is paraphrased or invented. + +--- + +## File Classification + +| New/Modified File | Create/Modify | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---|---| +| `eslint.config.mjs` | CREATE | config | transform (source -> findings) | `.fallowrc.jsonc` (comment-per-entry rationale), `packages/github-cache/vitest.config.mts` (decision-carrying tool config) | partial (convention-only; no linter exists today) | +| `packages/github-cache/src/.spec.ts` | CREATE | test (guard spec) | file-I/O + transform (ESLint Node API over the real config) | `public-surface.spec.ts` (explicit assertion lists) + `nx-target-inputs.spec.ts` (negative control) + `cleanup-workflow.spec.ts` (`import.meta.url` disk read) | role-match (no existing spec instantiates a vendor Node API) | +| D-19 drift guard (new file OR folded into the above) | CREATE | test (drift guard) | file-I/O | `docs-trust.spec.ts` (import the real single source, assert the derived copies agree) + `cleanup-workflow.spec.ts` / `ppe-action.spec.ts` (disk read + comment strip) | exact | +| `nx.json` `plugins[]` | MODIFY | config | n/a | `nx.json:27-33` (the `@nx/vitest` registration) | exact (self-analog) | +| `nx.json` `targetDefaults.lint` (new) | MODIFY | config | n/a | `nx.json:42-71` (`test`), `nx.json:72-87` (`integration`) | exact (self-analog) | +| `nx.json` `targetDefaults.test.inputs` (D-25) | MODIFY | config | n/a | `nx.json:44-70` itself | exact (self-analog) | +| `package.json` (root) | MODIFY | config | n/a | `package.json:5-19` scripts, `package.json:24-40` devDependencies | exact (self-analog) | +| `package-lock.json` | MODIFY | generated config | n/a | none - it is a PROCEDURE (D-05 container regen), not a code pattern | no analog | +| `packages/github-cache/src/pinned-deps.spec.ts` | MODIFY | test (name-list pin guard) | file-I/O | itself, `pinned-deps.spec.ts:63-87` (the ROOT-manifest `describe`) | exact (self-analog) | +| `packages/github-cache/src/nx-target-inputs.spec.ts` | MODIFY | test (inputs guard) | transform (Nx resolver trio) | itself, `nx-target-inputs.spec.ts:67-124` | exact (self-analog) | +| `.github/workflows/ci.yml` (new `lint` job) | MODIFY | config (CI job) | batch | `ci.yml:13-24` (`format-check`), `ci.yml:33-42` (`fallow`) | exact | +| `.fallowrc.jsonc` | MODIFY | config | n/a | `.fallowrc.jsonc:63` (`"@nx/vitest"` in `ignoreDependencies`), `.fallowrc.jsonc:38-41` (`entry` with rationale) | exact (self-analog) | +| The four CORR-05 sites (3 spec files, 4 described disables) | MODIFY | test (existing specs) | n/a | none - RESEARCH F21 measures ZERO existing `eslint-disable` comments in the tree | no analog (first of kind; follow the comment-density convention) | + +--- + +## Pattern Assignments + +### `packages/github-cache/src/pinned-deps.spec.ts` (test, file-I/O) - D-04, LINT-01 + +**Analog:** itself. Five new sibling `it()` blocks go in the SECOND `describe` +(`'pinned build tooling (ROBUST-03)'`), because that block already reads the ROOT +manifest and all five ESLint packages are ROOT devDependencies. + +**Root-manifest read idiom + exact-semver regex** (`pinned-deps.spec.ts:75-87`, verbatim): + +```ts +describe('pinned build tooling (ROBUST-03)', () => { + const workspaceManifest = JSON.parse( + readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), + ) as { devDependencies?: Record }; + + const EXACT_SEMVER = /^\d+\.\d+\.\d+$/; + + it('esbuild is pinned to an exact version in the workspace devDependencies, never a range (ROBUST-03)', () => { + const specifier = workspaceManifest.devDependencies?.['esbuild']; + + expect(specifier).toMatch(EXACT_SEMVER); + }); +}); +``` + +Copy exactly: `new URL('../../../package.json', import.meta.url)` (three levels up from +`src/`), the `as { devDependencies?: Record }` cast, the const named +`EXACT_SEMVER`, the blank line between the `const specifier` and the `expect`, and the +`it()` title shape `' is pinned to an exact version ..., never a range ()'`. + +**One-`it()`-per-package, never a loop** - `pinned-deps.spec.ts:22-60` shows five separate +`it()` blocks with no `it.each`. D-04 turns on this being a hard-coded NAME list; a loop +over `Object.keys(devDependencies)` would be a different (and wrong) guard. + +**Where the D-04 ROBUST-03-class rationale goes** - a block comment ABOVE the group of +`it()`s, in the same voice as `pinned-deps.spec.ts:45-49`: + +```ts + // The resilience pairing (F04) is a new supply-chain surface: @octokit/plugin-retry + // and @octokit/plugin-throttling. Both were verified against the registry (versions, + // core 7 peer range, no install scripts) and confirmed as octokit@5.0.5's own + // pairing, then pinned exact so a range operator can never silently pull an + // un-audited minor/patch. This spec fails the build the moment either widens. +``` + +That is the exact template for D-04's obligation ("ESLint deps join the class because +`lint` is a build gate ... unlike `prettier`, which is formatting-only and deliberately +out"). Note the closing sentence pattern - every rationale block in this file ends with +"This spec fails the build the moment ...". + +--- + +### `packages/github-cache/src/nx-target-inputs.spec.ts` (test, transform) - D-25, D-26, LINT-04 + +**Analog:** itself. Extend; do not build a new mechanism. + +**Vendor-resolver delegation - the exact import path and call shape** +(`nx-target-inputs.spec.ts:1-8` and `:67-78`, verbatim): + +```ts +import { readFileSync } from 'node:fs'; +import type { NxJsonConfiguration } from 'nx/src/config/nx-json.js'; +import { + extractPatternsFromFileSets, + filterUsingGlobPatterns, + splitInputsIntoSelfAndDependencies, +} from 'nx/src/hasher/task-hasher.js'; +import { describe, expect, it } from 'vitest'; +``` + +```ts +function hashedFilesFor(target: string): string[] { + const { selfInputs } = splitInputsIntoSelfAndDependencies( + nxJson.targetDefaults[target].inputs, + nxJson.namedInputs, + ); + + return filterUsingGlobPatterns( + PROJECT_ROOT, + PROBE_FILES.map((file) => ({ file, hash: 'probe' })), + extractPatternsFromFileSets(selfInputs), + ).map((entry) => entry.file); +} +``` + +`hashedFilesFor` already takes the target name as a parameter, so the `lint` probes need +NO new helper - call `hashedFilesFor('lint')`. + +**nx.json read idiom** (`nx-target-inputs.spec.ts:47-52`): + +```ts +const nxJson = JSON.parse( + readFileSync(new URL('../../../nx.json', import.meta.url), 'utf8'), +) as { + namedInputs: Record; + targetDefaults: Record; +}; +``` + +**THE NEGATIVE CONTROL that the new `lint` probes must carry an equivalent of** +(`nx-target-inputs.spec.ts:100-111`, verbatim - comment included, it is the load-bearing +part): + +```ts + // NON-VACUITY control, and it must be a NEGATIVE one. filterUsingGlobPatterns + // starts with `if (positive.length === 0 && negative.length === 0) return files` + // -- an empty pattern list returns the WHOLE probe list untouched. So every + // toContain() above would pass together on a resolver that resolved nothing, + // which is the same class of silent false pass this guard exists to prevent. + // `build` is the discriminator: its inputs genuinely exclude specs, so this + // assertion is true today and false the instant the filter stops filtering. + it('does NOT hash the spec sources for build, proving the filter filters', () => { + expect(hashedFilesFor('build')).not.toContain( + `${PROJECT_ROOT}/src/index.spec.ts`, + ); + }); +``` + +RESEARCH's Non-vacuous-assertions table says the `lint` probe set needs its own honest +negative: `lint`'s inputs start from `default` (`{projectRoot}/**/*`), so `build` is NOT a +usable discriminator for it. If no clean negative exists for `lint`, RESEARCH's explicit +instruction is to SAY SO in the comment rather than shipping a positive-only set. The +existing `build` assertion stays untouched and keeps covering `typecheck`. + +**The literal-pinning `{workspaceRoot}` assertion - the shape for the D-25 `test.inputs` +ESLint assertion** (`nx-target-inputs.spec.ts:114-125`, verbatim): + +```ts +describe('the guard cannot replay a stale pass', () => { + // This one DOES pin a literal, deliberately: there is no resolver to delegate + // to for a `{workspaceRoot}` entry, and the wiring IS the invariant. Its + // limitation is honest -- if the entry is removed, this test only fires once + // some other input busts the `test` hash. That is still the next unrelated + // source edit, and stating the requirement in code beats leaving it implicit. + it('nx.json is a test input, so editing it re-runs this file', () => { + expect(nxJson.targetDefaults.test.inputs).toContain( + '{workspaceRoot}/nx.json', + ); + }); +}); +``` + +D-25's assertion (`{workspaceRoot}/eslint.config.mjs` is in `test.inputs`) is the same +`toContain` on the same object, and belongs in this same `describe`. Copy the "this one +DOES pin a literal, deliberately" comment framing - it is how the file pre-empts the +"why isn't this delegated to the resolver?" review question. + +**Do NOT touch** the `expandSingleProjectInputs` warning recorded at `:28-43` (D-26: it +THROWS on this inputs array). + +--- + +### `.github/workflows/ci.yml` - new `lint` job (config, batch) - D-32, D-33 + +**Analog:** `ci.yml:13-24` (`format-check`) - the shortest complete non-dogfooded job. +Per D-33 the `lint` job gets NO sidecar dogfood block, so `format-check` / `fallow` / +`pack-check` are the shape, NOT `build` / `typecheck` / `test` / `integration`. + +**Shortest complete job** (`ci.yml:33-42`, `fallow` - verbatim, comment included): + +```yaml + # `fallow dead-code --fail-on-issues` gates the whole repo against dead code + # (unused files/exports/deps + reachability). It is config-declared-clean via + # .fallowrc.jsonc and base-independent, so it works identically on push and on + # shallow pull_request checkouts -- unlike `fallow audit`, which needs an + # origin/main diff base and fails open (exits 0) when that ref is absent. + # Future option: `fallow audit --changed-since origin/main` for faster, + # diff-scoped gating once the repo grows large enough to want it. + fallow: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + cache: 'npm' + - run: npm ci + - run: npm run fallow:ci +``` + +The exact five-line boilerplate to copy: `runs-on: ubuntu-24.04-arm`, +`actions/checkout@v7`, `actions/setup-node@v6` with `node-version-file: '.node-version'` +and `cache: 'npm'`, then `npm ci`, then the one `npm run