From 295156f221b0435b41c05e131622ce7019c406d6 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 15:44:04 +0200 Subject: [PATCH 001/511] docs: start milestone v0.0.2 OS-invariant cross-OS sharing --- .planning/PROJECT.md | 41 ++++++++++++++++++++++++++++++++++++++--- .planning/STATE.md | 30 +++++++++++++----------------- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 0db7c41e..d7bc4d2f 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -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,19 @@ 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 ADR Decision 6's documented-consumer- + discrimination alternative +- [ ] OS-sensitive targets stay separated by their declared Nx input, proven behaviourally (CORR-03) +- [ ] Nx task-hash parity for `build`/`typecheck`/`test` across Windows and Linux, root-caused + before it is fixed (PARITY-01..04) +- [ ] The `@actions/cache` archive path becomes a deliberate OS-invariant constant instead of an + inherited `os.tmpdir()` value, with `enableCrossOsArchive` hardcoded (VER-01..04) +- [ ] Live cross-OS proofs for O1-O4 in the mandated order (READ-01/02, CI-01/02, TEST-06/07) +- [ ] 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): @@ -134,4 +169,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-26 at v0.0.2 milestone start (OS-invariant cross-OS sharing) - 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 ARCHITECTURE-DECISION.md.* diff --git a/.planning/STATE.md b/.planning/STATE.md index 1cb76ced..786bb92e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,20 +1,16 @@ --- 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" +milestone: v0.0.2 +milestone_name: OS-invariant cross-OS sharing +status: planning +last_updated: "2026-07-26T13:43:31.953Z" 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 progress: - total_phases: 7 - completed_phases: 7 - total_plans: 33 - completed_plans: 33 - percent: 100 -current_phase_name: Distribution + Docs + Governance + total_phases: 0 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 + percent: 0 --- # Project State @@ -28,10 +24,10 @@ See: .planning/PROJECT.md (updated 2026-07-18) ## 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: Not started (defining requirements) +Plan: — +Status: Defining requirements +Last activity: 2026-07-26 — Milestone v0.0.2 started ## Performance Metrics From 62232cb522db8fb1a7ae068f8841fd531bf6e0b5 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 18:02:09 +0200 Subject: [PATCH 002/511] docs: define milestone v0.0.2 requirements --- .planning/REQUIREMENTS.md | 336 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 .planning/REQUIREMENTS.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000..4434e868 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,336 @@ +# 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 | ADR Decision 6's documented alternative 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) + +- [ ] **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. +- [ ] **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. +- [ ] **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. +- [ ] **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`). Three spec files + violate this today -- `cache-archive-path.spec.ts` (asserts `dirname === tmpdir()`), + `releases-backend.spec.ts` (derives a wrong-OS fixture from `process.platform`) and + `release-asset-name.spec.ts` (compares against `process.platform`) -- and all three are + eliminated as a side effect of VER-02 and CORR-02, which remove the platform-dependent + behaviour those specs exist to pin. +- [ ] **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: `releaseAssetName(hash, 'win32')` is the + designed test seam ("the platform parameter exists ONLY for test injection"). 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. + +- [ ] **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. New dev + dependencies are exact-pinned under the existing ROBUST-03 discipline and covered by the + `pinned-deps` guard. +- [ ] **LINT-02**: A `no-restricted-syntax` rule set bans AMBIENT platform reads in unit specs and + ALLOWS them in integration specs, scoped by the partition that already exists -- `files: + ['**/*.spec.ts']` with `ignores: ['**/*.integration.spec.ts']`, mirroring + `vitest.config.mts`'s include/exclude and `vitest.integration.config.mts`'s exclusive include. + 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. `releaseAssetName(hash, 'win32')` is the + designed test seam ("the platform parameter exists ONLY for test injection"). Only deriving an + expectation from the RUNNING machine is prohibited, which is precisely what the three CORR-05 + violations do. +- [ ] **LINT-03**: The rule set is proven RED before GREEN -- a deliberately violating fixture + fails `lint`, and each of the three CORR-05 violations is confirmed caught before it is removed. + A rule that matches nothing is indistinguishable from a rule that is not wired up. +- [ ] **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. + +### Nx task-hash parity (PARITY) + +- [ ] **PARITY-01**: The current cross-OS divergence for `build`/`typecheck`/`test` is root-caused + node-by-node (native Windows vs Linux) and RECORDED, with the capture command named, before any + fix is applied. +- [ ] **PARITY-02**: `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), and ubuntu-24.04-arm runner. Three values per target, + not two. Enforced continuously by CORR-03(c), not measured once. +- [ ] **PARITY-03**: `integration` computes a byte-identical hash between the native Windows + workstation and windows-11-arm (O2's precondition). +- [ ] **PARITY-04**: Every measurement records the Nx version, the Node version, and the install + mode (`npm ci` vs `npm install`). 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`). +- [ ] **PARITY-05**: The public-surface guard passes unchanged -- no new env knob, no new action + input, no new package export (D2-02). + +### Cache-version hardening (VER) + +- [ ] **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. +- [ ] **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()`. +- [ ] **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. +- [ ] **VER-04**: The process asserts its cwd is the Nx workspace root, failing loud otherwise. A + relative path plus a wrong cwd is a silent split-brain between the derived version and the file. +- [ ] **VER-05**: The resolved `@actions/cache` compression method is surfaced in the publish + summary. It is a third version component sensed at runtime by probing for `zstd`, so a runner + image that loses zstd silently re-partitions the version. Surfaced, NOT gated -- a hard failure + would punish an otherwise-healthy runner. +- [ ] **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. + +### Cross-OS outcomes (XOS) + +- [ ] **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) +- [ ] **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) +- [ ] **XOS-03**: Windows CI gets a cache MISS for `integration` produced by Linux CI. (O3) +- [ ] **XOS-04**: `ci.yml` runs `build`, `typecheck` and `test` on a windows-11-arm leg in addition + to the ubuntu leg, wired through the same sidecar block as the `integration` matrix. Without this + there is no Windows job that could exhibit O4's HIT. +- [ ] **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. (O4) +- [ ] **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. +- [ ] **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. + +### Retention and cleanup (RETAIN) + +- [ ] **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. + +### CREEP trust posture (TRUST) + +- [ ] **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. +- [ ] **TRUST-11**: The phase threat model records that C3/TRUST-07's "byte-identical under + CORR-01" premise is now FALSE, not merely re-founded: an Nx cache entry carries captured terminal + output (`ci.yml:652`), which embeds OS-specific paths, so two legs produce different bytes for + the same hash. First-write-wins therefore arbitrates between differing payloads, and the + month-shard newest-first read walk makes the winner shard-dependent. +- [ ] **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. +- [ ] **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. + +### Documentation (DOCS) + +- [ ] **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. +- [ ] **DOCS-08**: `docs/advanced.md:54-57` and `ci.yml:577-583` are corrected -- both currently + assert same-OS restore as a load-bearing invariant, which VER-03 inverts. `README.md`'s + unconditional "never a wrong result" gains its new precondition. The statement that correctness + now depends on the declared Nx input is added to `docs/trust-and-security.md`. + +### Verification (TEST) + +- [ ] **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 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. +- [ ] **TEST-09**: The O3 negative proof runs AFTER VER-01 and VER-03 have landed, so the MISS is + attributable to the Nx discriminator and not to the removed `@actions/cache` OS salt. It requires + a POSITIVE CONTROL in the same run: the Windows job must MISS the Linux `integration` hash AND + HIT at least one entry through the same code path. A run that MISSes everything is not a valid + proof. Both hashes are recorded and shown to differ. +- [ ] **TEST-10**: The O1/O2 local proofs begin from a cleared local Nx cache (`nx reset`). A HIT + recorded without a preceding reset is not accepted -- a local cache hit short-circuits before the + remote is ever queried. + +### Observability (OBS) + +- [ ] **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. +- [ ] **OBS-03**: Every mirrored asset records its producing OS 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. +- [ ] **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`); + a SECOND consecutive all-miss push is a FAILURE, not warm-up. It stays a warning, not a hard + failure -- the one-time rotation makes a full MISS legitimate exactly once. +- [ ] **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 the three violations before they are removed, or nothing shows the rule works | +| PARITY-01 | PARITY-02 | Root-cause before fixing | +| PARITY-01 | DOCS-07 | The checklist is derived from the findings | +| PARITY-02 | XOS-01 | Hash parity is O1's precondition | +| 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 | Disproportionate. NOTE: "O4's green CI is the portability evidence" is NOT the reason -- that argument is circular, since a restored task does not execute | +| Per-job or per-target OS-invariance flag | D2-02 (no adopters, so no exit is needed yet). 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. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| (pending roadmap) | | | + +--- +*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).* From 77c779fd490eafcedbabb7441a75d4fd731ced3c Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 18:02:47 +0200 Subject: [PATCH 003/511] docs: require described lint opt-outs and fail on stale disables --- .planning/REQUIREMENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 4434e868..bcbd8caf 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -129,6 +129,18 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own - [ ] **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. +- [ ] **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. +- [ ] **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) From 90491284ee6b46ef5575b270bfd8bd6b9bceff93 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 18:08:23 +0200 Subject: [PATCH 004/511] docs: record v0.0.2 key decisions and supersede the OS-namespacing default --- .planning/PROJECT.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index d7bc4d2f..d288be7c 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -147,9 +147,15 @@ Later-milestone revisit triggers carried out of v0.0.1 (re-evaluate together per | **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 | ADR Decision 6 sanctions both branches; 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 From d0f53991ebb23865eff863eeb887cc6d85284ca3 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 18:23:06 +0200 Subject: [PATCH 005/511] docs: create milestone v0.0.2 roadmap (6 phases) --- .planning/REQUIREMENTS.md | 49 +++- .planning/ROADMAP.md | 518 ++++++++++++++++++++++++++++++++++++-- .planning/STATE.md | 34 ++- 3 files changed, 573 insertions(+), 28 deletions(-) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index bcbd8caf..7758f05f 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -336,13 +336,58 @@ Consumed by the roadmapper as phase dependencies. ## Traceability -Populated during roadmap creation. +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 | |-------------|-------|--------| -| (pending roadmap) | | | +| LINT-01 | Phase 7 | Pending | +| LINT-02 | Phase 7 | Pending | +| LINT-03 | Phase 7 | Pending | +| LINT-04 | Phase 7 | Pending | +| LINT-05 | Phase 7 | Pending | +| LINT-06 | Phase 7 | Pending | +| CORR-06 | Phase 7 | Pending | +| PARITY-01 | Phase 8 | Pending | +| PARITY-02 | Phase 8 | Pending | +| PARITY-03 | Phase 8 | Pending | +| PARITY-04 | Phase 8 | Pending | +| PARITY-05 | Phase 8 | Pending | +| CORR-03 | Phase 8 | Pending | +| CORR-04 | Phase 8 | Pending | +| VER-01 | Phase 9 | Pending | +| VER-02 | Phase 9 | Pending | +| VER-03 | Phase 9 | Pending | +| VER-04 | Phase 9 | Pending | +| VER-05 | Phase 9 | Pending | +| VER-06 | Phase 9 | Pending | +| OBS-04 | Phase 9 | Pending | +| DOCS-08 | Phase 9 | Pending | +| CORR-02 | Phase 10 | Pending | +| RETAIN-04 | Phase 10 | Pending (same commit as CORR-02) | +| CORR-05 | Phase 10 | Pending (1st of 3 violations removed in Phase 9 with VER-02) | +| OBS-03 | Phase 10 | Pending | +| OBS-05 | Phase 10 | Pending (must land before CORR-02) | +| XOS-06 | Phase 10 | Pending | +| XOS-07 | Phase 10 | Pending | +| TRUST-10 | Phase 10 | Pending | +| TRUST-11 | Phase 10 | Pending | +| TRUST-12 | Phase 10 | Pending | +| TRUST-13 | Phase 10 | Pending | +| XOS-01 | Phase 11 | Pending (live-CI/live-workstation only) | +| XOS-02 | Phase 11 | Pending (baseline captured in Phase 10, before CORR-02) | +| XOS-03 | Phase 11 | Pending (live-CI only) | +| TEST-08 | Phase 11 | Pending (O4 evidence row appended in Phase 12) | +| TEST-09 | Phase 11 | Pending (live-CI only) | +| TEST-10 | Phase 11 | Pending | +| OBS-02 | Phase 11 | Pending | +| XOS-04 | Phase 12 | Pending | +| XOS-05 | Phase 12 | Pending (live-CI only) | +| DOCS-07 | Phase 12 | Pending | --- *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).* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 43762c28..2808fd41 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -9,26 +9,66 @@ 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: +signing; 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`. +**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-12 + +## 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 (6 phases). **Mode:** `mvp` is marked on the three phases that build +shippable capability (9, 10, 12); Phase 7 is toolchain adoption, Phase 8 is measurement and +configuration, Phase 11 is proof-only, so MVP slicing does not apply to them. 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 +77,445 @@ Full phase detail, success criteria, traceability, and coverage validation archi
-### 🚧 Next milestone (planning) +### v0.0.2 OS-invariant cross-OS sharing + +- [ ] **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. +- [ ] **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. +- [ ] **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. +- [ ] **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. +- [ ] **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. +- [ ] **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. + +## 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) + +**Plans**: TBD + +### 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, 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 between + native Windows and Linux for `build`/`typecheck`/`test`, names the capture command used, + and is dated BEFORE the first fix commit. It controls for `.nx/workspace-data` freshness on + both sides -- quick 260725-w3s showed both previously-cited hash values are reproducible on + ONE Windows machine by varying only that. (PARITY-01) + + 2. 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. Three recorded values per target, not two. (PARITY-02) + + 3. `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-03, CORR-04) + + 4. 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. (CORR-03) + + 5. Every recorded measurement carries the Nx version, the Node version and the install mode + (`npm ci` vs `npm install`), and the public-surface guard passes unchanged -- no new env + knob, no new action input, no new package export. (PARITY-04, PARITY-05) + +**Plans**: TBD + +**Live-CI close**: PARITY-02's windows-11-arm and ubuntu-24.04-arm observation points and +CORR-03's two-leg job exist only on real runners. + +### 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**: VER-01, VER-02, VER-03, VER-04, VER-05, VER-06, 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 its cwd IS the Nx + workspace root and fails loud otherwise. (VER-01, VER-02, VER-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. 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. + (VER-06) + + 4. The publish summary reports the resolved `@actions/cache` compression method, surfaced and + never gated: it is a third version component sensed at runtime by probing for `zstd`, so a + runner image that loses zstd silently re-partitions the version. (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 a + SECOND consecutive all-miss push is a FAILURE, not warm-up. `docs/advanced.md:54-57` and + `ci.yml:577-583`, which assert same-OS restore as a load-bearing invariant, are corrected; + `README.md`'s unconditional "never a wrong result" gains its precondition. (OBS-04, + DOCS-08) + +**Plans**: TBD + +**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, 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) + + 2. No target shared cross-OS has a spec that derives an expectation from the RUNNING machine: + all three CORR-05 violations are gone, removed as a side effect of VER-02 and CORR-02 + rather than relaxed, and Phase 7's lint rule is what stops them coming back. (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 its producing OS 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. (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. (XOS-06, TRUST-10, TRUST-11, TRUST-12, TRUST-13) + +**Plans**: TBD + +**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. One Windows CI run MISSES the Linux `integration` hash AND HITs at least one entry through + the same code path in that same run; both hashes are recorded and shown to differ. A run + that MISSes everything is not a valid proof. (XOS-03, TEST-09) + +**Plans**: TBD + +**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. + +### 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, 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, wired through the same sidecar block as the `integration` matrix. Without this + there is no Windows job that could exhibit O4's HIT. (XOS-04) + + 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**: TBD + +**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. + +## 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 | Byte-identical `build`/`typecheck`/`test` hash at all THREE observation points. | +| PARITY-03 | Phase 8 | Byte-identical `integration` hash between native Windows and windows-11-arm (O2's precondition). | +| PARITY-04 | Phase 8 | Every measurement records Nx version, Node version and install mode. | +| PARITY-05 | 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. | +| DOCS-07 | Phase 12 | Safe-by-default consumer recipe; portability checklist second, derived from PARITY-01; drift-guarded. | + +## Coverage Validation + +**Assertion: 43/43 v0.0.2 requirements map to exactly one phase. No orphans, no duplicates.** + +Per-phase counts: + +- Phase 7: 7 (LINT-01..06, CORR-06) +- Phase 8: 7 (PARITY-01..05, 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: 3 (XOS-04, XOS-05, DOCS-07) + +Total mapped: 7 + 7 + 8 + 11 + 7 + 3 = 43. Source categories: CORR 5, LINT 6, PARITY 5, VER 6, +XOS 7, RETAIN 1, TRUST 4, DOCS 2, TEST 3, OBS 4 = 43. + +### 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 (3 requirements).** 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: -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. +- 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). +- 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 +528,16 @@ 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 | 0/? | Not started | - | +| 8. Nx Task-Hash Parity | v0.0.2 | 0/? | Not started | - | +| 9. OS-Invariant Actions-Cache Version | v0.0.2 | 0/? | Not started | - | +| 10. OS-Invariant Releases Mirror | v0.0.2 | 0/? | Not started | - | +| 11. Live Proofs -- O1, O2, O3 | v0.0.2 | 0/? | Not started | - | +| 12. Windows CI Reuse (O4) + Consumer Recipe | v0.0.2 | 0/? | Not started | - | --- -*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), `.planning/PROJECT.md` (O1-O4 and the +mandatory ordering), and `.planning/ARCHITECTURE-DECISION.md` (Decision 6's documented +alternative branch, which 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 786bb92e..b31bdd3c 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,10 +3,10 @@ gsd_state_version: 1.0 milestone: v0.0.2 milestone_name: OS-invariant cross-OS sharing status: planning -last_updated: "2026-07-26T13:43:31.953Z" +last_updated: "2026-07-26T18:30:00.000Z" last_activity: 2026-07-26 progress: - total_phases: 0 + total_phases: 6 completed_phases: 0 total_plans: 0 completed_plans: 0 @@ -20,14 +20,15 @@ progress: 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 7 - Lint Toolchain and the Ambient-Platform-Read Ban (v0.0.2, not started) ## Current Position -Phase: Not started (defining requirements) -Plan: — -Status: Defining requirements -Last activity: 2026-07-26 — Milestone v0.0.2 started +Phase: 7 - Lint Toolchain and the Ambient-Platform-Read Ban (not started) +Plan: none yet +Status: Roadmap created, awaiting `/gsd:plan-phase 7` +Progress: 0/6 phases complete [------] 0% +Last activity: 2026-07-26 - v0.0.2 roadmap created (Phases 7-12, 43/43 requirements mapped) ## Performance Metrics @@ -196,6 +197,22 @@ Items acknowledged and carried forward: ## Session Continuity +### 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-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. 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). @@ -270,4 +287,5 @@ Next: lead verifies the series -> pushes gsd/v0.0.1-greenfield-rebuild + updates ## Operator Next Steps -- Start the next milestone with /gsd-new-milestone +- Plan the first v0.0.2 phase with `/gsd:plan-phase 7` (lint toolchain; it must precede the parity work because `@nx/eslint` inference changes `hash_project_config`) +- Before Phase 10 executes, capture or cite the pre-rename O2 baseline -- it is unrecoverable once CORR-02 lands From 46992322231fa5d8c660d1c71d3f902c8c45c4e1 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 21:49:50 +0200 Subject: [PATCH 006/511] docs(quick-260726-pjz): prove per-claim coverage before slimming the ADR Gate for the destructive removal: every distinctive claim of every removed section is classified by an EXECUTED grep, not asserted in prose. - 44 rows: 35 COVERED + 1 SPENT + 8 RESIDUE, one row per claim - the 36-assertion COVERED/SPENT battery exits 0 against the pre-removal tree - the 8 RESIDUE rows each record the claim-level negative probe (excluding the ADR, quick/ and archived milestones/) that proved them homeless Three ways a probe can lie are closed explicitly: one term standing in for a list (the 12-source bibliography is one wholesale row, not per-token); a term that only matches a shipped-requirements checklist tick (rows 05/06/17 point at substantive prose in INTEGRATIONS.md, FEATURES.md and MILESTONES.md instead); and a negative probe built from the ADR's own phrasing, which passes by accident - so each residue row carries substance probes as well as the literal token. Records one measured deviation from the plan's premise: the git-native / Actions-artifact rejection rationale IS covered at research/STACK.md:75,77 (verbatim "no clean eviction"), which the plan did not probe. Retained anyway and the reasoning recorded in Note C - retention is the lossless error, and it is a dead-end historical rationale, not a live invariant. --- .../260726-pjz-COVERAGE.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md diff --git a/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md new file mode 100644 index 00000000..14c94386 --- /dev/null +++ b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md @@ -0,0 +1,150 @@ +# Quick Task 260726-pjz: Coverage proof for the ARCHITECTURE-DECISION.md slimming + +**Measured:** 2026-07-26, against the PRE-removal tree (HEAD `fe25a3f`). +**Unit of proof:** a DISTINCTIVE CLAIM, not a section. + +Three dispositions, and RETENTION IS THE DEFAULT for anything not proven elsewhere: + +- **COVERED** - proven present in a NAMED artifact outside `.planning/ARCHITECTURE-DECISION.md` + by the executed assertion in the row. Safe to delete. +- **RESIDUE** - proven absent everywhere else, therefore RETAINED in the slimmed ADR under + `## Residual notes`. This is the KEEP criterion ("keep only what has no canonical home") doing + its job, not an escape hatch. +- **SPENT** - obsolete by a recorded event, evidenced by that event's record. + +Every positive assertion below was EXECUTED individually (36/36 exit 0) and then re-executed as +the single chained battery in the plan's Task 1 `` block. Every negative probe +below was EXECUTED with the three mandated exclusions - the ADR itself, this task's `quick/` +directory, and archived `.planning/milestones/` (an archived record is history, not a canonical +home, D-04). + +Negative probes are abbreviated in the table as `NEG()`, which expands to: + +``` +git grep -q -F "" -- . ':!.planning/ARCHITECTURE-DECISION.md' ':!.planning/quick' ':!.planning/milestones' +``` + +exiting NON-ZERO (no match = no home). + +## Disposition table + +| Section | Claim | Disposition | Home | Executed assertion | +|---------|-------|-------------|------|--------------------| +| Framing | The shipped implementation is a spike/PoC with sunk cost zero, so it may be rebuilt | SPENT | The rebuild happened; `MILESTONES.md` records v0.0.1 shipped | `git grep -q -F "v0.0.1" -- .planning/MILESTONES.md` | +| Decision 1 | One backend per process, selected by runtime context; no composite/registry | COVERED | `.planning/PROJECT.md` Key Decisions row | `git grep -q -F "One backend per process, context-selected" -- .planning/PROJECT.md` | +| Decision 1 | The selection function is `selectBackend(env)` | COVERED | `.planning/research/ARCHITECTURE.md` | `git grep -q -F "selectBackend" -- .planning/research/ARCHITECTURE.md` | +| Decision 1 | The port is `get`/`put` and `put` returns `PutResult` (no `exists` verb) | COVERED | `.planning/research/ARCHITECTURE.md` | `git grep -q -F "PutResult" -- .planning/research/ARCHITECTURE.md` | +| Decision 1 | RW-vs-RO is context-derived, never a caller-facing flag | COVERED | `.planning/codebase/INTEGRATIONS.md:84` (substantive prose, deliberately NOT PROJECT.md's dated log footer) | `git grep -q -F "context-derived" -- .planning/codebase/INTEGRATIONS.md` | +| Decision 1 | The no-flag property is load-bearing for CREEP, not a convenience | COVERED | `.planning/research/FEATURES.md:152` (substantive prose, deliberately NOT PROJECT.md's Validated checklist tick) | `git grep -q -F "no-flag safety property" -- .planning/research/FEATURES.md` | +| Decision 1 | The publish/retention subsystem is reader-specific and sits behind no port | COVERED | `.planning/research/ARCHITECTURE.md` | `git grep -q -F "behind no port" -- .planning/research/ARCHITECTURE.md` | +| Decision 1 | Do not assume the publisher is pluggable | COVERED | `.planning/research/ARCHITECTURE.md:161`, verbatim again at `.planning/research/SUMMARY.md:58` | `git grep -q -F "do not assume it is pluggable" -- .planning/research/ARCHITECTURE.md` | +| Decision 2 | Write-trust is an allowlist (configured replaces default), default-deny, no denylist | COVERED | `.planning/PROJECT.md` Key Decisions row | `git grep -q -F "Write-trust = allowlist-only" -- .planning/PROJECT.md` | +| Decision 2 | The sync gate is a separate, narrower predicate, not the write allowlist | COVERED | `.planning/PROJECT.md` Key Decisions row | `git grep -q -F "Sync gate = a separate predicate" -- .planning/PROJECT.md` | +| Decision 2 | The backstop is inferred from the `GITHUB_SERVER_URL` host, fail-closed on GHES | COVERED | `.planning/PROJECT.md` | `git grep -q -F "GITHUB_SERVER_URL" -- .planning/PROJECT.md` | +| Decision 2 | The Data-Residency `*.ghe.com` suffix is admitted alongside github.com | COVERED | `.planning/PROJECT.md` | `git grep -q -F "ghe.com" -- .planning/PROJECT.md` | +| Decision 2 | PR scope is activity-type dependent - a `[closed]` run gets base scope, so a blocked PR write is a benign no-op | COVERED | Shipped source `packages/github-cache/src/backend/actions-cache-backend.ts` (and its spec) | `git grep -q -F "base-scope" -- packages/github-cache/src/backend/actions-cache-backend.ts` | +| Decision 3 | Reader / cross-context adapter is LOCKED = GitHub Releases | COVERED | `.planning/PROJECT.md` Key Decisions row | `git grep -q -F "Reader / cross-context adapter: **GitHub Releases**" -- .planning/PROJECT.md` | +| Decision 3 | GHCR's >5000-download undeletable wall is a public poison-remediation gap | COVERED | `.planning/PROJECT.md` | `git grep -q -F "5000" -- .planning/PROJECT.md` | +| Decision 3 | The 1000-asset-per-release cap is a Releases-side scaling constraint | COVERED | `.planning/PROJECT.md` | `git grep -q -F "1000-asset" -- .planning/PROJECT.md` | +| Decision 3 | The cap is handled by a month-shard model | COVERED | `.planning/PROJECT.md` | `git grep -q -F "month-shard" -- .planning/PROJECT.md` | +| Decision 3 | The ~2 GiB per-asset ceiling collides with the 2 GB body cap | COVERED | `.planning/MILESTONES.md:28` (the publish-engine prose, deliberately NOT PROJECT.md's Validated checklist tick) | `git grep -q -F "2 GiB" -- .planning/MILESTONES.md` | +| Decision 3 | GHCR is revisited only when Docker (FOUND-03) and cosign (PROV-01) graduate together | COVERED | `.planning/PROJECT.md` | `git grep -q -F "PROV-01" -- .planning/PROJECT.md` | +| Decision 3 | Adopting a second store later is additive, not a switch - the pick is reversible | COVERED | `.planning/PROJECT.md` | `git grep -q -F "additive" -- .planning/PROJECT.md` | +| Decision 5 | A stateful LRU manifest is out of scope (mutable retention state is security-negative) | COVERED | `.planning/PROJECT.md` Key Decisions row | `git grep -q -F "no LRU manifest" -- .planning/PROJECT.md` | +| Decision 5 | Age-based cleanup is the mandatory floor, driven by `CACHE_MIRROR_MAX_AGE_DAYS` | COVERED | `.planning/PROJECT.md` | `git grep -q -F "CACHE_MIRROR_MAX_AGE_DAYS" -- .planning/PROJECT.md` | +| Decision 5 | LRU is native on the CI tier; the read-only tier is age-only | COVERED | `.planning/PROJECT.md` | `git grep -q -F "age-only" -- .planning/PROJECT.md` | +| Decision 5 | Retention is one coupled setting - never introduce a second knob | COVERED | Six homes: `.planning/PROJECT.md:115`, `.planning/RETROSPECTIVE.md:31`, `.planning/research/ARCHITECTURE.md:132,227`, `docs/trust-and-security.md:116`, shipped code `packages/github-cache/src/lib/retention.ts:5` | `git grep -q -F "never introduce a second knob" -- .planning/PROJECT.md` | +| Decision 6 | Both branches are sanctioned - OS-namespace the store OR documented consumer OS-discrimination | COVERED | `.planning/PROJECT.md` Key Decisions row (the CORR-01 either/or row) | `git grep -q -F "or documented consumer OS-discrimination" -- .planning/PROJECT.md` | +| Decision 6 | A cross-OS hit is a wrong result, not a MISS - a Core Value violation the CREEP controls do not cover | COVERED | `.planning/PROJECT.md` Key Decisions row | `git grep -q -F "wrong result" -- .planning/PROJECT.md` | +| Consequences | The FOUND-01 spike verdict favours Releases | COVERED | `.planning/spikes/MANIFEST.md` verdict column CONTENT (not merely `test -f` on the file) | `git grep -q -F "strongest pro-Releases dim" -- .planning/spikes/MANIFEST.md` | +| Consequences | The CI sidecar is served by the GA Actions background-step pattern, not a `services:` container | COVERED | `docs/advanced.md` | `git grep -q -F "background-step" -- docs/advanced.md` | +| Consequences | A composite action cannot declare `background:` internally, so the consumption action stays a JS action | COVERED | `docs/advanced.md` | `git grep -q -F "composite" -- docs/advanced.md` | +| Consequences | `services:` is Linux-hosted-only, which is why it does not serve the cross-OS case | COVERED | `.planning/codebase/CONCERNS.md` | `git grep -q -F "services:" -- .planning/codebase/CONCERNS.md` | +| Consequences | Docker's residual niche is genuinely hermetic / non-Node CI, a later-milestone item | COVERED | `.planning/codebase/CONCERNS.md` | `git grep -q -F "hermetic" -- .planning/codebase/CONCERNS.md` | +| Consequences | Docs must show the background-step pattern with an explicit teardown, because the runner runs an implicit `wait-all` | COVERED | `docs/examples/minimal-ci.yml` | `git grep -q -F "wait-all" -- docs/examples/minimal-ci.yml` | +| Consequences | `serve` must drain gracefully on `SIGTERM` or a RW job loses its last writes at teardown | COVERED | Shipped source `packages/github-cache/src/serve.ts` | `git grep -q -F "SIGTERM" -- packages/github-cache/src/serve.ts` | +| Consequences | Governance requires a vulnerability-disclosure policy | COVERED | Shipped `SECURITY.md` at the repo root | `test -f SECURITY.md` | +| Consequences | Governance requires a licence | COVERED | Shipped `LICENSE` at the repo root | `test -f LICENSE` | +| Consequences | Governance requires a versioned consumer contract / semver statement | COVERED | `docs/versioning.md` | `git grep -q -F "semver" -- docs/versioning.md` | +| Decision 1 (residue) | The D1 YAGNI deferral list - synchronous write fan-out and multiple simultaneous stores | RESIDUE | none (retained under `## Residual notes`) | `NEG("synchronous write fan-out")` and `NEG("multiple simultaneous stores")` both exit non-zero; substance probes `NEG("second store")` also zero. See Note A | +| Decision 2 (residue) | GHES anti-spoofing cross-check - absence of `/meta` `installed_version` plus the `X-GitHub-Enterprise-Version` header, and the dormant version-gate knob held OFF until a GHES floor publishes | RESIDUE | none (retained under `## Residual notes`) | `NEG("installed_version")`, `NEG("X-GitHub-Enterprise-Version")` and `NEG("/meta")` all exit non-zero. See Note B | +| Decision 3 (residue) | Read-time integrity is store-and-verify a published `content-sha256`, explicitly NOT `sha256(blob) == {hash}` because the Nx key hashes task inputs | RESIDUE | none (retained under `## Residual notes`) | `NEG("content-sha256")` exits non-zero; substance probes `NEG("store-and-verify")`, `NEG("read-time integrity")`, `NEG("hashes task")` all exit non-zero | +| Decision 3 (residue) | Rejection rationale - git-native is out for clone bloat and no clean eviction; Actions build artifacts are out for not being content-keyed | RESIDUE | none (retained under `## Residual notes`) | `NEG("clone bloat")` and `NEG("content-keyed")` exit non-zero. CONTRADICTED at claim level - see Note C | +| Decision 3 (residue) | CREEP-orthogonality scope check - the reader choice does not move the primary threat; it is a remediation win, not a CREEP-prevention one | RESIDUE | none (retained under `## Residual notes`) | `NEG("orthogonal")` exits non-zero; substance probes `NEG("remediation win")`, `NEG("primary threat")`, `NEG("does not move the primary threat")` all exit non-zero | +| Nx contract (residue) | The Nx client hardens tarball extraction against `..`/absolute/symlink/hardlink escape, so a malicious server cannot `zip-slip` the client - inherited protection | RESIDUE | none (retained under `## Residual notes`) | `NEG("zip-slip")`, `NEG("hardlink")`, `NEG("tarball extraction")`, `NEG("symlink escape")` all exit non-zero. See Note D | +| Consequences (residue) | Residual risk - CREEP containment is single-layer at the write/sync gates, so gate correctness is load-bearing with no backstop | RESIDUE | none (retained under `## Residual notes`) | `NEG("single-layer")`, `NEG("no backstop")`, `NEG("second layer")` all exit non-zero. See Note E | +| References (residue) | The twelve-source bibliography as a block - a single reference token cannot prove a twelve-item list, so it is retained wholesale | RESIDUE | none (retained byte-identical as `## References`) | `NEG("Cacheract")`, `NEG("sccache")`, `NEG("HeroDevs")`, `NEG("CodeQL")`, `NEG("nixcache-oci")`, `NEG("tag mutability")`, `NEG("nx.app/files")` and `NEG("bazel-remote")` all exit non-zero | + +**Row count:** 44 (36 COVERED/SPENT + 8 RESIDUE). No row carries any other disposition. + +The Nx contract section is deliberately absent from this battery except for its orphan +parenthetical: it is MOVED, not removed, and its assertion is Task 2's post-move check. + +## Notes on the residue probes + +**Note A - the D1 deferral list was already narrowed by the plan, and that narrowing holds.** +The third deferred item, a local read-write store, is COVERED at `PROJECT.md ## Out of Scope:104` +("Local read-write mode - by design local is read-only only") and is therefore NOT retained. The +nearest hits for "multi-store" (`PROJECT.md:92`, `research/ARCHITECTURE.md:167`) are the GHCR-01 +later-milestone ADDITIVE trigger, a different claim from "the port design defers simultaneous +stores as YAGNI"; adjacent, not equivalent. `synchronous write fan-out` is homeless outright - the +six "fan-out" hits in the tree are all about READ shard-walk API fan-out, an unrelated concept. + +**Note B - the dormant version-gate knob has partial coverage the plan did not measure.** +`research/PITFALLS.md:310` reads "keep the version-gate knob dormant/OFF", and +`docs/trust-and-security.md:76` carries the github.com-only backstop plus the GHES floor. So the +"knob stays OFF until a GHES floor publishes" sub-clause is arguably COVERED. The anti-spoofing +CROSS-CHECK MECHANISM itself - probing for the absence of `/meta` `installed_version` and the +`X-GitHub-Enterprise-Version` response header - is homeless everywhere, and that mechanism is the +distinctive claim. The row is retained whole rather than split, because splitting a two-clause +sentence to delete half of it buys nothing and risks losing the mechanism's motivation. + +**Note C - MEASURED DEVIATION FROM THE PLAN'S PREMISE. This row's claim IS covered.** +The plan asserts (Task 1, residue item 4) that "The MANIFEST records THAT they are out, not WHY". +That is true of `spikes/MANIFEST.md:7`, but the plan did not probe `research/STACK.md`, which +records both rejections WITH their reasons in its storage-primitive comparison table: + +- `.planning/research/STACK.md:77` - git objects / refs: "Repo-bloating; no per-object expiry ... + **Reject** - bloats history, no clean eviction". The phrase "no clean eviction" is VERBATIM the + ADR's. +- `.planning/research/STACK.md:75` - Actions Artifacts: "No (run-scoped, not key-scoped) ... + **Reject** - no anon read, wrong lookup shape", which is "not content-keyed" in substance plus + one further reason. + +The plan's own literal token probes (`clone bloat`, `content-keyed`) return homeless, but that is +exactly the phrasing accident the plan warns against - the probe was built from the sentence being +deleted. At CLAIM level this row is COVERED at `research/STACK.md:75,77`. + +**It is nonetheless RETAINED**, for three reasons, and the choice is recorded here rather than +silently taken. First, the plan's governing criterion is that retention is the DEFAULT and +deletion is the action requiring proof; retaining something that turns out to have a home is the +LOSSLESS error, whereas deleting on a contested measurement is not. Second, Task 2's gate +requires the `content-keyed` token to be present in the slimmed file, and this executor is bound +to run gates verbatim rather than edit them to match a fresh finding. Third, unlike the two +claims the plan DID reclassify from residue to covered (the "publisher is pluggable" warning and +the retention "no second knob" invariant), this is a dead-end historical rationale for primitives +that were never built, not a live invariant - so a second copy carries essentially no drift risk, +which is the specific harm this task exists to remove. Carried to the SUMMARY as a deferred +follow-up. + +**Note D - the only tree hits for this claim are generated output, and they are a different +subject.** `path traversal` matches three times inside `start-cache-server/index.js` (a 2.4 MB +vendored action bundle, generated), in a fixture table describing hostile PATH inputs to this +project's own server. That is neither a canonical home nor the same claim as the Nx CLIENT's +inherited tarball-extraction hardening. The claim is homeless in `.planning/`, `docs/` and +`packages/` source. + +**Note E - the two "residual risk" hits are a different risk.** `REQUIREMENTS.md:329` and +`ROADMAP.md:509` both record the TRUST-11 executor-portability residual risk, unrelated to +single-layer CREEP containment. The containment claim itself is homeless. + +## Two claims the plan reclassified out of residue, confirmed COVERED and DELETED + +Both were measured, not assumed, and both are deleted rather than retained because retaining +either would rebuild the two-sources-of-truth pair this task exists to remove: + +1. **"Do not assume the publisher is pluggable"** - `research/ARCHITECTURE.md:161`, verbatim again + at `research/SUMMARY.md:58`. +2. **The retention "never introduce a second knob" invariant** - six homes, including + `PROJECT.md:115` and SHIPPED CODE at `packages/github-cache/src/lib/retention.ts:5`. A doc-only + copy drifting away from executable code is the worst case of the pair. From 45dd0f4b2b58eee34569983d0387bd1df53a18db Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 21:55:49 +0200 Subject: [PATCH 007/511] docs(quick-260726-pjz): slim the ADR to the C1-C18 ledger and move the Nx contract The file was ~74 percent duplicate, and the duplication had already produced measurable drift: its cross-OS section read "Default to OS-namespacing the store" with no supersession note while PROJECT.md marked the same decision SUPERSEDED in v0.0.2. Two sources of truth, already disagreeing. Deleting the duplicate removes the drift surface. Removed whole (each claim proven redundant first, see COVERAGE.md): the spike/PoC framing (spent - the rebuild happened), and the one-backend-per- process, write-trust, storage-primitive, retention and cross-OS sections, plus the consequences/spike-scope block. Deletion rather than annotation: the cross-OS section IS the drift instance, so annotating it would patch one of two disagreeing copies instead of removing the disagreement. Kept, because the criterion is "keep only what has no canonical home": - the C1-C18 ledger, BYTE-IDENTICAL. The 18 rows still hash to bb8cd951..d1ce2f and the CVE preamble and bibliography are spliced in unmodified, not retyped. This is a documentation reorganisation, not a security-posture change. - a new ## Residual notes section carrying the seven claims measured homeless - the YAGNI port deferrals, the GHES anti-spoofing cross-check, read-time content-sha256 integrity, the rejected-primitive rationale, the CREEP orthogonality scope check, the Nx client's inherited zip-slip protection, and the single-layer containment risk. - ## References wholesale: a single token cannot prove a twelve-item bibliography, and none of its sources appears in any other tracked file. Two claims previously assumed homeless are deleted instead, both measured covered: "do not assume the publisher is pluggable" (research/ARCHITECTURE.md :161, verbatim at SUMMARY.md:58) and the retention "no second knob" invariant (six homes, including shipped code at src/lib/retention.ts:5). Retaining either would have rebuilt the two-sources-of-truth pair this change removes. The file keeps its name so the two out-of-tree references keep resolving, and it no longer names itself, so the inbound count is unaffected. Restructured for readability now that one section remains: a pointer block naming where each extracted piece went, the review cadence, and why a project-level control register has no canonical GSD home (it is a Standing Reference Artifact; it trips gsd health W019 before and after, and W019 does not apply here). PROJECT.md gains an **Nx contract** Constraints row carrying the operative hard floor - the Nx client matches PUT success strictly as 200 - plus a pointer to research/STACK.md section 1. Deliberately NOT a copy of the contract table: every fact already lives there, so a full copy would be a third one. The move still stands because ## Constraints is audited by complete-milestone checklist item 7 and research/STACK.md is audited by nothing. The Key Decisions pointer row now names what the file actually holds and states the cadence. The cadence is attached there, NOT to ## Evolution: neither transition nor complete-milestone reads that section - both carry hardcoded checklists - so a line there would be the exact inert artifact this change exists to fix. --- .planning/ARCHITECTURE-DECISION.md | 148 +++++++++++++++++------------ .planning/PROJECT.md | 3 +- 2 files changed, 89 insertions(+), 62 deletions(-) diff --git a/.planning/ARCHITECTURE-DECISION.md b/.planning/ARCHITECTURE-DECISION.md index 1692b697..2d78016b 100644 --- a/.planning/ARCHITECTURE-DECISION.md +++ b/.planning/ARCHITECTURE-DECISION.md @@ -1,49 +1,54 @@ -# 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 +# Architecture Decision Record: 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. +**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. + +## 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. @@ -68,24 +73,45 @@ CVE-2025-36852 (CVSS 9.4, CWE-829, GHSA-rrr2-jcr8-7q3x, no patched version): poi | 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. +## 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). +- **Why two storage primitives were rejected outright.** git-native storage (a cache branch or + LFS) is out for clone bloat and the absence of clean eviction; Actions build artifacts are out + because they are not `content-keyed` - they are run-scoped. (Both rejections are also recorded, + with the same reasons, in `.planning/research/STACK.md` section 2's primitive-comparison table, + which is the fuller treatment.) +- **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 against + `..`, absolute-path, symlink and `hardlink` escape, so a malicious cache server cannot + `zip-slip` the client. This project inherits that protection rather than implementing it. It is + worth a note in the consumer trust docs; that note has not been written. +- **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`. ## 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.** +*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/PROJECT.md b/.planning/PROJECT.md index d288be7c..953bff41 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -130,6 +130,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). @@ -140,7 +141,7 @@ 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/ARCHITECTURE-DECISION.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) | From 27e2cb6e1b3d4943069ba10ced9b8e610b1d66cf Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 21:58:48 +0200 Subject: [PATCH 008/511] docs(quick-260726-pjz): re-point the nine inbound references at what the ADR now holds Re-described in place. No pointer added, none deleted: the set of current .planning/ files referencing the ledger is still exactly the same nine, and the gate asserts the sorted FILE LIST rather than a count - a count of 9 also passes a simultaneous +1/-1 (REQUIREMENTS.md gaining the filename while STACK.md loses it) which would look clean while violating the constraint. Seven live sites cited a Decision number that no longer exists. Each moves to the surviving canonical home rather than to a dangling section: - PROJECT.md:80 and :154, REQUIREMENTS.md:59 and ROADMAP.md:541 all credited the cross-OS alternative branch to the deleted section. That authority already lives in PROJECT.md's own Key Decisions table - the CORR-01 row literally reads "or documented consumer OS-discrimination" - so the citations move there. They lose a pointer, not their authority. REQUIREMENTS.md deliberately does NOT gain the ADR filename, which would make the inbound set ten. - spikes/MANIFEST.md:13 keeps the ledger half of its canonical scope and moves the reader half to Key Decisions; :30 drops the dangling half of "(CORR-01 / Decision 6)", since CORR-01 alone is the durable identifier. - research/ARCHITECTURE.md:157 re-points and keeps its existing spike citation. Four more sites described the file as holding decisions it no longer holds - ROADMAP.md:13, STATE.md:95, the header blocks of research/ARCHITECTURE.md, research/STACK.md and research/FEATURES.md, FEATURES.md's bare-path entry in the locked-foundation list, and codebase/CONCERNS.md's deferred-trigger preamble. Each now attributes the decisions to PROJECT.md Key Decisions and the ADR to the control ledger. Research findings themselves are untouched; only the attribution changed. Deliberately NOT touched: the sealed spike record spikes/005-cross-os-roundtrip/ (README.md:5,16 and ci-roundtrip.mjs:4). Those three cite the decision number as the thing the spike was RUN TO VALIDATE, so rewriting them would falsify the evidence - the same rationale that keeps archived milestone artifacts out of scope. The gate excludes that directory by path and asserts it stays unmodified. Both out-of-tree references still resolve, and no file under packages/ is touched. --- .planning/PROJECT.md | 6 +++--- .planning/REQUIREMENTS.md | 2 +- .planning/ROADMAP.md | 9 +++++---- .planning/STATE.md | 2 +- .planning/codebase/CONCERNS.md | 8 +++++--- .planning/research/ARCHITECTURE.md | 7 ++++--- .planning/research/FEATURES.md | 5 +++-- .planning/research/STACK.md | 3 ++- .planning/spikes/MANIFEST.md | 5 +++-- 9 files changed, 27 insertions(+), 20 deletions(-) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 953bff41..05eaf685 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -77,8 +77,8 @@ Shipped and verified in **v0.0.1 Greenfield MVP Rebuild** (all 7 phases verified `.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 ADR Decision 6's documented-consumer- - discrimination alternative + "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) - [ ] Nx task-hash parity for `build`/`typecheck`/`test` across Windows and Linux, root-caused before it is fixed (PARITY-01..04) @@ -151,7 +151,7 @@ Later-milestone revisit triggers carried out of v0.0.1 (re-evaluate together per | **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 | ADR Decision 6 sanctions both branches; 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: 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) | diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 7758f05f..b9a4b26f 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -56,7 +56,7 @@ cache, and the public-repo exposure surface, are not deferred. | ID | Decision | Basis | |----|----------|-------| -| D2-01 | The store is OS-INVARIANT; OS discrimination lives exclusively in the declared Nx input | ADR Decision 6's documented alternative branch | +| 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 | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2808fd41..472d6ace 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -10,7 +10,8 @@ Foundations are LOCKED (grounding, not phase work): reader = GitHub Releases (FO 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; Nx PUT floor = hard `200`/Nx-21+; distribution = npm package + JS Action, Docker -deferred (FOUND-03). Decision record + CREEP control ledger C1-C18: +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/ARCHITECTURE-DECISION.md`. **v0.0.2 supersedes one locked decision.** CORR-01 was an either/or -- "OS-namespace the store @@ -537,7 +538,7 @@ Carried from REQUIREMENTS.md, listed so no phase picks them up: --- *v0.0.2 roadmap created 2026-07-26 from `.planning/REQUIREMENTS.md` (43 requirements, revised -after adversarial review by five independent critics), `.planning/PROJECT.md` (O1-O4 and the -mandatory ordering), and `.planning/ARCHITECTURE-DECISION.md` (Decision 6's documented -alternative branch, which D2-01 now takes). Granularity: standard (6 phases). Phase numbering +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 b31bdd3c..54335787 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -92,7 +92,7 @@ Last activity: 2026-07-26 - v0.0.2 roadmap created (Phases 7-12, 43/43 requireme ### 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/ARCHITECTURE-DECISION.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). diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 05b383bb..26d3b824 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -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/ARCHITECTURE-DECISION.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 diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index 4f7ebb03..a5cb67d7 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -5,9 +5,10 @@ **Confidence:** HIGH for the trigger/token/permission model (section 1) and testability seams (section 4); MEDIUM for recency/LRU retention (section 2, hinges on `download_count` semantics); HIGH-on-conclusion / MEDIUM-on-specifics for storage-primitive choice (section 3). > This research informs building the cache from scratch on the LOCKED foundation -> (`.planning/ARCHITECTURE-DECISION.md`): one `CacheBackend` read port, a context-derived +> (`.planning/PROJECT.md` `## Key Decisions`, with the CREEP controls in +> `.planning/ARCHITECTURE-DECISION.md`): one `CacheBackend` read port, a context-derived > `selectBackend`, a conservative write-trust gate, a separate `{push, schedule}` sync gate, -> and reader = GitHub Releases (FOUND-01). It does NOT re-derive the ADR. It answers how the +> and reader = GitHub Releases (FOUND-01). It does NOT re-derive them. It answers how the > domain capabilities are built against that architecture, validated against GitHub's 2026-06-26 > cache-token model and comparable systems. Phase structure lives in `.planning/ROADMAP.md`. @@ -154,7 +155,7 @@ a possible later-milestone spike gated on the `download_count` signal proving re ### Decision: reader = GitHub Releases is LOCKED -FOUND-01 is resolved: the cross-context reader is **GitHub Releases** (`.planning/ARCHITECTURE-DECISION.md` Decision 3; FOUND-01 spike `.planning/spikes/001-005`). The two-backend split maps cleanly onto the two access patterns - CI needs authenticated RW with native eviction (Actions cache); local needs an anonymous keyed read path (Releases). The GHCR/OCI registry was the one serious alternative and was validated in the spike; it is deferred to the **later-milestone GHCR revisit trigger (GHCR-01)**, to be re-evaluated only when the Docker container form and cosign provenance (PROV-01) graduate together. Do not build a runtime store-selection framework; build the one locked reader. +FOUND-01 is resolved: the cross-context reader is **GitHub Releases** (`.planning/PROJECT.md` `## Key Decisions` - the locked reader / cross-context adapter row; FOUND-01 spike `.planning/spikes/001-005`). The two-backend split maps cleanly onto the two access patterns - CI needs authenticated RW with native eviction (Actions cache); local needs an anonymous keyed read path (Releases). The GHCR/OCI registry was the one serious alternative and was validated in the spike; it is deferred to the **later-milestone GHCR revisit trigger (GHCR-01)**, to be re-evaluated only when the Docker container form and cosign provenance (PROV-01) graduate together. Do not build a runtime store-selection framework; build the one locked reader. ### How the read port isolates a future store change diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md index e416eeca..8d5102a2 100644 --- a/.planning/research/FEATURES.md +++ b/.planning/research/FEATURES.md @@ -6,7 +6,8 @@ > Greenfield note: this maps the *feature landscape* of Nx remote caches so the v0.0.1 capability set > can be categorized as the system is built from scratch on the LOCKED foundation (reader = GitHub -> Releases; `.planning/ARCHITECTURE-DECISION.md`). It is not a plan - phase structure lives in +> Releases; `.planning/PROJECT.md` `## Key Decisions`, with the CREEP controls in +> `.planning/ARCHITECTURE-DECISION.md`). It is not a plan - phase structure lives in > `.planning/ROADMAP.md`. The v0.0.1 capabilities are mapped to requirement IDs in the last section. ## Feature Landscape @@ -206,7 +207,7 @@ Category: **table stakes for adoption** (the value prop is wasted if external pr - `nx-remotecache-azure` / `nx-remotecache-custom` (independent `read`/`write` flags, Azure lifecycle retention): https://github.com/NiklasPor/nx-remotecache-azure - HIGH - "Exploring Nx self-hosted cache" community write-up (ecosystem history, deprecation, CVE framing): https://emilyxiong.medium.com/exploring-of-nx-self-hosted-cache-5bc39bd2ed7f - MEDIUM - CVE-2025-36852 (CREEP) background: https://nx.dev/blog/cve-2025-36852-critical-cache-poisoning-vulnerability-creep - HIGH -- Locked foundation: `.planning/ARCHITECTURE-DECISION.md`, `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, FOUND-01 reader spike `.planning/spikes/001-005` +- Locked foundation: `.planning/ARCHITECTURE-DECISION.md` (CREEP control ledger C1-C18), `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, FOUND-01 reader spike `.planning/spikes/001-005` - `npmx.dev/package/@nx/azure-cache` - config surface covered via the 21.nx.dev overview instead (page did not render through the fetch chain; not load-bearing). --- diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md index 73814855..0cb4ec56 100644 --- a/.planning/research/STACK.md +++ b/.planning/research/STACK.md @@ -6,7 +6,8 @@ > Greenfield note: this pins the contract/versions the build must target and confirms the storage > primitives are the right ones to build on in 2026. The storage model is LOCKED -> (`.planning/ARCHITECTURE-DECISION.md`): Actions cache = CI RW default; GitHub Releases = the +> (`.planning/PROJECT.md` `## Key Decisions`, with the CREEP controls in +> `.planning/ARCHITECTURE-DECISION.md`): Actions cache = CI RW default; GitHub Releases = the > cross-context reader (FOUND-01). This file grounds those choices; it does not re-open them. --- diff --git a/.planning/spikes/MANIFEST.md b/.planning/spikes/MANIFEST.md index 1bcaa65a..34ca1a8b 100644 --- a/.planning/spikes/MANIFEST.md +++ b/.planning/spikes/MANIFEST.md @@ -10,7 +10,8 @@ paper unknowns (GHCR atomic create-if-absent unavailable/low-severity; write-tru host-detected fail-closed; Nx PUT floor a hard 200) are resolved and must NOT be reopened. This spike produces a **symmetric** operational + security failure ledger for both readers. -Canonical scope: `.planning/ARCHITECTURE-DECISION.md` (Decision 3 + control ledger C1-C18) +Canonical scope: `.planning/ARCHITECTURE-DECISION.md` (the CREEP control ledger C1-C18), +`.planning/PROJECT.md` `## Key Decisions` (the locked reader / cross-context adapter row) and `.planning/REQUIREMENTS.md` (FOUND-01 rubric). ## Requirements @@ -27,7 +28,7 @@ Symmetric forward-merits rubric (from FOUND-01): - Per-primitive size ceiling (ROBUST-02) vs the 2 GB body cap - Poison-remediation capability - Docker-distribution synergy -- Cross-OS round-trip: a cross-OS hit must never serve a wrong-OS artifact (CORR-01 / Decision 6) +- Cross-OS round-trip: a cross-OS hit must never serve a wrong-OS artifact (CORR-01) GHCR-side burdens: >5000-download-undeletable wall; untagged child-manifest cleanup; mutable tags -> pull-by-digest; cleanup credential (classic PAT for org-owned/unlinked). From b9015508c403ffb0456cf423e4c36f5233672961 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 22:16:46 +0200 Subject: [PATCH 009/511] docs(quick-260726-pjz): extract and deduplicate ARCHITECTURE-DECISION.md into canonical GSD artifacts --- .planning/PROJECT.md | 8 +- .planning/STATE.md | 3 +- .../260726-pjz-CONTEXT.md | 169 ++++++ .../260726-pjz-PLAN.md | 564 ++++++++++++++++++ .../260726-pjz-RESEARCH.md | 382 ++++++++++++ .../260726-pjz-SUMMARY.md | 311 ++++++++++ .../260726-pjz-VERIFICATION.md | 202 +++++++ 7 files changed, 1635 insertions(+), 4 deletions(-) create mode 100644 .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-CONTEXT.md create mode 100644 .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-PLAN.md create mode 100644 .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-RESEARCH.md create mode 100644 .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-SUMMARY.md create mode 100644 .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-VERIFICATION.md diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 05eaf685..4bd31cc0 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -81,10 +81,12 @@ Shipped and verified in **v0.0.1 Greenfield MVP Rebuild** (all 7 phases verified 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) - [ ] Nx task-hash parity for `build`/`typecheck`/`test` across Windows and Linux, root-caused - before it is fixed (PARITY-01..04) + before it is fixed (PARITY-01..05) - [ ] The `@actions/cache` archive path becomes a deliberate OS-invariant constant instead of an - inherited `os.tmpdir()` value, with `enableCrossOsArchive` hardcoded (VER-01..04) -- [ ] Live cross-OS proofs for O1-O4 in the mandated order (READ-01/02, CI-01/02, TEST-06/07) + inherited `os.tmpdir()` value, with `enableCrossOsArchive` hardcoded (VER-01..06) +- [ ] ESLint adopted, with the ambient-platform-read ban enforced in unit specs and allowed in + integration specs (LINT-01..06, CORR-06) +- [ ] 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): diff --git a/.planning/STATE.md b/.planning/STATE.md index 54335787..3bad8c7e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ Phase: 7 - Lint Toolchain and the Ambient-Platform-Read Ban (not started) Plan: none yet Status: Roadmap created, awaiting `/gsd:plan-phase 7` Progress: 0/6 phases complete [------] 0% -Last activity: 2026-07-26 - v0.0.2 roadmap created (Phases 7-12, 43/43 requirements mapped) +Last activity: 2026-07-26 - Completed quick task 260726-pjz: extracted and deduplicated ARCHITECTURE-DECISION.md into canonical GSD artifacts (content -43% by bytes; C1-C18 ledger byte-identical and retained; verifier human_needed, 0 blockers) ## Performance Metrics @@ -178,6 +178,7 @@ 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/ARCHITECTURE-DECISION.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 (out of scope by scope discipline, not merit; `THREAT-MODEL.md`/`CONTROLS.md` trip `gsd health` W019 identically, so the rename gains nothing there). `.planning/milestones/`, `.planning/spikes/005-cross-os-roundtrip/` and all of `packages/` verified untouched. OPEN (verifier `human_needed`, 0 blockers): residue item 4 (git-native / build-artifact rejection rationale) is in fact covered at `research/STACK.md:75,77` verbatim and was retained anyway because Task 2's gate hard-requires its token -- one labelled duplicate that violates the file's own criterion; four minor removed sentences have neither a coverage row nor a home. | 2026-07-26 | 4699232..27e2cb6 | Needs Review | [260726-pjz-audit-triage-extract-and-deduplicate-arc](./quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/) | ## Deferred Items diff --git a/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-CONTEXT.md b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-CONTEXT.md new file mode 100644 index 00000000..ae76025b --- /dev/null +++ b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-CONTEXT.md @@ -0,0 +1,169 @@ +# Quick Task 260726-pjz: Extract and deduplicate ARCHITECTURE-DECISION.md - Context + +**Gathered:** 2026-07-26 +**Status:** Ready for planning +**Mode:** --full --auto (gray areas auto-resolved; one deliberately NOT auto-locked, see below) + + +## Task Boundary + +Audit, triage, extract and deduplicate the contents of the custom +`.planning/ARCHITECTURE-DECISION.md` into canonical GSD artifacts. Choose the most natural home +for each remaining part, removing anything irrelevant or stale. Add intentional +progressive-disclosure links from top-level canonical `.planning/` artifacts, and optionally +`AGENTS.md` if relevant. + +IN scope: content triage, extraction, deduplication, removal of stale content, inbound links. +OUT of scope: renaming the file (see Decision 3), rewriting archived milestone artifacts +(Decision 4), and any change to the CREEP controls themselves (this is a documentation +reorganisation, not a security-posture change). + + + + +## Implementation Decisions + +### 1. What counts as "canonical" + +GSD-SHIPPED artifacts are canonical: `PROJECT.md`, `STATE.md`, `ROADMAP.md`, `REQUIREMENTS.md`, +`MILESTONES.md`, `RETROSPECTIVE.md`, `research/*` (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY +all ship as templates), and `codebase/*` (gsd-codebase-mapper output). + +`ARCHITECTURE-DECISION.md` is the ONLY project invention under `.planning/`. + +CORRECTED BY RESEARCH: `spikes/` is CANONICAL GSD, not an invention -- 9 gsd-core references, it +is documented in `artifact-types.md:75-79`, written by `/gsd:spike`, and `next.md:202` scans +`spikes/*/README.md` for `verdict: PENDING`. This repo's layout matches the shipped one exactly. + +VERIFIED (with a caveat that matters): `rg` over the GSD install returns ZERO references to +`ARCHITECTURE-DECISION` in any workflow, agent or template. The FIRST pass was unsound -- +`~/.claude/gsd-core` is a SYMLINK and `rg` does not traverse it without `-L`, so it returned zero +hits for everything, which looks identical to a clean confirmation. Every negative was re-run +against the resolved path with a positive control. Any future check of the GSD install must use +`-L` or the resolved path. + +USEFUL RECATEGORISATION: GSD documents a "Standing Reference Artifact" category (see +`.planning/METHODOLOGY.md` in the GSD taxonomy) -- project-scoped, `.planning/`-root, consumed by +workflows. That is the category this file belongs to, which is a better frame than "invention". + +### 2. Disposition per section (from the pre-planning audit) + +The file is 91 lines. Roughly 74 percent is already duplicated into canonical artifacts or is +spent history; ~26 percent (the C1-C18 control ledger) has no canonical home. + +| Section | Lines | Disposition | +|---------|-------|-------------| +| Framing (spike/PoC, sunk cost zero) | 3 | REMOVE - spent; the rebuild happened, MILESTONES.md carries v0.0.1 | +| Nx contract (fixed constraint) | 5 | MOVE to `PROJECT.md ## Constraints` | +| Decision 1 - one backend per process | 9 | REMOVE - already a PROJECT.md Key Decisions row | +| Decision 2 - write-trust + sync gate | 5 | REMOVE - already two Key Decisions rows | +| Decision 3 - Releases LOCKED | 12 | REMOVE - Key Decisions row + evidence in `.planning/spikes/001-005` | +| **Decision 4 - C1-C18 control ledger** | **24** | **KEEP** - no canonical home exists | +| Decision 5 - retention/LRU | 3 | REMOVE - already a Key Decisions row | +| Decision 6 - cross-OS correctness | 3 | REMOVE - already a Key Decisions row, and the one that DRIFTED | +| Consequences & spike scope | 6 | REMOVE - duplicated by `.planning/spikes/` READMEs | +| References | 5 | REMOVE - the corpus is already in `.planning/research/*` | + +**Why the ledger cannot be extracted:** GSD models security strictly per-phase (a +`` block in PLAN.md, a per-phase SECURITY.md). There is no project-level control +register. `PROJECT.md ## Constraints` is the wrong shape - constraints are limits, controls are +mitigations - and 18 rows would swamp the section. Fragmenting the ledger into per-phase threat +models orphans cross-cutting controls: C1 applies to every future phase, so it cannot live in +Phase 5's SECURITY.md. + +**Deletion, not annotation.** Decision 6 currently reads "Default to OS-namespacing the store" +with no supersession note and the file has ZERO mentions of v0.0.2, while PROJECT.md marks that +same decision `[WARN] SUPERSEDED in v0.0.2`. Two sources of truth, already disagreeing. Deleting +the duplicate removes the drift surface; annotating it would only patch one instance. + +### 3. Renaming the file - DELIBERATELY NOT AUTO-LOCKED + +A slimmed file containing only a control ledger makes "ARCHITECTURE-DECISION" a misnomer, and +`THREAT-MODEL.md` / `CONTROLS.md` would be more honest. + +This sits in the `--auto` TRAP QUADRANT: HIGH impact (9 current inbound references plus ~35 +archived artifacts, a shipped `docs/trust-and-security.md` citation, and a source comment in +`actions-cache-backend.ts`) and NOT-HIGH confidence (the task description says extract, +deduplicate and link - it does not authorise a rename). + +DECISION: do NOT rename in this task. Record it as a follow-up for an interactive decision. +Scope discipline resolves the trap quadrant here - the rename is simply not what was asked. + +### 4. Archived artifacts are NOT updated + +~35 archived v0.0.1 phase artifacts reference the ADR. They are historical records; rewriting +them would falsify history. Only CURRENT artifacts are updated. Archived references remain valid +because the file keeps its name (Decision 3). + +### 5. Progressive-disclosure links + +CORRECTED BY RESEARCH: **the file is NOT orphaned.** Nine current artifacts already link it, +including `STATE.md:95` and `ROADMAP.md`. So the work is to make the EXISTING links describe the +right thing, not to add missing ones -- more pointers would be motion, not progress. Reachability +was never the failure; the missing REVIEW CADENCE was (Decision 6). + +Follow the shape GSD already models for annotated references, from +`workflows/discuss-phase/templates/context.md:84`: +`` `path/to/adr-or-spec.md` - [What it decides/defines that's relevant] ``. The ANNOTATION is the +load-bearing half. Optionally borrow the `(updated )` suffix from `templates/state.md:25`, +whose documented purpose is to trigger a re-read when stale. There is no GSD convention named +"progressive disclosure" (zero hits install-wide), so follow the annotated-reference shape rather +than inventing one. + +`AGENTS.md` is agent-facing Nx/worktree guidance, not project architecture. A pointer there is +Claude's discretion: add one only if a natural anchor exists, and skip it rather than inventing a +section. + +### 6. Close the review gap + +The root cause of the drift is that nothing schedules a review of this file. + +CORRECTED BY RESEARCH -- the originally planned fix DOES NOT WORK. Adding the control ledger to +`PROJECT.md`'s `## Evolution` section buys nothing: neither `transition.md:190` nor +`complete-milestone.md:249` READS that section. Both carry hardcoded checklists, and `## Evolution` +is a TRANSCRIPT of those lists, not an input to them. Writing there would produce exactly the +inert artifact this task exists to fix. + +WORKING FIX: those hardcoded checklists DO audit `## Key Decisions` (item 6) and `## Constraints` +(item 7) at every milestone. So attach the cadence to the existing Key Decisions row at +`PROJECT.md:143`, which already points at the file -- that row is audited every milestone by a +checklist that actually runs. The Nx-contract move into `## Constraints` picks up cadence for free +via item 7. + +Guiding doctrine, quotable from GSD's own taxonomy: "A well-formatted artifact that no workflow +reads is inert -- the consumption mechanism is what gives an artifact meaning." + +KNOWN AND ACCEPTED: the file trips `gsd health` W019 today and will still trip it after slimming +(verified by executing `isCanonicalPlanningFile`). `THREAT-MODEL.md` and `CONTROLS.md` trip it +identically, so the deferred rename gains nothing on that axis. W019's remediation text ("Move to +archive or delete if stale") is wrong for this file -- GSD's own METHODOLOGY.md trips it too, so +the checker and the taxonomy disagree and the taxonomy is the better authority. Do NOT act on W019. + +### Claude's Discretion + +- Exact wording of the extracted Nx-contract constraint in `PROJECT.md ## Constraints`. +- Whether the slimmed file keeps its `## Decision 4` heading or is restructured now that it is + the only section (prefer restructuring for readability, without renaming the file). +- Whether an `AGENTS.md` pointer is warranted (Decision 5). + + + + +## Specific Ideas + +- The two references OUTSIDE `.planning/` must keep resolving: `docs/trust-and-security.md` and + the comment in `packages/github-cache/src/backend/actions-cache-backend.ts`. The docs drift + guard covers the former, so a mistake there fails a test rather than rotting silently. +- Verify no canonical artifact is left asserting something the extraction deleted. + + + + +## Canonical References + +- `.planning/ARCHITECTURE-DECISION.md` - the file under triage +- `.planning/PROJECT.md` - `## Constraints`, `## Key Decisions`, `## Evolution` +- `.planning/spikes/001-005` - already holds the FOUND-01 evidence Decision 3 summarises +- GSD templates at `~/.claude/gsd-core/templates/` - the canonical artifact set + + diff --git a/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-PLAN.md b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-PLAN.md new file mode 100644 index 00000000..ff701d05 --- /dev/null +++ b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-PLAN.md @@ -0,0 +1,564 @@ +--- +phase: quick/260726-pjz +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: true +requirements: [D-01, D-02, D-03, D-04, D-05, D-06] +files_modified: + - .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md + - .planning/ARCHITECTURE-DECISION.md + - .planning/PROJECT.md + - .planning/ROADMAP.md + - .planning/STATE.md + - .planning/REQUIREMENTS.md + - .planning/research/ARCHITECTURE.md + - .planning/research/FEATURES.md + - .planning/research/STACK.md + - .planning/codebase/CONCERNS.md + - .planning/spikes/MANIFEST.md + +must_haves: + truths: + - "Every DISTINCTIVE CLAIM of every removed section is classified before the removal edit + runs, by an executed grep - not asserted in prose - into exactly one of COVERED (proven + present in a named canonical artifact OUTSIDE the ADR), RESIDUE (proven absent everywhere + else, therefore RETAINED in the slimmed ADR), or SPENT (obsolete by a recorded event). + One term per claim; a term that lands on a shipped-requirements checklist tick is NOT + coverage - the tick records what shipped, not the reasoning being deleted. The proof is + recorded in 260726-pjz-COVERAGE.md and the removal is gated on it." + - "Retention is the DEFAULT for uncovered content, not an escape hatch. The KEEP criterion + is 'keep only what has no canonical home', so content with no home stays in the ADR under + `## Residual notes` and the whole `## References` block is RETAINED. Homelessness is probed + at CLAIM level against the LIVE tree - a probe that searches the ADR's own phrasing passes + by phrasing accident, and an archived `.planning/milestones/` hit is a historical record, + not a canonical home (D-04), so both are excluded. SEVEN residue claim items survive that + probe (each measured homeless): the D1 YAGNI deferrals reduced to `synchronous write + fan-out` + `multiple simultaneous stores` ONLY; the GHES anti-spoofing cross-check; the + read-time `content-sha256` integrity note; the git-native / Actions-build-artifact + rejection rationale; the CREEP-orthogonality scope check; the Nx-client tarball-extraction + (`zip-slip`/`hardlink`) inherited-protection note carried out of the MOVE section; and the + single-layer-containment residual risk. Plus the `## References` block as one more RESIDUE + row = EIGHT RESIDUE rows. Two claims previously mis-filed as residue are COVERED and are + DELETED, not retained: the 'do not assume it is pluggable' clause (research/ARCHITECTURE.md:161, + verbatim in research/SUMMARY.md:58) and the retention 'no second knob' invariant (six homes, + incl. PROJECT.md:115 and shipped code at packages/github-cache/src/lib/retention.ts:5) - + retaining either would recreate the two-sources-of-truth pair this task exists to delete." + - "Controls C1-C18 survive BYTE-IDENTICAL. This is a documentation reorganisation, not a + security-posture change (D-02). The 18 table rows hash to + bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f after the rewrite, + exactly as before it." + - "The file KEEPS its name (D-03). Two references live outside .planning/ and must keep + resolving: docs/trust-and-security.md:15 and the C1 comment at + packages/github-cache/src/backend/actions-cache-backend.ts:88. No rename, no move." + - "No LIVE artifact cites a Decision number that no longer exists. Ten sites do today + (measured): seven live ones - PROJECT.md:80, PROJECT.md:153, REQUIREMENTS.md:59, + ROADMAP.md:541, research/ARCHITECTURE.md:157, spikes/MANIFEST.md:13, spikes/MANIFEST.md:30 + - are re-pointed; three are inside the SEALED spike record + `.planning/spikes/005-cross-os-roundtrip/` (README.md:5, README.md:16, ci-roundtrip.mjs:4) + and are EXCLUDED on the same rationale as D-04: a spike README/script records what was + validated at the time, so rewriting it falsifies the evidence. MANIFEST.md is the live + spike INDEX, not a sealed record, so both of its sites ARE re-pointed." + - "Decision 6's alternative branch is the load-bearing authority for D2-01/CORR-02. It is + NOT lost by the deletion - PROJECT.md Key Decisions already carries + 'or documented consumer OS-discrimination'. Citations move to that row, they do not + lose their authority." + - "The inbound link count stays EXACTLY 9 current .planning/ files (D-05). Links are + re-described in place. Adding pointers is motion, not progress; deleting one strands + a reader." + - "The review cadence attaches to the PROJECT.md ## Key Decisions row, NOT to ## Evolution + (D-06). Neither transition.md nor complete-milestone.md reads ## Evolution - both carry + hardcoded checklists that DO audit Key Decisions (item 6) and Constraints (item 7). + Writing to Evolution would ship the exact inert artifact this task exists to fix." + - "Archived milestone artifacts are NOT touched (D-04) and gsd health W019 is NOT acted on + (D-06). W019 fires today, fires after slimming, and fires for GSD's own METHODOLOGY.md; + the taxonomy is the better authority." + - "The MOVE is de-duplicated too, not just the REMOVEs. Every fact in the Nx contract + section ALREADY lives in `.planning/research/STACK.md` section 1 (OpenAPI 3.0 / + `version: 1.0.0` at :16,23; the 202->200 drift and static `info.version` at :36-38, + verbatim; the hash-the-vendored-spec requirement at :38; the hard Nx 21+ floor and + `HttpRemoteCache` strict-200 at :39-40). D-02's MOVE to `## Constraints` still stands - + it buys milestone cadence via complete-milestone checklist item 7, which STACK.md does + not get - so the row is TRIMMED to the hard floor plus a pointer to STACK.md rather than + copying the table a third time. `Nx 23` (Tech stack row) and the task-runner exclusion + (## Out of Scope:101) are also not restated." + artifacts: + - ".planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md + - the pre-removal coverage proof, one row per removed section" + - ".planning/ARCHITECTURE-DECISION.md - slimmed to the C1-C18 ledger plus its framing" + - ".planning/PROJECT.md - new **Nx contract** Constraints row + re-described Key Decisions row" + key_links: + - "PROJECT.md ## Key Decisions row (currently line 143) -> ARCHITECTURE-DECISION.md. This + row IS the cadence mechanism (milestone checklist item 6). If the annotation is left + generic, the drift recurs at the next milestone." + - "PROJECT.md ## Constraints **Nx contract** row -> picks up cadence for free via milestone + checklist item 7." + - "docs/trust-and-security.md:15 -> `.planning/ARCHITECTURE-DECISION.md`. The docs-trust + guard (docs-trust.spec.ts) asserts on the docs file but does NOT assert this path string, + so nothing tests that the pointer resolves. Keeping the filename is the only thing + holding it." + - "actions-cache-backend.ts:88 -> control C1. C1 must survive verbatim in the ledger or a + shipped source comment points at nothing." +--- + + +Audit, triage, extract and deduplicate `.planning/ARCHITECTURE-DECISION.md` into canonical GSD +artifacts: remove the 7 duplicated/spent sections, MOVE the Nx contract into +`PROJECT.md ## Constraints`, KEEP the C1-C18 control ledger plus the residue that has no home +anywhere else (`## Residual notes` and the retained `## References`), re-describe the 9 existing +inbound links so they name what the file actually holds, and attach a review cadence that a +workflow really runs. + +D-02's disposition table marks `References` REMOVE and marks five other sections REMOVE whole. +Measurement contradicts it: seven distinct claims plus the entire twelve-source bibliography +exist in no other tracked file. The governing criterion is the KEEP rule - "keep only what has no +canonical home" - so those claims stay, in place, in this file. That is a smaller diff than +inventing homes for them in five canonical artifacts, and it is what D-02's own criterion says. + +Every disposition is audited on the same terms, MOVE included. That is how the Nx contract +section's orphan parenthetical (the Nx client's inherited zip-slip protection) was found: it is +homeless, it is not part of the contract facts being moved, and it would otherwise have been +deleted silently. It joins the residue. Symmetrically, two claims previously assumed homeless are +COVERED and get deleted - the "publisher is pluggable" warning and the retention "no second knob" +invariant. + +Purpose: the file is ~74 percent duplicate. That duplication already produced measurable drift - +Decision 6 reads "Default to OS-namespacing the store" with no supersession note while PROJECT.md +marks the same decision `[WARN] SUPERSEDED in v0.0.2`. Two sources of truth, already disagreeing. +Deleting the duplicate removes the drift surface; the cadence stops it recurring. + +Doctrine, quotable from GSD's own taxonomy (`references/artifact-types.md:5`): "A well-formatted +artifact that no workflow reads is inert - the consumption mechanism is what gives an artifact +meaning." + +Output: a coverage proof, a ledger-only ADR, a PROJECT.md that carries the extracted material +under a mechanism that is audited every milestone, and nine inbound links that describe the right +thing. + +Scope: documentation reorganisation only. No source-code behaviour changes. No package installs. + + + +@~/.claude/gsd-core/workflows/execute-plan.md + + + +@.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-CONTEXT.md +@.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-RESEARCH.md +@.planning/ARCHITECTURE-DECISION.md +@.planning/PROJECT.md + +Decision IDs used below map to CONTEXT.md's numbered decisions: +D-01 = what counts as canonical (`spikes/` IS canonical GSD - do not echo the "project +invention" phrasing); D-02 = the per-section disposition table; D-03 = do NOT rename the file; +D-04 = archived artifacts are NOT updated; D-05 = re-describe the 9 existing links, do not add +new ones; D-06 = attach cadence to `## Key Decisions`, and do NOT act on `gsd health` W019. + +Shell note: this repo forbids `grep`. Use `git grep` for tracked files and `rg` otherwise. +All new prose must be ASCII - `-` not an em dash. The surviving C1-C18 rows are the one +exception and are preserved byte-identical, non-ASCII characters included. + + + + + + + + + Task 1: Classify every removed CLAIM as COVERED, RESIDUE or SPENT (gate) + .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md + +The unit of proof is a DISTINCTIVE CLAIM, not a section. One term standing in for a whole list is +the defect this task exists to avoid: a single reference token cannot prove a twelve-item +bibliography, and a term that lands on a ticked entry in PROJECT.md's shipped-requirements +checklist proves only that something shipped, NOT that the reasoning being deleted survives. + +Three dispositions, and RETENTION IS THE DEFAULT for anything not proven elsewhere: + +- **COVERED** - the claim is proven present in a NAMED artifact outside + `.planning/ARCHITECTURE-DECISION.md` by the executed assertion in the row. Safe to delete. +- **RESIDUE** - the claim is proven absent everywhere else, so it is RETAINED in the slimmed ADR + under `## Residual notes` (Task 2c). This is not a failure and not an escape hatch; it is the + KEEP criterion ("keep only what has no canonical home") doing its job. +- **SPENT** - obsolete by a recorded event, evidenced by the event's record. Applies to the + Framing section ONLY: the rebuild happened and `MILESTONES.md` records v0.0.1 shipped. + +Write `260726-pjz-COVERAGE.md` as one table, one row per claim: +`| Section | Claim | Disposition | Home | Executed assertion |`. At least 44 rows carry a +disposition cell of `COVERED`, `RESIDUE` or `SPENT`. Never write `UNCOVERED` or `TODO` - a claim +with no home is RESIDUE, and RESIDUE is a legal, complete outcome. The disposition-cell gate below +tolerates column-aligned padding, so you may align the table or not; both pass. + +**The 36 COVERED/SPENT assertions.** Run exactly the command in `` below - +it IS this battery, in this order, plus the two COVERAGE.md checks; do not retype a variant. +Each `git grep`/`test` in it is one table row, in this reading: + +- Framing (SPENT): `v0.0.1` in `MILESTONES.md`. +- Decision 1 (7 claims): one-backend-per-process, `selectBackend`, the `get`/`put` port shape + (`PutResult`), RW-vs-RO being `context-derived` (`codebase/INTEGRATIONS.md:84`, substantive + prose - NOT PROJECT.md's dated log footer), the `no-flag safety property` being load-bearing + (`research/FEATURES.md:152` - NOT PROJECT.md's Validated tick), the publisher/retention seam + sitting `behind no port`, and the "do not assume it is pluggable" clause + (`research/ARCHITECTURE.md:161`, verbatim at `research/SUMMARY.md:58`). +- Decision 2 (5 claims): write-trust allowlist, the separate sync-gate predicate, the + `GITHUB_SERVER_URL` host detection, the `ghe.com` Data-Residency suffix, and the PR + activity-type nuance (`base-scope`, which lives in the shipped backend source + its spec). +- Decision 3 (7 claims): the locked Releases reader, the >5000 wall, the 1000-asset cap, the + month-shard model, the ~2 GiB ceiling (`MILESTONES.md:28`, the publish-engine prose - NOT + PROJECT.md's Validated tick), the PROV-01 revisit trigger, and reversibility being `additive`. +- Decision 5 (4 claims): no LRU manifest, `CACHE_MIRROR_MAX_AGE_DAYS`, the age-only RO tier, and + the retention "never introduce a second knob" invariant (`PROJECT.md:115`, substantive prose; + five further homes at `RETROSPECTIVE.md:31`, `research/ARCHITECTURE.md:132,227`, + `docs/trust-and-security.md:116` and shipped code `packages/github-cache/src/lib/retention.ts:5`). +- Decision 6 (2 claims): both branches ("or documented consumer OS-discrimination"), and a + cross-OS hit being a `wrong result` rather than a MISS. +- Consequences (10 claims): the spike verdict (`strongest pro-Releases dim` in the MANIFEST + verdict column - the content, not `test -f` on the file), the `background-step` pattern and + the `composite`-action caveat in `docs/advanced.md`, the `services:` Linux-only limitation and + the `hermetic` non-Node niche in `codebase/CONCERNS.md`, the `wait-all`/cancel teardown in + `docs/examples/minimal-ci.yml`, the `SIGTERM` drain in the shipped `serve.ts`, and the + governance trio as the shipped `SECURITY.md` + `LICENSE` + the `semver` statement in + `docs/versioning.md`. + +**The 7 RESIDUE claim rows.** Probe each at CLAIM level, never by echoing the ADR's own +phrasing - a probe built from the sentence you are about to delete passes by phrasing accident. +Exclude the ADR itself, this task's `quick/` directory, and `.planning/milestones/` (an archived +record is history, not a canonical home - D-04). Record the executed probe as the row's +assertion, e.g. +`git grep -q -F "content-sha256" -- . ':!.planning/ARCHITECTURE-DECISION.md' ':!.planning/quick' ':!.planning/milestones'` +exits non-zero. All 7 were re-measured homeless at plan time: + +1. Decision 1 YAGNI deferrals - `synchronous write fan-out` and `multiple simultaneous stores` + ONLY. The "do not assume the publisher is pluggable" warning is NOT residue: it is COVERED + above (`research/ARCHITECTURE.md:161`, verbatim at `research/SUMMARY.md:58`) and is deleted. +2. Decision 2 GHES anti-spoofing cross-check - absence of `/meta` `installed_version` plus the + `X-GitHub-Enterprise-Version` header, and the dormant version-gate knob held OFF until a GHES + floor publishes. +3. Decision 3 read-time integrity - store-and-verify a published `content-sha256`, and + explicitly NOT `sha256(blob) == {hash}` because the Nx key hashes task inputs. +4. Decision 3 rejection rationale - git-native (clone bloat, no clean eviction) and Actions build + artifacts (not content-keyed). The MANIFEST records THAT they are out, not WHY. +5. Decision 3 CREEP-orthogonality scope check - the reader choice does not move the primary + threat; it is a remediation win, not a CREEP-prevention one. +6. Nx contract section, the parenthetical at ADR line 15 - the Nx client hardens tarball + extraction against `..`/absolute/symlink/hardlink escape, so a malicious server cannot + `zip-slip` the client; inherited protection. This one sits in the MOVE section, not a REMOVE + section, and is the disposition table's only un-audited row - it would otherwise be deleted + silently along with its unactioned "worth a docs note" recommendation. The claim is homeless + in `.planning/`, `docs/` and `packages/` source (the only tree hits are inside the vendored + `start-cache-server/index.js` bundle, which is generated output, not a home). The docs-note + recommendation itself is carried forward in the SUMMARY's deferred follow-ups. +7. Consequences residual risk - containment is single-layer at the write/sync gates, so gate + correctness is load-bearing with no backstop. + +NOT residue: the retention "no second knob" invariant. It has six homes (see Decision 5 above), +so it is COVERED and deleted. Keeping it would recreate exactly the two-sources-of-truth pair +this task exists to remove. + +The whole `## References` block is RETAINED wholesale (Task 2c), so it needs no per-token row - +record it as one RESIDUE row citing the measured absence of `Cacheract`, `sccache`, `HeroDevs`, +`CodeQL`, `nixcache-oci`, `tag mutability` and `nx.app/files` from every other tracked file. + +The Nx contract section is NOT in this battery - it is MOVED, not removed, and its assertion is +Task 2's post-move check. + + + git grep -q -F "v0.0.1" -- .planning/MILESTONES.md && git grep -q -F "One backend per process, context-selected" -- .planning/PROJECT.md && git grep -q -F "selectBackend" -- .planning/research/ARCHITECTURE.md && git grep -q -F "PutResult" -- .planning/research/ARCHITECTURE.md && git grep -q -F "context-derived" -- .planning/codebase/INTEGRATIONS.md && git grep -q -F "no-flag safety property" -- .planning/research/FEATURES.md && git grep -q -F "behind no port" -- .planning/research/ARCHITECTURE.md && git grep -q -F "do not assume it is pluggable" -- .planning/research/ARCHITECTURE.md && git grep -q -F "Write-trust = allowlist-only" -- .planning/PROJECT.md && git grep -q -F "Sync gate = a separate predicate" -- .planning/PROJECT.md && git grep -q -F "GITHUB_SERVER_URL" -- .planning/PROJECT.md && git grep -q -F "ghe.com" -- .planning/PROJECT.md && git grep -q -F "base-scope" -- packages/github-cache/src/backend/actions-cache-backend.ts && git grep -q -F "Reader / cross-context adapter: **GitHub Releases**" -- .planning/PROJECT.md && git grep -q -F "5000" -- .planning/PROJECT.md && git grep -q -F "1000-asset" -- .planning/PROJECT.md && git grep -q -F "month-shard" -- .planning/PROJECT.md && git grep -q -F "2 GiB" -- .planning/MILESTONES.md && git grep -q -F "PROV-01" -- .planning/PROJECT.md && git grep -q -F "additive" -- .planning/PROJECT.md && git grep -q -F "no LRU manifest" -- .planning/PROJECT.md && git grep -q -F "CACHE_MIRROR_MAX_AGE_DAYS" -- .planning/PROJECT.md && git grep -q -F "age-only" -- .planning/PROJECT.md && git grep -q -F "never introduce a second knob" -- .planning/PROJECT.md && git grep -q -F "or documented consumer OS-discrimination" -- .planning/PROJECT.md && git grep -q -F "wrong result" -- .planning/PROJECT.md && git grep -q -F "strongest pro-Releases dim" -- .planning/spikes/MANIFEST.md && git grep -q -F "background-step" -- docs/advanced.md && git grep -q -F "composite" -- docs/advanced.md && git grep -q -F "services:" -- .planning/codebase/CONCERNS.md && git grep -q -F "hermetic" -- .planning/codebase/CONCERNS.md && git grep -q -F "wait-all" -- docs/examples/minimal-ci.yml && git grep -q -F "SIGTERM" -- packages/github-cache/src/serve.ts && test -f SECURITY.md && test -f LICENSE && git grep -q -F "semver" -- docs/versioning.md && test "$(rg -c -N '^\|.*\|\s*(COVERED|RESIDUE|SPENT)\s*\|' .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md)" -ge 44 && ! rg -q '^\|.*\|\s*(UNCOVERED|TODO)\s*\|' .planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-COVERAGE.md + + COVERAGE.md exists with at least 44 disposition rows (36 COVERED/SPENT + 7 RESIDUE claim +rows + the `## References` RESIDUE row = 44), every row reading COVERED, RESIDUE or SPENT and +naming the executed assertion that produced it. No row reads UNCOVERED or TODO. The 36-assertion +COVERED/SPENT battery exits 0 against the pre-removal tree, and the 8 RESIDUE rows are each +recorded with the claim-level negative probe - excluding the ADR, `quick/` and `milestones/` - +that proved them homeless. + + + + Task 2: Move the Nx contract into PROJECT.md, slim the ADR to the ledger, attach the cadence + .planning/PROJECT.md, .planning/ARCHITECTURE-DECISION.md + +Gated on Task 1: do not start until COVERAGE.md exists with every row classified COVERED, RESIDUE +or SPENT. Every RESIDUE row must land in the slimmed ADR here - that is what makes the removal +lossless. + +**(a) `PROJECT.md ## Constraints` - add one `**Nx contract**` row** (per D-02's MOVE). + +Match the LIVE shape, not the template: `- **[Type]**: [What] - [Why]`, ASCII ` - ` separator +(never an em dash), one semicolon-chained sentence, no sub-bullets. A new type label is +well-precedented - `Platform`, `Auth / repo scope` and `Distribution` are all locally invented. + +**The MOVE is a de-dup too, and it is the disposition table's only un-audited row.** Measured: +every fact in the ADR's Nx contract section ALREADY lives in `.planning/research/STACK.md` +section 1 - OpenAPI 3.0 / `version: 1.0.0` at `:16,23`; the 202->200 drift and the static +`info.version` at `:36-38` (verbatim, same sentence); the hash-the-vendored-spec requirement at +`:38`; the hard Nx 21+ floor and `HttpRemoteCache` strict-200 at `:39-40`. So a full copy into +`## Constraints` would be a THIRD copy, which is the defect this task exists to remove. + +D-02's MOVE is NOT overridden - it stands, because `## Constraints` is audited by +complete-milestone checklist item 7 and `research/STACK.md` is audited by nothing. So the row +carries the HARD FLOOR (the operative constraint) plus a pointer to STACK.md for the detail, +rather than restating the contract table. + +Do NOT re-state `Nx 23` (already in the Tech stack row) and do NOT re-state the deprecated +task-runner exclusion (already at `## Out of Scope:101`) - CONTEXT.md's `` requires +that no canonical artifact be left asserting the same thing twice. + +Recommended wording (exact wording is your discretion per D-02, but keep the live shape and the +pointer): + +`- **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 the conformance fixture pins a named Nx version and hashes the full vendored spec; the endpoint/status table, the `202`->`200` drift and the reason `info.version` cannot detect it are in `.planning/research/STACK.md` section 1.` + +**(b) `PROJECT.md ## Key Decisions` - re-describe the pointer row and attach the cadence** (D-05, D-06). + +The row whose Outcome cell currently reads `[OK] Decided (see ARCHITECTURE-DECISION.md)` (the +one-backend-per-process row) is the cadence carrier: `/gsd:complete-milestone` runs a hardcoded +"Key Decisions audit" over this table every milestone. Rewrite that Outcome cell into the +annotated-reference shape from `workflows/discuss-phase/templates/context.md:84` +(`` `path` - what it decides/defines ``), name what the file NOW holds, and state the cadence. +Borrow the `(updated )` staleness suffix from `templates/state.md:25`. + +Recommended Outcome cell: + +`[OK] Decided - the project-level CREEP control ledger C1-C18 backing this and every other trust decision is `.planning/ARCHITECTURE-DECISION.md`; re-read and reconcile it at each milestone Key Decisions audit (updated 2026-07-26)` + +Do NOT write the cadence into `## Evolution` (D-06). Neither `transition.md` nor +`complete-milestone.md` reads that section - both carry hardcoded checklists, and `## Evolution` +is a transcript of them, not an input. A line there would be inert. + +Leave `PROJECT.md:17` alone - it already reads "the full CREEP control ledger is in +`.planning/ARCHITECTURE-DECISION.md`", which is exactly right. Leave the dated `Last updated` +footer alone; it is a log entry, and rewriting it would falsify the log (same principle as D-04). + +**(c) Rewrite `.planning/ARCHITECTURE-DECISION.md` to the ledger only.** + +Keep the filename (D-03). Restructure for readability now that one section remains: + +1. `# Architecture Decision Record: CREEP-Safety Control Ledger` - the H1 may say what the file + now is; the FILENAME must not change. +2. A Status/Scope block stating that this file holds only the control register, and pointing at + where the extracted material went: `PROJECT.md ## Key Decisions` (every locked decision), + `PROJECT.md ## Constraints` (the Nx contract), `.planning/spikes/001-005` (the FOUND-01 + evidence and verdict), `.planning/research/*` (the source corpus). Use the same annotated + `` `path` - what it holds `` shape. +3. A Review cadence line: audited every milestone via the `## Key Decisions` row in PROJECT.md + that points here. +4. A short "why the ledger has no canonical GSD home" note: GSD models security strictly + per-phase (a `` in PLAN.md, a per-phase SECURITY.md) with no project-level + register; C1 applies to every future phase so it cannot live in one phase's SECURITY.md; + `## Constraints` is the wrong shape because constraints are limits and controls are + mitigations. GSD's taxonomy sanctions this as a project-scoped "Standing Reference Artifact" + at the `.planning/` root, the same category as its own METHODOLOGY.md. Record that the file + trips `gsd health` W019 today, will keep tripping it, and that W019's remediation text does + not apply here - do NOT act on it (D-06). Write that note in the FIRST PERSON of the document + ("this file trips ...") and do NOT name its own filename: a self-reference would make the ADR + a tenth inbound match and break Task 3's link count. Do not describe the file as a "project + invention" and do not repeat CONTEXT.md's claim that `spikes/` is one - `spikes/` is canonical + GSD (D-01). +5. The CVE-2025-36852 preamble paragraph and the C1-C18 table, BYTE-IDENTICAL. Copy the rows; + do not retype them, do not ASCII-fold them, do not renumber, do not reword. Any change to the + controls themselves is out of scope. (C10's row cites a Decision number; it stays verbatim and + is the single sanctioned exception to point 7's rule.) +6. `## Residual notes` - the SEVEN RESIDUE claims Task 1 proved have no home anywhere else, kept + here because the KEEP criterion is "keep only what has no canonical home" and these qualify. + One short bullet each, faithful to the original meaning, preserving these exact tokens so the + gate can see them: `synchronous write fan-out` and `multiple simultaneous stores` (the D1 + YAGNI deferrals); `installed_version` and `X-GitHub-Enterprise-Version` (the GHES + anti-spoofing cross-check, and the dormant version-gate knob held OFF until a GHES floor + publishes); `content-sha256` (store-and-verify at publish, NOT a hash of the blob against the + key, which hashes task inputs); the git-native and Actions-build-artifact rejection rationale + (`content-keyed`); the reader choice being `orthogonal` to CREEP - a remediation win, not a + prevention one; the Nx client's inherited tarball-extraction hardening against + `..`/absolute/symlink/`hardlink` escape, so a malicious server cannot `zip-slip` the client + (carried out of the Nx contract section, whose other facts all moved or were already in + `research/STACK.md`); and the residual risk that containment is `single-layer` at the + write/sync gates, so gate correctness is load-bearing with `no backstop`. + + Do NOT carry over the "do not assume the publisher is pluggable" warning or the retention + `no second knob` invariant. Task 1 classifies both COVERED (six homes for the latter, + including shipped code); re-writing them here would rebuild the duplicate pair this task + deletes. The gate does not look for them. +7. `## References` - RETAINED, byte-identical. The corpus pointer `.planning/research/*` covers + only the corpus, not the twelve named sources; `Cacheract`, `sccache`, `HeroDevs`, `CodeQL`, + `nixcache-oci`, tag mutability and `nx.app/files` appear in no other tracked file, so + deleting the block would strand them. +8. A trimmed provenance footer keeping only the revision history that is about the ledger + (the C1/C4/C11/C16/C18 revisions). Drop the FOUND-01 sentence's `(Decision 3)` parenthetical + along with the rest of the non-ledger history. + +Outside the C10 row, the slimmed file must cite NO Decision number - not in the Status block, not +in the residual notes, not in the footer. Name the surviving home instead +(`PROJECT.md ## Key Decisions`). + +Delete the Framing, Nx contract, Decision 1, Decision 2, Decision 3, Decision 5, Decision 6 and +Consequences-and-spike-scope sections, minus the residue carried into point 6. Deletion, not +annotation: Decision 6 is the drift instance, and annotating it would patch one of two +disagreeing copies rather than removing the disagreement. + + + test "$(rg -N '^\| C[0-9]+ \|' .planning/ARCHITECTURE-DECISION.md | sha256sum | cut -c1-64)" = "bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f" && test "$(rg -c -N '^\| C[0-9]+ \|' .planning/ARCHITECTURE-DECISION.md)" = "18" && ! rg -q '^## (Framing|Nx contract|Consequences)' .planning/ARCHITECTURE-DECISION.md && ! rg -q '^## Decision [0-9]' .planning/ARCHITECTURE-DECISION.md && rg -q '^## Residual notes' .planning/ARCHITECTURE-DECISION.md && rg -q '^## References' .planning/ARCHITECTURE-DECISION.md && test "$(rg -c 'Decision [0-9]' .planning/ARCHITECTURE-DECISION.md)" = "1" && { m=0; for t in "synchronous write fan-out" "multiple simultaneous stores" "installed_version" "X-GitHub-Enterprise-Version" "content-sha256" "content-keyed" "orthogonal" "zip-slip" "hardlink" "single-layer" "no backstop" "Cacheract" "sccache" "HeroDevs" "CodeQL" "nixcache-oci" "tag mutability" "nx.app/files"; do git grep -q -F "$t" -- .planning/ARCHITECTURE-DECISION.md || { echo "RESIDUE MISSING: $t"; m=1; }; done; test "$m" = "0"; } && git grep -q -F "CVE-2025-36852" -- .planning/ARCHITECTURE-DECISION.md && git grep -q -F "**Nx contract**" -- .planning/PROJECT.md && git grep -q -F "HttpRemoteCache" -- .planning/PROJECT.md && rg -q '^- \*\*Nx contract\*\*:.*research/STACK\.md' .planning/PROJECT.md && ! rg -q 'Nx contract.*—' .planning/PROJECT.md && test "$(git grep -c -F 'Nx custom task runner API' -- .planning/PROJECT.md | cut -d: -f2)" = "1" && git grep -q -F "milestone Key Decisions audit" -- .planning/PROJECT.md && ! git grep -q -F 'ARCHITECTURE-DECISION' -- .planning/ARCHITECTURE-DECISION.md && test -f .planning/ARCHITECTURE-DECISION.md + + The 18 control rows hash unchanged. The 8 removed headings are gone and no `## Decision N` +heading survives, while `## Residual notes` and the retained `## References` both exist and carry +all 18 residue tokens. `Decision [0-9]` appears on exactly one line - C10's verbatim ledger row. +The ADR contains no self-reference, so Task 3's 9-file set still holds. PROJECT.md has a new +`**Nx contract**` Constraints row using the ASCII separator, carrying the hard Nx 21+ floor +(`HttpRemoteCache`) plus a `.planning/research/STACK.md` pointer instead of a third copy of the +contract table; the task-runner exclusion still appears exactly once in the file; and the Key +Decisions pointer row names the ledger and its milestone cadence. + + + + Task 3: Re-point the 9 inbound references so none cites deleted content + .planning/PROJECT.md, .planning/ROADMAP.md, .planning/STATE.md, .planning/REQUIREMENTS.md, .planning/research/ARCHITECTURE.md, .planning/research/FEATURES.md, .planning/research/STACK.md, .planning/codebase/CONCERNS.md, .planning/spikes/MANIFEST.md + +Re-describe in place. Do NOT add a pointer to any file that does not already have one, and do NOT +delete an existing pointer (D-05) - the count of current `.planning/` files referencing the ADR +must stay exactly 9. Do not touch anything under `.planning/milestones/` (D-04). Do not add an +`AGENTS.md` pointer: it is Nx/worktree agent guidance with no natural anchor, and D-05 says skip +rather than invent a section. + +Use the annotated shape `` `path` - what it decides/defines `` throughout. + +**Group A - seven LIVE sites cite a Decision number that will no longer exist. Re-point each to +the surviving canonical home:** + +- `PROJECT.md:80` and `PROJECT.md:153` - both credit the CORR-01 alternative branch to the ADR's + Decision 6. That authority now lives in this same table: the CORR-01 Key Decisions row already + reads "or documented consumer OS-discrimination". Cite that row, not a deleted section. +- `REQUIREMENTS.md:59` - the D2-01 source cell has the same problem; re-point it the same way. + `REQUIREMENTS.md` does NOT currently contain the ADR filename and must not gain it, or the + inbound count below reads 10. +- `ROADMAP.md:541` - the provenance footer credits the same deleted section; re-point to + `PROJECT.md ## Key Decisions` (the CORR-01 either/or row). +- `spikes/MANIFEST.md:13` - "Canonical scope" cites the deleted reader section plus the ledger. + Keep the ledger half; move the reader half to `PROJECT.md ## Key Decisions`. +- `spikes/MANIFEST.md:30` - the CORR-01 rubric bullet ends "(CORR-01 / Decision 6)". Drop the + dangling half; `CORR-01` alone is the durable identifier. MANIFEST.md is the LIVE spike index - + it is the same file being edited at line 13 - so it is in scope even though the per-spike + records below are not. +- `research/ARCHITECTURE.md:157` - cites the deleted reader section for FOUND-01 being resolved; + re-point to `PROJECT.md ## Key Decisions` and keep the existing spike citation. + +**Explicitly EXCLUDED - the sealed spike record `.planning/spikes/005-cross-os-roundtrip/`** +(`README.md:5` in the `validates:` frontmatter, `README.md:16`, and the header comment at +`ci-roundtrip.mjs:4`). These three cite Decision 6 as the thing the spike was run to validate. +They are historical evidence of what was checked at the time, so rewriting them falsifies the +record - the same rationale D-04 applies to archived milestone artifacts. Do not touch them; the +gate below excludes that directory by path and asserts it stays unmodified. + +**Group B - four sites describe the file as holding decisions it no longer holds:** + +- `ROADMAP.md:13-14` - "Decision record + CREEP control ledger C1-C18" - drop the decision-record + half; the locked foundations listed above it are PROJECT.md's Key Decisions. +- `STATE.md:95` - "Full log in PROJECT.md Key Decisions + .planning/ARCHITECTURE-DECISION.md" - + keep both pointers but split them honestly: the decision log is PROJECT.md; the ADR is the + CREEP control ledger C1-C18. +- `research/ARCHITECTURE.md:8-10` and `research/STACK.md:9-10` and `research/FEATURES.md:9` - + each header block attributes the port/`selectBackend`/write-trust/reader choices to the ADR. + Those are PROJECT.md Key Decisions now; the ADR is the control ledger. Adjust the attribution + only - do not rewrite the research findings. +- `research/FEATURES.md:209` - a bare path in the "Locked foundation" list; give it the same + annotation `research/ARCHITECTURE.md:239` already has. +- `codebase/CONCERNS.md:389-391` - "These are LOCKED architectural decisions ... recorded in + `.planning/ARCHITECTURE-DECISION.md`". The GHCR-01 deferral rationale is a PROJECT.md Key + Decisions row; what the ADR still records is the control surface those triggers carry + (C6/C10/C11/C13/C18, already cited in the bullet below it). Re-attribute accordingly. + +**Leave alone - already accurate after slimming:** +`PROJECT.md:17`; `PROJECT.md:178` (dated log footer); `codebase/CONCERNS.md:198` (control C5) and +`:414` (control C7); `research/ARCHITECTURE.md:239`; `spikes/004-ghcr-hazards/README.md:25` +(C10/C11); `docs/trust-and-security.md:15` (already "CREEP control ledger (C1-C18)"); and the +`actions-cache-backend.ts:88` C1 comment. The two out-of-tree references keep resolving because +the filename is unchanged (D-03) - note that no test asserts that path string, so the filename is +the only thing holding them. + + + ! rg -q 'Decision [0-9]' .planning --glob '!**/milestones/**' --glob '!**/quick/**' --glob '!ARCHITECTURE-DECISION.md' --glob '!**/spikes/005-cross-os-roundtrip/**' && test "$(git grep -l -F 'ARCHITECTURE-DECISION' -- .planning ':!.planning/milestones' ':!.planning/quick' ':!.planning/ARCHITECTURE-DECISION.md' | LC_ALL=C sort | tr '\n' ' ')" = ".planning/PROJECT.md .planning/ROADMAP.md .planning/STATE.md .planning/codebase/CONCERNS.md .planning/research/ARCHITECTURE.md .planning/research/FEATURES.md .planning/research/STACK.md .planning/spikes/004-ghcr-hazards/README.md .planning/spikes/MANIFEST.md " && git grep -q -F ".planning/ARCHITECTURE-DECISION.md" -- docs/trust-and-security.md && git grep -q -F "ARCHITECTURE-DECISION.md control C1" -- packages/github-cache/src/backend/actions-cache-backend.ts && test -f .planning/ARCHITECTURE-DECISION.md && test "$(git status --porcelain .planning/milestones | wc -l)" = "0" && test "$(git status --porcelain .planning/spikes/005-cross-os-roundtrip | wc -l)" = "0" + + The gate greps BARE `Decision [0-9]` - not just the `ADR `-prefixed or filename-adjacent +form - across all of `.planning`, excluding four paths only: `milestones/` (D-04), `quick/` (this +task's own artifacts), the ADR itself (C10's verbatim row legitimately cites one, gated separately +in Task 2), and the sealed `spikes/005-cross-os-roundtrip/` record. It returns zero, so all seven +live sites including both MANIFEST.md lines are re-pointed. The inbound gate asserts the exact +sorted FILE LIST, not a count - a count of 9 also passes a simultaneous +1/-1 (e.g. REQUIREMENTS.md +gains the filename while research/STACK.md loses it), which violates D-05 while looking clean. The +list must be exactly: PROJECT.md, ROADMAP.md, STATE.md, codebase/CONCERNS.md, +research/ARCHITECTURE.md, research/FEATURES.md, research/STACK.md, +spikes/004-ghcr-hazards/README.md, spikes/MANIFEST.md. Both out-of-tree references resolve. +Neither `.planning/milestones/` nor `.planning/spikes/005-cross-os-roundtrip/` is modified. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Security documentation -> shipped code | `actions-cache-backend.ts:88` and `docs/trust-and-security.md:15` treat the ledger as the source of truth for the CREEP posture; a silent edit to it changes what "settled" means. | +| `.planning/` docs -> future planners | Planners and the milestone audit read these artifacts as authority; a false or dangling pointer propagates into future phase plans. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-pjz-01 | Tampering | C1-C18 ledger in `.planning/ARCHITECTURE-DECISION.md` | high | mitigate | The rewrite preserves the 18 rows BYTE-IDENTICAL; Task 2's gate compares their sha256 against `bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f`. A reword, an ASCII fold or a dropped row fails the task. If the hash differs, diff the rows before accepting - only a line-ending delta is benign. | +| T-pjz-02 | Repudiation | The 7 removed sections + the MOVEd Nx contract section | high | mitigate | Task 1 gates removal on a per-CLAIM executed battery (36 positive assertions + 8 negative probes), recorded row-by-row in COVERAGE.md; Task 2 then gates on all 18 residue tokens being present in the slimmed file. No claim is removed on assumption, and no claim is removed at all unless it is proven to live somewhere else. Three ways a probe can lie, all closed: a single term standing in for a list; a term that only matches a shipped-requirements checklist tick (assertions 05/06/17 are pointed at substantive prose instead); and a negative probe built from the ADR's own phrasing, which passes by phrasing accident rather than genuine homelessness (probe the CLAIM, and exclude archived `milestones/` - history is not a home). The MOVE section is audited on the same terms as the REMOVEs, which is how its orphan `zip-slip` parenthetical was caught. | +| T-pjz-03 | Information disclosure | Newly authored prose in a public repo | low | mitigate | All new text is derived from tracked files in this repo; no contact details, credentials or tokens are authored. No new email-shaped token is introduced by this change. | +| T-pjz-04 | Denial of service | Out-of-tree references at `docs/trust-and-security.md:15` and `actions-cache-backend.ts:88` | medium | mitigate | The filename is unchanged (D-03) and Task 3's gate asserts both references still resolve. No test asserts the path string, so the gate is the only guard. | + +No package-manager installs occur in this plan, so the package legitimacy gate does not apply. + + + +- Task 1's 36-assertion battery is green BEFORE any removal edit; COVERAGE.md has at least 44 + rows (36 COVERED/SPENT + 8 RESIDUE), every one classified COVERED, RESIDUE or SPENT, and none + reading UNCOVERED or TODO. The disposition-cell gate tolerates column-aligned padding. +- The 18 control rows hash to the pre-change value after the rewrite. +- `.planning/ARCHITECTURE-DECISION.md` retains its filename, contains no `## Decision N` heading, + and carries all 18 residue tokens under `## Residual notes` plus the retained `## References`. +- `Decision [0-9]` matches exactly one line in the slimmed ADR - C10's verbatim ledger row. +- The ADR contains no reference to its own filename, so the inbound count is unaffected by it. +- `PROJECT.md ## Constraints` gained exactly one `**Nx contract**` row, ASCII separator, carrying + the hard Nx 21+ floor plus a `.planning/research/STACK.md` pointer rather than a third copy of + the contract table; the task-runner exclusion still appears exactly once in the file. +- The PROJECT.md Key Decisions pointer row names the ledger and its milestone cadence; nothing + was written to `## Evolution`. +- A BARE `Decision [0-9]` grep over `.planning` returns zero outside `milestones/`, `quick/`, the + ADR, and the sealed `spikes/005-cross-os-roundtrip/` record; the inbound reference SET (not + merely its count) is byte-identical to the 9-file list; both out-of-tree references resolve. +- `git status --porcelain` is empty for both `.planning/milestones` and + `.planning/spikes/005-cross-os-roundtrip`. +- No file under `packages/` is modified - this plan changes documentation only. + + + +`.planning/ARCHITECTURE-DECISION.md` holds the C1-C18 ledger, the residue that has no canonical +home, and nothing that duplicates a canonical artifact; every removed claim was proven redundant +before it was removed and every claim that was not is still readable in the file; the nine +existing inbound links describe what the file actually contains; the extracted Nx contract and +the ledger pointer both sit under a milestone checklist item that really runs. + + + +Create `.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-SUMMARY.md` +when done. Record: the COVERAGE.md disposition per CLAIM (COVERED / RESIDUE / SPENT counts), which +claims were retained as residue and why, the ledger hash before/after, and the final inbound-link +SET (the nine paths, not just the number). + +Name `.planning/research/STACK.md` section 1 explicitly as the SECOND, more detailed copy of the +Nx contract, so a future reader who finds the trimmed `## Constraints` row knows where the +endpoint/status table and the `202`->`200` drift analysis live and does not re-inflate the row. + +Two deferred follow-ups, both needing an interactive decision: + +1. From D-03 - the rename to `THREAT-MODEL.md`/`CONTROLS.md` is deliberately NOT done here (note + that it would gain nothing on W019, which fires for any non-canonical root `.md`). +2. Carried out of the Nx contract section - the Nx client hardens tarball extraction against + `..`/absolute/symlink/hardlink escape, so a malicious server cannot zip-slip the client. The + original text flagged this as "worth a docs note" and nothing was ever written; the claim is + preserved in the ADR's `## Residual notes`, but the recommendation is a candidate paragraph + for `docs/trust-and-security.md` and is out of scope here (this task reorganises existing + documentation, it does not author new consumer docs). + diff --git a/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-RESEARCH.md b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-RESEARCH.md new file mode 100644 index 00000000..dcb0a501 --- /dev/null +++ b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-RESEARCH.md @@ -0,0 +1,382 @@ +# Quick Task 260726-pjz: Extract and deduplicate ARCHITECTURE-DECISION.md - Research + +**Researched:** 2026-07-26 +**Domain:** GSD artifact taxonomy / documentation reorganisation (local only - no web research) +**Confidence:** HIGH (all claims verified against the shipped GSD install and this repo's tracked files) + +## Summary + +Four questions were asked. Three have clean answers; the fourth surfaced two corrections to +CONTEXT.md's premises. The headline finding is not in any of the four questions: **GSD names the +exact failure mode this task is fixing, and it names the cure - a consumption mechanism.** The +shipped taxonomy states "A well-formatted artifact that no workflow reads is inert" +[VERIFIED: `gsd-core/references/artifact-types.md:5`]. That single sentence is the design +principle the extraction should be built around. + +Second headline: **CONTEXT.md Decision 6 does not work as written.** Adding the control ledger to +PROJECT.md's `## Evolution` section will NOT give it GSD's review cadence, because neither +`transition.md` nor `complete-milestone.md` reads that section - both carry hardcoded checklists. +Details and the fix in Q-EXTRA below. + +**Primary recommendation:** Keep the disposition table from CONTEXT.md Decision 2 unchanged, but +re-target Decision 6 from `## Evolution` to `## Key Decisions` (which IS audited every milestone), +and adopt the `path - what it decides` annotation shape GSD already uses for ``. + +## Q1 - Does GSD have a cross-artifact linking / progressive-disclosure convention? + +**Answer: No generic "see also" convention exists. But three specific, quotable pointer shapes do. +Use shape (b).** [VERIFIED: full-text search of `gsd-core/templates/` and `gsd-core/workflows/`] + +`rg -i "progressive.disclosure"` over the whole install returns **zero** hits. There is no named +convention by that term. + +What exists instead, in order of relevance to this task: + +**(a) `See: (updated [date])`** - the only literal "See:" pointer in any template. Appears +once, in `state.md`, under a dedicated heading: + +```markdown +## Project Reference + +See: .planning/PROJECT.md (updated [date]) +``` +[VERIFIED: `templates/state.md:23-25`, mirrored at `templates/project.md:195`] + +Note the `(updated [date])` suffix - the template's own rationale is a staleness trigger: "Last +update date (triggers re-read if stale)" [VERIFIED: `templates/state.md`, `### Project Reference`]. +This is the closest GSD gets to a drift guard on a pointer. + +**(b) `` `path` - [what it decides/defines] `` - the canonical-refs annotation shape.** This is the +established style and the one the extraction should copy: + +```markdown +### [Topic area 1] +- `path/to/adr-or-spec.md` - [What it decides/defines that's relevant] +- `path/to/doc.md` SS N - [Specific section reference] +``` +[VERIFIED: `workflows/discuss-phase/templates/context.md:84-85`; same shape at +`workflows/plan-phase.md:314`] + +The load-bearing part is the annotation, not the path. `discuss-phase.md` is emphatic that a bare +path is insufficient: "Every entry needs a full relative path - not just a name" +[VERIFIED: `workflows/discuss-phase/templates/context.md:80`]. CONTEXT.md Decision 5 already +reaches this conclusion independently ("make that pointer explicit about WHAT the file now +contains ... rather than a generic 'see also'") - shape (b) is the shipped precedent for it. + +**(c) `@path` mentions** - used for *required reading* in agent prompts +(`@.planning/PROJECT.md`) [VERIFIED: `templates/phase-prompt.md:48-50`, +`templates/planner-subagent-prompt.md:16-28`]. Not applicable here; this is prompt-injection +syntax, not a document cross-reference. + +**Also relevant:** `templates/project.md` models an explicit out-of-tree pointer field - +`**Strategy notes**: [Link to external strategy doc, if any]` [VERIFIED: `templates/project.md`, +`## Business Context`], with guidance "Use **Strategy notes** to link out to a dedicated strategy +doc rather than duplicating it here". So GSD does sanction "point out, don't duplicate" - that is +precisely this task's thesis. (The field itself is unusable here: Business Context is explicitly +"only for monetized or customer-facing projects. Delete this section otherwise", and this +PROJECT.md correctly omits it.) + +**Actionable:** use shape (b) for every inbound pointer added. Consider borrowing (a)'s +`(updated )` suffix on the PROJECT.md pointer - it is shipped precedent for making a +pointer carry its own staleness signal, which is the exact drift this task is remediating. + +## Q2 - Is there any canonical GSD home for a project-level SECURITY CONTROL REGISTER? + +**Answer: No. CONTEXT.md's assertion survives falsification. But there is one near-miss worth +recording, and it reframes the file's category favourably.** + +Falsification attempts, all negative [VERIFIED: `rg -uu -i` over the real install path]: + +| Probe | Result | +|-------|--------| +| `control register\|control ledger\|security register\|controls.md` | 0 hits anywhere in gsd-core | +| `ARCHITECTURE-DECISION` | 0 hits anywhere in gsd-core | +| `templates/SECURITY.md` frontmatter | `phase: {N}` - structurally per-phase, cannot be project-scoped | +| `gsd-security-auditor.md` | Scope is "Verifies threat mitigations from PLAN.md threat model"; reads one phase's PLAN.md, writes one phase's SECURITY.md. No project-level input or output. | +| `workflows/secure-phase.md` | Reads `PLAN.md ` per phase; `register_authored_at_plan_time` is a per-phase flag | +| `templates/README.md` root-artifact table | 11 exact names, none security-related | + +**The near-miss: `.planning/METHODOLOGY.md`, a "Standing Reference Artifact".** +[VERIFIED: `references/artifact-types.md:95-131`] + +GSD documents a third artifact category beyond Core and Extended - **Standing Reference +Artifacts** - explicitly "project-scoped, not phase-scoped", living at the `.planning/` root. Its +one member is METHODOLOGY.md, and its listed example lenses include **"STRIDE threat modeling"**. + +I checked whether this is real or aspirational. It is **partly real**: + +- `workflows/discuss-phase-assumptions.md:195` genuinely runs `cat .planning/METHODOLOGY.md` [VERIFIED] +- `workflows/pause-work.md:182` genuinely lists it as required reading [VERIFIED] +- `references/artifact-types.md:110` claims `plan-phase` reads it - **this claim is false**; + `rg -F METHODOLOGY` over `workflows/plan-phase.md` returns zero hits [VERIFIED] + +**Why it is still not the home for C1-C18:** METHODOLOGY.md holds *lenses* - "reusable +interpretive frameworks", each with "What it diagnoses / What it recommends / When to apply". That +is a how-to-reason artifact. C1-C18 is a what-we-decided-to-build artifact. Putting concrete +controls in a lens registry would be read by `discuss-phase-assumptions` as analytical guidance and +mis-applied. **Does not change the plan.** + +**Why it matters anyway - two consequences:** + +1. **Recategorise the file.** ARCHITECTURE-DECISION.md is not a rogue invention; it is a + *Standing Reference Artifact* - a category GSD documents but under-populates. This is a better + frame than "project invention" for the pointer text and for the deferred rename discussion. +2. **GSD's own criterion for this category is a consumption mechanism.** Quoting in full: + + > **Why consumption matters:** A METHODOLOGY.md that no workflow reads is inert. The lenses only + > take effect when an agent loads them into its reasoning context before analysis. This is why + > both the discuss-phase-assumptions and pause-work workflows explicitly reference this file. + > + > -- `references/artifact-types.md:114-116` [VERIFIED] + + Also `artifact-types.md:5`: "A well-formatted artifact that no workflow reads is inert - the + consumption mechanism is what gives an artifact meaning." This is the doctrinal basis for the + whole task. Cite it in the plan. + +## Q-EXTRA (not asked, but it breaks CONTEXT.md Decision 6) + +**CONTEXT.md Decision 6 says:** "`PROJECT.md`'s `## Evolution` section lists what to review at each +milestone; add the control ledger to that checklist so it inherits GSD's review cadence." + +**This does not work.** Neither workflow reads PROJECT.md's `## Evolution` section. Both carry +their own hardcoded checklists: + +- `workflows/complete-milestone.md:249-292` - `` with a + fixed 7-item list [VERIFIED] +- `workflows/transition.md:190-212` - `` with a fixed list [VERIFIED] + +The `## Evolution` section in PROJECT.md is a *transcript* of those hardcoded lists, not an input +to them. Adding a line to it is human-readable prose that no workflow will execute - the precise +"inert artifact" failure mode Q2 just quoted. + +**What IS in the hardcoded milestone checklist** (and therefore does inherit the cadence): + +| # | Checklist item | Reviews | +|---|----------------|---------| +| 6 | "Key Decisions audit" | `## Key Decisions` table | +| 7 | "Constraints check: Any constraints changed during development? Update as needed" | `## Constraints` | + +[VERIFIED: `workflows/complete-milestone.md:284-292`] + +**Fix - both halves are already in the plan, they just need re-pointing:** + +- The Nx-contract row moving to `## Constraints` (Decision 2) **already** picks up cadence via + item 7. No extra work. +- Re-target Decision 6 from `## Evolution` to `## Key Decisions`. The row + `| One backend per process ... | ... | [OK] Decided (see ARCHITECTURE-DECISION.md) |` + (PROJECT.md:143) already exists and already gets audited every milestone under item 6. + Strengthening that row's pointer text is strictly more load-bearing than an Evolution line. +- Keeping the Evolution line as well is harmless documentation - just do not rely on it. + +## Q3 - Exact shape of PROJECT.md `## Constraints` + +**Template shape** [VERIFIED: `templates/project.md`]: + +```markdown +## Constraints + +- **[Type]**: [What] - [Why] +- **[Type]**: [What] - [Why] + +Common types: Tech stack, Timeline, Budget, Dependencies, Compatibility, Performance, Security +``` + +Guidance: "Hard limits on implementation choices" and **"Include the 'why' - constraints without +rationale get questioned"** [VERIFIED: `templates/project.md`, ``, `**Constraints:**`]. + +**Live shape** (`.planning/PROJECT.md:130-137`) - 6 rows, all conforming: + +| Line | Type label | +|------|-----------| +| 132 | `**Tech stack**` | +| 133 | `**Platform**` | +| 134 | `**Auth / repo scope**` | +| 135 | `**Security**` | +| 136 | `**Compatibility**` | +| 137 | `**Distribution**` | + +Three shape facts the new row must match: + +1. **Separator is an ASCII hyphen-space, ` - `, not an em dash.** The template prints an em dash; + the live file uses ASCII throughout `## Constraints` (per the repo's ASCII-only rule). Match the + live file, not the template. +2. **Type labels are open-ended.** `Platform`, `Auth / repo scope`, and `Distribution` are all + invented locally, outside the template's "Common types" list. So a new `**Nx contract**` label + is well-precedented and preferable to force-fitting the material into `**Compatibility**` + (already occupied by cross-OS hash parity). +3. **Rows are one long sentence, semicolon-chained.** No sub-bullets anywhere in the section. + +**Pre-existing duplication to reconcile** - the Nx-contract material is not fully novel: + +- `## Constraints` line 132 already declares `Nx 23`. +- `## Out of Scope` line 101 already says "Nx custom task runner API - deprecated; target only the + current self-hosted-cache HTTP contract" - which covers the ADR's final sentence. + +So the genuinely-new content to carry over is narrower than the ADR's 5 lines: the OpenAPI 3.0.0 +spec is embedded in the Nx docs with no standalone artifact; the PUT success code moved 202 -> 200 +between Nx 20 and Nx 21 while `info.version` stayed `1.0.0` (so version-watching does not detect +drift); the conformance fixture must hash the full vendored spec and pin a named Nx version; and +the Nx 21+ floor is hard because `HttpRemoteCache` matches `200` strictly. Per CONTEXT.md's own +`` ("Verify no canonical artifact is left asserting something the extraction deleted"), +do not re-state the task-runner exclusion - it is already in Out of Scope. + +## Q4 - Other project-invented files under `.planning/` + +**Answer: ARCHITECTURE-DECISION.md is the ONLY invention. `spikes/` is canonical - CONTEXT.md +Decision 1 is wrong about it.** + +### Correction: `spikes/` is shipped GSD, not a project invention + +[VERIFIED: 9 gsd-core files reference `.planning/spikes/`] + +| Evidence | Location | +|----------|----------| +| Documented artifact type, incl. `MANIFEST.md` | `references/artifact-types.md:75-79` | +| `/gsd:spike` writes there | `workflows/spike.md:4` | +| `discuss-phase` probes for `MANIFEST.md` | `workflows/discuss-phase.md:255` | +| `new-project` probes for `MANIFEST.md` | `workflows/new-project.md:341` | +| `next` scans `spikes/*/README.md` for `verdict: PENDING` | `workflows/next.md:202` | +| `pause-work` writes spike handoffs there | `workflows/pause-work.md:21,31` | + +The shipped layout is `.planning/spikes/NNN-name/README.md` + `.planning/spikes/MANIFEST.md`. This +repo's tree matches it exactly (`001-reader-round-trip` .. `005-cross-os-roundtrip`, `MANIFEST.md`). +Only `spikes/CONVENTIONS.md` is a local addition, and it is inside a canonical directory. + +**Consequence for this task: none** - `spikes/` was already out of scope. But CONTEXT.md +Decision 1's sentence "`ARCHITECTURE-DECISION.md` and `spikes/` are project inventions" should not +be echoed into any artifact the plan writes. + +### Full `.planning/` root inventory vs. the canonical set + +The authoritative list is `CANONICAL_EXACT` in `gsd-core/bin/lib/artifacts.cjs:18-30`: +`PROJECT.md`, `ROADMAP.md`, `STATE.md`, `REQUIREMENTS.md`, `MILESTONES.md`, `BACKLOG.md`, +`LEARNINGS.md`, `THREADS.md`, `config.json`, `CLAUDE.md`, `RETROSPECTIVE.md`; plus regex patterns +for `vX.Y-*.md`. + +| Entry | Canonical? | Evidence | +|-------|-----------|----------| +| `PROJECT.md` `ROADMAP.md` `STATE.md` `REQUIREMENTS.md` `MILESTONES.md` `RETROSPECTIVE.md` `config.json` | yes | `CANONICAL_EXACT` | +| `codebase/` | yes | `gsd-codebase-mapper`; 21 gsd-core refs | +| `research/` | yes | `templates/research-project/` ships all 5 files, names match exactly | +| `phases/` `milestones/` `debug/` `quick/` | yes | 52 / 11 / 10 / 5 gsd-core refs | +| `spikes/` | **yes** (correction above) | 9 gsd-core refs | +| **`ARCHITECTURE-DECISION.md`** | **NO** | sole non-canonical entry | + +**No further orphans exist. Nothing to scout for follow-ups.** + +### Machine-checkable signal: W019 + +`.planning/` root `.md` files not in `CANONICAL_EXACT` are flagged by `gsd health`: + +``` +addIssue('warning', 'W019', `Unrecognized .planning/ file: ${entry.name} - not a canonical GSD artifact`, + 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md ...') +``` +[VERIFIED: `gsd-core/bin/lib/verify.cjs:1495-1496`; catalogued at `workflows/health.md:182`] + +Evaluated directly against the shipped predicate: + +``` +ARCHITECTURE-DECISION.md -> W019 +THREAT-MODEL.md -> W019 +CONTROLS.md -> W019 +METHODOLOGY.md -> W019 +PROJECT.md -> canonical +``` +[VERIFIED: `node -e` against `artifacts.cjs.isCanonicalPlanningFile`] + +Three things follow: + +1. **The file trips W019 today and will still trip it after slimming.** This task cannot clear the + warning; it is inherent to any non-canonical root `.md`. Do not let the plan claim otherwise. +2. **The deferred rename gains nothing here** - `THREAT-MODEL.md` and `CONTROLS.md` are equally + non-canonical. Useful input for that follow-up discussion: rename on naming merit only, not to + satisfy health. +3. **W019's remediation text ("Move to ... or delete if stale") is wrong for this file** and should + not be followed. GSD's own `artifact-types.md` sanctions project-scoped standing references at + the `.planning/` root, and METHODOLOGY.md - GSD's own example - trips W019 too. The checker and + the taxonomy disagree; the taxonomy is the better authority for a deliberately-kept file. + Directories are never checked (`if (!entry.isFile()) continue`), so `spikes/` etc. are unaffected. + +### Inbound reference counts, CURRENT artifacts only + +[VERIFIED: `git grep -c -F "ARCHITECTURE-DECISION"`] + +| File | Count | +|------|-------| +| `.planning/research/ARCHITECTURE.md` | 3 | +| `.planning/PROJECT.md` | 3 | +| `.planning/codebase/CONCERNS.md` | 3 | +| `.planning/ROADMAP.md` | 2 (lines 14, 541) | +| `.planning/research/FEATURES.md` | 2 | +| `.planning/STATE.md` | 1 (line 95) | +| `.planning/research/STACK.md` | 1 | +| `.planning/spikes/MANIFEST.md` | 1 | +| `.planning/spikes/004-ghcr-hazards/README.md` | 1 | +| `docs/trust-and-security.md` | 1 (line 15, table row "CREEP control ledger (C1-C18)") | +| `packages/github-cache/src/backend/actions-cache-backend.ts` | 1 (line 88, control C1) | + +**9 current `.planning/` files + 2 outside.** Confirms CONTEXT.md Decision 3's count exactly. +Archived (`milestones/`) and historical (`quick/`) refs excluded per Decision 4. + +**Reframe this changes:** the file is *not* orphaned - it is already linked from 9 current +artifacts, including STATE.md and ROADMAP.md, which CONTEXT.md Decision 5 does not mention. Decision +6 correctly diagnoses the real root cause (nothing *schedules a review*), so the work is not "add +missing links" but "make the existing links describe the right thing, and attach the one that gets +audited". Adding many more pointers would be motion, not progress. + +**Drift-guard note:** `packages/github-cache/src/docs-trust.spec.ts:25` asserts on +`docs/trust-and-security.md` [VERIFIED], so CONTEXT.md `` is right that a mistake in the +docs file fails a test. But the guard does **not** assert the `ARCHITECTURE-DECISION.md` path +string - it does not check the pointer resolves. Keeping the filename (Decision 3) is what keeps +that link alive; there is no test backing it. + +## Don't Hand-Roll + +| Problem | Don't build | Use instead | +|---------|-------------|-------------| +| Pointer format for the slimmed file | A bespoke "See also" block | `` `path` - [what it decides] `` from `context.md:84` | +| Staleness signal on a pointer | A custom review-date comment | `See: (updated [date])` from `state.md:25` | +| Scheduling a milestone review | A line in `## Evolution` (inert) | A row in `## Key Decisions` or `## Constraints` (hardcoded checklist items 6 and 7) | + +## Assumptions Log + +| # | Claim | Section | Risk if wrong | +|---|-------|---------|---------------| +| A1 | `## Evolution` re-targeting is a plan change, not a scope expansion | Q-EXTRA | If treated as out of scope, Decision 6 ships inert and the drift recurs at the next milestone - the exact failure this task exists to fix | +| A2 | `**Nx contract**` is the right new Constraints label | Q3 | Cosmetic; CONTEXT.md marks exact wording as Claude's discretion | + +No `[ASSUMED]` package or version claims - this task installs nothing. + +## Sources + +**Primary (HIGH):** all read directly from the installed GSD 1.6.1 at +`~/.gsd-opengsd/1.6.1/package/gsd-core` and from this repo's git index. + +- `references/artifact-types.md` - artifact taxonomy, Standing Reference Artifacts, inertness doctrine +- `templates/README.md` - GSD Canonical Artifact Registry, W019 statement +- `templates/project.md`, `templates/state.md`, `templates/SECURITY.md` +- `workflows/discuss-phase/templates/context.md`, `workflows/plan-phase.md` +- `workflows/complete-milestone.md`, `workflows/transition.md`, `workflows/health.md` +- `workflows/secure-phase.md`, `workflows/spike.md`, `workflows/next.md`, `workflows/pause-work.md`, + `workflows/discuss-phase-assumptions.md` +- `bin/lib/artifacts.cjs`, `bin/lib/verify.cjs` (executed, not just read) +- `~/.claude/agents/gsd-security-auditor.md` +- This repo: `.planning/PROJECT.md`, `.planning/ARCHITECTURE-DECISION.md`, `.planning/ROADMAP.md`, + `.planning/STATE.md`, `packages/github-cache/src/docs-trust.spec.ts` + +**Method note:** `~/.claude/gsd-core` is a symlink. `rg` does not traverse symlinks without `-L`, +so an initial pass silently returned zero hits for every query. All negative results above were +re-run against the resolved real path and validated with a positive control +(`planning/codebase` -> 21 files) before being recorded as genuine zeros. + +## Metadata + +**Confidence breakdown:** +- Q1 linking convention: HIGH - exhaustive template search, exact line cites +- Q2 no security-register home: HIGH - six independent falsification probes, all negative +- Q3 Constraints shape: HIGH - template and live file both read in full +- Q4 inventory + counts: HIGH - `CANONICAL_EXACT` executed against real filenames +- Q-EXTRA Evolution finding: HIGH - both workflow steps read in full + +**Valid until:** next `/gsd:update` (findings are pinned to GSD 1.6.1 internals). diff --git a/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-SUMMARY.md b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-SUMMARY.md new file mode 100644 index 00000000..e05ac855 --- /dev/null +++ b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-SUMMARY.md @@ -0,0 +1,311 @@ +--- +status: complete +task: 260726-pjz +title: Audit, triage, extract and deduplicate .planning/ARCHITECTURE-DECISION.md +executed: 2026-07-26 +branch: main +base: fe25a3f +head: 27e2cb6 +commits: 3 +gates: 3/3 green, each run verbatim from the plan's block +ledger_hash_before: bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f +ledger_hash_after: bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f +source_files_changed: 0 +pushed: false +pr: none +--- + +# Quick Task 260726-pjz -- Execution Summary + +Three atomic commits on `main`, in plan order. Documentation only: no file under `packages/` was +touched, and `git diff fe25a3f..HEAD -- packages/` is empty. Nothing pushed. + +`.planning/ARCHITECTURE-DECISION.md` went from 91 lines carrying ten sections to 118 lines +carrying one control ledger, seven residual notes and a retained bibliography. The line count +went UP because the framing that explains where everything went is new; the DUPLICATED content +is what left. + +## Task -> commit + +| Task | SHA | Subject | Files | +| --- | --- | --- | --- | +| 1 | `4699232` | prove per-claim coverage before slimming the ADR | `260726-pjz-COVERAGE.md` (NEW, 150 lines) | +| 2 | `45dd0f4` | slim the ADR to the C1-C18 ledger and move the Nx contract | `.planning/ARCHITECTURE-DECISION.md`, `.planning/PROJECT.md` | +| 3 | `27e2cb6` | re-point the nine inbound references at what the ADR now holds | 9 files, `27 insertions(+), 20 deletions(-)` | + +Every gate was executed verbatim, in the order the plan specifies, and Task 2's gate was re-run +after Task 3 to confirm the later PROJECT.md edits had not regressed it. Task 1's 36-assertion +battery was ALSO run assertion-by-assertion first (36/36 individually PASS) so that each +COVERAGE.md row records a probe that genuinely ran, rather than a share of one chained result. + +## COVERAGE.md dispositions, per claim + +44 rows, one per distinctive claim, no row reading anything else: + +| Disposition | Count | What it means here | +| --- | --- | --- | +| COVERED | 35 | proven present in a named artifact outside the ADR; deleted | +| SPENT | 1 | the spike/PoC framing; obsolete because the rebuild happened and `MILESTONES.md` records v0.0.1 shipped | +| RESIDUE | 8 | proven absent everywhere else; RETAINED in the slimmed ADR | + +Three ways a coverage probe can lie were closed explicitly rather than assumed away: + +1. **One term standing in for a list.** The twelve-source bibliography is a single wholesale + RESIDUE row citing eight measured-absent tokens, not one token pretending to prove twelve. +2. **A term that only matches a shipped-requirements checklist tick.** A tick records that + something SHIPPED, not that the reasoning being deleted survives. Three assertions were + therefore pointed at substantive prose instead: `context-derived` at + `codebase/INTEGRATIONS.md:84`, `no-flag safety property` at `research/FEATURES.md:152`, and + `2 GiB` at `MILESTONES.md:28`. +3. **A negative probe built from the sentence being deleted**, which passes by phrasing accident. + Each residue row therefore carries substance probes as well as the literal token, and excludes + the ADR, this task's `quick/` directory, and archived `.planning/milestones/` (an archived + record is history, not a canonical home). + +That third safeguard is what caught the one measured deviation from the plan -- see below. + +## The seven claims retained as residue, and why + +Retention is the DEFAULT here, not an escape hatch: the governing criterion is "keep only what has +no canonical home", so a claim with no home stays where it is. All seven live in the new +`## Residual notes` section: + +1. **The D1 YAGNI deferrals** -- `synchronous write fan-out` and `multiple simultaneous stores`. + The third item in the original list, a local read-write store, is COVERED at + `PROJECT.md ## Out of Scope:104` and was NOT retained. +2. **The GHES anti-spoofing cross-check** -- absence of `/meta` `installed_version` plus the + `X-GitHub-Enterprise-Version` header, and the dormant version-gate knob held OFF until a GHES + floor publishes. +3. **Read-time integrity** -- store-and-verify a published `content-sha256`, and explicitly NOT + `sha256(blob) == {hash}`, because the Nx key hashes task inputs rather than the stored bytes. +4. **The rejection rationale** for git-native storage and Actions build artifacts. See the + deviation note below: this one is genuinely covered and was retained deliberately. +5. **The CREEP-orthogonality scope check** -- the reader choice does not move the primary threat; + it is a remediation win, not a prevention one. +6. **The Nx client's inherited tarball-extraction hardening** against `..`/absolute/symlink/ + `hardlink` escape, so a malicious server cannot `zip-slip` the client. This one was found by + auditing the MOVE section on the same terms as the REMOVEs -- it is the disposition table's only + un-audited row, it is not part of the contract facts being moved, and it would otherwise have + been deleted silently along with its unactioned recommendation. +7. **The single-layer containment residual risk** -- gate correctness is load-bearing with + `no backstop`, because the only true second layer (C7 provenance attestation) is deferred. + +Plus the whole `## References` block as an eighth RESIDUE row, retained byte-identical: +`Cacheract`, `sccache`, `HeroDevs`, `CodeQL`, `nixcache-oci`, tag mutability and `nx.app/files` +appear in no other tracked file, so deleting the block would strand twelve sources. + +### Two claims previously assumed homeless were measured COVERED and deleted + +Both were deleted rather than retained because a second copy would rebuild exactly the +two-sources-of-truth pair this task exists to remove: + +- **"Do not assume the publisher is pluggable"** -- `research/ARCHITECTURE.md:161`, verbatim again + at `research/SUMMARY.md:58`. +- **The retention "never introduce a second knob" invariant** -- six homes, including + `PROJECT.md:115` and SHIPPED CODE at `packages/github-cache/src/lib/retention.ts:5`. A doc-only + copy drifting away from executable code is the worst version of this failure. + +## The ledger survived byte-identical + +This was a documentation reorganisation, not a security-posture change, and the 18 control rows +prove it: + +| Tree | sha256 of the 18 control rows | +| --- | --- | +| before (`fe25a3f`) | `bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f` | +| after (`27e2cb6`) | `bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f` | + +The rows were never retyped. The rebuild spliced them in by line extraction from a pristine copy +of the original, so a reword, an ASCII fold or a dropped row was not merely gated against but +structurally impossible. The same splice preserved the CVE-2025-36852 preamble and the +`## References` block; all three regions were then byte-diffed against the original and came back +identical. Non-ASCII characters survive only on those preserved lines (line 53 and control rows +C1/C3/C4/C5/C6/C7/C11/C16/C18) -- every line authored by this task is ASCII, verified by a +character-class scan over the authored line ranges. + +C10's row still cites a decision number. That is the single sanctioned exception, and +`Decision [0-9]` now matches exactly one line in the whole file. + +## What moved, and where the second copy lives + +`PROJECT.md ## Constraints` gained one `**Nx contract**` row carrying the operative hard floor -- +the Nx client (`HttpRemoteCache`) matches PUT success strictly as `200`, so a `202`-returning +server breaks it -- plus a pointer, not a copy. + +**`.planning/research/STACK.md` section 1 is the SECOND, more detailed copy of the Nx contract.** +A future reader who finds the trimmed `## Constraints` row should go there and should NOT +re-inflate the row. STACK.md section 1 already holds, measured: the OpenAPI 3.0 spec and +`version: 1.0.0` (`:16,23`); the `202`->`200` drift and the static `info.version` (`:36-38`, +verbatim the same sentence); the hash-the-vendored-spec requirement (`:38`); and the hard Nx 21+ +floor with `HttpRemoteCache`'s strict-200 match (`:39-40`). Copying the endpoint/status table into +`## Constraints` would have made a third copy -- the exact defect this task exists to remove. + +The MOVE itself still stands because `## Constraints` is audited by `/gsd:complete-milestone` +checklist item 7 and `research/STACK.md` is audited by nothing. `Nx 23` (already in the Tech stack +row) and the deprecated task-runner exclusion (already at `## Out of Scope:101`) were deliberately +not restated; the task-runner phrase still appears exactly once in the file. + +## The cadence is attached to something that actually runs + +The `## Key Decisions` pointer row now names what the file holds and states the cadence: +re-read and reconcile at each milestone Key Decisions audit. That row is covered by +`/gsd:complete-milestone`'s hardcoded checklist item 6. + +Nothing was written to `## Evolution`, and the diff confirms that section is untouched. Neither +`transition.md` nor `complete-milestone.md` reads it -- both carry hardcoded checklists, and +`## Evolution` is a transcript of them rather than an input -- so a line there would have been the +precise inert artifact this task exists to fix. + +## The inbound link SET, unchanged at nine + +The gate asserts the sorted FILE LIST, not a count, because a count of 9 also passes a +simultaneous +1/-1 (REQUIREMENTS.md gaining the filename while research/STACK.md loses it) which +would violate the constraint while looking clean. The set after the change is exactly: + +1. `.planning/PROJECT.md` +2. `.planning/ROADMAP.md` +3. `.planning/STATE.md` +4. `.planning/codebase/CONCERNS.md` +5. `.planning/research/ARCHITECTURE.md` +6. `.planning/research/FEATURES.md` +7. `.planning/research/STACK.md` +8. `.planning/spikes/004-ghcr-hazards/README.md` +9. `.planning/spikes/MANIFEST.md` + +Plus the two out-of-tree references, both still resolving because the filename did not change: +`docs/trust-and-security.md:15` and the C1 comment at +`packages/github-cache/src/backend/actions-cache-backend.ts:88`. Worth recording: `docs-trust.spec.ts` +asserts on the docs FILE but not on this path STRING, so no test backs either reference. The +unchanged filename is the only thing holding them. + +Seven live sites that cited a now-deleted decision number were re-pointed to +`PROJECT.md ## Key Decisions`. A bare `Decision [0-9]` grep over all of `.planning` now returns +zero outside four excluded paths. + +`REQUIREMENTS.md` was re-pointed WITHOUT gaining the ADR filename, which would have made the set +ten. + +## Deliberately not touched + +- **`.planning/milestones/**`** -- archived records; rewriting them falsifies history. + `git status --porcelain` on that path is empty. +- **`.planning/spikes/005-cross-os-roundtrip/**`** (`README.md:5`, `README.md:16`, + `ci-roundtrip.mjs:4`) -- these three cite the decision number as the thing the spike was RUN TO + VALIDATE, so rewriting them falsifies the evidence, on the same rationale as the archived + milestones. `git status --porcelain` on that path is empty too. `spikes/MANIFEST.md` IS the live + spike index rather than a sealed record, so both of its sites were re-pointed. +- **`gsd health` W019** -- it fires today, fires after slimming, and fires for GSD's own + METHODOLOGY.md. Its remediation text ("move to archive or delete if stale") is wrong for a + deliberately-kept standing reference; the taxonomy is the better authority. The ADR now records + this in the first person so it is not re-litigated. +- **`PROJECT.md:17`** (already accurate), the dated `Last updated` footer (a log entry; rewriting + it would falsify the log), and `AGENTS.md` (Nx/worktree agent guidance with no natural anchor). + +## Deviations + +### D1 -- one residue row is genuinely covered, and was retained anyway (documented, not silent) + +The plan asserts that residue item 4, the git-native / Actions-build-artifact rejection rationale, +is homeless, on the grounds that "the MANIFEST records THAT they are out, not WHY". That is true +of `spikes/MANIFEST.md:7`, but the plan did not probe `research/STACK.md`, which records both +rejections WITH their reasons: + +- `research/STACK.md:77` -- git objects / refs: "**Reject** - bloats history, no clean eviction". + The phrase "no clean eviction" is VERBATIM the ADR's. +- `research/STACK.md:75` -- Actions Artifacts: "No (run-scoped, not key-scoped) ... **Reject** - no + anon read, wrong lookup shape", which is "not content-keyed" in substance plus a further reason. + +The plan's literal token probes (`clone bloat`, `content-keyed`) do return homeless -- which is +exactly the phrasing accident the plan itself warns against, since the probe was built from the +sentence being deleted. + +**Retained, deliberately, for three reasons.** Retention is the plan's stated default and is the +LOSSLESS error, whereas deleting on a contested measurement is not. Task 2's gate requires the +`content-keyed` token to be present, and gates were to be run verbatim rather than edited to match +a fresh finding. And unlike the two claims the plan DID reclassify, this is a dead-end historical +rationale for primitives that were never built, not a live invariant, so a second copy carries +essentially no drift risk. The retained bullet now signposts `research/STACK.md` section 2 as the +fuller treatment, which converts a silent duplicate into a labelled one. Recorded in full as Note +C in COVERAGE.md. + +### D2 -- a second, smaller partial-coverage finding + +The dormant version-gate knob half of residue item 2 has partial coverage at +`research/PITFALLS.md:310` ("keep the version-gate knob dormant/OFF") and +`docs/trust-and-security.md:76`. The distinctive claim -- the anti-spoofing cross-check MECHANISM +itself -- is homeless everywhere. The row was retained whole rather than split, since splitting a +two-clause sentence to delete half of it buys nothing and risks losing the mechanism's motivation. +Recorded as Note B in COVERAGE.md. + +### D3 -- `.planning/STATE.md` was committed as part of Task 3 + +The orchestrator brief says not to commit STATE.md. The plan's Task 3, however, lists STATE.md in +its `` and requires re-pointing `STATE.md:95`, and that file must remain in the nine-file +inbound set. Committing the one-line content edit with the rest of Task 3 keeps the task atomic; +leaving it dangling in the working tree would have mixed plan content into the orchestrator's +bookkeeping commit. Only the line-95 attribution changed -- no counters, no position, no session +fields. The orchestrator's own STATE.md update is unaffected and will apply cleanly on top. + +## Deferred follow-ups (both need an interactive decision) + +### 1. The rename to `THREAT-MODEL.md` or `CONTROLS.md` + +Not done here, and out of scope by SCOPE DISCIPLINE rather than by merit. A file containing only a +control ledger does make "ARCHITECTURE-DECISION" a misnomer, and the honest names are better ones. +But the task authorised extract, deduplicate and link -- not a rename -- and the blast radius is +real: nine current inbound references, roughly 35 archived artifacts, a shipped +`docs/trust-and-security.md` citation and a source comment in `actions-cache-backend.ts`, none of +which is covered by a test that asserts the path string. + +Worth carrying into that discussion: **a rename would gain nothing on W019.** Evaluated directly +against GSD's shipped `isCanonicalPlanningFile` predicate, `THREAT-MODEL.md` and `CONTROLS.md` +trip W019 exactly as `ARCHITECTURE-DECISION.md` does -- the warning fires for any non-canonical +root `.md`. Rename on naming merit only. + +### 2. The zip-slip docs note that was recommended and never written + +The Nx contract section carried an unactioned recommendation: the Nx client's inherited +tarball-extraction hardening is "worth a docs note". The CLAIM is now preserved in the ADR's +`## Residual notes`, but the RECOMMENDATION is a consumer-docs change, and this task reorganises +existing documentation rather than authoring new consumer docs. + +Candidate paragraph for `docs/trust-and-security.md`, offered as a starting point rather than +final wording: + +> **What the Nx client protects on its own.** Cache entries are tar archives, and the Nx client +> hardens extraction against archive members that try to escape the extraction directory -- `..` +> path segments, absolute paths, and symlink or hardlink targets pointing outside it. A malicious +> or compromised cache server therefore cannot use a crafted archive to write outside the cache +> directory (a "zip-slip"). This protection is inherited from the Nx client and is not implemented +> by this project, so it applies to any self-hosted cache server the client talks to, not just +> this one. It is not a CREEP control: it defends the extraction step, whereas CREEP poisons the +> bytes before they are ever hashed (see the write-trust and sync gates above). + +Before it ships, someone should re-verify the hardening against the pinned Nx version rather than +inheriting this claim from the original record -- a consumer-facing security statement should rest +on a fresh check. + +## Verification at HEAD + +All three gates re-run at `27e2cb6`, plus a consolidated pass: + +- 44 disposition rows, none reading anything but COVERED / RESIDUE / SPENT +- 18 control rows, hash unchanged, no removed heading survives, `## Residual notes` and + `## References` both present, all 18 residue tokens present, `Decision [0-9]` on exactly one line +- the ADR contains no reference to its own filename, so it is not a tenth inbound match +- `**Nx contract**` row present with the ASCII separator, `HttpRemoteCache`, and the STACK.md + pointer on the same line; task-runner exclusion still appears exactly once +- bare `Decision [0-9]` over `.planning` returns zero outside the four excluded paths; the inbound + file SET matches the nine exactly; both out-of-tree references resolve +- `.planning/milestones` and `.planning/spikes/005-cross-os-roundtrip` both clean +- no `packages/` file modified; no AI-attribution trailer; no email-shaped token introduced; + committer identity is the public address on all three commits + +## Self-Check: PASSED + +Every artifact this summary claims was created exists on disk (`COVERAGE.md`, the rewritten +`ARCHITECTURE-DECISION.md`, `PROJECT.md`, this file). All three commit hashes resolve in +`git log`. Both hygiene scans that reported a failure on the first pass were re-run after the +checks themselves were corrected -- the ASCII check had a wrong expected line count, and +`git log --grep` exits 0 whether or not it matches, so it could never have reported a clean +result. Both are clean under the corrected checks. diff --git a/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-VERIFICATION.md b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-VERIFICATION.md new file mode 100644 index 00000000..f5ff21bd --- /dev/null +++ b/.planning/quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/260726-pjz-VERIFICATION.md @@ -0,0 +1,202 @@ +--- +phase: quick/260726-pjz +verified: 2026-07-26T22:20:00Z +status: human_needed +score: 9/10 must-haves verified +behavior_unverified: 0 +overrides_applied: 0 +human_verification: + - test: "Decide whether the `## Residual notes` bullet 'Why two storage primitives were rejected + outright' stays. Read `.planning/ARCHITECTURE-DECISION.md:95-98` next to + `.planning/research/STACK.md:75,77`." + expected: "Independently re-measured and CONFIRMED: STACK.md:77 carries 'Reject - bloats + history, no clean eviction' (the phrase 'no clean eviction' is verbatim the ADR's) and + STACK.md:75 carries 'No (run-scoped, not key-scoped) ... Reject - no anon read, wrong lookup + shape' (substantively 'not content-keyed'). This residue row HAS a canonical home, so the + KEEP criterion ('keep only what has no canonical home') says delete it. It was retained + because Task 2's gate hard-requires the `content-keyed` token and gates were run verbatim. + Deleting the bullet also requires editing that gate token." + why_human: "A deliberate, disclosed deviation from the phase's governing criterion. Whether a + labelled duplicate of a dead-end historical rationale is acceptable is a judgment call on a + security-adjacent document, not a grep result." + - test: "Decide whether four removed reasoning sentences from Decision 3 / Decision 2 needed a + COVERAGE.md row. They are absent from the 44-row table and homeless in the live tree." + expected: "Independently probed homeless (excluding the ADR, quick/ and milestones/): + (a) Decision 3's honesty caveat - 'total control surface is ~a wash ... Releases wins under a + remediation/safety-weighted lens, a defensible judgment for this tool, not a raw-count fact' + (`raw-count`, `safety-weighted`, `scaling-correctness`, `defensible judgment` all homeless); + (b) the reversibility COST detail - 'costs a later-milestone publish/cleanup build + + re-populate, with no consumer-contract or migration impact' (`re-populate`, + `no consumer-contract`, `migration impact` homeless; the 'additive, not a switch' half IS + covered at `codebase/CONCERNS.md:412`); (c) the GHES version specifics - 'GHES 3.21 GA'd + 2026-06-11 ... earliest possible is an unannounced 3.22+' (`3.21`/`3.22` homeless; the + substance IS covered at `research/PITFALLS.md:65-79` and `PROJECT.md:148`); (d) GHCR's + digest-pin edge being 'minor, self-inflicted' (`self-inflicted` homeless; the mild-edge + substance IS at `spikes/003-size-ceiling` and STACK.md). Item (a) is arguably SPENT rather + than lost: `spikes/004-ghcr-hazards/README.md:84-88` empirically supersedes it with a + HARDER claim ('materially larger operational + security control surface than Releases')." + why_human: "Whether a hedge on a claim is itself a 'distinctive claim' is a judgment. Nothing + operative was lost - no control, no invariant, no constraint - but the enumeration was not + exhaustive, and the task's own standard was that it would be." + - test: "Confirm the uncommitted `.planning/PROJECT.md` working-tree change is unrelated and + intended. Run `git diff -- .planning/PROJECT.md`." + expected: "The only uncommitted hunk widens v0.0.2 requirement ID ranges (PARITY-01..04 -> + ..05, VER-01..04 -> ..06, adds LINT-01..06/CORR-06, XOS-01..07/TEST-08..10/OBS-02..05). It + does NOT touch the Nx contract Constraints row, the Key Decisions cadence row, or any ADR + pointer, so it does not affect this task's result - but it means PROJECT.md on disk differs + from commit 27e2cb6." + why_human: "Ownership of an unrelated uncommitted edit is outside this task's scope to judge." +--- + +# Quick Task 260726-pjz: Audit / triage / extract / deduplicate ARCHITECTURE-DECISION.md - Verification Report + +**Task Goal:** Audit, triage, extract and deduplicate `.planning/ARCHITECTURE-DECISION.md` into +canonical GSD artifacts; keep only what has no canonical home; re-describe existing inbound links; +attach a working review cadence. +**Verified:** 2026-07-26 +**Status:** human_needed +**Re-verification:** No - initial verification +**Commits under review:** `4699232`, `45dd0f4`, `27e2cb6` (base `d0f5399`; the prompt's `fe25a3f` +is five milestone-setup commits earlier - those five are NOT part of this task's diff) + +Every finding below was re-derived by executing probes against the tree. No claim in SUMMARY.md or +COVERAGE.md was accepted on its own authority; where this report agrees with them, it is because +the independent probe returned the same result. + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | Every distinctive claim of every removed section classified as COVERED / RESIDUE / SPENT by an executed grep, recorded in COVERAGE.md, before the removal edit | WARNING - PARTIAL | 44 disposition rows present, 0 reading UNCOVERED/TODO. I re-ran the 36-assertion positive battery: **36/36 pass**. I re-ran every negative probe independently: 7 of 8 residue rows re-measure homeless. But the enumeration is NOT exhaustive - four removed reasoning sentences (Decision 3's weighting caveat, the reversibility cost detail, GHES 3.21/3.22 specifics, GHCR's "self-inflicted edge") have no row and no home. See Human Verification item 2 | +| 2 | Retention is the default; 8 RESIDUE rows survive in the slimmed ADR; the two mis-filed claims are COVERED and deleted | VERIFIED | All 18 residue tokens present in the ADR (checked one by one). Both reclassifications independently confirmed: "do not assume it is pluggable" at `research/ARCHITECTURE.md:162` + verbatim `research/SUMMARY.md:58`; "never introduce a second knob" at `PROJECT.md:115` + shipped `packages/github-cache/src/lib/retention.ts`. Neither appears in the slimmed ADR | +| 3 | Controls C1-C18 survive byte-identical; 18 rows hash to `bb8cd95...1ce2f` | VERIFIED | Hashed BOTH trees myself: `git show fe25a3f:...\| rg '^\| C[0-9]+ \|' \| sha256sum` = `bb8cd9515a8e1477ea557ebc3aa5ce820fbc032744b2459e9b80fa2b44d1ce2f`; same command on the working file = identical. 18 rows. The CVE preamble (`59963177...`) and the `## References` line (`2bfaa99a...`) also hash identical across both trees | +| 4 | The file keeps its name; both out-of-tree references keep resolving | VERIFIED | `docs/trust-and-security.md:15` -> `` `.planning/ARCHITECTURE-DECISION.md` ``; `packages/github-cache/src/backend/actions-cache-backend.ts:88` -> "ARCHITECTURE-DECISION.md control C1". C1 survives byte-identical. All ten `ADR C` citations across `docs/`, `ppe/action.yml`, `cache-key.ts` and `actions-cache-backend.spec.ts` target surviving controls | +| 5 | No LIVE artifact cites a Decision number that no longer exists; 7 sites re-pointed; 3 sealed spike sites excluded | VERIFIED | Bare `rg 'Decision [0-9]'` over `.planning` excluding milestones/quick/ADR/spikes-005 returns **zero**. All 7 re-pointings read correctly in the diff. `.planning/spikes/005-cross-os-roundtrip/` has zero diff | +| 6 | Decision 6's alternative branch is not lost - it lives in PROJECT.md Key Decisions | VERIFIED | `PROJECT.md:151` CORR-01 row: "**OS-namespace the store by default** (or documented consumer OS-discrimination)". All three ex-Decision-6 citations (PROJECT.md:80, PROJECT.md:153, REQUIREMENTS.md:59) now name that row explicitly | +| 7 | The inbound link SET stays exactly 9 current `.planning/` files | VERIFIED | Sorted set matches the required list byte-for-byte. REQUIREMENTS.md was re-pointed WITHOUT gaining the filename (would have made ten); no file lost its pointer. The ADR contains zero self-references, so it is not a tenth match | +| 8 | The review cadence attaches to something a workflow genuinely consumes, NOT to `## Evolution` | VERIFIED | Verified against the GSD install with `rg -L`: `complete-milestone.md:293` is a hardcoded "**Key Decisions audit:**" checklist item and `:298` is "**Constraints check:**". Both new/edited rows sit under those two headings. A full `rg -L "Evolution"` over `gsd-core/` finds only WRITERS (`new-project.md:505`, `new-milestone.md:153`) - no workflow reads `## Evolution`. The PROJECT.md diff does not touch it | +| 9 | Archived milestone artifacts untouched; `gsd health` W019 not acted on | VERIFIED | `git diff d0f5399..27e2cb6 -- .planning/milestones/` is empty; `git status --porcelain` on that path is empty. The ADR records W019 in the first person at lines 45-49 and does not act on it | +| 10 | The MOVE is de-duplicated too - Constraints carries the hard floor plus a pointer, not a third copy | VERIFIED | Read `research/STACK.md:15-40` directly: it carries the full endpoint/status table, "single token; server decides read-only vs read-write", required `Content-Length`, 409, GET 200/403/404, the `202 -> 200` drift verbatim, hash-the-vendored-spec, and the hard Nx 21+ floor with `HttpRemoteCache` strict-200. The new `PROJECT.md:133` row is ONE sentence: hard floor + STACK.md section 1 pointer. `Nx 23` appears only in the Tech stack row; "Nx custom task runner API" appears exactly once | + +**Score:** 9/10 truths verified (0 present, behavior-unverified; 1 partial routed to human) + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `.planning/quick/260726-pjz-.../260726-pjz-COVERAGE.md` | Pre-removal coverage proof, one row per removed claim | VERIFIED | 150 lines, 44 disposition rows, 0 UNCOVERED/TODO, five explanatory notes (A-E) including the self-disclosed Note C deviation. Pure ASCII | +| `.planning/ARCHITECTURE-DECISION.md` | Slimmed to the C1-C18 ledger plus framing | VERIFIED | 7 headings (was 11). All 8 removed headings gone, no `## Decision N` survives. `Decision [0-9]` matches exactly one line: C10's verbatim row (line 66) | +| `.planning/PROJECT.md` | New `**Nx contract**` Constraints row + re-described Key Decisions row | VERIFIED | Line 133 (Constraints, ASCII ` - ` separator throughout, no em dash) and line 146 (Key Decisions Outcome cell with the cadence sentence and `(updated 2026-07-26)` suffix) | + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|-----|-----|--------|---------| +| `PROJECT.md ## Key Decisions` row | `.planning/ARCHITECTURE-DECISION.md` | milestone checklist item 6 | WIRED | Row names the ledger by content ("the project-level CREEP control ledger C1-C18") and states the cadence. `complete-milestone.md:293` consumes it | +| `PROJECT.md ## Constraints` **Nx contract** row | `research/STACK.md` section 1 | milestone checklist item 7 | WIRED | `complete-milestone.md:298` consumes it. Pointer target verified to hold the detail | +| `docs/trust-and-security.md:15` | `.planning/ARCHITECTURE-DECISION.md` | filename unchanged | WIRED | Resolves. Confirmed no `.spec.ts` asserts the path string - the unchanged filename is the only thing holding it | +| `actions-cache-backend.ts:88` | control C1 | filename + byte-identical C1 | WIRED | C1 row hash-verified unchanged | + +### Data-Flow Trace (Level 4) + +Not applicable - documentation-only change, no dynamic data rendering. + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| Task 1 coverage battery (36 assertions) | 36 individual `git grep -q -F` / `test -f` | 36/36 pass | PASS | +| Task 2 gate (verbatim from PLAN) | full chained gate | exit 0 | PASS | +| Task 3 gate (verbatim from PLAN) | full chained gate | exit 0 | PASS | +| Ledger hash parity across trees | `sha256sum` on both `fe25a3f` and working tree | identical | PASS | +| Residue homelessness (26 tokens) | `git grep -q -F` excluding ADR/quick/milestones | 7 of 8 rows homeless; row 4 has a home | PARTIAL | +| Non-ASCII confined to preserved lines | `rg '[^\x00-\x7F]'` on the ADR | only line 53 + C1/C3/C4/C5/C6/C7/C11/C16/C18 | PASS | +| `packages/` untouched | `git diff d0f5399..27e2cb6 -- packages/` | empty | PASS | + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +|------|------|---------|----------|--------| +| - | - | none introduced | - | No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/`PLACEHOLDER` in any line added by these three commits. The `**Plans**: TBD` hits in ROADMAP.md are pre-existing from `d0f5399` (milestone setup), not this task | + +### The size question, answered honestly + +`wc` on both trees: + +| Tree | lines | words | bytes | +|------|-------|-------|-------| +| before (`fe25a3f`) | 91 | 2676 | 19611 | +| after | 117 | 1589 | 11140 | + +**Lines are the wrong metric here and the +29 percent is not scope drift.** The deleted Decision +sections were single mega-paragraph lines of 800-2000 characters each; the authored replacement +wraps at ~95 columns. By the metrics that track content, the file **shrank 41 percent by words and +43 percent by bytes**. Of the 11140 remaining bytes, 4616 (41 percent) is the byte-identical +preserved core - the 18 control rows, the CVE preamble and the `## References` block. The rest is +the Status block, "Where the rest of this record went", the cadence line, the W019 note, and the +seven residual bullets. The SUMMARY's framing ("the line count went UP because the framing that +explains where everything went is new") is accurate but undersells it; the honest headline is that +the file lost 1087 words. + +### The three focus items in the brief + +**Focus 3 - the self-reported deviation. CONFIRMED, and the reading is correct.** I read +`research/STACK.md:75` and `:77` directly. Line 77 (git objects / refs): "Repo-bloating; no +per-object expiry ... **Reject** - bloats history, no clean eviction" - "no clean eviction" is +verbatim the ADR's phrase and "bloats history" is "clone bloat". Line 75 (Actions Artifacts): "No +(run-scoped, not key-scoped) ... **Reject** - no anon read, wrong lookup shape" - "run-scoped, not +key-scoped" is exactly "not content-keyed" in substance, plus a further reason. The literal token +probes (`clone bloat`, `content-keyed`) do return homeless, which is precisely the phrasing-accident +failure mode the plan warned against. **Retention leaves a real duplicate** - one bullet, ADR:95-98 +- but a labelled one: the bullet carries its own pointer to "STACK.md section 2's +primitive-comparison table, which is the fuller treatment", and section 2 is indeed where that +table lives (STACK.md:60, table at :72-78). Low drift risk (a dead-end rationale for primitives +never built), but it does violate the phase's KEEP criterion. Routed to human. + +**Focus 3, smaller sibling - the dormant version-gate knob. CONFIRMED partial.** +`research/PITFALLS.md:310` reads "keep the version-gate knob dormant/OFF", so that sub-clause is +duplicated. But the distinctive half - the anti-spoofing cross-check MECHANISM (`/meta` +`installed_version` absence plus the `X-GitHub-Enterprise-Version` header) - re-measures homeless +in every live artifact. The only full-claim hit is `.planning/milestones/v0.0.1-REQUIREMENTS.md:57`, +which D-04 rules out as a historical record rather than a canonical home. Retaining the row whole +is the right call; the leftover duplication is one clause. + +**Focus 6 - the cadence. It works.** This was the whole point of the task and it landed correctly. +`## Evolution` is write-only in GSD (only `new-project.md` and `new-milestone.md` mention it, both +as writers); nothing reads it, so a cadence line there would have been the exact inert artifact the +task existed to avoid. `complete-milestone.md:293` ("Key Decisions audit") and `:298` ("Constraints +check") are hardcoded checklist steps, and both edited rows sit under those headings. Nothing was +written to `## Evolution`. + +**Focus 5 - untouched-by-contract. Zero changes, confirmed three ways:** `git diff +d0f5399..27e2cb6` shows no path under `.planning/milestones/`, `.planning/spikes/005-cross-os-roundtrip/` +or `packages/`; `git status --porcelain` is empty for the first two; `git diff fe25a3f..HEAD -- +packages/` is empty. + +### Gaps Summary + +No blockers. The destructive edit is genuinely lossless for everything that matters: the 18 controls +are byte-identical (hash-verified against the pre-change tree, not against a claim), the CVE preamble +and bibliography are byte-identical, every one of the 36 coverage assertions re-runs green, and 7 of +the 8 retained residue rows re-measure homeless under my own probes rather than the executor's. + +Two loose ends, both judgment calls rather than defects: + +1. **One retained residue row has a canonical home** (`STACK.md:75,77`). Retained deliberately and + disclosed in both COVERAGE.md Note C and the SUMMARY. It is the lossless error, and the bullet + signposts the fuller treatment, but it is a duplicate in a file whose stated criterion forbids + duplicates. + +2. **The 44-claim enumeration was not exhaustive.** Four reasoning sentences were deleted without a + classification row and have no home. The most substantive is Decision 3's honesty caveat about + the control-surface comparison being "a defensible judgment for this tool, not a raw-count fact" + - and that one is arguably SPENT rather than lost, since `spikes/004-ghcr-hazards/README.md:84-88` + empirically supersedes it with a harder claim in the same direction. None of the four is a + control, an invariant, or an operative constraint, and the decision they hedge is recorded in + three independent places plus the full spike records. + +--- +*Verified: 2026-07-26* +*Verifier: Claude (gsd-verifier)* From a0bf8ebf9647b70d0bf439aea866137c03a96249 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 22:54:22 +0200 Subject: [PATCH 010/511] docs(research): add v0.0.2 milestone research and synthesis --- .planning/research/v0.0.2/ARCHITECTURE.md | 674 +++++++++++++++++ .planning/research/v0.0.2/FEATURES.md | 498 ++++++++++++ .planning/research/v0.0.2/PITFALLS.md | 880 ++++++++++++++++++++++ .planning/research/v0.0.2/STACK.md | 766 +++++++++++++++++++ .planning/research/v0.0.2/SUMMARY.md | 400 ++++++++++ 5 files changed, 3218 insertions(+) create mode 100644 .planning/research/v0.0.2/ARCHITECTURE.md create mode 100644 .planning/research/v0.0.2/FEATURES.md create mode 100644 .planning/research/v0.0.2/PITFALLS.md create mode 100644 .planning/research/v0.0.2/STACK.md create mode 100644 .planning/research/v0.0.2/SUMMARY.md diff --git a/.planning/research/v0.0.2/ARCHITECTURE.md b/.planning/research/v0.0.2/ARCHITECTURE.md new file mode 100644 index 00000000..0c784733 --- /dev/null +++ b/.planning/research/v0.0.2/ARCHITECTURE.md @@ -0,0 +1,674 @@ +# Architecture Research -- v0.0.2 OS-invariant cross-OS sharing + +**Domain:** integrating OS-invariance into the shipped v0.0.1 ports-and-adapters cache +**Researched:** 2026-07-26 +**Consumer:** gsd-roadmapper and the Phase 7-12 planners +**Confidence:** HIGH for blast radius and the publish path (read directly from the tree and from +the installed `@actions/cache@6.2.0` source); HIGH for the layer-coupling verdict; MEDIUM for the +Phase 7 lint-inference risk (unmeasurable until Phase 8 runs). + +> Carries forward `.planning/research/ARCHITECTURE.md` (v0.0.1) without repeating it: the +> `CacheBackend` port, `selectBackend`'s one-backend-per-process rule, the two-predicate trust +> split, and the "publish/cleanup is reader-specific and behind no port" boundary all still hold +> and are unchanged by v0.0.2. What follows is only what v0.0.2 touches. + +--- + +## 0. Headline findings + +Seven things the committed roadmap does not say, ordered by how much they change planning: + +1. **TEST-09 / XOS-03 as written becomes unsatisfiable after Phase 9.** After VER-01/VER-03 the + Actions cache no longer partitions by OS, so a Windows runner CAN restore the Linux + `integration` entry at the storage layer. "Windows CI MISSES the Linux entry" is now a + statement about **Nx hashes**, not about cache storage. See section 6.1. This needs a + re-specification before Phase 11 is planned. +2. **`start-cache-server/index.js` inlines both comment-locked helpers.** Every Phase 9 and Phase + 10 source edit must run `npm run build:action` in the same commit, or the sidecar and the + publish action compute different cache versions. Section 2.3. +3. **`ci.yml` is NOT in `nx.json`'s `test` inputs.** Phase 10's OBS-05/XOS-06/XOS-07 guards and + Phase 12's DOCS-07 drift guard will serve stale cached PASSes. Section 5.4. +4. **Phase 12's O4 HIT has no producer-to-consumer ordering.** Parallel ubuntu and Windows legs at + the same hash both MISS on the first run. Section 6.3. +5. **CORR-05 has four violation sites in three files, and one of them survives CORR-02.** + `release-asset-name.spec.ts:60` is not eliminated by the rename, because OBS-03 keeps + `cachePlatform` alive. Section 2.2. +6. **The Phase 9-to-Phase 10 window doubles shard asset growth** (every hash mirrored under both + `-linux` and `-windows`). Bounded and safe, but it must not be misread as a regression. + Section 4.3. +7. **TRUST-11's arbitration point is wrong.** First-write-wins between differing payloads happens + at the Actions-cache `saveCache`, not at the Release upload. Section 4.4. + +--- + +## 1. What v0.0.2 does and does not touch + +| Layer | v0.0.2 impact | +|-------|---------------| +| HTTP protocol (`server/server.ts`) | NONE | +| `CacheBackend` port (`backend/types.ts`) | NONE | +| `selectBackend` (`lib/select-backend.ts`) | NONE. No new branch, no new env read. This is what makes PARITY-05 / D2-02 cheap to satisfy | +| Trust predicates (`lib/trust.ts`, `lib/sync-gate.ts`) | NONE (TRUST-10 verifies, does not change) | +| Retention (`lib/retention.ts`) | NONE. The one coupled knob and the shard-tag scheme are untouched | +| Cache-key namespace (`lib/cache-key.ts`) | NONE to the code; it becomes the SOURCE for the new asset name (D2-03) | +| `lib/cache-archive-path.ts` | REWRITTEN (VER-01/VER-02/VER-04) | +| `lib/release-asset-name.ts` | REWRITTEN (CORR-02/RETAIN-04/OBS-03) | +| `backend/actions-cache-backend.ts` | MODIFIED at 3 call sites (VER-03) + archive-dir creation | +| `publish/publish-mirror.ts` | MODIFIED (label plumbing for OBS-03) | +| `action/index.ts` | MODIFIED (`uploadReleaseAsset` adapter gains `label`) | +| `cleanup/cleanup.ts` | UNCHANGED code; its filter's BEHAVIOUR changes via `isServerProducedAssetName` | +| `start-cache-server/index.js` | REGENERATED (never hand-edited) | +| `nx.json` | MODIFIED (CORR-04 discriminator audit, `lint` inputs, `ci.yml` as a `test` input) | +| `.github/workflows/ci.yml` | MODIFIED in Phases 8, 9, 10, 11 and 12 | + +Two files carry the whole milestone's code risk: `cache-archive-path.ts` and +`release-asset-name.ts`. Both are comment-locked single sources whose failure mode is a silent +MISS, not a crash. + +--- + +## 2. Blast radius of the two comment-locked helpers + +### 2.1 `cacheArchivePath` (VER-01, VER-02, VER-04) + +Current: `join(tmpdir(), 'nx-github-cache-.tar')`. +Target: a hardcoded forward-slash workspace-relative literal under `.nx/cache/`. + +**Production call sites (complete):** + +| Location | Role | +|----------|------| +| `packages/github-cache/src/lib/cache-archive-path.ts:34` | definition | +| `packages/github-cache/src/backend/actions-cache-backend.ts:45` | `get` -> `restoreCache` + `readFile` + `rm` | +| `packages/github-cache/src/backend/actions-cache-backend.ts:67` | `put` -> `writeFile` + `saveCache` + `lookupOnly` probe + `rm` | +| `packages/github-cache/src/publish/publish-mirror.ts:175`, `:216` | transitive, via `createActionsCacheBackend()` | +| `packages/github-cache/src/serve.ts:90` | transitive, via `selectBackend` | +| `start-cache-server/index.js` (generated) | INLINED copy consumed by every `uses: ./start-cache-server` sidecar | + +**Test pins, classified:** + +| Pin | Verdict | +|-----|---------| +| `lib/cache-archive-path.spec.ts:22-27` (`isAbsolute` true, `dirname === tmpdir()`) | **REPLACE.** VER-02 says so explicitly. Both clauses invert: the new path is relative and has no `tmpdir` relationship. This is CORR-05 violation 1 of 4 | +| `lib/cache-archive-path.spec.ts:16-20` (`basename(path) === 'nx-github-cache-abc123.tar'`) | **REPLACE.** Pin the FULL literal string, not `basename()`. The whole point is that the exact bytes handed to `@actions/cache` are what get hashed; a `basename()` assertion cannot see a directory-prefix change | +| `lib/cache-archive-path.spec.ts:29-36` (byte-identical / differs by hash) | KEEP unchanged | +| `backend/actions-cache-backend.spec.ts:340-355` (`restoreCache`/`saveCache` receive exactly `[cacheArchivePath(hash)]`) | KEEP, and EXTEND for VER-03 (argument list + call count across all three sites) | +| `backend/actions-cache-backend.spec.ts:32,38,50,54,58,92,131,160,169,241,242,351` | Derived from the helper, so they relax automatically. BUT they do real filesystem I/O -- see the ENOENT hazard below | +| `lib/select-backend.spec.ts:53`, `serve.spec.ts:421` | Derived cleanup `rm` calls. Same ENOENT/dir hazard | +| `action/index.ts:259` (error text naming `cacheArchivePath`) | Text only; keep, it stays accurate | + +**Three implementation hazards the requirements do not name:** + +1. **ENOENT on save.** `tmpdir()` always exists; `.nx/cache/` may not. `put` does + `writeFile(path, bytes)` BEFORE `saveCache`, so the backend must `mkdir` the directory + recursively first. The specs that write archives directly need the same setup. One `mkdir` + with `{ recursive: true }` in `actions-cache-backend.ts` covers both `get` and `put`. +2. **Glob base vs tar base.** Verified in `node_modules/@actions/cache/lib/internal/cacheUtils.js` + and `tar.js` at the pinned 6.2.0: + - `resolvePaths()` globs the pattern against **`process.cwd()`**; + - it then makes each match relative to **`GITHUB_WORKSPACE ?? cwd()`** for the tar entry name; + - `extractTar` runs `tar -C `; + - our own `readFile`/`writeFile` resolve against **`process.cwd()`**. + + A relative path is therefore only coherent when `cwd === GITHUB_WORKSPACE`. VER-04's assertion + should be exactly that (plus "and it is the Nx workspace root", cheaply `existsSync('nx.json')`), + not a looser "cwd looks like a workspace". Place the assert at + `createActionsCacheBackend()` construction, NOT inside `cacheArchivePath` -- the helper must stay + a pure string function so its specs stay pure, and construction covers both `serve()` and + `publishMirror()` in one place. It is naturally scoped: the Actions backend is only ever + constructed in a write-trusted CI context, so a local developer's sidecar never trips it. +3. **`.nx/cache/` is Nx's own directory.** Writing `nx-github-cache-.tar` beside Nx's task + cache is harmless (file vs directory, no name collision) but `nx reset` clears it. Consider + `.nx/cache/github-cache/` as the literal instead of `.nx/cache/` to keep the two tenants + visibly separate; either satisfies D2-04. + +**Verified upstream facts backing VER-01/VER-03/VER-05** (`@actions/cache@6.2.0`, read from +`node_modules`, not from docs): + +```js +export function getCacheVersion(paths, compressionMethod, enableCrossOsArchive = false) { + const components = paths.slice(); // the RAW strings, never resolved + if (compressionMethod) components.push(compressionMethod); + if (process.platform === 'win32' && !enableCrossOsArchive) components.push('windows-only'); + components.push(versionSalt); // '1.0' + return crypto.createHash('sha256').update(components.join('|')).digest('hex'); +} +``` + +So the version has exactly three variable components, matching VER-01 (path), VER-05 +(compression), VER-03 (`windows-only`). `paths` is the untouched caller array, which is why a +separator difference is a silent MISS and why the literal must be pinned. + +**VER-03's positional-index claim is CONFIRMED at 6.2.0.** From `lib/cache.d.ts`: + +- `restoreCache(paths, primaryKey, restoreKeys?, options?, enableCrossOsArchive?)` -> **index 4** +- `saveCache(paths, key, options?, enableCrossOsArchive?)` -> **index 3** + +and `saveCache`'s JSDoc lists `@param enableCrossOsArchive` BEFORE `@param options`, while the +signature is the reverse. The three edits are therefore: + +```ts +cache.restoreCache([path], cacheKeyFor(hash), [], undefined, true) // get +cache.saveCache([path], cacheKeyFor(hash), undefined, true) // put +cache.restoreCache([path], cacheKeyFor(hash), [], { lookupOnly: true }, true) // put probe +``` + +Note the `undefined` placeholders. A spec that asserts only "the last argument is `true`" would +pass on a wrong-index call; assert the full argument LIST per site, as VER-03 requires. + +### 2.2 `releaseAssetName` (CORR-02, RETAIN-04, OBS-03) + +Current: `` `${hash}-${cachePlatform(platform)}` ``. +Target: `nx-cache-`, single-sourced from `CACHE_KEY_PREFIX` (D2-03). + +**Production call sites (complete):** + +| Location | Role | +|----------|------| +| `lib/release-asset-name.ts:50` | definition | +| `backend/releases-backend.ts:75` | the reader's single derivation (`assetNaming.releaseAssetName(hash)`) | +| `publish/publish-mirror.ts:226` | the publisher's single derivation | +| `cleanup/cleanup.ts:89` | consumes the sibling `isServerProducedAssetName` filter | +| `start-cache-server/index.js:68387` (generated) | INLINED copy | + +**Test pins, classified:** + +| Pin | Verdict | +|-----|---------| +| `lib/release-asset-name.spec.ts:21-23` (`'abc123-linux'` literal) | **REPLACE** with the `'nx-cache-abc123'` literal | +| `lib/release-asset-name.spec.ts:31-35` ("differs for the same hash under a different platform") | **REPLACE BY ITS INVERSE.** This is the single most important pin flip in the milestone. It must become "IDENTICAL across platforms" -- deleting it instead would leave nothing at all asserting OS-invariance | +| `lib/release-asset-name.spec.ts:37-41` (default arg equals `process.platform`) | **DELETE.** The parameter goes away. CORR-05 violation 2 of 4 | +| `lib/release-asset-name.spec.ts:50-57` (`cachePlatform` mapping `it.each`) | KEEP. Injected values only, and OBS-03 keeps `cachePlatform` alive for the asset label | +| `lib/release-asset-name.spec.ts:59-61` (`cachePlatform()` vs `cachePlatform(process.platform)`) | **CORR-05 violation 3 of 4, and it is NOT removed by CORR-02.** See the gap note below | +| `lib/release-asset-name.spec.ts:68-100` (`isServerProducedAssetName` accept/reject/round-trip) | **EXTEND, do not replace.** Add `nx-cache-` accepts; keep every legacy accept (RETAIN-04); keep every reject. Rewrite the round-trip against the new producer | +| `lib/release-asset-name.spec.ts:98-100` (`CACHE_OS_VALUES` content pin) | KEEP. RETAIN-04 requires the tuple to survive | +| `lib/release-asset-name.spec.ts:106-121` (`.gitattributes` LF guard) | KEEP. Unrelated and still load-bearing | +| `backend/releases-backend.spec.ts:37-38` (`OTHER_PLATFORM` from `process.platform`) | **REPLACE.** CORR-05 violation 4 of 4 | +| `backend/releases-backend.spec.ts:103-118` ("MISSES an OS-sensitive hash present ONLY under another platform") | **REPLACE.** It asserts the exact property CORR-02 removes. See the coverage note below | +| `backend/releases-backend.spec.ts:86-101` (returns THIS platform's bytes) | REPLACE with a single-name resolution assertion | +| `backend/releases-backend.spec.ts:127-134` (derives the name ONLY through the helper) | KEEP. Derived, and it is the anti-drift guard that matters most | +| `publish/publish-mirror.spec.ts:100,101,122,135,165,216,239,399` | All derived. Survive unchanged | +| `lib/select-backend.spec.ts:130` | Derived. Survives | +| `cleanup/cleanup.spec.ts:77,167,168,191,216-218,240,241,260,261,303` | **KEEP AS-IS.** These twelve `-linux` fixtures ARE the legacy-family coverage RETAIN-04 demands. Add new-family fixtures and a mixed-shard dry-run alongside them | + +**Gap 1 -- CORR-05 is not fully closed by CORR-02.** REQUIREMENTS.md says all three violating +files are "eliminated as a side effect of VER-02 and CORR-02". That holds for +`cache-archive-path.spec.ts`, for `releases-backend.spec.ts:38`, and for +`release-asset-name.spec.ts:39`. It does NOT hold for `release-asset-name.spec.ts:60`: OBS-03 +deliberately keeps `cachePlatform` (it is what derives the producing-OS label), so the +`cachePlatform()` default-argument test is still meaningful and still an ambient read. Phase 10 +must make an explicit call: delete it and accept the lost default-arg branch coverage, or move it +into `public-server.integration.spec.ts` where LINT-02 allows it. Phase 10 SC 2 should say which. + +**Gap 2 -- CORR-02 destroys a negative control with nothing named to replace it.** +`releases-backend.spec.ts:103-118` is explicitly documented as the non-vacuous proof of CORR-01 +("a positive-only correct-hit assertion above still passes with OS-namespacing deleted entirely"). +CORR-02 deletes OS-namespacing on purpose, so that test must go -- and the reason it existed +(a positive-only assertion is vacuous) does not go away. The replacement should be: assert the +reader requested EXACTLY ONE asset name and that the recorded name equals the imported +`releaseAssetName(hash)` and contains no platform token. Phase 10's plan must name this +explicitly or coverage drops silently. + +**Gap 3 -- `releaseAssetName`'s `platform` parameter is dead after Phase 10, but LINT-02, +CORR-06 and ROADMAP Phase 7 SC 2 all use `releaseAssetName(hash, 'win32')` as the canonical +"injected value is allowed" example.** Phase 7 lands three phases earlier, so the example is +valid when written and dead when Phase 10 ships (and `fallow` will flag the unused parameter). +Tell the Phase 7 planner to write the LINT-03 fixture against a symbol Phase 10 keeps -- +`cachePlatform('win32')` is the obvious substitute, since OBS-03 preserves it. + +**Naming convergence worth exploiting.** With the new name being `nx-cache-`, the asset name +is byte-identical to the Actions-cache key produced by `cacheKeyFor(hash)`. The laziest correct +implementation is therefore: + +```ts +export function releaseAssetName(hash: Hash): string { return cacheKeyFor(hash); } + +export function isServerProducedAssetName(name: string): boolean { + return isServerProducedKey(name) || isLegacyOsSuffixedAssetName(name); +} +``` + +This satisfies D2-03 literally (one authored prefix, in `cache-key.ts`) and keeps the legacy +branch as an isolated, deletable function. The two name families are provably disjoint: the legacy +accepter splits on the LAST `-`, so `nx-cache-abc123` yields hash `nx-cache`, which fails +`HASH_PATTERN`; and no legacy `-` name starts with `nx-cache-`. **TRUST-10 caveat:** if +`isServerProducedAssetName` delegates to `isServerProducedKey`, the auditor verifying that C16's +Actions-cache-side filter is "unchanged" must check the function's behaviour AND its +`publish-mirror.ts:191` call site, not merely that the file diff is empty -- a new caller has been +added. + +### 2.3 The generated bundle is a third call site (highest-severity drift risk) + +`start-cache-server/index.js` is an esbuild bundle of `start-cache-server/entry.ts`, which reaches +`serve()` -> `selectBackend` -> BOTH backends. Confirmed by inspection: the committed bundle +contains `getCacheVersion`'s `windows-only` branch at line 30367 and `cachePlatform` / +`releaseAssetName` at lines 68378 / 68387. + +The four sidecar jobs in `ci.yml` (`build`, `typecheck`, `test`, `integration`) run the COMMITTED +bundle from the git ref, never a build output. The `publish` and `dogfood-*` jobs run +`dist/action/index.js`, built fresh. **If Phase 9 changes `cache-archive-path.ts` without +regenerating the bundle, the sidecar writes entries at cache version V_old while the publish +action restores at V_new: the mirror silently stops receiving anything, surfacing only as the +soft all-restore-MISS warning.** The `action-bundle-drift` CI job does catch this, but only on +push, so name `npm run build:action` as an explicit task in every Phase 9 and Phase 10 plan that +touches a `serve()`-reachable source. The same applies if any Phase 7 ESLint autofix rewrites one +of those files. + +--- + +## 3. Layer independence: confirmed for reads, false for writes + +**Verdict: the O1/O2 (Releases) and O3/O4 (Actions cache) layers are genuinely independent on the +READ path, which is what the mandated ordering needs. They are COUPLED on the WRITE path, and the +roadmap's "two independent layers" framing understates this.** + +The read paths are cleanly separated by `selectBackend`: an untrusted context (a local Windows +workstation) gets `createReleasesReadBackend` and never constructs the Actions backend or touches +`cacheArchivePath`; a trusted CI context gets `createActionsCacheBackend` and never touches +`releaseAssetName`. No shared state, no shared code path. O1/O2 cannot be affected by a +`cacheArchivePath` change at read time, and O3/O4 cannot be affected by a `releaseAssetName` +change at all. + +**The coupling the requirements missed: `publishMirror` is the bridge.** It constructs +`createActionsCacheBackend()` (`publish-mirror.ts:175`) and restores through it +(`:216`) before uploading under `releaseAssetName` (`:226`). So the Releases mirror's CONTENT is +determined by Actions-cache restore semantics. Three consequences the plans need: + +1. **O1 and O2 depend on Phase 9, not just Phase 10.** Today a Windows-produced entry only reaches + the mirror via the Windows publish leg (a same-OS restore). Phase 9 removes that constraint. + The roadmap captures this only as a TRUST-12 argument for Phase 9-before-Phase-10; it should + also appear as a functional dependency of the Phase 11 proofs. +2. **The publisher labels by the PUBLISHING leg's OS, not the producing OS.** + `releaseAssetName(hash)` at `:226` uses the running platform. Today publisher-OS equals + producer-OS because restore is same-OS; Phase 9 breaks that identity while Phase 10 has not yet + removed the suffix. See section 4.3 for the (bounded, non-correctness) consequence. +3. **`read-back.ts` is a Releases-side proof with an Actions-cache dependency.** Its hash is + `GITHUB_RUN_ID`, seeded by the publish job's `operation: seed` step through the Actions-cache + write path. OBS-05 fixes the leg-distinguishability but the cross-layer dependency remains, so + an Actions-cache regression can present as a Releases-side `publish-verify` failure. + +**One further coupling, in the hashing layer rather than the storage layer.** Both `integration` +(the OS-sensitive target, O3's mechanism) and `test`/`build`/`typecheck` (the shared targets, O1/O4) +derive from the same `nx.json` and the same `hash_project_config`. Any change to inference plugins +or target inputs moves BOTH at once. This is why Phase 7 must precede Phase 8, and it is also why +Phase 8's CORR-03 job is the continuous guard for Phases 9-12 that the roadmap already identifies. + +--- + +## 4. The publish path after OS-invariance + +### 4.1 What actually changes + +Before Phase 9, `getCacheVersion` on a Windows runner appends `windows-only`, so the ubuntu +publish leg's `restoreCache` on a Windows-saved key MISSes and the engine's miss branch +(`publish-mirror.ts:218-222`) skips it. After Phase 9, both legs compute the same version for the +same key, so **either leg can restore every entry the `ref`-scoped `listCacheEntries` enumerates.** + +`listCacheEntries` was already OS-blind: it is scoped only by `ref` +(`action/index.ts:40-43`). The OS filter was purely the restore step. This is precisely TRUST-12's +"sole-mechanism collapse", and it is why TRUST-10 pins the `ref` scoping by spec -- it becomes the +only in-repo control keeping non-default-branch trusted writes out of the world-readable mirror +(`TRUSTED_EVENTS` includes `push` with no ref check; `isSyncTrusted` does check the default branch, +but that gates the JOB, not the enumeration). + +### 4.2 Is the per-OS publish matrix still needed? + +**Not for coverage. Keep it for v0.0.2 anyway, and record that it is on borrowed time.** + +- Coverage: one leg now suffices. `ci.yml:580-584`'s claim that the matrix is "LOAD-BEARING and + self-enforcing" and that "collapsing this to one OS SILENTLY drops the other OS's entries" + becomes FALSE. That is DOCS-08's `ci.yml:577-583` correction. +- Honest post-Phase-10 state: under `max-parallel: 1` with the matrix ordered + `[ubuntu-24.04-arm, windows-11-arm]`, the ubuntu leg uploads everything and the Windows leg + finds every name already present, hits the first-write-wins skip branch + (`publish-mirror.ts:262-266`), and mirrors **zero** real assets. Its only genuine output becomes + OBS-05's leg-distinguishable seed -- that is, the Windows publish leg becomes a test of itself. + This is the strongest argument for the deferred collapse and should be written into the Phase 10 + record so the v0.0.3 follow-on is not re-derived. +- Why keep it in v0.0.2 regardless: REQUIREMENTS.md puts the collapse out of scope until XOS-05 is + proven, and removing a leg before O4 is demonstrated would remove the evidence needed to justify + removing it. Correct sequencing. + +### 4.3 `max-parallel: 1`, first-write-wins, and the Phase 9-to-Phase 10 window + +XOS-06 retains `max-parallel: 1`. It is still needed for its stated reason -- the shard-cap race, +where two concurrent legs both observe 999 assets and both pass the soft cap check. That reason is +unaffected by OS-invariance. + +**The window nobody costed.** Between Phase 9 landing and Phase 10 landing, the asset name still +carries the publishing leg's OS while both legs can restore everything. Each hash is therefore +mirrored TWICE, once as `-linux` and once as `-windows`. Analysis: + +- Not a correctness bug. A Linux reader derives `-linux` and only ever computes Linux + hashes; a Windows reader derives `-windows`. The only OS-sensitive target (`integration`) + already has distinct hashes per OS via CORR-04, so the mislabeled duplicate is unreachable by + name from the wrong-OS reader. O2 continues to work: the Windows leg still uploads + `-windows` because the ubuntu leg only created `-linux`. +- It is a **2x shard-growth effect**. `ci.yml:640-647` estimates roughly 5 real assets per + input-changing push and ~125 pushes per calendar month before `RELEASE_ASSET_CAP`. In this + window that becomes ~10 per push and ~62 pushes of headroom. Still comfortable, but the + arithmetic in that comment goes stale, and an operator watching shard growth must not read the + doubling as a bug. Record the expected signal alongside OBS-04's rotation signal, and keep the + Phase 9-to-Phase 10 window short. + +### 4.4 TRUST-11's arbitration point needs correcting before TRUST-13 audits it + +TRUST-11 states: "an Nx cache entry carries captured terminal output, which embeds OS-specific +paths, so two legs produce different bytes for the same hash. First-write-wins therefore +arbitrates between differing payloads." + +The premise is right; the arbitration point named is wrong, in a way that matters to the auditor: + +- **Two publish legs never produce differing payloads.** They both RESTORE the same Actions-cache + entry and upload those bytes verbatim; they do not re-execute the task. Byte-identical. +- **The real arbitration is at `saveCache`.** Once Phase 12's XOS-04 puts `build`/`typecheck`/`test` + on a Windows leg, two jobs compute the same hash H and both call `saveCache(nx-cache-H)`. The + Actions cache is first-write-wins, so whichever job's save lands first owns the entry, including + its OS-specific captured terminal output. That IS ordering-dependent (the two legs run in + parallel), and it is the correct target of TRUST-11's residual-risk statement. XOS-06 is + satisfied because no requirement depends on the winner, not because the race does not exist. +- **The Releases-side first-write-wins matters only across shards**, which is TRUST-11's + second clause and is correctly stated: a hash mirrored in June and re-mirrored in July under a + different producer is resolved July-first by `shardTagsForWindow`, so the reader's winner is + shard-dependent. +- **Cross-OS restore is byte-faithful.** The stored object is tar-in-tar: `@actions/cache` wraps + our single `.tar` file, whose entry name is forward-slash-normalized by `resolvePaths`. Restoring + on a different OS reproduces the inner file's bytes exactly, so mirroring a Windows-produced + artifact from the ubuntu leg does not corrupt it. The residual file-mode question in the + out-of-scope list applies to the Nx client's extraction of the INNER tar, not to our transport. + +Hand this to gsd-security-auditor as corrected INPUT, per TRUST-13. + +### 4.5 XOS-07 changes the publish job's critical path + +`needs: build` becomes `needs: [build, typecheck, test, integration]`. `integration` is a +two-leg matrix including `windows-11-arm` with `timeout-minutes: 20`, so publish now waits on the +slowest Windows leg. `if: ${{ !cancelled() && github.event_name == 'push' }}` is preserved +correctly: a failing Windows integration leg still lets the mirror run, which is the existing +"a failing test leg must never skip the mirror" property. No change needed to the `if:`, but the +plan should note the added wall clock. + +### 4.6 OBS-03 requires widening a seam + +`PublishClient.uploadReleaseAsset(releaseId, name, bytes)` has no label parameter. OBS-03 needs one: + +- `publish/publish-mirror.ts:64-68` -- widen the interface to `(releaseId, name, bytes, label)` +- `publish/publish-mirror.ts:269` -- pass `cachePlatform()` (the publishing leg's OS) +- `action/index.ts:81-96` -- pass `label` through to `octokit.rest.repos.uploadReleaseAsset` +- `publish/publish-mirror.spec.ts` -- every `uploadReleaseAsset` fake gains the parameter + +Note the semantics: the label records the OS of the leg that UPLOADED, which after Phase 9 is not +necessarily the OS that PRODUCED the entry. If OBS-03's incident-response goal is producer +attribution, the label as derived from `cachePlatform()` is misleading for cross-OS-restored +entries. There is no producing-OS signal available at the publish site -- `listCacheEntries` +returns only `{ key }`, and the Actions-cache API exposes no producing-OS field. Phase 10 must +either (a) label it honestly as `mirrored-by: ` rather than "producing OS", or (b) accept the +approximation and record the limitation. Recommend (a): it is one word of prose and it is true. + +--- + +## 5. Integration points not owned by the two helpers + +### 5.1 Phase 7 lint toolchain + +- `@nx/eslint` is NOT currently a devDependency. Adding it also requires an + `.fallowrc.jsonc` `ignoreDependencies` entry next to `@nx/vitest` (inference plugins are never + imported, so fallow reads them as unused). A root `eslint.config.mjs` is likewise never imported + and will probably need an `entry` declaration, alongside `esbuild.action.mjs`. +- `pinned-deps.spec.ts` is an **explicit per-package list of `it()` blocks**, not a blanket rule + over `dependencies`. LINT-01's "covered by the `pinned-deps` guard" therefore means adding one + `it()` per new dev dependency, in the `pinned build tooling (ROBUST-03)` describe block, which + already reads the ROOT manifest. Note the existing devDeps are a MIX (`@nx/js: "23.1.0"` exact, + `prettier: "^3.8.1"` ranged), so the guard is opt-in by design and the new entries must be + added deliberately. +- LINT-02's scoping mirrors real config: `vitest.config.mts` includes + `{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}` and excludes + `{src,tests}/**/*.integration.spec.{ts,mts,cts}`; `vitest.integration.config.mts` includes only + the latter. There is exactly ONE integration spec today + (`server/public-server.integration.spec.ts`), so the `ignores:` list has one real member. The + ESLint `files`/`ignores` glob pair must cover `.mts`/`.cts` too, or a future `*.spec.mts` slips + the rule. +- **The complete current inventory of ambient platform reads in spec files is four sites in three + files** -- `cache-archive-path.spec.ts:1,26`, `releases-backend.spec.ts:38`, + `release-asset-name.spec.ts:39`, `release-asset-name.spec.ts:60`. LINT-03's "confirmed CAUGHT" + evidence should enumerate all four, not the three files. Non-spec sources + (`release-asset-name.ts`, `cache-archive-path.ts`, `read-back.ts`) also read + `process.platform`/`node:os` and must remain OUT of the rule's scope. + +### 5.2 Phase 8 hash parity + +- `nx.json` currently declares the discriminator on exactly one target: + `targetDefaults.integration.inputs` carries `{ "runtime": "node -p process.platform" }` + (`nx.json:85`). CORR-04's "and is the ONLY target that does" is TRUE today; the requirement is + to keep it true mechanically. +- `{workspaceRoot}/nx.json` IS a `test` input (`nx.json:50`), so `nx-target-inputs.spec.ts` cannot + serve a stale pass on a discriminator change. Good precedent for CORR-04's guard: extend + `nx-target-inputs.spec.ts` rather than creating a new spec. +- CORR-03's requirement that a textual `nx.json` assertion does NOT satisfy the guard means the + two-leg job must capture real `nx show project` / hash output per leg. Note DOCS-07's warning + that `hash_runtime` hashes stdout AND stderr, so the capture must record both, and the + discriminator command itself must be stderr-silent. + +### 5.3 Phase 9 CI wiring + +`dogfood-verify` is ubuntu-only today (`ci.yml:451-470`). VER-06 needs it on `windows-11-arm` +while `dogfood-seed` stays on ubuntu. The existing `needs: dogfood-seed` already gives the +producer-then-consumer ordering, so this is a `runs-on:` change plus a note that the pair is now +the cross-OS control. This same shape is the answer to Phase 12's ordering problem (section 6.3). + +### 5.4 `ci.yml` is not hashed by `test` -- a live stale-PASS hole + +`nx.json:41-71` lists `{workspaceRoot}/.github/workflows/cleanup.yml` as a `test` input but NOT +`ci.yml`. `cleanup-workflow.spec.ts` exists and is safe; any new spec asserting on `ci.yml` is not. +This affects: + +- Phase 10's OBS-05 (leg-distinguishable seed), XOS-06 (the `max-parallel: 1` comment lock) and + XOS-07 (`publish` dependency list), if any are enforced by spec; +- Phase 12's DOCS-07, which explicitly says "registered in `nx.json`'s `test` inputs and guarded + against drift"; +- Phase 9's DOCS-08 correction of the `ci.yml` comments. + +`ci.yml:249-253` already documents this exact hazard as an unguarded invariant. Adding +`{workspaceRoot}/.github/workflows/ci.yml` to `targetDefaults.test.inputs` is the fix; it must land +BEFORE the first spec that reads `ci.yml`, i.e. early in Phase 10. It is OS-uniform so it has no +parity impact -- but see section 6.3 for how it interacts with the O4 proof. + +**DOCS-08 misses a third location.** It names `docs/advanced.md:54-57` and `ci.yml:577-583`. +`ci.yml:356-360` makes the same now-false claim in the integration job's comment ("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"). Add it to the DOCS-08 list. +`ci.yml:693` (`- via releaseAssetName`) and `roundtrip/read-back.ts:10-31,52-56` +likewise assert the same-OS contract and belong to Phase 10's CORR-02/OBS-05 work. + +Also worth noting for scoping: `README.md:125` and `docs/trust-and-security.md:155` frame "never a +wrong result" as a consequence of FAULT DEGRADATION ("every read fault degrades to a MISS"), which +stays true. DOCS-08's correction there is ADDITIVE (a new precondition about target +platform-agnosticism), not a contradiction. `docs/advanced.md:45` says the same thing and needs the +same treatment. + +--- + +## 6. Build order + +**Recommended order: 7 -> 8 -> 9 -> 10 -> 11 -> 12. The committed roadmap's sequence is correct.** +Three of its stated justifications need adjusting, and three phases need content the roadmap does +not currently allocate. + +### 6.1 Phase 11's O3 proof must be re-specified BEFORE Phase 11 is planned + +This is the most consequential disagreement. + +TEST-09 requires: "the Windows job must MISS the Linux `integration` hash AND HIT at least one +entry through the same code path in that same run", and Phase 11 SC 4 repeats it. + +After VER-01/VER-03, a storage-level probe for the Linux hash from a Windows runner would **HIT**, +not miss. The cache version is now identical across OSes, so `restoreCache([path], +'nx-cache-')` on `windows-11-arm` matches the entry the ubuntu leg saved. Asserting a 404 +there would be asserting a property this milestone deliberately destroyed. + +Worse, if such a probe DID return 404, the most likely cause would be a **compression-method +divergence** (VER-05's third version component: a runner image without `zstd` re-partitions the +version). That is exactly the "the proof passes for the pre-change reason" failure TEST-09 exists +to prevent, inverted. + +The proof that is actually available, and actually proves CORR-04, is: + +1. Record `H_linux` and `H_win` for `integration` at one commit and show they differ (this is + CORR-03(b), already a Phase 8 build-gating job -- Phase 11 can cite it rather than re-derive). +2. Show the Windows `integration` task EXECUTED (no `[remote cache]` label) in a run where + `nx-cache-` demonstrably existed in the Actions cache at the time. +3. Positive control in the same job: a scripted authed GET on a known-present key returns 200 + through the same sidecar and backend, so step 2 is not an artifact of a dead sidecar. The + existing `Wait for the loopback sidecar` step (`ci.yml:391-407`) is already a scripted authed + GET, so this is a small extension of a proven pattern, not new machinery. + +Reframed this way the proof is stronger: it demonstrates that the ONLY thing separating the two +targets is the declared Nx input, which is precisely CORR-04's claim. Recommend the roadmapper +amend XOS-03 / TEST-09 / Phase 11 SC 4 accordingly. + +### 6.2 Phase 11 is not proof-only + +The roadmap says "Phase 11 is proof-only, so MVP slicing does not apply". It carries real +implementation: + +- the scripted MISS-plus-positive-control probe above (new `ci.yml` steps); +- TEST-08's "the premise that Windows CI produces no `build`/`typecheck`/`test` hash is asserted + MECHANICALLY against the resolved Nx task graph" -- that is new tooling, not a proof artifact. + +Allocate plan capacity for both. MVP slicing still does not apply, but "no code" does not either. + +### 6.3 Phase 12's O4 HIT needs a producer-to-consumer ordering, and `ci.yml`-as-test-input forces the issue + +XOS-05 requires the Windows `build`/`typecheck`/`test` legs to HIT on entries the ubuntu leg saved. +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`. + +Two mechanisms, and section 5.4 makes the choice for us: + +- **Same-run, ordered.** Give the Windows legs `needs:` on the corresponding ubuntu jobs, mirroring + `dogfood-seed` -> `dogfood-verify`. Deterministic, provable in one run, costs the Windows legs + the ubuntu legs' wall clock. +- **Cross-push.** Prove on a follow-up push that changes no hashed input. Free, but fragile. + +Once `ci.yml` is a `test` input (needed for Phase 10 and Phase 12's own drift guard), the very +commit that ADDS the Windows legs invalidates the `test` hash -- so the cross-push option requires +a second, no-op push and the same-run option becomes the only clean proof on the enabling commit. +**Recommend `needs:`.** Phase 12's SC 1 currently says only "wired through the same sidecar block +as the `integration` matrix", which is exactly the wiring that will NOT produce the HIT. Amend it. + +### 6.4 Phase 7 first: agree, with one addition and one cheaper alternative named + +The `hash_project_config` argument is sound: `@nx/eslint` is an inference plugin, an inferred +`lint` target changes the project configuration hash, and that folds into every task hash. Adopting +it after Phase 8's node-by-node record would invalidate the record. + +What the roadmap flags but does not measure: `@nx/eslint` would be a THIRD inference plugin +alongside `@nx/js/typescript` and `@nx/vitest`, and the recorded root cause of this repo's +cross-OS divergence is ProjectConfiguration divergence from exactly that class of plugin. Phase 7 +therefore ADDS divergence risk immediately before the phase that measures it. Phase 7 has no +cross-OS success criterion at all. Two options: + +- **Recommended:** keep the order, and make PARITY-01's node-by-node record explicitly enumerate + the `lint` target's inferred configuration nodes. Phase 8 runs after Phase 7, so it naturally + absorbs the risk -- but only if the record is required to cover it. +- Alternative worth one line: skip the Nx target entirely and run `eslint .` as a plain npm script + in the CI battery. Zero `hash_project_config` change, the LINT-01 -> PARITY-01 sequencing + constraint disappears, and Phase 8 could go first. Cost: no Nx caching for lint (irrelevant on a + one-project workspace, seconds), and LINT-04 becomes moot. Rejected here because LINT-01 + specifies a `lint` target and AGENTS.md prefers running tasks through Nx -- but it is the lazier + option if the parity risk turns out to be real. + +### 6.5 Phase 9 before Phase 10: agree, but the roadmap's mechanical argument is weaker than it thinks + +The roadmap's stated reason -- all four TRUST requirements land in one phase with verifiable code +behind them -- is the RIGHT reason, and it holds. + +The mechanical constraint is weak in both directions. Phase-10-first would also be safe: with the +version still OS-partitioned, the two legs would restore disjoint entry sets and upload into a +shared name space with no collision. What Phase-9-first buys mechanically is only that OBS-05 has +a concrete reason to exist before CORR-02 removes the OS suffix that currently distinguishes the +legs. Keep the order; do not over-claim the mechanics. + +The real cost of Phase-9-first is the 2x shard-growth window (section 4.3). Keep the two phases +back-to-back. + +### 6.6 Sequencing rows the roadmap gets right and should keep + +- LINT-02 before CORR-05 violation removal (the rule must be proven to catch all four sites). +- RETAIN-04 in the SAME COMMIT as CORR-02. Confirmed necessary by reading `cleanup.ts:89`: the + filter is the ONLY gate on the delete path, so a publisher writing `nx-cache-` against the + unextended filter makes every new asset unprunable, silently, until the shard hits the 1000-asset + cap. +- OBS-05 before CORR-02. Confirmed by reading `read-back.ts:37` and `publish-mirror.ts:262-266`: + both legs seed `GITHUB_RUN_ID`; after the rename the ubuntu leg uploads `nx-cache-` first + (under `max-parallel: 1`), the Windows leg skips on name-present, and BOTH `publish-verify` legs + read back the ubuntu-produced asset. The Windows publish path could be entirely dead and the job + would stay green. Exactly as OBS-05 states. +- XOS-01 proven before XOS-04/XOS-05. Non-negotiable and correctly placed at the Phase 11/12 + boundary. + +--- + +## 7. New vs modified, by phase + +**Phase 7 (lint):** NEW `eslint.config.mjs`, NEW `lint` target, NEW LINT-03 violating fixture. +MODIFIED root `package.json` (devDeps + script), `pinned-deps.spec.ts` (one `it()` per new dep), +`.fallowrc.jsonc` (`ignoreDependencies` for `@nx/eslint`, likely an `entry` for the config). +Regenerate the action bundle if any autofix touches a `serve()`-reachable file. + +**Phase 8 (parity):** NEW root-cause record, NEW CORR-03 two-leg CI job. MODIFIED `nx.json` only if +the root cause demands it; MODIFIED `nx-target-inputs.spec.ts` for the CORR-04 sole-discriminator +guard. No production source changes expected. + +**Phase 9 (Actions-cache version):** MODIFIED `lib/cache-archive-path.ts` (rewrite + comment lock +rewrite), `lib/cache-archive-path.spec.ts` (two pins REPLACED), `backend/actions-cache-backend.ts` +(3 call sites + `mkdir` + the VER-04 cwd assert at construction), +`backend/actions-cache-backend.spec.ts` (extend to VER-03's argument-list and call-count +assertions), `publish/publish-mirror.ts` or `action/index.ts` (VER-05 compression surfacing in the +summary), `ci.yml` (VER-06 Windows `dogfood-verify`, OBS-04 wording, DOCS-08 comment corrections at +`:356-360` and `:577-583`), `docs/advanced.md`, `README.md`, `docs/trust-and-security.md`. +REGENERATE `start-cache-server/index.js`. + +**Phase 10 (Releases name):** MODIFIED `lib/release-asset-name.ts` (rewrite; `releaseAssetName` +delegates to `cacheKeyFor`; `isServerProducedAssetName` gains the legacy branch; `cachePlatform` +and `CACHE_OS_VALUES` retained and annotated), `lib/release-asset-name.spec.ts` (three pins +replaced, one deleted, accept-list extended), `backend/releases-backend.spec.ts` (cross-OS block +replaced -- supply the new negative control), `cleanup/cleanup.spec.ts` (add new-family and +mixed-shard cases; keep all twelve legacy fixtures), `publish/publish-mirror.ts` + +`action/index.ts` + `publish/publish-mirror.spec.ts` (OBS-03 label seam), +`roundtrip/read-back.ts` (OBS-05 leg-distinguishable seed + comment rewrite), `ci.yml` (OBS-05 +seeds, XOS-06 comment lock, XOS-07 `needs`, `:693` comment), `nx.json` (add `ci.yml` to `test` +inputs -- land this FIRST), `SECURITY.md` (TRUST-13, authored by gsd-security-auditor). +REGENERATE `start-cache-server/index.js`. + +**Phase 11 (proofs):** NEW `ci.yml` probe steps for the re-specified O3 proof, NEW task-graph +assertion tooling for TEST-08, NEW evidence record. No production source changes. + +**Phase 12 (O4 + docs):** MODIFIED `ci.yml` (Windows `build`/`typecheck`/`test` legs WITH `needs:` +on the ubuntu jobs), NEW consumer recipe doc, MODIFIED `nx.json` (register the recipe in `test` +inputs per DOCS-07), MODIFIED `docs-adoption.spec.ts` for the drift guard, MODIFIED the Phase 10 +threat record if the Windows legs write. + +--- + +## 8. Decisions owed before planning + +1. **O3's proof shape** (section 6.1). Blocking for Phase 11; should be settled at roadmap + amendment time, not at plan time. +2. **Phase 12's O4 ordering mechanism** (section 6.3). `needs:` is recommended; the decision + changes Phase 12 SC 1. +3. **`release-asset-name.spec.ts:60`** (section 2.2, Gap 1). Delete or move to integration. +4. **The OBS-03 label's semantics** (section 4.6). "mirrored-by" is honest; "producing OS" is not + derivable at the publish site. +5. **`.nx/cache/` vs `.nx/cache/github-cache/`** as the VER-01 literal (section 2.1, hazard 3). + Cosmetic but the literal is comment-locked, so decide once. +6. **Whether the Windows publish leg survives v0.0.2** (section 4.2). Keeping it is correct per + REQUIREMENTS.md; record the "it mirrors zero real assets" finding so v0.0.3 does not re-derive + it. + +--- + +*Architecture research for v0.0.2 OS-invariant cross-OS sharing. Researched 2026-07-26 against the +shipped v0.0.1 tree at `main` (fe25a3f) and `@actions/cache@6.2.0` as installed. Carries forward +`.planning/research/ARCHITECTURE.md` (v0.0.1) without repeating it.* diff --git a/.planning/research/v0.0.2/FEATURES.md b/.planning/research/v0.0.2/FEATURES.md new file mode 100644 index 00000000..26f4298d --- /dev/null +++ b/.planning/research/v0.0.2/FEATURES.md @@ -0,0 +1,498 @@ +# Feature Research -- v0.0.2 OS-invariant cross-OS sharing + +**Domain:** Cross-platform artifact sharing in content-addressed build caches +**Researched:** 2026-07-26 +**Confidence:** HIGH for Q1/Q2 (every claim below is quoted from vendored source or primary docs, +fetched this session); MEDIUM-HIGH for Q3 (negative claim -- "no build cache detects portability +violations at serve time" -- is an absence-of-evidence argument over the five systems surveyed, +not an exhaustive proof). + +> Carries forward `.planning/research/FEATURES.md` (v0.0.1). That document's landscape -- Nx HTTP +> contract, CREEP posture, retention, adoption ergonomics, competitor matrix -- is NOT repeated. +> This one answers only the cross-OS-sharing question. Where the two disagree, this document wins +> for v0.0.2 and the disagreement is called out explicitly (see `## Contradictions and sharpenings`). + +--- + +## 1. Prior art: portability lives in the KEY, and the key is always DECLARED + +### The headline + +**No surveyed system infers portability. Every one of them is declaration-based.** They differ only +in (a) what the default declaration is, and (b) how much of the execution environment the substrate +happens to capture by accident. There is no system that computes "is this artifact portable?" + +Three distinct mechanisms exist. They are not interchangeable, and conflating them is the trap: + +| Mechanism | How portability is decided | Systems | Failure mode when wrong | +|-----------|---------------------------|---------|-------------------------| +| **A. Platform in the key, declared** | The task/action author declares the execution platform; it is hashed into the action key | Bazel (REAPI `Platform`), Nx (`runtime` input -- our CORR-04) | Over-partition = silent MISS. Under-declare = wrong artifact | +| **B. Content-derived implicit** | The key hashes the actual environment-varying content (compiler binary digest, preprocessed system headers), so a platform difference changes the key without anyone naming "platform" | ccache, sccache | Practically MISS-only: the differing content IS hashed, so a wrong hit needs a hash collision | +| **C. Author's cacheability declaration** | An annotation asserts the output is reproducible and relocatable; the key never mentions platform | Gradle `@CacheableTask` + `@PathSensitive` | Wrong artifact, silently. Correctness is 100% on the annotation | + +**Nx is mechanism A with an empty default.** That is the single most important finding for this +milestone: Nx gives you the declaration slot but declares nothing for you (Section 2). + +### Bazel -- platform IS in the action key, but only what you declare + +Verified against the Remote Execution API proto (`bazelbuild/remote-apis`, +`build/bazel/remote/execution/v2/remote_execution.proto`, fetched at `main`). + +The `Platform` message is documented as "a set of requirements, such as hardware, operating system, +or compiler toolchain, for an `Action`'s execution environment." The load-bearing sentence, verbatim +from the `Platform.Property` doc comment: + +> "Note that the platform is implicitly part of the action digest, so even tiny changes in the names +> or values (like changing case) may result in different action cache entries." + +And, on why ordering is normative: + +> "In order to ensure that equivalent `Platform`s always hash to the same value, the properties MUST +> be lexicographically sorted by name, and then by value." + +So the mechanism is real and the platform genuinely enters the cache key. **But Bazel's own caching +docs (https://bazel.build/remote/caching) document unsound cross-machine sharing anyway**, verbatim: + +> "Bazel currently does not track tools outside a workspace. This can be a problem if, for example, +> an action uses a compiler from `/usr/bin/`. Then, two users with different compilers installed +> will wrongly share cache hits because the outputs are different but they have the same action hash." + +and + +> "An action definition contains environment variables. This can be a problem for sharing remote +> cache hits across machines. For example, environments with different `$PATH` variables won't share +> cache hits." + +This pair is the most useful thing Bazel contributes to our milestone: **the strongest +platform-in-the-key system in the industry still ships a documented wrong-result hole**, because +the key only captures what was declared. Bazel's answer is hermeticity discipline (glossary: "A build +is hermetic if there are no external influences on its build and test operations"), not detection. + +Bazel's glossary defines the action key as "computed based on action metadata, which might include +the command to be executed in the action, compiler flags, library locations, or system headers, +**depending on the action**" -- note "depending on the action", i.e. author-determined. + +*UNVERIFIED / ASSUMED:* whether Bazel populates `Platform` with OS properties **by default** when +using `--remote_cache` without remote execution. The REAPI mechanism is verified; the default +population path was not traced. Do not cite Bazel as "OS is automatically in the key". + +### Gradle -- portability is an annotation, and the docs say so plainly + +Verified against `docs.gradle.org` (current). + +Cache key components, verbatim from `build_cache_concepts.html`: + +> "The following inputs contribute to the build cache key for a task: The task implementation, The +> task action implementations, The names of the output properties, The names and values of task +> inputs" + +**No operating system, no platform, no architecture.** The page has no statement at all about +caching across different operating systems. + +Declaration lives in the annotation. Verbatim from the `@CacheableTask` javadoc: + +> "Attached to a task type to indicate that task output caching should be enabled by default for +> tasks of this type." +> "Only tasks that produce **reproducible and relocatable** output should be marked with +> `CacheableTask`." + +Path sensitivity is the relocatability half, verbatim: + +> "To allow cached results to be shared even when builds are executed from different root +> directories, Gradle needs to understand which inputs can be relocated and which cannot." +> "Task properties declared with `ABSOLUTE` path sensitivity are considered non-relocatable [...] +> Therefore, the path sensitivity for the sources of the `JavaCompile` task is `RELATIVE`." + +**Direct relevance to VER-01.** Gradle's `ABSOLUTE`-is-non-relocatable rule is the same defect +VER-01 removes: an absolute `os.tmpdir()` string baked into the `@actions/cache` version is exactly +an `ABSOLUTE` path sensitivity, and the fix (a workspace-relative forward-slash literal) is exactly +Gradle's `RELATIVE`. v0.0.2's VER-01 is not a novel idea; it is the industry-standard relocatability +fix, and DOCS-07 can say so. + +### Turborepo -- platform is genuinely ASSUMED (the one true "assumed" case) + +Verified at source, not docs (`vercel/turborepo`, `crates/turborepo-hash/src/lib.rs`, +`crates/turborepo-task-hash/src/global_hash.rs`, fetched at HEAD). + +`GlobalHashable` fields: `global_cache_key`, `global_file_hash_map`, +`root_external_dependencies_hash`, `root_internal_dependencies_hash`, `engines`, `env`, +`resolved_env_vars`, `pass_through_env`, `env_mode`, `framework_inference`, `global_configuration`. + +`TaskHashable` fields: `global_hash`, `task_dependency_hashes`, `hash_of_files`, +`external_deps_hash`, `package_dir`, `task`, `outputs`, `pass_through_args`, `env`, +`resolved_env_vars`, `pass_through_env`, `env_mode`, `command_override`. + +**No OS, no platform, no arch field in either struct.** The docs confirm the omission is not a +docs gap: the caching page enumerates the hash inputs and mentions no OS, and makes no statement +about cross-OS cache sharing. Turborepo is the closest structural analogue to Nx (JS monorepo task +cache, remote cache, same problem) and it simply assumes portability with no declaration slot and +no warning. **That is the ecosystem's weakest position, and it is the one v0.0.2 must NOT be +mistaken for.** Our CORR-04 discriminator is what separates us from Turborepo's posture. + +### sccache and ccache -- mechanism B, correctness for free from content + +sccache, verified at source (`mozilla/sccache`, `src/compiler/c.rs`). The hash key is, in order: +`compiler_digest`, a `plusplus` flag byte, `CACHE_VERSION`, `language`, all `arguments`, +`extra_hashes`, allowlisted `env_vars`, then the preprocessor output. `CACHE_VERSION` is +`b"12"`, guarded by the doc comment: + +> "If you change any of the inputs to the hash, you should change `CACHE_VERSION`." + +ccache, verified against the manual (`ccache.dev/manual/latest.html`): + +> "The following information is always included in the hash: the extension used by the compiler for +> a file with preprocessor output [...], the compiler's size and modification time (or other +> compiler-specific information specified by compiler_check), the name of the compiler, the current +> directory (if hash_dir is enabled), contents of files specified by extra_files_to_hash" + +**Neither hashes "the OS".** Platform reaches the key implicitly: a different OS means a different +compiler binary (hashed via digest/mtime+size) and different system headers (hashed via the +preprocessed source). ccache's explicit cross-machine guidance, verbatim: + +> "It is recommended to use the same operating system version when using a shared cache. If +> operating system versions are different then system include files will likely be different and +> there will be few or no cache hits between the systems." + +Read that carefully: the stated consequence is **"few or no cache hits"** -- a MISS problem, not a +wrong-result problem. ccache can afford to be relaxed because the thing that differs across OSes is +itself inside the hash. + +Both also ship a relocatability knob mirroring Gradle's path sensitivity: ccache's `base_dir` +("Ccache will convert absolute paths under this directory to relative paths before hashing") and +sccache's `basedirs` ("This enables cache hits across different absolute paths (similar to ccache's +CCACHE_BASEDIR)"). Again: same defect class as VER-01. + +### The asymmetry that matters most for us + +**Nx has no mechanism-B safety net.** Nx's documented hash inputs are "All the source files of +`remixapp` and its dependencies / Relevant global configuration / Versions of external dependencies +/ Runtime values provisioned by the user such as the version of Node / CLI Command flags" +(`how-caching-works.mdoc`, Nx 23.1.0, local clone). + +Everything in that list is **inside the workspace or explicitly declared**. The Node binary, the OS, +glibc-vs-musl, the arch -- none are hashed unless a `runtime` input names them. Where ccache +accidentally captures the toolchain by hashing preprocessed system headers, Nx captures nothing +outside your repo. So the ccache posture ("relax, you'll just get misses") **does not transfer to +Nx**, and DOCS-07 must not borrow it. Under Nx, an undeclared platform dependency is a +wrong-artifact bug, not a miss. + +--- + +## 2. Ecosystem norm in Nx: trust-the-hash, confirmed, and nobody documents cross-OS + +### `nx-remotecache-custom` -- CONFIRMED verbatim + +Verified at source (`NiklasPor/nx-remotecache-custom`, `main`). `lib/get-file-name-from-hash.ts` in +full: + +```ts +import { HASH_SUFFIX } from "./hash-suffix"; + +export function getFileNameFromHash(hash: string): string { + return hash + HASH_SUFFIX; +} +``` + +`lib/hash-suffix.ts` in full -- and the comment confirms the suffix is a **format epoch**, exactly +as the research question framed it: + +```ts +/** + * Hash suffix will be modified whenever the archiving method is beeing updated. + * This will prevent incorrect cache-hits with older versions. + * + * Examples: + * - .zip + * - .tar.gz + * - -v2.tar.gz + */ +export const HASH_SUFFIX = ".tar.gz"; +``` + +`hash + ".tar.gz"`, no OS component, and the only suffix concept is a scheme/format epoch. **D2-05's +premise is verified exactly as stated.** + +### Survey of the other implementations + +| Implementation | Status | Key derivation | OS in key? | Documents cross-OS? | +|---|---|---|---|---| +| `nx-remotecache-custom` (base lib) | Live, pushed 2025-06-11 | `hash + ".tar.gz"` | No | No | +| `nx-remotecache-azure` | Live, pushed 2025-06-11 | Passes `filename` straight through to `BlockBlobClient`; the adapter never touches the key | No | No | +| `nx-remotecache-minio` | Live (NiklasPor) | Same base lib | No | No | +| `nx-remotecache-gcs` (`wvanderdeijl`) | **Actively maintained, pushed 2026-07-24; current HTTP contract, not legacy runner** | `GET/PUT /v1/cache/:hash` -> GCS object `${prefix}${hash}`; `prefix` is an operator-set string with no OS awareness | No | No | +| `nx-remotecache-s3` (`robinpellegrims`) | **Archived 2026-04-11** | Same base lib | No | No | +| `@nx/azure-cache` / `@nx/s3-cache` / `@nx/gcs-cache` (1st-party Powerpack) | Deprecated May 2026, closed source | Not inspectable | Not documented | No | +| Nx Cloud | Closed SaaS | Not public | Unknown | No | + +**Answer to "does ANY of them namespace by OS or document cross-OS correctness at all": no. Not +one.** The most recently maintained third-party implementation (`nx-remotecache-gcs`, four days +ago) is a bare `hash` -> object mapping. + +### The protocol itself forecloses it + +Verified against the Nx client (local clone at tag `23.1.0`, +`packages/nx/src/native/cache/http_remote_cache.rs`). `retrieve` builds +`format!("{}/v1/cache/{}", self.url, hash)` and sends `Accept: application/octet-stream` plus the +bearer token. **There is no OS field, no platform header, nothing but the hash on the wire.** A +self-hosted server can only namespace by OS by sniffing its own `process.platform` out of band -- +which is precisely what v0.0.1's CORR-01 did, and precisely why it was invisible to Nx and cost +every cross-OS hit. + +The Nx client also carries a comment that is the clearest statement of the trust-the-hash contract +in the whole ecosystem, verbatim from `store`: + +> "We can change the creation of the tar in a future version without worrying about breaking +> existing user cache's, because when the user updates their task's hashes will be changed... so +> users retrieving old hashes will not be affected, and new entries will have distinct hashes." + +Nx's position: **the hash is the entire key, and anything that should invalidate must be made to +change the hash.** Not the store. + +### Nx documents nothing about OS -- verified by absence + +Searched the Nx 23.1.0 docs tree (`astro-docs/src/content/docs/guides/Tasks & Caching/*`, +`concepts/how-caching-works.mdoc`) for `operating system`, `cross-os`, `cross-platform`, +`different machines`, `process.platform`. **Zero hits.** + +The one adjacent statement is about replay fidelity, not correctness: "Captures stdout and stderr to +make sure the replayed output looks the same, including on Windows." (Relevant to TRUST-11 -- Nx +confirms terminal output is part of the stored entry.) + +### Nx's own guidance already endorses DOCS-07's ordering + +This is the most directly reusable finding for the recipe author. Verbatim from +`configure-inputs.mdoc` (Nx 23.1.0): + +> "Nx errs on the side of caution when using inputs. Ideally, the "perfect" configuration of inputs +> will allow Nx to never re-run something when it does not need to. In practice though, it is better +> to play it safe and include more than strictly necessary in the inputs of a task. Forgetting to +> consider something during computation hash calculation may lead to negative consequences for end +> users. **Start safe and fine-tune your inputs when there are clear opportunities to improve the +> cache hit rate.**" + +That is DOCS-07's mandated structure -- declare broadly first, remove per target only after proving +portability -- stated by Nx itself. **DOCS-07 should quote it.** It converts the recipe's primary +instruction from "this project's opinion" into "the framework's documented guidance". + +The declaration mechanism is also already documented in the shape we use. Nx's canonical example +for a workspace-wide runtime input: + +```jsonc +// nx.json +{ + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "sharedGlobals": [{ "runtime": "node --version" }] + } +} +``` + +Our platform discriminator is the same construct with a different command. DOCS-07 can present it +as "the documented `sharedGlobals` runtime-input pattern, applied to platform instead of Node +version" -- no novel concept to teach. + +--- + +## 3. Detection and enforcement: the honest answer is "nobody does this" + +**No surveyed build cache detects a portability violation at serve time.** Detection exists only in +reproducibility tooling adjacent to caches, and every instance works by **re-executing the task** -- +which structurally defeats the purpose of a cache. That is not an implementation gap; it is the +shape of the problem. + +| System | What it does | Detects | Cost | Cache-compatible? | +|---|---|---|---|---| +| **Nix `nix-store --realise --check`** | "This option allows you to check whether a derivation is deterministic. It rebuilds the specified derivation and checks whether the result is bitwise-identical with the existing outputs, printing an error if that's not the case." Requires "The outputs of the specified derivation must already exist." With `-K`, "the new output path is left in `/nix/store/name.check`". Dedicated exit code **104 Not deterministic** | Non-determinism / impurity vs an existing store path | Full rebuild | **No** -- rebuilds the thing you cached | +| **reprotest** (Debian) | "builds the same source code twice in different environments, and then checks the binaries produced by each build for differences. If any are found, then diffoscope(1) [...] is used to display them in detail". Default variations: `environment, build_path, kernel, aslr, num_cpus, time, user_group, fileordering, domain_host, home, locales, exec_path, timezone, umask` | Environment-sensitivity, by deliberate variation | Two builds | **No** -- double-build | +| **Develocity Build Validation Scripts** (Gradle) | Five scripted "experiments", incl. `04-validate-remote-build-caching-ci-ci.sh` ("when invoked from different CI agents") and `05-validate-remote-build-caching-ci-local.sh` ("when invoked on CI agent and local machine") | **MISSES only** -- cacheability/work-avoidance gaps across environments. Not wrong results | Two builds + Develocity | Partly -- it is a measurement harness, run out of band | +| **Bazel** | Documents the unsound-sharing hole (Section 1); offers `--experimental_guard_against_concurrent_changes` for input mutation *during* a build | Concurrent input changes. **Not** portability | Low | Yes, but wrong problem | +| **ccache / sccache** | Nothing. Correctness comes from mechanism B | n/a | n/a | n/a | + +### The three conclusions the roadmapper needs + +1. **The existing `## Out of Scope` call is correct and now has evidence.** "Empirical + divergence-detection subsystem -- Disproportionate" is right: the entire industry agrees, and the + reason is structural (detection requires execution). Keep the row; the justification can now cite + Nix `--check` and reprotest rather than resting on proportionality alone. + +2. **The circularity note in that same row is independently confirmed by Nix.** REQUIREMENTS.md + already says "O4's green CI is the portability evidence" is circular "since a restored task does + not execute." Nix's `--check` exists *precisely because* an existing store path proves nothing + about reproducibility until you rebuild. Same argument, external corroboration. + +3. **Develocity experiment 05 is a direct structural analogue of O1** ("invoked on CI agent and + local machine"). Gradle -- the vendor with the most mature build-cache tooling -- ships a + *scripted manual experiment* for exactly the scenario XOS-01 proves, rather than any automated + store-side guarantee. **v0.0.2's TEST-08/TEST-10 recorded live proofs are the industry-standard + answer, not a weak substitute for one.** Worth saying in the milestone record so a future reader + does not mistake "we proved it by hand once" for a gap. + +### The lazy version of the rejected subsystem (NOT recommended for v0.0.2) + +If a sampled detector is ever wanted, the near-zero-complexity form already exists in Nx: a +scheduled CI leg running the shared targets with cache bypass on the non-producing OS and failing on +a red verdict. `skipNxCache` is a real flag in Nx 23.1.0 (verified: declared in +`packages/nx/src/command-line/yargs-utils/shared-options.ts:38,127`). That is Nix's `--check` model +sampled on a cron instead of on every read, and for `typecheck`/`test` -- which cache a **verdict** +-- comparing verdicts is far cheaper than comparing bytes. + +**Complexity LOW (one scheduled workflow leg). Recommendation: do NOT add it to v0.0.2.** There are +zero adopters, XOS-04's Windows leg plus CORR-03's continuous hash assertion already cover the +milestone's outcomes, and adding a CI job that deliberately defeats the cache during the milestone +that is trying to prove the cache works muddies the O1 attribution evidence TEST-08 depends on. +Record it as a later-milestone trigger if an adopter with a non-arm64 or musl environment ever +appears -- those are exactly the axes DOCS-07 flags as unexercisable here. + +--- + +## 4. Table stakes vs differentiator vs anti-feature (v0.0.2) + +### Table stakes -- the ecosystem does these; not doing them is a defect + +| Capability | Why table stakes | Complexity | Depends on (already built) | v0.0.2 reqs | +|---|---|---|---|---| +| **OS-invariant store key** | Universal. Every Nx implementation surveyed keys on bare `hash`; the Nx wire protocol has no OS field at all. v0.0.1's OS-namespaced store is the **ecosystem outlier** | LOW (a rename) | Releases reader/publisher, `CACHE_KEY_PREFIX` | CORR-02, RETAIN-04 | +| **A declared platform discriminator on non-portable targets** | Bazel = REAPI `Platform` in the action digest; Gradle = `@CacheableTask`; Nx = `runtime` input. The declaration is the *whole* correctness mechanism once the store is invariant | LOW (already exists on `integration`) | `nx.json` inputs | CORR-04, CORR-03 | +| **Relocatable (non-absolute) paths in the key** | Gradle `@PathSensitive(RELATIVE)`, ccache `base_dir`, sccache `basedirs`. An absolute path in a cache key is a named industry defect | LOW-MED | `cacheArchivePath()` single source of truth | VER-01..04 | +| **A bumpable scheme epoch / salt** | REAPI `Action.salt`, `HASH_SUFFIX`, sccache `CACHE_VERSION="12"`, Turborepo `GLOBAL_CACHE_KEY`. Universal | **ZERO -- already satisfied** | `CACHE_KEY_PREFIX` is already single-sourced | CORR-02 (implicitly) | +| **Mechanical enforcement of the portability strategy** | Not universal as tooling, but Gradle/Bazel both make the declaration a *compile-time-visible* artifact. A strategy enforced only by prose is the failure v0.0.2 is fixing | MED (new toolchain) | none (repo has no linter) | LINT-01..06, CORR-06 | +| **Cross-environment proof before trusting the cache** | Develocity ships five scripted experiments for it; Gradle does not assume | MED (live proofs) | CI matrix, sidecar | XOS-01..05, TEST-08..10 | + +**On the epoch row -- a free win worth one doc line.** REAPI states the purpose exactly: the salt +"allows disowning an entire set of ActionResults that might have been poisoned by buggy software or +tool failures." v0.0.2 already gets this for free: CORR-02's rename from `-` to +`nx-cache-` *is* a one-time epoch rotation (which is why OBS-04 correctly predicts a single +legitimate all-miss push). D2-03 rejected a *suffix accept-list*, which is right -- but the prefix +is the better epoch anyway, and it is already single-sourced. **Recommendation: DOCS-07 spends one +sentence naming `CACHE_KEY_PREFIX` as the poison-disowning epoch knob.** No code, no new +requirement, no scope change -- it just makes an existing capability legible, and it pre-answers +"what do we do if a bad artifact gets mirrored" without adding the signing that +`## Key Decisions` already rejected. + +### Differentiators -- nobody else does these + +| Capability | Value | Complexity | v0.0.2 reqs | +|---|---|---|---| +| **Producer-OS attribution outside the lookup key** (Release asset `label`) | **Genuinely novel.** No surveyed cache records producer provenance at all. Bazel/Gradle/Turborepo/ccache give you nothing when a bad artifact is served. Recovers exactly the incident-response capability CORR-02 removes, at zero correctness cost since `label` is not part of lookup | LOW | OBS-03 | +| **Enforcing the portability strategy with a linter** | Gradle relies on an annotation a human must remember; Bazel on hermeticity discipline. A rule that *fails the build* when a unit spec reads ambient platform state is stricter than any comparator | MED | LINT-02/03, CORR-06 | +| **Continuous cross-OS hash-parity assertion in CI** (CORR-03) | Develocity's equivalent is a manual script run out of band. Making it a **build-gating job with a non-vacuity control** is stronger than the industry norm | MED | CORR-03 | +| **Safe-by-default adoption recipe** | ccache tells you to use the same OS (and is wrong-by-analogy for Nx). Turborepo says nothing. A recipe that says "declare everywhere, then earn removals" is better guidance than any comparator ships | LOW-MED | DOCS-07 | + +### Anti-features -- confirmed against the ecosystem + +| Anti-feature | Why tempting | Why wrong | Evidence | +|---|---|---|---| +| **OS component in the store key** | "Obviously safer" | Buys nothing the declared input does not, costs every cross-OS hit, and is invisible to Nx (no OS on the wire). Zero of 7 surveyed Nx implementations do it | Section 2 survey; `http_remote_cache.rs` | +| **Empirical divergence-detection subsystem** | "Prove portability instead of declaring it" | Detection requires re-execution (Nix `--check`, reprotest). A cache that re-runs tasks is not a cache | Section 3 | +| **Ordering / first-write-wins as a correctness control** | "Linux writes first, so the Linux verdict wins" | Already REJECTED in PROJECT.md and REQUIREMENTS.md. Nothing in the ecosystem grounds correctness in write ordering; REAPI explicitly requires that *any* satisfying worker produce the same result ("the client SHOULD ensure that running the action on any such worker will have the same result") | REAPI `Action.platform` comment | +| **A per-target OS-invariance opt-out knob** | "Give adopters an exit" | D2-02, zero adopters. Note the comparators put the knob in the **task declaration** (`@CacheableTask`, `runtime` input), never in the **cache backend**. A backend-level knob would be an ecosystem inversion, not just YAGNI | Sections 1-2 | +| **Borrowing ccache's "same OS recommended" guidance into DOCS-07** | It is the only cross-OS advice in the comparator set | ccache can be relaxed because system headers are inside its hash. Nx hashes nothing outside the workspace. Copying the posture would be actively unsafe | Section 1, "the asymmetry" | + +--- + +## Contradictions and sharpenings vs the locked v0.0.2 decisions + +**No decision is contradicted.** Two are sharpened, one gap is named. + +1. **D2-05 ("Ecosystem norm is trust-the-hash") -- VERIFIED, but the one-line framing under-sells + its precondition.** Trust-the-hash is the norm *for the store*. It is not the norm for the + *system*: Bazel puts platform in the action key, ccache/sccache capture it via content, Gradle + requires an annotation. Only Turborepo genuinely assumes. The honest statement is **"trust the + hash, having first made the hash trustworthy"** -- which is exactly the CORR-02 + CORR-04 pair. + Recommend the roadmapper and DOCS-07 use the longer form, because the short form invites a reader + to conclude the declaration is optional. It is the only thing standing between us and Turborepo's + posture. + +2. **D2-04 / VER-01 are better-supported than the requirement text claims.** REQUIREMENTS.md + justifies the relative path via `@actions/cache` docs and gitignore mechanics. It is also the + textbook relocatability fix (Gradle `ABSOLUTE` vs `RELATIVE`, ccache `base_dir`, sccache + `basedirs`). Cheap credibility for DOCS-08. + +3. **Gap, not contradiction: `## Out of Scope` "Executor portability classification -- Not knowable + a priori and project-dependent."** True, and every comparator agrees -- but all of them still + ship a *default posture* for it. Gradle: not cacheable unless annotated. Bazel: hermeticity + required. Nx: "start safe [...] play it safe and include more than strictly necessary." **v0.0.2 + has the right posture (DOCS-07's declare-first ordering) but it currently lives only in a docs + requirement.** That is a defensible call for a repo with zero adopters and a mechanically-enforced + discriminator (CORR-04 + CORR-03 + LINT-02). Flagging it so the roadmapper records it as a + deliberate choice rather than an oversight: **the default posture is documentation-only, and it + is load-bearing.** If an adopter ever appears, that is the first thing to harden. + +--- + +## Findings that should shape DOCS-07 (for the recipe author) + +Ranked by usefulness. + +1. **Quote Nx's own words for the primary instruction.** "Start safe and fine-tune your inputs when + there are clear opportunities to improve the cache hit rate" (`configure-inputs.mdoc`). This is + the recipe's ordering, stated by the framework. Lead with it. +2. **Present the discriminator as the documented `sharedGlobals` runtime-input pattern**, using Nx's + own `{ "runtime": "node --version" }` example as the shape. Nothing new to teach. +3. **State the Nx-specific asymmetry explicitly, and do not let readers import ccache intuitions.** + Nx's hash covers the workspace plus what you declare -- not the Node binary, not the OS, not + libc, not the arch. Under ccache an undeclared platform dependency is a missed hit; under Nx it + is a wrong artifact. This single paragraph is the strongest possible motivation for + declare-first. +4. **Derive the portability checklist axes from the Reproducible Builds catalogue, filtered to what + Nx tasks can actually hit.** The published 16 environment variations are: Archive Metadata, + Architecture Information, Build ID, Build Path, Build Timestamp, File Encoding, Filesystem + Ordering, File Permission, Locale, Package Dependency, Randomness, Reference to Memory Address, + Snippet Encoding, System DNS Name, Uninitialized Memory, User Information. The ones that plausibly + bite a JS/TS Nx task: **Architecture Information, Build Path, File Encoding (our CRLF history), + Filesystem Ordering, File Permission, Locale, Package Dependency, Build Timestamp.** + DOCS-07 mandates the checklist be derived from PARITY-01's root-cause record rather than + prejudged -- so use this as a **cross-check for omissions after** PARITY-01 lands, not as the + starting list. Note that `process.platform` covers none of Architecture, Locale, File Permission + or Filesystem Ordering, which supports DOCS-07's requirement to name arch and libc as uncovered + axes. +5. **Name `CACHE_KEY_PREFIX` as the epoch / poison-disowning knob** (one sentence). Cite the REAPI + salt rationale if a rationale is wanted. +6. **Frame VER-01 as the standard relocatability fix**, with the Gradle/ccache/sccache parallel. + Turns a repo-specific quirk into a recognisable pattern. +7. **Set expectations honestly on proof, using Develocity as cover.** Gradle's answer to "does my + cache work across CI and local" is a manual scripted experiment (`05-validate-remote-build- + caching-ci-local.sh`). Our recorded live proofs are the same class of evidence. Adopters should + expect to run a proof, not to trust a guarantee. + +--- + +## Sources + +All fetched or read 2026-07-26. + +| Source | Used for | Confidence | +|---|---|---| +| `bazelbuild/remote-apis` `remote_execution.proto` @ `main` (gh api, read verbatim) | Platform-in-action-digest; `Action.salt` | HIGH (source) | +| https://bazel.build/remote/caching | Unsound cross-machine sharing; `$PATH`; `/usr/bin` compiler hole | HIGH | +| https://bazel.build/reference/glossary | Action key, hermeticity definitions | HIGH | +| https://docs.gradle.org/current/userguide/build_cache_concepts.html | Cache key components; relocatability; path sensitivity | HIGH | +| https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/CacheableTask.html | "reproducible and relocatable" declaration | HIGH | +| `gradle/develocity-build-validation-scripts` `Gradle.md` (gh api) | The five experiments; 04/05 cross-environment | HIGH (source) | +| `vercel/turborepo` `crates/turborepo-hash/src/lib.rs`, `crates/turborepo-task-hash/src/global_hash.rs` (gh api) | `GlobalHashable`/`TaskHashable` fields; `GLOBAL_CACHE_KEY` | HIGH (source) | +| https://turborepo.dev/docs/crafting-your-repository/caching | Documented hash inputs; absence of OS statement | HIGH | +| `mozilla/sccache` `src/compiler/c.rs` (gh api) | `compute()` hash inputs; `CACHE_VERSION`; `basedirs` | HIGH (source) | +| https://ccache.dev/manual/latest.html | Common hashed information; same-OS recommendation; `base_dir` | HIGH | +| `NiklasPor/nx-remotecache-custom` `lib/*` (gh api) | `hash + ".tar.gz"`; `HASH_SUFFIX` epoch comment | HIGH (source) | +| `NiklasPor/nx-remotecache-azure` `lib/index.ts` (gh api) | Adapter passes `filename` through untouched | HIGH (source) | +| `wvanderdeijl/nx-remotecache-gcs` `adapter.ts` (gh api) | Current-contract impl keys on bare `hash` + operator prefix | HIGH (source) | +| GitHub repo search (`nx-remotecache-*`) | Implementation survey, archived/live status | HIGH | +| Local Nx clone, tag `23.1.0`: `http_remote_cache.rs`, `how-caching-works.mdoc`, `configure-inputs.mdoc`, `shared-options.ts`; negative grep over the caching docs tree | Wire protocol has no OS field; documented hash inputs; "start safe" guidance; `sharedGlobals` runtime pattern; `skipNxCache`; zero cross-OS mentions | HIGH (source) | +| https://nix.dev/manual/nix/2.24/command-ref/nix-store/realise.html | `--check` semantics; exit code 104 | HIGH | +| https://manpages.debian.org/unstable/reprotest/reprotest.1.en.html | Two-build comparison; diffoscope; default variation list | HIGH | +| https://reproducible-builds.org/docs/env-variations/ | The 16 environment variation axes | HIGH | + +**Note on the local Nx clone:** the working tree is checked out at tag **`23.0.2`**, not `23.1.0` as +the research brief stated. All Nx claims above were read via `git show 23.1.0:` against the +`23.1.0` tag object, which is present in the clone -- so they are 23.1.0 facts. Flagging because +anything read from the working tree by another agent would be 23.0.2, and PARITY-04 records that the +23.0.2 -> 23.1.0 hash-planner rewrite makes cross-version measurements non-comparable. + +**Not obtained:** `@nx/azure-cache` / `@nx/s3-cache` / `@nx/gcs-cache` key derivation (closed +source, deprecated May 2026) and Nx Cloud internals (closed SaaS). Their rows in the Section 2 table +are marked "Not documented" / "Unknown" rather than "No" for that reason. Neither gap is +load-bearing: the seven inspectable implementations plus the wire protocol settle the norm. + +--- +*Feature research for: v0.0.2 OS-invariant cross-OS cache sharing* +*Researched 2026-07-26. Carries forward `.planning/research/FEATURES.md` (v0.0.1) without repeating it.* diff --git a/.planning/research/v0.0.2/PITFALLS.md b/.planning/research/v0.0.2/PITFALLS.md new file mode 100644 index 00000000..5051dfcf --- /dev/null +++ b/.planning/research/v0.0.2/PITFALLS.md @@ -0,0 +1,880 @@ +# Pitfalls Research -- v0.0.2 OS-invariant cross-OS sharing + +**Domain:** removing OS partitioning from a SHIPPED remote cache (Actions-cache version + Releases +asset name), while keeping one declared Nx input as the sole separation mechanism +**Researched:** 2026-07-26 +**Confidence:** HIGH for everything marked [VERIFIED] (read from this tree, this commit, at +`@actions/cache` 6.2.0 / `nx` 23.1.0 / the local `actions/cache` clone). MEDIUM where marked. + +This is a SUBSEQUENT-milestone pitfalls doc. It assumes v0.0.1 shipped and deliberately does NOT +re-derive greenfield hazards. Its subject is the specific failure surface of *taking two working +barriers out of a running cache*. + +## How to read this + +Every pitfall carries a **Silence** rating. This project's signature defect class is the green +build that is wrong: + +| Rating | Meaning | +|--------|---------| +| **SILENT-3** | No log line, no warning, no failing check. Only a later measurement reveals it. | +| **SILENT-2** | Logged at `core.debug` or buried in a count that conflates causes. Invisible without `ACTIONS_STEP_DEBUG` or a log dive. | +| **SILENT-1** | Warns, but the warning is expected/noisy enough to be ignored. | +| **LOUD** | Fails a check. Listed only when the failure is easy to misdiagnose. | + +Weight SILENT-3 above everything else. Two of the three v0.0.1 production defects were SILENT-3. + +--- + +# Section A -- Carry-forward audit of `.planning/research/PITFALLS.md` + +Every v0.0.1 claim that v0.0.2 touches, re-verified against the current tree. **Nothing here was +copied forward unchecked.** The verdict column is the actionable part. + +## A.1 -- Pitfall 7, the MUST-NOT-REOPEN block: claim-by-claim + +| v0.0.1 claim | Verdict | Evidence | +|---|---|---| +| **P7.1** `.gitattributes` `* text=auto eol=lf` keeps content hashes cross-OS identical | **STANDS, unchanged, MORE load-bearing than before** | `.gitattributes` still holds exactly `* text=auto eol=lf` with its "why" comment [VERIFIED]. v0.0.2 removes two other barriers, so CRLF becomes a larger share of the remaining risk. Deleting it now costs a wrong-*shape* MISS on every target, not just `integration`. | +| **P7.2a** `@actions/cache` version-hashes the LITERAL path strings; `cacheArchivePath()` must stay the single source and save/restore must pass byte-identical paths | **STANDS, and TIGHTENS** | `getCacheVersion(paths, compressionMethod, enableCrossOsArchive)` does `crypto.createHash('sha256').update(components.join('|'))` over `paths.slice()` + method + optional `'windows-only'` + `'1.0'` salt (`node_modules/@actions/cache/lib/internal/cacheUtils.js:157-172`) [VERIFIED]. v0.0.2 upgrades the requirement from "byte-identical across CALL SITES" to "byte-identical across CALL SITES **and across OSes**". | +| **P7.2b** "the version is still OS-distinct through the OS temp path ... so a cross-OS restore still MISSes" | **STALE BY DESIGN -- v0.0.2 inverts it** | This is the *outcome* VER-01/VER-03 exist to remove. The MECHANISM statement is correct; the CONSEQUENCE must be rewritten, not deleted. The comment lock in `cache-archive-path.ts:5-33` and `release-asset-name.ts:33-48` must be REWRITTEN in the same commit as the change -- a stale comment lock that says the opposite of the code is worse than none. | +| **P7.2c** compression parenthetical ("windows-11-arm lacks zstd") | **ALREADY CORRECTED** in `PITFALLS.md:159` by the `windows-publish-one-asset` debug (E9 Correction 1). No further action. | -- | +| **P7.3** per-OS publish matrix is load-bearing; collapsing it silently drops the other OS's entries | **RATIONALE DIES, MATRIX SURVIVES FOR DIFFERENT REASONS** | After VER-03 a single leg CAN restore every OS's entries, so the original justification evaporates. The matrix must still be retained -- for `max-parallel: 1`'s shard-race protection (XOS-06) and for OBS-05's per-leg liveness proof. Collapsing it is explicitly out of scope until XOS-05 is proven. **Re-justify the comment; do not leave the dead reason in place.** | +| **P7.3 symbol** `uploadHash` | **STALE (already noted).** Current site is the `restored.kind === 'miss'` branch at `publish-mirror.ts:218` [VERIFIED] -- note this has MOVED again since the debug doc cited `:184-189`, because the hash dedup landed. Cite the branch by name, never by line. | -- | + +## A.2 -- `PITFALLS.md:361`, the trailing carry-forward paragraph + +> "...Pitfall 7's cross-OS `@actions/cache` version hashing incl. zstd-vs-gzip - and note +> `enableCrossOsArchive` does NOT rescue a compression-method mismatch (actions/cache#1622), so +> OS-partition rather than rely on the flag..." + +**Split verdict. The mechanism is TRUE; the recommendation is STALE.** + +- **TRUE and still true [VERIFIED]:** `compressionMethod` is pushed into the version components + **unconditionally**, before and independent of the `enableCrossOsArchive` branch + (`cacheUtils.js:162-168`). The flag ONLY suppresses the `'windows-only'` component, and only + when `process.platform === 'win32'`. So the flag cannot and does not rescue a compression + mismatch. Upstream's own docs agree: *"If you are using a self-hosted Windows runner, GNU tar + and zstd are **required** for Cross OS caching to work"* (`actions/cache` README, local clone) + [VERIFIED]. +- **STALE:** "so OS-partition rather than rely on the flag". v0.0.2 relies on the flag on purpose, + under a documented precondition (both sides resolve the same compression method). Rewrite as: + *"the flag makes the version OS-invariant only while the compression method matches on both + sides -- so compression is a third, runtime-sensed version component that must be observed + (VER-05), not assumed."* + +**Corollary worth writing down explicitly, because it is not obvious from the flag's name +[VERIFIED]:** on non-Windows platforms `enableCrossOsArchive` has *zero* effect on the version. +Setting it is a no-op on Linux and macOS. Consequences: + +1. The version rotation caused by VER-03 alone is **asymmetric** -- Windows entries rotate, Linux + entries do not. Only VER-01 (the path change) rotates both. Plan the OBS-04 expected-signal + record around that: the first post-change push all-MISSes on **both** legs because of the PATH, + not because of the flag. +2. macOS already shares a version with Linux today (no `'windows-only'`, same salt). If a macOS + consumer ever appears, the OS-invariance is already there and the Nx discriminator (CORR-04) is + already the only thing separating them. + +## A.3 -- Other v0.0.1 pitfalls, status for v0.0.2 + +| # | Status for v0.0.2 | Phase | +|---|---|---| +| **P1** CREEP / wrong trigger set | Unchanged. TRUST-10 must **verify** the allowlist and sync gate rather than assume them. | 10 | +| **P2** mirror as the cross-trust bridge | **SHARPENED, not unchanged.** See B7. The Actions-cache OS-version barrier was an accidental second filter on what a publish leg could pull into the public mirror; VER-03 removes it. `listCacheEntries`' `ref` scoping (`action/index.ts:40-43`) becomes the sole in-repo control. | 10 | +| **P3** GHES read-only-token floor | Untouched by v0.0.2. Do not let DOCS-08's README edits drop the existing caveat. | 9 (docs only) | +| **P4/P5/P6** LRU-via-manifest | Still out of scope. RETAIN-04 is a filter widening, not retention state. | -- | +| **P8** fault-as-absence in cleanup | Unchanged and directly re-exercised: RETAIN-04 edits the cleanup DELETE filter. `cleanupMirror`'s list-then-delete structure and the `Number.isNaN(createdMs)` never-delete-on-unknown branch are intact [VERIFIED, `cleanup.ts:74-115`]. Widening the filter must not weaken either. | 10 | +| **P9** MISS-only, never wrong result | **The invariant most affected by this milestone.** v0.0.1 had three overlapping barriers (Nx hash divergence, `@actions/cache` version, `-` asset name). v0.0.2 deliberately removes two and *fixes the third's accidental version so it can no longer be a barrier either*. Afterwards, P9 rests on exactly ONE declared input (CORR-04) plus the CORR-05 platform-agnosticism claim. Every phase 8-12 gate is ultimately a P9 gate. | all | + +--- + +# Section B -- New pitfalls, ordered by silence then blast radius + +## B1. The three-anchor split-brain: `cwd` vs `GITHUB_WORKSPACE` vs glob base + +**Silence: SILENT-3. Phase 9 (VER-01, VER-04). Highest-priority item in this document.** + +**What goes wrong.** Today the archive path is absolute (`join(tmpdir(), ...)`) and tar stores it +with `-P`, so *where the process runs* is irrelevant. VER-01 makes it **relative**. A relative path +is resolved against three DIFFERENT anchors inside `@actions/cache` 6.2.0 [all VERIFIED]: + +| Step | Anchor | Source | +|---|---|---| +| `saveCache` -> `resolvePaths` -> `glob.create` expands the pattern | `process.cwd()` | `@actions/glob/lib/internal-pattern.js:132,160` | +| `resolvePaths` relativizes each match for the tar manifest | `process.env.GITHUB_WORKSPACE ?? process.cwd()` | `cacheUtils.js:62` | +| `createTar` / `extractTar` run `tar -C ` | `process.env.GITHUB_WORKSPACE ?? process.cwd()` | `tar.js:125-128, 228` | + +They coincide only when `process.cwd() === GITHUB_WORKSPACE`. When they do not: + +- **Save** globs one tree and writes manifest entries relative to another (`../../...` escapes). +- **Restore** returns a **HIT**, extracts the file under `$GITHUB_WORKSPACE/.nx/cache/...`, and then + `actions-cache-backend.ts:53` does `readFile(path)` -- resolved against `process.cwd()` -- and + throws ENOENT. +- `server.ts` `handleGet` catches every `backend.get` fault and returns **404 MISS** [VERIFIED, + `server.ts:141-160`]. So the whole cache degrades to a permanent all-MISS with **no log line + anywhere**, while `@actions/cache` cheerfully logs `Cache hit for: nx-cache-`. + +**Why it happens.** VER-04 is written as *"assert its cwd is the Nx workspace root"*. That is the +wrong variable. `@actions/cache` never reads "the Nx workspace root" -- it reads `GITHUB_WORKSPACE`. +A container action, a `working-directory:` on the step, a monorepo sub-package invocation, or a +consumer running the sidecar from a subdirectory all break the identity while cwd is still "a" +workspace root. + +**Prevention.** +- Assert the **conjunction**: cwd is the Nx workspace root **AND** (`GITHUB_WORKSPACE` is unset OR + `resolve(GITHUB_WORKSPACE) === resolve(cwd)`). Fail loud on either half. Compare resolved, + case-normalised paths -- on Windows `D:\a\r\r` and `d:\a\r\r` are the same directory and + different strings. +- Assert it **once at process start**, not per-request. A per-request check that fires inside + `get()` gets swallowed by `handleGet`'s catch and becomes another silent MISS. +- Add an asymmetry note: the SAME fault is **loud** in `publishMirror` (nothing catches around + `actionsCache.get(hash)`, so the run throws) and **silent** in `serve`. Do not let a green + publish job be read as evidence the serve path is healthy. + +**Warning signs.** `@actions/cache` logs `Cache hit for:` but the Nx run shows no `[remote cache]` +label. A `.tar` appears under `$GITHUB_WORKSPACE/.nx/cache/` and is never deleted (the `rm` in the +`finally` also misses). + +--- + +## B2. The relative path's directory: must exist, must stay gitignored, must survive `nx reset` + +**Silence: SILENT-3 (hash instability) / LOUD-then-silent (ENOENT). Phases 9 and 11.** + +Three distinct traps in one decision (D2-04, `.nx/cache/`). + +**(a) The directory is no longer guaranteed to exist.** `os.tmpdir()` always exists; `.nx/cache` +does not. `put()` does `writeFile(path, bytes)` **before** anything creates it +(`actions-cache-backend.ts:68`). On a fresh runner or after `nx reset`, that is ENOENT -> the +`catch` rethrows (not a `ReserveCacheError`) -> `handlePut` -> **500**, which fails the build +(writes fail closed by design). Cheap prevention: `mkdir` with `recursive: true` immediately before +the write, in `cacheArchivePath`'s caller or a one-line helper. Note this ALSO matters for the read +path in an unexpected way -- `extractTar` runs `io.mkdirP(workingDirectory)` and GNU tar creates +intermediate dirs, so the read path self-heals and the write path does not. Asymmetric. + +**(b) The literal MUST stay under a gitignored path.** `.gitignore` ignores `.nx/cache`, +`.nx/workspace-data`, `.nx/polygraph`, `.nx/self-healing`, `.nx/migrate-runs` -- **it does NOT ignore +`.nx/` wholesale** [VERIFIED]. So an innocent "tidy" from `.nx/cache/nx-github-cache-.tar` to +`.nx/github-cache/.tar` puts a transient multi-megabyte file into Nx's workspace file map. +The result is a **self-referential, intermittent task-hash perturbation**: whether a task's hash +changes depends on whether an archive happened to be on disk at hash time. That is a flaky +cross-OS MISS with no error and no reproducible repro. **Comment-lock the literal with "this path +is chosen because it is gitignored", not just "because it is workspace-relative".** + +**(c) `nx reset` deletes `.nx/cache`.** TEST-10 *mandates* `nx reset` before the O1/O2 proofs. If +the sidecar is already running, the reset deletes the directory out from under it and the next PUT +500s. **Ordering for the Phase 11 proof script: `nx reset` FIRST, then start the sidecar, then +run.** Also worth checking whether Nx 23's own local-cache size eviction can remove foreign files +from `.nx/cache` (unverified; treat the archive as deletable-at-any-time and never assume it +survives between two of our own calls -- the existing `withHashLock` already assumes this). + +**Adopter note for DOCS-07:** a consumer whose `default` named input is `{workspaceRoot}/**/*` +(not Nx's default, but a common hand-roll) gets trap (b) even with our gitignored literal, unless +their `.gitignore` covers it. Say so. + +--- + +## B3. `nx show target inputs` cannot see the node that most likely diverges + +**Silence: SILENT-3. Phase 8 (PARITY-01). This will waste a whole root-cause pass if not planned around.** + +**What goes wrong.** The natural capture command for PARITY-01's "node-by-node" record is Nx 23's +`nx show target inputs :` (a real, documented subcommand -- `nx show` registers +`target inputs [target]` with `--check`, `command-object.js:172-196`) [VERIFIED]. It routes through +`HashPlanInspector.inspectInputs`, whose own API doc says: + +> *"TsConfiguration is resolved to the root tsconfig file path. JsonFileSet is resolved to the +> matched JSON file paths ... **ProjectConfiguration is skipped for now. Cwd is skipped as it's +> ambient.**"* -- `node_modules/nx/dist/src/native/index.d.ts:83-86` [VERIFIED] + +Two blind spots, and v0.0.1's own research named both as the leading divergence sources: + +1. **`ProjectConfiguration` is not reported at all.** `PITFALLS.md:329` states *"Nx folds a built-in + ProjectConfiguration node into every task hash, and inference plugins can make that node + OS-dependent"*. The instrument cannot see it. (The node name is real -- `ProjectConfiguration`, + `TsConfig`, `AllExternalDependencies` and the symbol `hash_project_config` are all present in the + Nx native binary [VERIFIED by binary string probe].) +2. **It reports file PATHS, not file HASHES** (`HashInputs { files: Array, runtime, + environment, depOutputs, external }`) [VERIFIED]. So a CRLF divergence, a `.tsbuildinfo` + case-normalisation divergence, or any content-level difference shows up as **two identical + input lists with two different task hashes**. + +The failure mode is a researcher diffing two `nx show target inputs` dumps, finding them +byte-identical, and concluding "no divergence" or "must be a bug in Nx". + +**Prevention.** PARITY-01 must name an instrument that emits the **per-node `details` map**, not +the structured inputs. The hasher populates `task.hashDetails` = `TaskHashDetails.details` +(`Record`, node -> hash) via `hashTask` / `hashTasks` +(`nx/dist/src/hasher/hash-task.js:74-86,159`) [VERIFIED], and nothing in the CLI prints it -- so a +small script is required. **The repo already has the precedent for reaching into `nx/src/hasher/*` +from committed code** (`packages/github-cache/src/nx-target-inputs.spec.ts` imports +`nx/src/hasher/task-hasher.js` and documents the no-semver-guarantee trade-off) [VERIFIED]. Reuse +that shape, and record the same loud-import-failure rationale. + +Use `nx show target inputs` as a *secondary* diff (it is genuinely good for file-set questions), +but state in PARITY-01 that it is blind to `ProjectConfiguration` and to content hashes, so a +"no difference" result from it is not evidence. + +--- + +## B4. Graph-state freshness reproduces the entire "cross-OS" divergence on ONE machine + +**Silence: SILENT-3. Phases 8 and 11. This can make CORR-03 green while XOS-01 misses.** + +**What goes wrong.** Already measured in this repo and easy to forget: +`.planning/quick/260725-w3s-.../260725-w3s-RESULTS.md` section 4 shows that on **one Windows box, +one commit**, varying only `.nx/workspace-data` freshness, **all four cacheable targets compute a +different hash** [VERIFIED from the artifact]: + +| target | COLD (fresh workspace-data) | WARM-GRAPH | +|---|---|---| +| build | 13655686526929222562 | 14522047022641658505 | +| typecheck | 3381254060286801611 | 17612203514283256006 | +| test | 5027851155743781967 | 12332927989897543193 | +| integration | 13758457399293023985 | 18311993323643153366 | + +Both of the values `STATE.md` attributes to "ubuntu CI" vs "windows CI" are reproducible on the one +Windows machine by varying nothing but graph freshness. + +**Why this bites v0.0.2 specifically.** CORR-03 is a **CI job comparing two runners**. CI runners +are always COLD. XOS-01's consumer is a **native Windows workstation**, which is WARM by default +(that is what "established local box" means). So: + +- CORR-03(c) can be permanently green -- cold-ubuntu == cold-windows -- while the O1 proof MISSES, + because the warm local box computes a fourth value that CI never produced. +- Nothing fails. `Cache: 0/4 hit (0%)` prints, and that line is non-discriminating (see D1). + +**Prevention.** +- PARITY-02's three observation points must each **record the graph state**, and at least the + Windows-workstation point must be taken in BOTH states. Warm-vs-cold is a *fourth* axis alongside + the three observation points, not a nuisance variable. +- Treat "the hash a warm local box computes equals the hash cold CI published" as a **separate, + named acceptance question** from PARITY-02's cross-OS parity. If the answer is no, O1 is + unreachable regardless of OS parity, and the honest fix is either "make the graph deterministic" + or "the documented O1 recipe includes a cold-graph step". +- Do NOT resolve this by making `nx reset` part of the recipe without saying so: `nx reset` clears + BOTH `.nx/cache` and `.nx/workspace-data`, so TEST-10's mandated reset silently forces the local + box into the COLD state. That is convenient for the proof and **misleading as evidence for the + everyday developer experience**. Record which question the proof answers. +- Root-cause the freshness sensitivity itself in Phase 8. It is almost certainly the same + inference-plugin / external-dependency-graph class as `PITFALLS.md:329-331`, which means fixing + it also fixes part of the OS axis. + +--- + +## B5. Verdict artifacts: what a `test` cache entry actually is + +**Silence: SILENT-3. Phases 10 (record) and 12 (mitigate). See also Section C.** + +**What goes wrong.** Nx's remote-cache contract stores `{ code: number, terminalOutput?: string, +outputsPath: string }` [VERIFIED: `CachedResult`, `nx/dist/src/native/index.d.ts:277-282`; the +store signature is literally `store(hash, cacheDirectory, terminalOutput, code)`]. For `test` and +`typecheck`, which declare no `outputs`, the artifact IS the exit code plus the replayed log. + +So enabling O4 means: **Windows CI stops executing `build`, `typecheck` and `test` entirely** and +replays Linux's exit 0. Any Windows-only regression -- in the *code under test*, not in the spec -- +is then invisible forever, and the CI page shows a green Windows job with `[remote cache]`. + +**What CORR-06's lint rule does and does not cover.** LINT-02 bans **ambient platform reads in +`**/*.spec.ts`**. That catches the three known violations and future ones of the same shape. It +does NOT catch: + +- platform sensitivity in `src/**` (e.g. `cache-archive-path.ts` uses `node:path` today); +- Node/libuv behavioural differences the spec never mentions (case-insensitive FS, path length, + `EPERM` on rename over an open handle, socket/`ECONNRESET` timing, `EOL` in a snapshot); +- toolchain differences (`tsc` resolving a mis-cased import on a case-insensitive FS -- partially + closed by `forceConsistentCasingInFileNames: true` in both tsconfigs, which REQUIREMENTS.md + already relies on); +- architecture and libc, which `process.platform` does not distinguish at all and which this repo + cannot exercise (every machine here is arm64) -- DOCS-07 already commits to naming these. + +**Prevention (proportionate, and cheap).** The requirement set deliberately puts "executor +portability classification" and "an empirical divergence-detection subsystem" out of scope, with +the residual risk recorded in TRUST-11. That is a defensible call, but it leaves *no* detector at +all. The lazy version of a detector already has a home in this repo: the workspace already runs a +**scheduled** workflow (`cleanup.yml`). Add one scheduled Windows job that runs the three targets +with `--skip-nx-cache`. Cost: one job, once a day/week. Benefit: a Windows-only regression is +caught within one schedule tick instead of never. Recommend it in Phase 12 alongside XOS-05's +"whether the Windows legs also WRITE" recorded decision; if that decision is "they write", the +scheduled no-cache run stops being optional. + +**Warning sign.** A Windows job's wall time collapses to the sidecar overhead and every target +carries `[remote cache]`. That is the SUCCESS signal for O4 and the RISK signal for B5 -- they are +the same observation, which is exactly why it needs a separate detector. + +--- + +## B6. First-write-wins now arbitrates between payloads that are NOT byte-identical + +**Silence: SILENT-3. Phase 10 (TRUST-11), and it is a code comment, not only a threat-model entry.** + +TRUST-11 records the premise change. The concrete follow-through that is easy to miss: the claim is +written into the **code**, and the code comment becomes false in the same commit. + +`publish-mirror.ts:159` currently reads: + +> `* First-write-wins (D-05/TRUST-07): a name already present is a benign no-op (the shard` +> `* asset set is byte-identical under CORR-01); a duplicate-upload race returning 422 is` +> `* likewise benign.` + +[VERIFIED]. After CORR-02 both legs derive the SAME name for the same hash, and the two legs' +payloads differ because the cached artifact embeds captured terminal output with OS-specific paths. +So "benign because byte-identical" becomes "benign because we assert the two payloads are +*semantically* interchangeable" -- a materially weaker claim that a reader must not inherit +unexamined. + +**Prevention.** +- Rewrite that comment and the 422 branch's comment in the SAME commit as CORR-02. A comment lock + that survives the change it describes is how a false premise gets re-adopted three milestones + later. +- The reader's shard walk is **newest-shard-first**, so if a hash is mirrored into two month shards + (e.g. the rename push straddles a month boundary) the winner is shard-dependent, not + first-write-dependent. TRUST-11 says this; make sure the Phase 11 proofs are not run within a day + or two of a month rollover, or add the shard tag to the recorded evidence. +- OBS-03's producing-OS `label` is what makes this recoverable after the fact. It is doing more work + than "nice-to-have attribution" -- it is the only way to answer "whose bytes did the developer + get" once the namespaces collapse. Treat it as load-bearing, not observability garnish. + +--- + +## B7. The mirror's cross-trust bridge widens (P2, sharpened) + +**Silence: SILENT-3 for the exposure; the code change itself is loud. Phase 10 (TRUST-10, TRUST-12, TRUST-13).** + +Before v0.0.2, a publish leg could only pull entries its OWN OS had saved -- the measured 4/1 split +in `windows-publish-one-asset.md`. That was an *accidental* second filter on what could enter the +world-readable Releases mirror. VER-03 removes it: after the change, the ubuntu leg can restore and +mirror **every** OS's entries, and therefore every OS's captured terminal output. + +The concrete consequences a planner must handle: + +1. **`listCacheEntries`' `ref` scoping (`action/index.ts:40-43`) becomes the sole in-repo control** + keeping non-default-branch trusted writes out of the public mirror. `TRUSTED_EVENTS` includes + `push` with no ref check, so the server writes on any pushed branch; the sync gate + (`isSyncTrusted`) gates *whether publish runs*, and the `ref` scoping gates *what it can see*. + TRUST-10 pins this by spec and comment-lock -- do not let it be simplified as "redundant with the + sync gate". +2. **Both legs now mirror the same set, so the Windows leg's `mirrored` count collapses toward 0 + permanently** (ubuntu runs first under `max-parallel: 1` and wins every name). This is the + mechanism that makes OBS-05 load-bearing: without a leg-distinguishable seed, a completely dead + Windows publish path presents identically to a healthy one. +3. Conversely, the all-restore-MISS warning becomes MORE meaningful (a full miss is no longer + legitimately explained by "different OS"), which is exactly what OBS-04 codifies. Keep the + warning; it just changes meaning. + +--- + +## B8. `publish-verify` and `dogfood-verify` both go vacuous under an OS-invariant name + +**Silence: SILENT-3. Phases 9 (VER-06) and 10 (OBS-05).** + +OBS-05 names the `publish-verify` half. The **VER-06 half has the mirror-image trap and it is not +spelled out anywhere**: + +- `dogfood-seed` and `dogfood-verify` are both `ubuntu-24.04-arm` today, `dogfood-verify` has + `needs: dogfood-seed`, and both are `if: github.event_name == 'push'` [VERIFIED, + `ci.yml:428-468`]. +- VER-06 adds a windows-11-arm `dogfood-verify` leg reading back the ubuntu-seeded entry. **That is + a valid cross-OS proof only while there is NO Windows `dogfood-seed` leg.** The seed key is + `nx-cache-` -- one key per run, not per OS -- so the moment a Windows seed leg + exists, the Windows verify restores the Windows-written entry and passes even if cross-OS restore + is completely broken. +- Same shape as `read-back.ts:37`'s `GITHUB_RUN_ID` seed, which OBS-05 already flags for the + Releases side [VERIFIED]. + +**Prevention.** Write the vacuity condition into the job comment ("this leg proves nothing if a +Windows seed leg is ever added"), and add a mechanical guard: the verify leg should assert the +restored entry's *provenance*, not just its presence -- e.g. seed a body that encodes the producing +OS (`dogfoodBody` already gives a deterministic body per hash; extend it or add a second field) and +assert the Windows leg read a **linux**-produced body. A presence-only assertion cannot distinguish +the two worlds. + +--- + +## B9. `getCompressionMethod` is not exported, so VER-05 must re-implement it + +**Silence: SILENT-2. Phase 9 (VER-05).** + +`@actions/cache` 6.2.0's exports map is exactly `{".": {"types": "./lib/cache.d.ts", "import": +"./lib/cache.js"}}` [VERIFIED]. `getCompressionMethod` lives in `lib/internal/cacheUtils.js` and is +therefore unreachable -- the same `ERR_PACKAGE_PATH_NOT_EXPORTED` wall VER-02 already documents for +`getCacheVersion`. + +So VER-05's "surface the resolved compression method" means **re-implementing upstream's probe**, +and a re-implementation can silently disagree with the value `@actions/cache` actually used. The +exact upstream rule is subtle [VERIFIED, `cacheUtils.js:100-136`]: + +- it runs `zstd --quiet --version`, collecting **stdout AND stderr** into one string; +- `exec` runs with `ignoreReturnCode: true`, and a thrown error is caught and swallowed to `''`; +- the branch is **`versionOutput === '' ? Gzip : ZstdWithoutLong`** -- the parsed semver is computed + and then **not used** for the decision; +- so a *broken but present* zstd that prints anything at all still selects zstd, and the failure + surfaces later as a tar error, which `saveCache` swallows into a warning and `-1`. + +**Prevention.** Mirror the command, the stdout+stderr capture, and the empty-string rule exactly; +comment-lock it against the pinned `@actions/cache` version with a pointer to `cacheUtils.js`; and +keep it **advisory** as VER-05 already specifies -- a mismatch between our reported value and the +real one must not gate a build. Add a line to the `@actions/cache` bump checklist: *"re-read +`getCompressionMethod`; VER-05 duplicates it."* + +--- + +## B10. The cleanup filter: two name families are not the whole population + +**Silence: SILENT-2 (unbounded growth toward the asset cap). Phase 10 (RETAIN-04).** + +RETAIN-04 admits `nx-cache-` and legacy `-`. Three things it does not say: + +1. **The shard already holds ~50 PoC-era `.tar.gz` assets that NO filter matches** + [VERIFIED from `260725-w3s-RESULTS.md`: "50 are PoC-era `.tar.gz`"]. They have never been + prunable and RETAIN-04 does not change that. They are permanent occupants of the 1000-asset + per-release cap. Decide explicitly: prune them once by hand, add a third accept branch, or + record them as accepted dead weight with a count. Do not leave the question unasked -- the cap's + skip-and-warn degradation makes the eventual failure a *warning*, not an error (SILENT-1). +2. **The two branches must be mutually exclusive and both individually tested.** The current + function splits on the **last** `-` [VERIFIED, `release-asset-name.ts:69-83`]. `nx-cache-` + fails that parse (`os` half would be the hash), so the branches happen not to overlap -- but that + is a property of the current split, not of the design. Assert non-overlap directly. +3. **The new `CACHE_KEY_PREFIX` coupling is a NEW single point of failure.** D2-03 single-sources + the asset name from `CACHE_KEY_PREFIX = 'nx-cache-'` (`cache-key.ts:18`) [VERIFIED], which is + *also* the Actions-cache key prefix and the `isServerProducedKey` filter's prefix. After CORR-02 + one literal governs: the cache key, the enumeration filter, 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 `-`. Comment-lock `CACHE_KEY_PREFIX` as + now quadruply load-bearing and pin the literal by spec. + +--- + +## B11. Two one-time all-MISS windows that can collide + +**Silence: SILENT-1 turning into a FALSE RED. Phases 9 and 10.** + +VER-01 rotates the Actions-cache version (one full MISS wave). CORR-02 rotates the Releases asset +name (one full mirror republish). OBS-04 records the expected signal in advance and makes a +**second consecutive** all-miss push a FAILURE. + +The trap: if the Phase 10 rename lands inside Phase 9's rotation window, a correct implementation +produces two consecutive all-miss pushes and trips the tripwire. The ROADMAP already flags this +("Sequence Phase 10's warming push after the rotation push has been observed"). Reinforce it in the +tripwire's own definition: gate on **"two consecutive all-miss pushes with no version-affecting +change in between"**, not on a raw push counter. A tripwire that fires on correct work gets +disabled, and then it is not a tripwire. + +--- + +## B12. TEST-08's producer attribution: "which targets a job runs" != "which hashes a job produces" + +**Silence: SILENT-3 (a false-positive O1 proof). Phase 11.** + +TEST-08 already requires the premise be asserted *mechanically against the resolved Nx task graph, +not assumed from the job list*. The reason that wording is load-bearing, made concrete: the +`windows-publish-one-asset` investigation found the ubuntu **`typecheck` job runs 2 Nx tasks**, the +second being `build`, satisfied from cache (E2) [VERIFIED from the debug artifact]. A job that +"runs typecheck" therefore touches the `build` hash. + +Today `integration`'s `dependsOn: ["^build"]` resolves to nothing (single-project workspace), which +is why the Windows leg produced exactly one entry. That is a property of the current project graph, +not of the config. Add a second project, or change `^build` to `build`, and the Windows +`integration` job starts producing the `build` hash -- destroying O1's attribution *without anyone +touching the job list*. + +**Prevention.** Resolve the task graph for the Windows leg's actual command (`nx run-many -t +integration ... --graph` or the equivalent programmatic call) and assert the produced hash set does +not intersect `{build, typecheck, test}`. Capture the assertion output as part of the TEST-08 +evidence, not as a pre-flight check whose result is discarded. + +--- + +## B13. `max-parallel: 1` is doing three unrelated jobs + +**Silence: SILENT-2. Phase 10 (XOS-06).** + +XOS-06 says it is retained for shard-race reasons and must never become a correctness control. Two +additions: + +- It is ALSO what makes ubuntu the deterministic first-writer, which is precisely the rejected + ordering argument. The comment must say "ubuntu wins every race today, and no requirement may + depend on that" -- naming the coincidence is stronger than only forbidding the dependency, + because the coincidence is what a future reader will notice first. +- After B7, serializing also means the Windows leg's work is almost entirely `skipped`. If someone + later removes `max-parallel: 1` for speed, the two legs race the shard **and** race the same asset + names, turning the currently-benign 422 branch into the hot path. The 422 branch is correct + today; it has never been exercised at volume. + +--- + +# Section C -- Verdict-vs-file artifacts: what the ecosystem knows + +Answering "what are the known hazards of sharing a verdict across machines". + +**In Nx, first-party and verified.** The remote-cache artifact is `{ code, terminalOutput?, +outputsPath }` and the store call is `store(hash, cacheDirectory, terminalOutput, code)` +[VERIFIED, `nx/dist/src/native/index.d.ts:97-101, 277-282`]. A restored task does **not execute**; +its exit code and its log are replayed. Two consequences specific to this milestone: + +1. **The proof of portability cannot come from the green CI run.** REQUIREMENTS.md already rejects + "O4's green CI is the portability evidence" as circular. That rejection is correct and the + mechanism above is why: a restored task produces a green result by definition. It is worth + restating in DOCS-07, because it is the single most natural wrong argument an adopter will make. +2. **The verdict carries OS-specific text.** The `terminalOutput` embeds absolute paths, separators, + and timing. That is what makes B6's "byte-identical" premise false, and it is also the public-repo + exposure TRUST-12 records. + +**Comparable systems -- the same class, different mitigations** (MEDIUM confidence; stated as +patterns, not as citations, since these were not re-verified against primary sources this session): + +- **Bazel** puts platform properties into the action key and leans on *hermeticity* (sandboxing, + declared toolchains) so that an action's result is a function of its declared inputs. The known + failure is the non-hermetic action: an undeclared dependency on the host makes a cache hit + *incorrect*, not merely stale. Nx has no sandbox, so the entire burden lands on the declared + `inputs` -- which is exactly what CORR-04 is. +- **Gradle** caches `Test` task results and documents that a cacheable task must declare every + input, with `org.gradle.caching.debug` to dump the cache-key components. Its canonical bug report + is "my test passed from cache on a machine where it fails" traced to an undeclared environmental + input. +- **Turborepo** caches and *replays the logs*, which makes the replay visually indistinguishable + from a real run -- the same trap as Nx's `terminalOutput`. + +The transferable lesson is uniform across all four: **when the artifact is a verdict, the only +defence is the completeness of the declared input set, and the only detector is a periodic run with +the cache disabled.** That is the argument for B5's scheduled `--skip-nx-cache` Windows job. + +--- + +# Section D -- Proof hazards: how teams fool themselves + +## D1. `Cache: n/m hit (p%)` is non-discriminating in BOTH directions + +**Already burned this repo once.** Recorded twice in-tree [VERIFIED]: + +- `Cache: 0/1 hit (0%)` printed identically on run 30169158892, which had **no sidecar at all** -- + so a 0% line is not evidence a remote was consulted. +- A **non-zero** hit counts LOCAL hits identically; `260725-w3s` produced a `4/4` with **zero** + remote consults (warm `.nx/workspace-data` served everything locally). + +OBS-02 already mandates the `[remote cache]` label as the evidence. Reinforce the negative half in +the phase plans: **record the `Cache:` line, mark it non-discriminating, and never conclude from +it.** Nx 23.1's end-of-run performance report has the same defect and OBS-02 already demotes it to +"supporting context". + +## D2. A HIT without a preceding reset proves nothing (TEST-10) -- and neither does a reset alone + +TEST-10 mandates `nx reset`. Two ways the reset can still leave a false proof: + +- **Copying `.nx/workspace-data` back** (or restoring it from a backup, or an editor/daemon + recreating it) carries `cache_outputs` rows, and Nx then serves tasks locally from an artifact + directory containing no artifacts, **never consulting the remote at all** [VERIFIED, w3s + "A warm-graph copy masks the remote entirely"]. The run looks like a 4/4 success. +- **The reset changes the hash under test** (B4). The proof then answers "does a cold Windows box + hit" and not "does my everyday box hit". Both are legitimate questions; record which one was + asked. + +## D3. An all-MISS is indistinguishable from a broken measurement + +The Releases reader degrades **every** fault to a MISS by design (`releases-backend.ts:88-99` +[VERIFIED]) and warns at most **once per process** with only a numeric status. A mistyped port, a +token mismatch, a failed `gh auth token`, a rate limit and a genuine absence all render identically. + +**Prevention (the w3s pattern, and it worked):** prove the measurement sound BEFORE running the +thing being measured -- a 401-vs-404 readiness pair on a known-absent hash proves auth AND +reachability, and a differential control against a dead port proves the requests actually left the +process. `ci.yml`'s readiness poll already demands exactly 404 or 200 for this reason +[VERIFIED, `ci.yml:320-332`]. Do the same locally before every Phase 11 proof, and record the +ordering (soundness probe timestamp before first Nx run). + +## D4. Restore MISSes are invisible, and partial misses are silent by construction + +`@actions/cache` logs a restore MISS at **`core.debug`** +(`cache.js` v2 path: `core.debug('Cache not found for version ...')`) [VERIFIED]. With +`ACTIONS_STEP_DEBUG` unset, every miss is absent from the job log. The publish summary now reports +`scanned` and `readMisses` (the `windows-publish-one-asset` proposal 1 has landed -- `publishMirror` +returns `{ scanned, mirrored, skipped, readMisses, failed }` [VERIFIED]), which closes the worst of +it. The residual: the all-miss **warning** still only fires on a TOTAL miss, so a partial regression +stays silent. For the Phase 9/11 live closes, **turn `ACTIONS_STEP_DEBUG` on for the specific +proving run** rather than reasoning from a normal log. + +## D5. Green legs prove less than they look like they prove + +From the same investigation: run 30181729913's Windows publish leg **mirrored zero task-hash +assets, MISSed 13 of 27, and exited green**. A leg whose Actions-cache read scope had fully +regressed would present identically. Any Phase 9-12 evidence of the form "the job was green" must be +paired with a **count** that would differ under the failure hypothesis. Name the count in the plan, +not after the run. + +## D6. Attribution evidence is perishable and the window closes earlier than Phase 12 + +TEST-08 correctly captures producer attribution before Phase 12 enables O4. Note that Phase 9 +already moves the boundary a little: once the Actions-cache version is OS-invariant, the ubuntu +publish leg starts mirroring the **Windows** `integration` entry too, so the shard gains +Windows-produced assets before Phase 11. `build`/`typecheck`/`test` attribution survives (Windows +runs none of them -- assert it, per B12), but the shard is no longer "everything here is ubuntu's". +Capture `created_at` and the OBS-03 `label` per asset, not just the asset list. + +--- + +# Section E -- ESLint adoption in an Nx 23 workspace with no linter + +## E1. Verified facts (so nobody re-litigates them) + +| Claim | Verdict | +|---|---| +| Nx 23.1 requires ESLint v9+ | **CONFIRMED.** `@nx/eslint@23.1.0` peer: `eslint: "^9.0.0 \|\| ^10.0.0"` [VERIFIED via registry]. v8 is out of range. LINT-01's "v9 is mandatory, not preference" stands. | +| typescript-eslint supports this repo's TypeScript | **CLEARED -- not a pitfall.** `typescript-eslint@8.65.0` peer: `typescript: ">=4.8.4 <6.1.0"` [VERIFIED via registry]; this repo pins `typescript ~6.0.3`. No unsupported-version warning to plan around. | +| `@nx/eslint-plugin` pulls extra peers | **YES.** `@nx/eslint-plugin@23.1.0` peers `@typescript-eslint/parser ^8.0.0` and `eslint-config-prettier ^10.0.0`, and depends on `@typescript-eslint/utils` + `@typescript-eslint/type-utils` [VERIFIED]. | +| The project has no `nx` block today | **CONFIRMED.** `packages/github-cache/package.json` has no `nx` key [VERIFIED]; all four targets are inferred or come from `targetDefaults`. | + +## E2. The graph-perturbation question, and the lazier answer + +**The pitfall as stated is real.** `@nx/eslint/plugin` is an inference plugin. Adding it changes the +project's resolved target set, which changes the `ProjectConfiguration` hash node, which is folded +into every task hash -- so it invalidates any hash measurement taken before it. That is why the +ROADMAP puts Phase 7 first. Worse, an inference plugin is *exactly* the mechanism that produced the +`@nx/js/typescript` / atomizer OS-divergence class (`PITFALLS.md:329-330`), so a new inference +plugin is a new opportunity for an OS-divergent target inference. + +**The lazier alternative, worth one sentence in the Phase 7 plan before it is dismissed:** a `lint` +target declared explicitly (in `nx.json` `targetDefaults` plus the project's own `nx.targets`, or as +a plain script target) needs **no inference plugin at all**. ESLint's flat config already knows what +to lint; `@nx/eslint`'s value here is target inference and a `@nx/eslint:lint` executor, neither of +which LINT-01..06 requires. Choosing the explicit target: + +- removes the inference-plugin OS-divergence risk entirely; +- makes the `lint` target's `inputs` and `outputs` fully hand-controlled, which is what LINT-04 + needs anyway; +- **dissolves the Phase 7 -> Phase 8 ordering constraint**, since nothing about the project graph + changes beyond one declared target (which still changes `ProjectConfiguration`, so keep the + ordering -- but the *risk* it exists to manage shrinks a lot). + +If `@nx/eslint` is adopted anyway (reasonable -- it is the ecosystem norm and the generator does the +wiring), then **verify the inferred `lint` target is byte-identical on Windows and Linux as part of +Phase 8's CORR-03 job**, treating it as a fourth target. It is the newest and least-tested inference +in the workspace. + +## E3. `lint`'s own stale-cache false PASS (LINT-04) -- the specifics + +This repo has already shipped this exact defect once (`typecheck` compiled specs its inputs +excluded). Three `lint`-specific instances: + +1. **The flat config file must be an input.** `eslint.config.*` lives at the workspace root, so + `{workspaceRoot}/eslint.config.mjs` (and any file it imports) must be listed explicitly. Editing + a rule otherwise replays a cached PASS -- and the RED-before-GREEN proof (LINT-03) is exactly the + activity that edits rules, so the false PASS will show up during LINT-03 itself and be + misread as "the rule does not fire". +2. **Every file ESLint actually reads must be hashed.** ESLint lints more than `src/**`: config + files (`vitest.config.mts`, `esbuild.action.mjs`), `start-cache-server/entry.ts`, and `*.cjs` + helpers. If the `lint` inputs are narrower than the lint *scope*, the same bug returns. +3. **Do NOT enable type-aware linting unless a rule needs it.** `parserOptions.projectService` / + `project` makes ESLint build a TypeScript program, so `lint` becomes sensitive to every file in + that program plus the tsconfigs -- a much wider input set to declare correctly, and a much bigger + stale-cache blast radius. None of LINT-02/05/06's rules are type-aware + (`no-restricted-syntax` is syntactic; `ban-ts-comment` and `require-description` are + comment/AST-level). Skip `projectService`. + +**Reuse the existing guard.** `packages/github-cache/src/nx-target-inputs.spec.ts` already delegates +glob decisions to Nx's own resolver (`splitInputsIntoSelfAndDependencies` + +`extractPatternsFromFileSets` + `filterUsingGlobPatterns`) so it cannot drift from Nx's behaviour +[VERIFIED]. Extend it with `lint` probe files rather than writing a new mechanism. Note its own +recorded caveat: reading `nx.json` from a spec is only safe because `{workspaceRoot}/nx.json` is a +`test` input -- and **only `test` declares that input** (`build`, `typecheck` and `integration` do +not) [VERIFIED]. Do not move the guard to another target without moving the input. + +## E4. The ban rule is an AST matcher, so it has evasions + +`no-restricted-syntax` matches selectors, not semantics. All of these read the running machine and +none matches a naive `MemberExpression[object.name='process'][property.name='platform']`: + +``` +const { platform } = process; // ObjectPattern destructuring +const p = process; p.platform; // aliased object +import { platform } from 'node:os'; // named import, no member expression +import * as os from 'node:os'; os.tmpdir(); +const key = 'platform'; process[key]; // computed member +``` + +LINT-03 requires the rule be proven RED. **Make the RED fixture cover the evasion shapes, not just +the three known violations**, or the rule is proven only against the cases that already exist. Two +of the three current violations are member expressions; the third +(`releases-backend.spec.ts` "derives a wrong-OS fixture from `process.platform`") may already be an +evasion shape -- check its actual expression before writing the selector. + +Add `no-restricted-imports` for `node:os`/`os` in the unit-spec scope alongside the syntax rules; +it closes the whole import family in one line, which `no-restricted-syntax` cannot. + +## E5. The scope split mirrors vitest by convention, not by construction + +LINT-02 scopes with `files: ['**/*.spec.ts']` + `ignores: ['**/*.integration.spec.ts']` to mirror +`vitest.config.mts` / `vitest.integration.config.mts`. The integration config's include is +`['{src,tests}/**/*.integration.spec.{ts,mts,cts}'] ` [VERIFIED] -- note **`.mts` and `.cts` are in +the vitest include but would NOT match the lint `ignores: ['**/*.integration.spec.ts']`**. An +integration spec written as `.integration.spec.mts` would be linted as a UNIT spec and its +legitimate `process.platform` read would fail lint. Mirror the extension set, and add a spec that +asserts the two globs agree (the repo already does this class of drift guard for +`trust.ts`/`sync-gate.ts` allowlists). + +## E6. The pin guard is name-scoped, not blanket + +LINT-01 says new dev dependencies are "covered by the `pinned-deps` guard". **The guard does not +work that way.** `pinned-deps.spec.ts` asserts an exact-semver specifier for a **hard-coded list of +names** (`@actions/cache`, `@actions/core`, `@octokit/rest`, `@octokit/plugin-retry`, +`@octokit/plugin-throttling`, `esbuild`) [VERIFIED]. It does not enforce "every dependency is +exact", and indeed the workspace already carries ranges (`typescript ~6.0.3`, `vitest ~4.1.0`, +`prettier ^3.8.1`, `@types/node ^24.0.0`). + +So "add the deps exact-pinned" and "the guard covers them" are two separate tasks. **Adding +exact-pinned ESLint deps without adding their names to `pinned-deps.spec.ts` leaves them +unguarded**, and a later `npm install eslint@latest` widening the specifier passes every check. +Decide deliberately whether ESLint deps are in the ROBUST-03 supply-chain class at all -- they are +build tooling, like `esbuild`, which IS in the list; `prettier`, comparable tooling, is NOT. Whatever +the answer, write it down in the spec's comment, which is where every other such decision lives. + +--- + +# Section F -- "Looks done but isn't" checklist + +- [ ] **VER-01/VER-04:** the cwd assertion covers `GITHUB_WORKSPACE`, not just "the Nx workspace + root", and compares resolved case-normalised paths (B1). +- [ ] **VER-01:** `.nx/cache` is created before the first `writeFile`, and the literal's comment + lock says *"gitignored"* as a reason, not only *"workspace-relative"* (B2). +- [ ] **VER-03:** the argument-position spec asserts index 3 for `saveCache(paths, key, options, + enableCrossOsArchive)` and index 4 for `restoreCache(paths, key, restoreKeys, options, + enableCrossOsArchive)` [both VERIFIED]. `saveCache`'s own JSDoc lists `@param + enableCrossOsArchive` **before** `@param options` and is WRONG [VERIFIED, `cache.js:334-339`] + -- assert against the implementation, never the doc comment. +- [ ] **VER-05:** the compression probe mirrors upstream's stdout+stderr capture and its + empty-string rule, and is comment-locked to the pinned version (B9). +- [ ] **VER-06:** the Windows `dogfood-verify` leg asserts *provenance*, not presence, and its + vacuity condition (no Windows seed leg) is written into the job (B8). +- [ ] **PARITY-01:** the named capture command emits per-node hash **details**, and the record says + in one line why `nx show target inputs` is not sufficient (B3). +- [ ] **PARITY-02:** every recorded hash carries its graph state (cold / warm `.nx/workspace-data`) + alongside Nx version, Node version and install mode (B4). +- [ ] **CORR-02/RETAIN-04:** the two filter branches are asserted mutually exclusive; the ~50 + PoC-era `.tar.gz` orphans have an explicit disposition; `CACHE_KEY_PREFIX` is pinned by spec + and comment-locked as now governing four things (B10). +- [ ] **CORR-02:** `publish-mirror.ts:159`'s "byte-identical under CORR-01" comment is rewritten in + the SAME commit (B6). +- [ ] **CORR-05:** the removals are verified against the LINT-02 rule *before* removal, and the RED + fixture covers evasion shapes, not only the three known violations (E4). +- [ ] **LINT-04:** editing `eslint.config.*` re-runs `lint` -- proven by differential, not by + reading the config; and the lint input set covers everything ESLint actually reads (E3). +- [ ] **LINT-01:** every new dev dependency name is ADDED to `pinned-deps.spec.ts`, not merely + pinned in `package.json` (E6). +- [ ] **OBS-04:** the "second consecutive all-miss" tripwire is qualified by "with no + version-affecting change in between" (B11). +- [ ] **TEST-08:** the Windows-produces-no-hash premise is asserted against the RESOLVED task graph + for the Windows leg's actual command, and the assertion output is captured as evidence (B12). +- [ ] **TEST-10:** `nx reset` runs BEFORE the sidecar starts, and the proof records which question + the cold state answers (B2c, D2). +- [ ] **All Phase 9-12 live closes:** `ACTIONS_STEP_DEBUG` on for the proving run; every "job was + green" claim paired with a count that would differ under the failure hypothesis (D4, D5). +- [ ] **Comment locks:** `cache-archive-path.ts` and `release-asset-name.ts` headers rewritten, not + left asserting the inverted invariant (A.1 P7.2b). + +--- + +# Section G -- Pitfall-to-phase map + +| # | Pitfall | Silence | Phase(s) | +|---|---|---|---| +| B1 | cwd / `GITHUB_WORKSPACE` / glob three-anchor split-brain | SILENT-3 | **9** | +| B2 | `.nx/cache` existence, gitignore-dependence, `nx reset` | SILENT-3 / LOUD | **9**, 11 | +| B3 | `nx show target inputs` blind to `ProjectConfiguration` and to content hashes | SILENT-3 | **8** | +| B4 | `.nx/workspace-data` freshness reproduces the divergence | SILENT-3 | **8**, 11 | +| B5 | Verdict sharing: a restored task never executes | SILENT-3 | **12**, record in 10 | +| B6 | First-write-wins over non-identical payloads; stale code comment | SILENT-3 | **10** | +| B7 | Cross-trust bridge widens; `ref` scoping becomes sole control | SILENT-3 | **10** | +| B8 | `publish-verify` and `dogfood-verify` vacuity | SILENT-3 | **9**, **10** | +| B9 | `getCompressionMethod` unexported; VER-05 duplicates it | SILENT-2 | **9** | +| B10 | Cleanup filter population; `CACHE_KEY_PREFIX` coupling | SILENT-2 | **10** | +| B11 | Two rotation windows colliding into a false red | SILENT-1 | **9**, **10** | +| B12 | Task graph vs job list for producer attribution | SILENT-3 | **11** | +| B13 | `max-parallel: 1` doing three jobs at once | SILENT-2 | **10** | +| D1 | `Cache: n/m hit` non-discriminating both ways | SILENT-3 | 8, **11**, 12 | +| D2 | Reset-related false proofs | SILENT-3 | **11** | +| D3 | All-MISS == broken measurement | SILENT-3 | **11** | +| D4 | Restore MISSes at `core.debug`; partial misses silent | SILENT-2 | 9, **11** | +| D5 | Green leg proves less than it looks | SILENT-3 | 9, 10, **11**, 12 | +| D6 | Attribution window closes at Phase 9, not Phase 12 | SILENT-3 | 9, **11** | +| E2 | Inference-plugin graph perturbation (and the explicit-target alternative) | LOUD | **7**, verify in 8 | +| E3 | `lint` stale-cache false PASS; no type-aware linting | SILENT-3 | **7** | +| E4 | AST-selector evasions | SILENT-3 | **7** | +| E5 | Lint scope vs vitest scope drift (`.mts`/`.cts`) | SILENT-2 | **7** | +| E6 | `pinned-deps` guard is name-scoped | SILENT-2 | **7** | +| A.1 P7.2b | Comment locks left asserting the inverted invariant | SILENT-3 | **9**, **10** | +| A.3 P2/P9 | Sole-mechanism collapse; MISS-not-wrong-result | SILENT-3 | all | + +--- + +# Verification log + +Everything marked [VERIFIED] was read this session from the paths below at the current `main` +(`fe25a3f`). No source, workflow, config, or GitHub state was modified. + +**This tree** +- `node_modules/@actions/cache/lib/internal/cacheUtils.js:57-94` (`resolvePaths`), `:100-136` + (`getVersion`/`getCompressionMethod`), `:157-172` (`getCacheVersion`) +- `node_modules/@actions/cache/lib/cache.js:118-144` (`restoreCache` signature + JSDoc), `:245-310` + (`restoreCacheV2`, the `core.debug` miss), `:334-382` (`saveCache` signature + WRONG JSDoc order) +- `node_modules/@actions/cache/lib/internal/tar.js:56-100`, `:125-128` (`getWorkingDirectory`), + `:228` +- `node_modules/@actions/cache/package.json` (exports map: `"." ` only) +- `node_modules/@actions/glob/lib/internal-pattern.js:132,160` (`process.cwd()` base) +- `node_modules/nx/dist/src/native/index.d.ts:76-101` (`HashPlanInspector`, `HttpRemoteCache`), + `:277-282` (`CachedResult`), `:450-480` (`TaskHashDetails`, `HashInputs`) +- `node_modules/nx/dist/src/hasher/hash-task.js:26-172`, + `node_modules/nx/dist/src/command-line/show/command-object.js:129-230`, + `node_modules/nx/dist/src/command-line/show/show-target/inputs.js:34-55` +- Binary string probe of `@nx/nx-win32-arm64-msvc` for `ProjectConfiguration`, `TsConfig`, + `AllExternalDependencies`, `hash_project_config` +- `packages/github-cache/src/backend/actions-cache-backend.ts:41-143` (three `@actions/cache` call + sites at `:46`, `:101`, `:107`) +- `packages/github-cache/src/lib/cache-archive-path.ts`, `lib/release-asset-name.ts`, + `lib/cache-key.ts:18-21` +- `packages/github-cache/src/publish/publish-mirror.ts:150-320` (dedup, `:159` byte-identical + comment, `:218` miss branch, `:226` name derivation, all-miss warning, returned + `{scanned, mirrored, skipped, readMisses, failed}`) +- `packages/github-cache/src/cleanup/cleanup.ts:60-115`, + `src/backend/releases-backend.ts:1-105`, `src/server/server.ts:141-160`, + `src/roundtrip/read-back.ts:1-60` +- `packages/github-cache/src/pinned-deps.spec.ts`, `src/nx-target-inputs.spec.ts` +- `nx.json`, `package.json`, `packages/github-cache/package.json`, `.gitattributes`, `.gitignore`, + `packages/github-cache/vitest.integration.config.mts` +- `.github/workflows/ci.yml:320-332, 335-345, 361-366, 428-468, 577-615, 640-665`, + `docs/advanced.md:40-70` + +**Planning artifacts** +- `.planning/research/PITFALLS.md` (the v0.0.1 baseline audited in Section A) +- `.planning/debug/windows-publish-one-asset.md` (E1-E9; the measured 4/1 split, the `core.debug` + miss, the summary blind spots, the two Pitfall 7 corrections) +- `.planning/quick/260725-w3s-.../260725-w3s-RESULTS.md` section 4 + `-LEARNINGS.md` + (the graph-freshness table, the 50 PoC-era `.tar.gz` assets, `Cache: n/m` non-discrimination, + the warm-graph masking) +- `.planning/quick/260725-rk4-.../260725-rk4-SUMMARY.md` (the `[remote cache]` label, the + near-false-positive, the cross-OS probe) +- `.planning/PROJECT.md`, `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, + `.planning/RETROSPECTIVE.md` + +**External (registry, read-only)** +- `registry.npmjs.org/@nx/eslint/23.1.0` -- peer `eslint ^9.0.0 || ^10.0.0` +- `registry.npmjs.org/@nx/eslint-plugin/23.1.0` -- peers `@typescript-eslint/parser ^8.0.0`, + `eslint-config-prettier ^10.0.0` +- `registry.npmjs.org/typescript-eslint` -- latest 8.65.0, peer `typescript >=4.8.4 <6.1.0` + +**Local clone** +- `D:/projects/github/actions/cache/tips-and-workarounds.md#cross-os-cache` and `README.md` + (absolute paths / `~` / `${{ github.workspace }}` forbidden cross-OS; GNU tar + zstd required on + Windows; symlinks and file compatibility caveats) + +**NOT re-verified this session (stated at MEDIUM confidence where used)** +- `actions/cache#1622` itself was not fetched; the claim it supports is instead established + directly from `getCacheVersion`'s source, which is stronger evidence than the issue. +- The Bazel / Gradle / Turborepo comparisons in Section C are pattern-level, from background + knowledge, not re-checked against primary docs. They are used as corroboration, never as the + basis for a recommendation. +- Whether Nx 23's own local-cache eviction can delete foreign files from `.nx/cache` (B2). + +--- +*Pitfalls research for: v0.0.2 OS-invariant cross-OS sharing* +*Researched: 2026-07-26* + + diff --git a/.planning/research/v0.0.2/STACK.md b/.planning/research/v0.0.2/STACK.md new file mode 100644 index 00000000..0ce537ca --- /dev/null +++ b/.planning/research/v0.0.2/STACK.md @@ -0,0 +1,766 @@ +# Stack Research - v0.0.2 OS-invariant cross-OS cache sharing + +**Domain:** Cross-OS artifact sharing for a shipped self-hosted Nx remote cache +**Researched:** 2026-07-26 +**Confidence:** HIGH for everything read out of the pinned local clones +(`actions/toolkit` @ `@actions/cache` 6.2.0, `nrwl/nx` @ tag `23.1.0`) and the live npm registry; +MEDIUM for the one contested upstream fact (windows-11-arm zstd, section 2.4) which is flagged as +MUST-MEASURE rather than resolved. + +> **Carry-forward, not repeated.** `.planning/research/STACK.md` (v0.0.1) remains authoritative for +> the Nx OpenAPI contract, the storage-primitive selection, the `pull_request`/`release` trust +> model, the deprecated-Nx-API trap list, and the `@octokit/rest` posture. This file adds ONLY what +> cross-OS sharing needs. Where v0.0.1 said "pin `@actions/cache` exact and treat the archive path +> as load-bearing", that is now the CENTRE of the milestone, so section 2 supersedes v0.0.1 +> section 5's one-paragraph treatment with mechanism-level detail. + +--- + +## 0. Net recommendation in one table + +| Add | Exact version | Where | Why | +|-----|---------------|-------|-----| +| `eslint` | `9.39.5` | root `devDependencies` | LINT-01. Latest 9.x. See 1.2 for the v9-vs-v10 call. | +| `@eslint/js` | `9.39.5` | root `devDependencies` | Core recommended rule set. Keep the version in lockstep with `eslint`. | +| `typescript-eslint` | `8.65.0` | root `devDependencies` | The single meta-package (parser + plugin + `config()` helper). Peer `typescript >=4.8.4 <6.1.0` covers our `~6.0.3`. | +| `@eslint-community/eslint-plugin-eslint-comments` | `4.7.2` | root `devDependencies` | LINT-05 `require-description`. Named in the requirement. Has a `./configs` flat-config subpath export. | +| `@nx/eslint` | `23.1.0` | root `devDependencies` | LINT-01/LINT-04. Inference plugin; must match `nx`/`@nx/js`/`@nx/vitest` exactly at 23.1.0. | + +**Nothing else.** No `jiti`, no `@vitest/eslint-plugin`, no `eslint-plugin-n`, no `prettier` ESLint +bridge, no new runtime dependency of any kind. Section 4 argues each rejection. + +`@actions/cache` stays at exactly `6.2.0` - v0.0.2 changes ARGUMENTS, never the version. A version +bump and a cross-OS cutover in the same milestone would make an all-MISS push un-attributable. + +--- + +## 1. ESLint 9 flat config + +### 1.1 Verified registry state (2026-07-26) + +| Package | `latest` | Published | Peer ranges (verified from the registry manifest) | +|---------|----------|-----------|---------------------------------------------------| +| `eslint` | `10.8.0` | 2026-07-24 | v10 peer: `{"jiti":"*"}`, **optional**. v10 engines: `^20.19.0 \|\| ^22.13.0 \|\| >=24` | +| `eslint` (9.x head) | `9.39.5` | 2026-07-10 | engines `^18.18.0 \|\| ^20.9.0 \|\| >=21.1.0` | +| `@eslint/js` | `10.0.1` | 2026-02-06 | 9.x head is `9.39.5` | +| `typescript-eslint` | `8.65.0` | 2026-07-20 | `eslint: ^8.57.0 \|\| ^9.0.0 \|\| ^10.0.0`, `typescript: >=4.8.4 <6.1.0` | +| `@eslint-community/eslint-plugin-eslint-comments` | `4.7.2` | 2026-05-26 | `eslint: ^6 \|\| ^7 \|\| ^8 \|\| ^9 \|\| ^10` | +| `@nx/eslint` | `23.1.0` | 2026-07-13 | `eslint: ^9.0.0 \|\| ^10.0.0`; `@nx/jest` and `@zkochan/js-yaml` peers are **optional** | + +`@nx/eslint@23.1.0`'s own `dependencies` include `typescript: ~6.0.3` - byte-identical to this +repo's pin, so no resolution conflict. + +**LINT-01's premise is VERIFIED, not assumed.** `@nx/eslint@23.1.0`'s published peer range is +`^9.0.0 || ^10.0.0`. ESLint v8 is genuinely dropped. + +### 1.2 Take ESLint 9.39.5, not 10.x + +Every peer range above already admits v10, and v10 is flat-config-only (which we would be anyway), +so v10 is *unblocked*. Take 9.39.5 regardless: + +- LINT-01 says v9. Nothing in the milestone needs a v10 feature. +- `eslint@10.8.0` is two days old at time of writing. This milestone's whole premise is removing + accidental correctness; adding a two-day-old major to the toolchain that gates it is the wrong + trade. +- **Unverified risk that 9.39.5 sidesteps:** `@nx/eslint`'s `resolveESLintClass` + (`packages/eslint/src/utils/resolve-eslint-class.ts` @ 23.1.0) calls + `eslintModule.loadESLint({ useFlatConfig })`. Whether `loadESLint` survives ESLint 10's removal of + the eslintrc loader is **ASSUMED-UNKNOWN** - not checked. On 9.39.5 the call is documented and + present. + +The v10 bump is a one-line change later; nothing in the config shape below differs. + +### 1.3 What `@nx/eslint` inference actually adds, and its OS-variance verdict + +Register it in `nx.json` `plugins` (there is no auto-registration): + +```jsonc +{ "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } } +``` + +The inferred target, read from `packages/eslint/src/plugins/plugin.ts` @ 23.1.0 +(`buildEslintTargets`, lines 453-516): + +```js +{ + command: 'eslint .', + cache: true, + options: { cwd: '' }, + inputs: [ + 'default', + '^default', + '{workspaceRoot}/eslint.config.mjs', + '{workspaceRoot}/tools/eslint-rules/**/*', + { externalDependencies: ['eslint'] }, + ], + outputs: ['{options.outputFile}'], + metadata: { /* technologies, description, help */ }, +} +``` + +#### The hash mechanism the sequencing constraint rests on - CONFIRMED + +`hash_project_config` (`packages/nx/src/native/tasks/hashers/hash_project_config.rs:10-66`) hashes, +for the project: + +``` +project.root + tags + (for each target, sorted by name: + targetName + executor + outputs.concat() + options + configurations + parallelism) + + project.namedInputs +``` + +and `HashInstruction::ProjectConfiguration(project)` is emitted for **every** task, on both branches +of `gather_self_inputs` (`packages/nx/src/native/tasks/hash_planner.rs:643` and `:655`). + +So the REQUIREMENTS.md sequencing row "LINT-01 before PARITY-01" is mechanically correct: adding a +`lint` target inserts a new entry into the sorted `targets` string, changing +`hash_project_config`, which is folded into `build`, `typecheck`, `test` and `integration` alike. + +**Two rotations, not one.** `nx.json` is ALSO an explicit fileset input of `test` +(`nx.json` targetDefaults `test.inputs` -> `{workspaceRoot}/nx.json`), so registering the plugin +rotates `test` twice over. Plan for the Phase 7 default-branch push to be an **all-MISS** push, and +reconcile that with OBS-04's "a SECOND consecutive all-miss push is a FAILURE" rule - Phase 7 and +Phase 9 each legitimately produce one. + +#### OS-variance audit of the inferred target - CLEAN, with two caveats + +Field by field against the hashed set: + +| Hashed field | Value | OS-invariant? | +|--------------|-------|---------------| +| target name | `lint` | Yes - literal | +| executor | `nx:run-commands` (from `command` sugar) | Yes | +| `outputs` | `['{options.outputFile}']` | Yes - literal token, `outputFile` is never set | +| `options.command` | `eslint .` | Yes - literal | +| `options.cwd` | `packages/github-cache` | Yes - `dirname()` from `node:path/posix` (plugin.ts:22) over Nx's forward-slash glob results | +| `options.env` | absent | Only set for eslintrc (`ESLINT_USE_FLAT_CONFIG: 'false'`); we are flat | +| `metadata` | contains `${pmc.exec}` | **Not hashed at all** - `metadata` is absent from `hash_project_config` | + +The `pmc.exec` scare is a non-issue *for the hash*: `metadata` is not hashed. (`pmc.exec` DOES reach +the hash elsewhere - see 3.3.) + +Two residual caveats worth naming rather than asserting away: + +1. **Target EXISTENCE, not target CONTENT, is the divergence risk.** If `@nx/eslint` infers a `lint` + target on Linux and not on Windows (or vice versa), `hash_project_config` diverges and EVERY + target's hash diverges - a new PARITY bug of exactly the class PARITY-01 exists to find. The + existence gate runs `eslint.isPathIgnored(join(workspaceRoot, file))` where `join` is the + **posix** join applied to an absolute Windows root (plugin.ts:22, :105), producing mixed + separators (`D:\...\github-cache/packages/...`). Windows `existsSync` and ESLint's internal + `path.resolve` both tolerate this, so it SHOULD be fine - but "should" is exactly what CORR-03's + two-leg measurement is for. Verify empirically; do not reason it closed. +2. **`existsSync(join(workspaceRoot, projectRoot, 'src'))` is case-insensitive on Windows** + (plugin.ts:424-428). It only matters for the ROOT project. Verified: this repo's root has no + `src` and no `lib`, so `getProjectUsingESLintConfig` returns `null` for `.` (plugin.ts:430-432) + and the root gets **no** lint target on either OS. Do not create a root `src/` or `lib/` + directory during this milestone. + +#### `.eslintignore`: do not create one + +If `packages/github-cache/.eslintignore` exists, the plugin (a) constructs a per-project `ESLint` +instance instead of the shared one and (b) appends `{workspaceRoot}/packages/github-cache/.eslintignore` +to the inputs. Flat config ignores live in the config's `ignores` key; an `.eslintignore` file adds +an OS-touching `existsSync` branch for zero benefit. Skip it. + +### 1.4 LINT-04: the inferred inputs have a real stale-PASS hole + +`{ externalDependencies: ['eslint'] }` covers `eslint` and nothing else. A bump of +`typescript-eslint`, `@eslint/js`, or the comments plugin would NOT invalidate the `lint` cache - +precisely LINT-04's named failure class ("`typecheck`'s inputs excluded `*.spec.ts` while its +command compiled them"). Close it in `nx.json` `targetDefaults`: + +```jsonc +"lint": { + "inputs": [ + "default", + "^default", + "{workspaceRoot}/eslint.config.mjs", + "{workspaceRoot}/tools/eslint-rules/**/*", + { "externalDependencies": [ + "eslint", + "@eslint/js", + "typescript-eslint", + "@eslint-community/eslint-plugin-eslint-comments" + ]} + ], + "outputs": [] +} +``` + +Two notes on that block: + +- `targetDefaults..inputs` **REPLACES** the inferred inputs; it does not merge into them. + Verified empirically on this repo: `nx.json`'s `test.inputs` fully replaced `@nx/vitest`'s + inferred list, including dropping the plugin's `{ env: 'CI' }` entry (see 3.4 - that is + load-bearing good news for O1). So the block above must restate every input it wants to keep. +- Pin `outputs: []`. Empty is honest (`eslint .` with no `--output-file` writes nothing) and it + removes the `{options.outputFile}` token from `hash_project_config` entirely. + +**Keep the rule set in the single root `eslint.config.mjs`.** A helper module imported by it would +NOT be a declared input unless it lives under `tools/eslint-rules/**/*` (already an inferred input) +or inside `{projectRoot}`. One file, or `tools/eslint-rules/` - nothing else. + +### 1.5 `no-restricted-syntax` scoping and the two-rule requirement + +Flat config `files`/`ignores` are **per config-object array-element** globs, evaluated against the +path relative to the config file's directory. The LINT-02 scoping mirrors `vitest.config.mts` +exactly: + +```js +{ + files: ['**/*.spec.ts'], + ignores: ['**/*.integration.spec.ts'], + rules: { /* the ban */ }, +} +``` + +An entry in `ignores` **alongside `files`** removes those files from THIS object only - it does not +globally ignore them (that is a config object with `ignores` and no other keys). So +`*.integration.spec.ts` still gets every other rule, and only loses the platform ban. That is +exactly CORR-06's "same APIs stay ALLOWED in `integration`". + +**Two core rules are needed, not one** - because the repo uses both import styles +(`import { tmpdir } from 'node:os'` in `cache-archive-path.ts:1`, and +`import * as cache from '@actions/cache'` namespace style elsewhere): + +| Access shape | Rule that catches it | +|--------------|----------------------| +| `import { tmpdir, EOL } from 'node:os'` / `import { sep } from 'node:path'` | `no-restricted-imports` with `paths: [{ name: 'node:os', importNames: [...] }]` | +| `os.tmpdir()`, `path.sep`, `process.platform`, `process.arch` | `no-restricted-syntax` with `MemberExpression` selectors | + +Both are **core ESLint rules** - no plugin, no dependency. `no-restricted-imports` alone cannot ban +a specific member of a namespace import; `no-restricted-syntax` alone misses destructured named +imports. Wire both, or LINT-03's RED proof will pass for one shape and silently miss the other. + +Selector sketch (esquery; `MemberExpression` matches both `a.b` and the computed form when +`[computed=false]` is omitted - constrain it): + +```js +"no-restricted-syntax": ["error", + { selector: "MemberExpression[object.name='process'][property.name=/^(platform|arch)$/]", + message: "..." }, + { selector: "MemberExpression[property.name=/^(tmpdir|EOL|platform|arch|homedir|type|release)$/][object.name=/^(os|nodeOs)$/]", + message: "..." }, + { selector: "MemberExpression[property.name=/^(sep|delimiter|win32|posix)$/][object.name='path']", + message: "..." }, +] +``` + +Also cover `import('node:os')` dynamic form if any spec uses it - `git grep -n "node:os\|node:path" -- 'packages/github-cache/src/**/*.spec.ts'` +before finalising the selector set, so LINT-03's three known violations are provably in scope. + +### 1.6 LINT-05 / LINT-06 wiring + +- `@eslint-community/eslint-plugin-eslint-comments@4.7.2` exposes flat configs at the + **`./configs` subpath** (verified export map: `{".": ..., "./configs": ..., "./package.json": ...}`). + Import `from '@eslint-community/eslint-plugin-eslint-comments/configs'`, then set + `'@eslint-community/eslint-comments/require-description': ['error', { ignore: [] }]`. + (Note the flat-config rule prefix is the scoped `@eslint-community/eslint-comments/`, not the + legacy bare `eslint-comments/` that LINT-05's text uses. Same rule.) +- `@typescript-eslint/ban-ts-comment` with `{ 'ts-expect-error': 'allow-with-description', + 'ts-ignore': true }` - comes free with `typescript-eslint`, no extra package. +- LINT-06: 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 is one line. (`reportUnusedInlineConfigs` is a separate, newer `linterOptions` key worth a + glance but is NOT required by any v0.0.2 requirement - skip it.) + +### 1.7 LINT-01's pinning obligation - the guard already exists + +`packages/github-cache/src/pinned-deps.spec.ts` already reads the ROOT manifest via +`new URL('../../../package.json', import.meta.url)` and asserts +`devDependencies['esbuild']` matches `/^\d+\.\d+\.\d+$/`. Adding the five new dev dependencies means +adding five sibling `it()` blocks in that same `describe` - no new file, no new mechanism. + +--- + +## 2. `@actions/cache` 6.2.0 cross-OS support + +All of section 2 is read from the local clone at exactly our pinned version +(`D:\projects\github\actions\toolkit\packages\cache`, `package.json` `"version": "6.2.0"`). + +### 2.1 `enableCrossOsArchive` positions - VERIFIED, and the JSDoc IS wrong + +```ts +// cache.ts:148 index 4 (5th arg) +export async function restoreCache( + paths: string[], primaryKey: string, restoreKeys?: string[], + options?: DownloadOptions, enableCrossOsArchive = false +): Promise + +// cache.ts:472 index 3 (4th arg) +export async function saveCache( + paths: string[], key: string, + options?: UploadOptions, enableCrossOsArchive = false +): Promise +``` + +**The public `saveCache` JSDoc (cache.ts:463-471) lists the parameters in the order +`paths, key, enableCrossOsArchive, options` - which is NOT the signature.** Anyone writing the call +from the doc comment passes `true` where `options` belongs. TypeScript catches it here +(`boolean` is not `UploadOptions`), but the private `saveCacheV2` JSDoc (cache.ts:630-638) has the +CORRECT order, so the two disagree inside one file. This is exactly the trap VER-03 names. +`restoreCache`'s JSDoc has the right order but calls the 4th param `downloadOptions` while the +signature calls it `options`. + +The three call sites in `packages/github-cache/src/backend/actions-cache-backend.ts` become: + +| Line | Today | v0.0.2 | +|------|-------|--------| +| `:46` | `cache.restoreCache([path], cacheKeyFor(hash))` | `cache.restoreCache([path], cacheKeyFor(hash), [], undefined, true)` | +| `:101` | `cache.saveCache([path], cacheKeyFor(hash))` | `cache.saveCache([path], cacheKeyFor(hash), undefined, true)` | +| `:107` | `cache.restoreCache([path], cacheKeyFor(hash), [], { lookupOnly: true })` | `cache.restoreCache([path], cacheKeyFor(hash), [], { lookupOnly: true }, true)` | + +Note `actions-cache-backend.spec.ts:91-95` already asserts the `:107` argument list - VER-03's +"a spec asserts the argument list of each call and the call count" is an extension of an existing +assertion, not a new pattern. + +### 2.2 `getCacheVersion` - the exact recipe + +`internal/cacheUtils.ts:136-159`: + +```ts +const versionSalt = '1.0' // module constant, line 16 +export function getCacheVersion(paths, compressionMethod?, enableCrossOsArchive = false) { + const components = paths.slice() // RAW strings, not resolved, not normalised + if (compressionMethod) components.push(compressionMethod) + if (process.platform === 'win32' && !enableCrossOsArchive) components.push('windows-only') + components.push(versionSalt) + return sha256(components.join('|')) +} +``` + +Three consequences, each mapping to a v0.0.2 requirement: + +1. **`paths` is hashed RAW.** `saveCacheV2` calls `utils.getCacheVersion(paths, ...)` at cache.ts:689 + with the caller's array - NOT the `cachePaths` that `resolvePaths()` produced two lines earlier. + So `join(tmpdir(), ...)` really does put `C:\Users\RUNNER~1\AppData\Local\Temp\...` vs + `/home/runner/work/_temp/...` into the version. **VER-01 is correct and load-bearing.** +2. `compressionMethod` is a **runtime probe**, not a constant - see 2.4. **VER-05.** +3. The `'windows-only'` salt is the ONLY thing `enableCrossOsArchive` suppresses. It changes + nothing else about the version. **VER-03.** + +`getCacheVersion` is genuinely not exported from the package (`exports` map in +`packages/cache/package.json` has a single `"."` entry resolving to `lib/cache.js`, which does not +re-export `cacheUtils`). VER-02's "the derived version is NOT assertable" is confirmed. + +### 2.3 A relative `.nx/cache/...` path: what breaks, what does not + +VER-01/D2-04 change the path to a workspace-relative forward-slash literal. Four things to get right: + +**(a) `@actions/glob` accepts it.** The validator is +`literalSegments.every((x, i) => (x !== '.' || i === 0) && x !== '..')` +(`packages/glob/src/internal-pattern.ts:205-211`). A leading `.` SEGMENT is explicitly allowed at +`i === 0`; and `.nx` is a segment literally named `.nx`, not `.`. So +`.nx/cache/nx-github-cache-.tar` passes. **Upstream issue #1087 does not apply to us** - it is +about a `/./` segment appearing MID-path in an absolute path (`/home/runner/..././/dictionaries`), +which our literal never produces. #1087 is OPEN with no activity since 2024-03; treat it as +irrelevant, not as a blocker. + +**(b) `saveCache` resolves the pattern against `cwd`, but relativises against `GITHUB_WORKSPACE`.** +`resolvePaths` (`internal/cacheUtils.ts:48-70`): + +```ts +const workspace = process.env['GITHUB_WORKSPACE'] ?? process.cwd() +const globber = await glob.create(patterns.join('\n'), { implicitDescendants: false }) +// ... path.relative(workspace, file).replace(/\\/g, '/') +``` + +**(c) `extractTar` extracts relative to `GITHUB_WORKSPACE`, NOT to `cwd`.** +`internal/tar.ts:164-166`: `function getWorkingDirectory() { return process.env['GITHUB_WORKSPACE'] ?? process.cwd() }`, +used for both create (`:63`) and extract (`:278`). + +Combining (b) and (c): **if `process.cwd() !== GITHUB_WORKSPACE`, restore writes the archive to +`$GITHUB_WORKSPACE/.nx/cache/...` while `readFile(path)` reads `$CWD/.nx/cache/...` - an ENOENT that +today's absolute `tmpdir()` path makes impossible.** So **VER-04's cwd assertion must check BOTH**: +that `cwd` is the Nx workspace root AND that, when `GITHUB_WORKSPACE` is set, it resolves to the same +directory. A cwd-only check leaves the split-brain open. (Locally `GITHUB_WORKSPACE` is unset and +falls back to `cwd`, so this is a CI-only hazard - which is where the Actions backend lives anyway.) + +**(d) The directory must exist.** `writeFile('.nx/cache/nx-github-cache-.tar', bytes)` does NOT +create `.nx/cache`. `tmpdir()` always exists; `.nx/cache` may not on a cold runner (and Nx's cache +dir is relocatable via `NX_CACHE_DIRECTORY` / `nx.json` `cacheDirectory`, so its existence is not +even implied by Nx running). Add one `mkdir(dir, { recursive: true })` at backend construction. + +**Collision note, worth one line of thought at plan time.** `.nx/cache/` is also where Nx keeps its +own decimal-hash-named entry directories AND `run.json` (section 3.5). Dropping our tar files +alongside them is safe but noisy. Recommend a subdirectory - `.nx/cache/github-cache/` - which still +satisfies D2-04 ("under `.nx/cache/`") and keeps our files out of Nx's namespace. Either choice is a +byte-identical literal; pick one and never touch it again. + +### 2.4 Issue #1622 - the one thing that can defeat `enableCrossOsArchive` outright + +`actions/cache#1622` "windows-latest cache is not compatible with windows-11-arm" - **OPEN**, last +activity 2026-07-10. Read in full. + +The mechanism: `getCompressionMethod()` (`internal/cacheUtils.ts:102-112`) shells out to +`zstd --quiet --version`; empty output -> `CompressionMethod.Gzip` (`'gzip'`), otherwise +`ZstdWithoutLong` (`'zstd-without-long'`). That string is a version component. **The windows-11-arm +image historically omitted `zstd`, so it produced `gzip` while ubuntu produced `zstd-without-long` - +different versions, cross-OS restore MISSes even with `enableCrossOsArchive: true`.** This is the +exact runner pair our CI uses (`ci.yml`: `ubuntu-24.04-arm` and `windows-11-arm`). + +**The evidence is CONTRADICTORY and must be measured, not assumed:** + +| Source | Says | Checked | +|--------|------|---------| +| `actions/cache#1622` last comment (Vampire, 2026-07-10) | "the arm runners now have zstd installed by default already" | 2026-07-26 | +| `actions/partner-runner-images` `images/arm-windows-11-image.md` (Image Version `20260105.41.1`) | `zstd` is STILL listed under `## Omitted software` -> "Tools that are not available on Windows 11 Arm image", line 217 | 2026-07-26 | + +One of the two is stale. **Do not let a phase plan assume either.** Consequences: + +- If windows-11-arm has no zstd, **O4 / XOS-05 is impossible at the library level** regardless of + `enableCrossOsArchive`, and the milestone's Actions-cache layer stalls. O1/O2 (the Releases mirror) + are unaffected - they do not go through `@actions/cache` at all. +- The proven workaround from the issue thread is a pre-step `choco install zstandard` on the + windows-11-arm leg. Chocolatey **2.6.0 is present on the image** (verified in the same readme), so + the workaround is available. +- The compression method is still NOT configurable in 6.2.0 (that is why #1622 remains open, per the + 2026-07-10 comment). Forcing gzip on both legs is not an option. + +**This elevates VER-05 from observability to the tripwire for this exact regression.** Surfacing the +resolved compression method in the publish summary is how a future zstd removal (image change, +self-hosted runner, consumer runner) becomes visible instead of a silent full MISS. Keep it +surfaced-not-gated as the requirement says, and record the measured value for both legs in the +CORR-03 / VER-06 evidence. + +**Recommended addition to the Phase 9 plan (cheap, high value):** before any code lands, run a +throwaway workflow on both legs that just prints `zstd --version || echo NONE` and `tar --version`. +Two minutes of CI answers whether O4 is reachable. + +### 2.5 The other cross-OS axis upstream names: the tar binary + +`actions/cache` README:88 - "If you are using a `self-hosted` Windows runner, `GNU tar` and `zstd` +are required for Cross-OS caching to work." `getTarPath()` (`internal/tar.ts:18-50`) prefers +`%PROGRAMFILES%\Git\usr\bin\tar.exe` (GNU) on Windows and falls back to +`%SYSTEMDRIVE%\Windows\System32\tar.exe` (BSD), which triggers a `BSD_TAR_ZSTD` two-step workaround. +The arm-windows-11 image ships Git 2.52.0.windows.1, so GNU tar SHOULD be present - **ASSUMED, not +verified**. Fold `where tar` / `tar --version` into the same two-minute probe as 2.4. + +Upstream's own cross-OS guidance, which is the doc-level basis for VER-01 +(`tips-and-workarounds.md:33-36`): + +> - Only cache files that are compatible across OSs. +> - Be mindful when caching files from outside your github workspace directory as the directory is +> located at different places across OS. +> - Avoid using directory pointers such as `${{ github.workspace }}` or `~` (home) which eventually +> evaluate to an absolute path that does not match across OSs. + +--- + +## 3. Nx 23.1.0 specifics for hash parity + +### 3.1 The complete hash-instruction taxonomy (what PARITY-01 must partition by) + +From `hash_planner.rs` `gather_self_inputs` + `task_hasher.rs`, the instruction kinds a task hash is +built from: + +| Instruction | Source in our config | Cross-OS risk | +|-------------|----------------------|---------------| +| `ProjectFileSet` | `{projectRoot}/**/*` filesets | File CONTENT (CRLF - closed by `.gitattributes`) and the file LIST (case, presence) | +| `WorkspaceFileSet` | `{workspaceRoot}/...` entries in `test.inputs` | Same | +| `ProjectConfiguration` | ALWAYS, every task | See 3.2 - audited clean today | +| `TsConfiguration` | ALWAYS, every task | `tsconfig.base.json` compilerOptions + the `typescript` external-node hash | +| `External` | `{ externalDependencies: [...] }` | Lockfile-derived. See 3.3 | +| `Runtime` | `{ runtime: 'node -p process.platform' }` on `integration` ONLY | Deliberate - CORR-04 | +| `Environment` | none in our targetDefaults (see 3.4) | Would be a silent local-vs-CI divergence | +| `Cwd` | none | - | +| `DepsOutputs` | `dependentTasksOutputFiles` | Content of upstream outputs | + +PARITY-01's root-cause record should be structured on exactly this list - it is the complete set, +and "which instruction differs" is a strictly smaller question than "why do the hashes differ". + +### 3.2 `hash_project_config` - audited on this repo, on Windows, today + +Ran `npx nx show project @op-nx/github-cache --json` on the native Windows workstation. Every hashed +field is already forward-slashed and `{projectRoot}`-tokenised: + +``` +typecheck outputs {projectRoot}/tsconfig.tsbuildinfo, {projectRoot}/dist/**/*.{d.ts,d.cts,d.mts}[.map], + {projectRoot}/dist/tsconfig.lib.tsbuildinfo, + {projectRoot}/out-tsc/vitest/**/*.{d.ts,d.cts,d.mts}[.map], + {projectRoot}/out-tsc/vitest/tsconfig.spec.tsbuildinfo + options {"cwd":"packages/github-cache","command":"tsc --build tsconfig.json --emitDeclarationOnly"} +build outputs {projectRoot}/dist/**/*.{js,cjs,mjs,jsx,d.ts,d.cts,d.mts}{,.map}, + {projectRoot}/dist/tsconfig.lib.tsbuildinfo + options {"cwd":"packages/github-cache","command":"tsc --build tsconfig.lib.json"} +test outputs {projectRoot}/test-output/vitest/coverage + options {"cwd":"packages/github-cache","command":"vitest"} +integration outputs undefined + options {"command":"vitest run --config vitest.integration.config.mts","cwd":"packages/github-cache"} +root packages/github-cache tags ["npm:public"] namedInputs undefined +``` + +Source-level confirmation that this is by construction, not luck: + +- `@nx/js/typescript` `pathToInputOrOutput` (`packages/js/src/plugins/typescript/plugin.ts:1144-1162`) + ends every output with `joinPathFragments('{projectRoot}', normalizePath(relative(...)))`, and + `joinPathFragments` = `normalizePath(path.join(...))` where + `normalizePath = removeWindowsDriveLetter(p).split('\\').join('/')` + (`packages/nx/src/utils/path.ts:12-22`). Backslashes and the drive letter are stripped. +- `@nx/vitest` `normalizeOutputPath` (`packages/vitest/src/plugins/plugin.ts:437-460`) has ONE + OS-sensitive branch: `isAbsolute(outputPath) -> \`{workspaceRoot}/${relative(workspaceRoot, outputPath)}\`` + uses NATIVE `relative`, which yields backslashes on Windows. **We do not hit it** - + `vitest.config.mts` sets `coverage.reportsDirectory: './test-output/vitest/coverage'` (relative, + no leading `..`), so the `joinPathFragments('{projectRoot}', ...)` branch runs. Empirically + confirmed above. **Never make `reportsDirectory` absolute** - that single change would diverge + `hash_project_config` cross-OS and therefore diverge every task hash. Worth a comment lock in + `vitest.config.mts`. + +**Interim conclusion for the roadmapper: `hash_project_config` is NOT the current +`build`/`typecheck`/`test` divergence.** That is a narrowing result, not an answer - PARITY-01's +root-cause work remains genuinely open and should start from `External` and `ProjectFileSet`. + +### 3.3 `External` - the strongest remaining PARITY-01 hypothesis + +`build`/`typecheck` declare `{ externalDependencies: ['typescript', 'tslib', '@types/node'] }` and +`test` declares `{ externalDependencies: ['vitest'] }`. Nx hashes external nodes transitively from +the lockfile. Two reasons to look here first: + +1. This repo already has a recorded incident of a Windows `npm install` pruning the Linux + `@emnapi` WASM-fallback subtrees from `package-lock.json` (see the project memory + `windows-npm-install-prunes-linux-optional-deps`). Any lockfile asymmetry across platform-specific + optional dependencies - `@rollup/rollup-*`, `@swc/core-*`, `esbuild` platform packages, all + reachable from `vitest` -> `vite` -> `rollup` - lands directly in this instruction. +2. PARITY-04 already mandates recording the install mode (`npm ci` vs `npm install`) precisely + because of this. Honour it: a measurement taken after a bare `npm install` on Windows is not + comparable to one taken after `npm ci`. + +Also in this bucket, and relevant to DOCS-07 rather than to us: the `watch-deps` inferred target's +`options.command` is `npx nx watch ...`, where `npx` comes from `getPackageManagerCommand().exec`. +For npm that is the constant `npx`. For **pnpm** it is `modernPnpm ? 'pnpm exec' : 'pnpx'` and for +**yarn** it depends on a berry check - both resolved from the LOCALLY INSTALLED package-manager +version (`packages/nx/src/utils/package-manager.ts:185-190`, `:214`). Since `options` IS hashed by +`hash_project_config`, a pnpm/yarn consumer whose two machines have different PM versions gets a +cross-machine hash divergence on EVERY target. We are on npm so this is inert here, but it belongs +in DOCS-07's portability checklist as an axis `process.platform` does not cover. + +### 3.4 `Environment` - a trap this repo has already (accidentally) avoided + +`@nx/vitest`'s inferred `test` target includes `{ env: 'CI' }` in its inputs +(`packages/vitest/src/plugins/plugin.ts`, `testTarget`). `CI` is `true` on any GitHub runner and +unset on a developer workstation - **which would make O1 structurally impossible for `test`**, since +a local Windows `test` hash could never equal a CI-produced one. + +**We are safe, verified empirically:** `nx.json`'s `targetDefaults.test.inputs` REPLACES the inferred +list, and the resolved `test.inputs` (printed from `nx show project --json`) contains no `env` entry. + +Two obligations follow, and neither is currently written down anywhere: + +- **Do not "restore" the inferred inputs** for `test` (e.g. by deleting the targetDefaults block, or + by adding `'default', '^default'` back in a way that lets the plugin's list through). Add a + comment in `nx.json` recording WHY the explicit list exists. +- **Apply the same discipline to the new `lint` target** (1.4). `@nx/eslint`'s inferred inputs happen + not to include an `env` entry today, but the general rule - "an inferred input list is not audited + until you have printed it" - is the lesson. + +### 3.5 The capture command PARITY-01 asks for: `.nx/cache/run.json` + +PARITY-01 requires "the capture command named". Nx 23.1.0 has **no** CLI flag that prints a task +hash - verified empirically: `NX_VERBOSE_LOGGING=true npx nx run @op-nx/github-cache:typecheck` +prints plugin-worker noise and `[local cache]` labels, and no hash. + +It does write one, unconditionally. `StoreRunInformationLifeCycle` +(`packages/nx/src/tasks-runner/life-cycles/store-run-information-life-cycle.ts`) is pushed onto the +lifecycle list with **no condition** at `packages/nx/src/tasks-runner/run-command.ts:1118`, and its +`endCommand()` writes `/run.json`. Verified by reading the file this repo just produced: + +```json +{ "run": { "command": "nx run @op-nx/github-cache:typecheck", ... }, + "tasks": [ + { "taskId": "@op-nx/github-cache:build", "target": "build", + "projectName": "@op-nx/github-cache", "hash": "3919282196916976507", + "cacheStatus": "local-cache-hit", "status": 0, "startTime": ..., "endTime": ... }, + { "taskId": "@op-nx/github-cache:typecheck", "target": "typecheck", + "hash": "12605558494450641434", "cacheStatus": "local-cache-hit", ... } ] } +``` + +`cacheStatus` is one of `remote-cache-hit` / `local-cache-hit` / `cache-miss` +(mapped from the task status at lines 68-75). + +**This one file services five requirements at once**, with zero new tooling: + +| Requirement | What `run.json` gives it | +|-------------|--------------------------| +| PARITY-01/02/03 | `hash` per `target` per leg; the named capture command is `nx run-many -t build typecheck test && cat .nx/cache/run.json` | +| CORR-03 | Both matrix legs upload their `run.json`; a comparison job asserts exactly two records, `integration` differs, the other three are identical | +| TEST-08 / TEST-09 | The recorded hash, machine-readable, per proof | +| OBS-02 | `cacheStatus === 'remote-cache-hit'` is the STRUCTURED equivalent of the `[remote cache]` terminal label - a non-zero count named per target, without scraping ANSI-coloured stdout | + +Three caveats to write into the plan: + +- It is **overwritten by every `nx` invocation**. Copy it out immediately after the measured run, + before any other Nx command (including a `nx show project` in a later step) runs. +- It lands in `cacheDir`, which respects `NX_CACHE_DIRECTORY` / `nx.json` `cacheDirectory`. Resolve + the location rather than hardcoding `.nx/cache` in the capture step if a consumer recipe ever + quotes it. +- Only tasks that participated in THAT command appear. `nx run-many -t build typecheck test` in one + invocation gives all three in one file; three separate invocations give three files, each + clobbering the last. + +`.nx/cache` is gitignored (`.gitignore`), so the captured copy must be written elsewhere (a CI +artifact, or the phase's evidence directory). + +### 3.6 `targetDefaults` `outputs` pinning and the filtered nested-array shape (#36049) + +**Pinning `outputs`.** `targetDefaults` entries become *synthetic targets* merged into the +inferred/specified target in document order +(`packages/nx/src/project-graph/utils/project-configuration/target-defaults.ts`, +`buildSyntheticTargetsForRoot`). Setting `outputs` there makes the value an authored literal instead +of a plugin computation - which is what takes the `@nx/vitest` `isAbsolute` branch (3.2) and the +`{options.outputFile}` token (1.4) permanently out of `hash_project_config`. Recommended for the new +`lint` target (`outputs: []`); **optional** for `build`/`typecheck`/`test`, whose computed values are +already verified invariant, and where restating a long generated glob list by hand is a maintenance +liability with no proven benefit. Do not pin what you have measured to be stable. + +**The nested-array shape.** At 23.1.0 a `targetDefaults` value is `oneOf`: + +```jsonc +"targetDefaults": { + "test": { /* plain config object - what nx.json uses today */ }, + "lint": [ // NEW: ordered array of filtered entries + { "outputs": [] }, // no `filter` = catch-all baseline + { "filter": { "plugin": "@nx/eslint" }, "inputs": [ /* ... */ ] } + ] +} +``` + +`filter` accepts `plugin` (originating plugin), `projects` (names / globs / `tag:` selectors, via +`findMatchingProjects`) and `executor`. Entries apply in document order, **last match winning**; an +entry incompatible with the effective target shape (e.g. it sets a foreign `executor`) is dropped +individually rather than replacing the target wholesale. + +**Do not use the array form in v0.0.2.** One project, one plugin per target - the filter has nothing +to narrow. The plain object form is what the existing `nx.json` uses and is what the roadmapper +should keep. The shape is documented here only so nobody reaches for it and so nobody is surprised by +a schema that permits both. Note the merge is per-entry with source-map attribution - useful for +debugging a future divergence, not needed now. + +### 3.7 Two smaller Nx facts worth carrying into plans + +- `@nx/js/typescript` and `@nx/vitest` each maintain their own `PluginCache` file in + `.nx/workspace-data/` keyed by an options hash (`tsc-.hash`, `vitest-.hash`, and `@nx/eslint` + will add `eslint-.hash`). These are LOCAL, gitignored, and per-worktree - not hash inputs. A + stale one can produce a confusing local-only graph; `nx reset` clears them. Say so in the PARITY + measurement procedure so a "divergence" is never actually a stale plugin cache. +- Nx renders a task hash as an unsigned 64-bit **decimal** string (18-20 digits) - `ci.yml:666-671` + already relies on this, and `run.json` confirms it (`3919282196916976507`). The `nx-cache-` + asset name under CORR-02 therefore stays inside the server's `^[a-f0-9]{1,512}$` validator only + because decimal digits are a subset of hex. Nothing to change; just do not let anyone "improve" the + validator. + +--- + +## 4. What NOT to add + +The project ships **five** runtime dependencies and one of them (`@actions/core`) is shared with the +root. Keep it that way. Each rejection below is a thing a reasonable person will propose. + +| Tempting | Why NOT | +|----------|---------| +| **`jiti`** (for `eslint.config.ts`) | Only needed if the flat config is TypeScript. Write `eslint.config.mjs`. The config is a plain object literal with two rule blocks; types buy nothing and `jiti` is an extra install plus a transpile step in the `lint` critical path. ESLint 10 lists `jiti` as an OPTIONAL peer for exactly this reason. | +| **`@vitest/eslint-plugin`** (`1.6.24`) | LINT-02's ban is about `process.platform` and `node:os`, not about Vitest idioms. Nothing in LINT-01..06 or CORR-06 needs a Vitest rule. It also declares `@typescript-eslint/eslint-plugin` as a peer, pulling a second path to the TS toolchain. (Note in passing: the OLD `eslint-plugin-vitest` is dead - last publish 2024-04-23 - so anyone who does add Vitest rules later must use the `@vitest/` scoped one.) | +| **`eslint-plugin-n`, `eslint-plugin-import`, `eslint-plugin-unicorn`** | Each would flag hundreds of pre-existing findings across a shipped codebase, turning a one-phase lint adoption into an open-ended cleanup. LINT-01's scope is "a `lint` target exists and the platform ban is enforced". Ecosystem hygiene rules are a later, separate decision. | +| **`eslint-config-prettier` / `eslint-plugin-prettier`** | The repo already runs `nx format:check` (Prettier directly). A bridge package only matters if ESLint enables stylistic rules - `@eslint/js` recommended and `typescript-eslint` recommended do not. Adding it pre-emptively is a dependency for a conflict that does not exist. | +| **A custom ESLint rule / `tools/eslint-rules/` workspace-rules project** | `no-restricted-syntax` + `no-restricted-imports` are CORE rules and cover every shape in LINT-02 (see 1.5). A custom rule means a new buildable project, its own tsconfig, its own tests, and its own place in the project graph - i.e. a fresh `hash_project_config` surface in the milestone that is trying to stabilise hashes. | +| **Bumping `@actions/cache` past 6.2.0** | The version is a cache-version input by construction (2.2). Bumping it inside this milestone makes the mandatory all-MISS push (OBS-04) impossible to attribute between "our path change" and "their compression/salt change". Bump it in a later, isolated change gated on the `test:act` end-to-end restore. | +| **A `zstd`-forcing dependency, or vendoring zstd** | Not configurable in 6.2.0 (2.4). The supported lever is the runner image (`choco install zstandard` as a workflow step), which is a workflow change, not a package. | +| **`node:path` anywhere near `cacheArchivePath()`** | VER-01 forbids it and the forbidding is the point: `join`/`resolve`/`normalize` are exactly how a forward-slash literal becomes a backslash one on Windows. The replacement is a template literal. This is a REMOVAL of an import, not an addition. | +| **An OS-separation env knob / action input** | D2-02, PARITY-05. Zero adopters. The public-surface guard already fails on a new export or input, and that guard passing unchanged is itself a v0.0.2 requirement. | +| **A cross-process lock library for the new `.nx/cache/` path** | The existing `withHashLock` is in-process and the `cacheArchivePath` comment already documents the cross-process invariant and its upgrade path (an `fs.mkdir` sentinel, no dependency). Moving the path from `tmpdir()` to `.nx/cache/` does not change the concurrency story - it is the same single deterministic path per hash. | +| **A hash-diffing tool / library for CORR-03** | `run.json` (3.5) is first-party, machine-readable JSON. Two `node -e`-free comparisons over two uploaded artifacts. No `jq` dependency needed either - Node is already installed on both legs. | +| **`@nx/jest`** | `@nx/eslint@23.1.0` declares it as a peer, but `peerDependenciesMeta` marks it **optional** (verified on the published manifest). npm will not install it and will not warn. Do not add it. | + +--- + +## 5. Installation + +```bash +# root devDependencies, exact-pinned per ROBUST-03 / LINT-01 +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 +``` + +`-E` (`--save-exact`) is required - a `^` specifier fails `pinned-deps.spec.ts` (1.7). + +**Regenerate the lockfile in a linux/arm64 container, not on Windows.** A bare Windows `npm install` +prunes the Linux-only optional subtrees from `package-lock.json`, which breaks CI `npm ci` and is +invisible locally (recorded project memory). This is doubly important in THIS milestone, because +lockfile asymmetry is also the leading `External`-instruction hypothesis for the parity bug (3.3). + +No runtime dependency changes. `packages/github-cache/package.json` is untouched by Phase 7. + +--- + +## 6. Version compatibility + +| Package A | Compatible with | Verified how | +|-----------|-----------------|--------------| +| `eslint@9.39.5` | `@nx/eslint@23.1.0` (peer `^9 \|\| ^10`), `typescript-eslint@8.65.0` (peer `^8.57 \|\| ^9 \|\| ^10`), comments plugin `4.7.2` (peer `^6..^10`) | registry manifests, 2026-07-26 | +| `typescript-eslint@8.65.0` | `typescript@~6.0.3` (peer `>=4.8.4 <6.1.0`) | registry manifest | +| `@nx/eslint@23.1.0` | `nx`/`@nx/js`/`@nx/vitest` @ `23.1.0`; carries `typescript ~6.0.3` as a direct dep | registry manifest + `nrwl/nx` tag 23.1.0 | +| `@actions/cache@6.2.0` | Node 24; `@actions/core ^3.0.1` (we pin `3.0.1`) | local clone `packages/cache/package.json` | +| Node 24 | `eslint@9` engines `^18.18 \|\| ^20.9 \|\| >=21.1`; `eslint@10` engines `^20.19 \|\| ^22.13 \|\| >=24` | registry manifests | + +--- + +## 7. Open items, labelled + +| Item | Status | +|------|--------| +| Does windows-11-arm ship `zstd` today? | **MUST-MEASURE.** Two authoritative sources disagree (2.4). Gates O4/XOS-05. Two-minute CI probe. | +| Is GNU tar present at `%PROGRAMFILES%\Git\usr\bin\tar.exe` on windows-11-arm? | **ASSUMED** present (image ships Git 2.52.0.windows.1). Same probe. | +| Does `eslint@10`'s API still expose `loadESLint`? | **NOT CHECKED.** Irrelevant at 9.39.5; check before any v10 bump. | +| Which hash instruction actually diverges for `build`/`typecheck`/`test`? | **OPEN - this is PARITY-01.** `ProjectConfiguration` is ruled out (3.2, verified on Windows). Start at `External` (3.3), then `ProjectFileSet`. | +| Does `@nx/eslint` infer the `lint` target identically on both OSes? | **UNVERIFIED BY DESIGN.** Reads clean at source (1.3) but the mixed-separator `isPathIgnored` path is not provable by reading. CORR-03's two-leg job settles it. | +| ESLint 9's default `reportUnusedDisableDirectives` value | Moot - LINT-06 sets it explicitly to `'error'` (1.6). | + +--- + +## Sources + +Local clones, read directly (HIGHEST confidence - these are our exact pinned versions): + +- `D:\projects\github\actions\toolkit\packages\cache` @ `6.2.0` - `src/cache.ts` (signatures + the + wrong JSDoc at :463-471), `src/internal/cacheUtils.ts` (`getCacheVersion` :136-159, + `getCompressionMethod` :102-112, `resolvePaths` :48-70), `src/internal/tar.ts` + (`getWorkingDirectory` :164-166, `getTarPath` :18-50), `src/internal/constants.ts` +- `D:\projects\github\actions\toolkit\packages\glob\src\internal-pattern.ts:205-211` - the + "Relative pathing" assertion that #1087 is about +- `D:\projects\github\actions\cache` - `README.md:88`, `tips-and-workarounds.md:29-36` +- `D:\projects\github\nrwl\nx` @ tag `23.1.0` - + `packages/nx/src/native/tasks/hashers/hash_project_config.rs`, + `packages/nx/src/native/tasks/hash_planner.rs:620-680`, + `packages/nx/src/native/tasks/task_hasher.rs:550-556`, + `packages/nx/src/tasks-runner/life-cycles/store-run-information-life-cycle.ts`, + `packages/nx/src/tasks-runner/run-command.ts:1118`, + `packages/nx/src/utils/path.ts:12-22`, `packages/nx/src/utils/package-manager.ts:139-250`, + `packages/nx/schemas/nx-schema.json` (`targetDefaults`, `targetDefaultArrayEntry`), + `packages/nx/src/project-graph/utils/project-configuration/target-defaults.ts`, + `packages/eslint/{package.json,plugin.ts}` + `src/plugins/plugin.ts` + + `src/utils/{config-file.ts,flat-config.ts,resolve-eslint-class.ts}`, + `packages/js/src/plugins/typescript/plugin.ts:955-1162`, + `packages/vitest/src/plugins/plugin.ts:340-460` + +Live, verified 2026-07-26: + +- `registry.npmjs.org` - versions, publish dates, `peerDependencies`, `peerDependenciesMeta` and + export maps for every package in section 0 - HIGH +- `actions/cache#1622` (OPEN, last activity 2026-07-10) via `gh api` - the compression-method + mismatch, the `choco install zstandard` workaround, the "arm runners now have zstd" claim - HIGH + for what the thread says, MEDIUM for whether the claim is currently true +- `actions/cache#1087` (OPEN, no activity since 2024-03-25) via `gh api` - confirmed NOT applicable + to a `.nx/cache/...` literal - HIGH +- `actions/partner-runner-images` `images/arm-windows-11-image.md` (Image Version `20260105.41.1`) + via `gh api` - `zstd` under "Omitted software" (:217), Chocolatey 2.6.0 (:30), + Git 2.52.0.windows.1 (:61) - HIGH for what the doc says, contradicts #1622's last comment + +Empirical, run against this repo on the native Windows workstation 2026-07-26: + +- `npx nx show project @op-nx/github-cache --json` - resolved targets, inputs, outputs, options +- `NX_VERBOSE_LOGGING=true npx nx run @op-nx/github-cache:typecheck` - confirms no hash is printed +- `.nx/cache/run.json` - confirms the hash/cacheStatus capture surface exists and is populated + +--- +*Stack research for: v0.0.2 OS-invariant cross-OS cache sharing* +*Researched 2026-07-26. Carries forward `.planning/research/STACK.md` (v0.0.1); supersedes its +section 5 treatment of `@actions/cache` cache-version sensitivity with mechanism-level detail.* diff --git a/.planning/research/v0.0.2/SUMMARY.md b/.planning/research/v0.0.2/SUMMARY.md new file mode 100644 index 00000000..d6eb72a7 --- /dev/null +++ b/.planning/research/v0.0.2/SUMMARY.md @@ -0,0 +1,400 @@ +# Project Research Summary -- v0.0.2 OS-invariant cross-OS sharing + +**Synthesized:** 2026-07-26 +**Sources:** `STACK.md`, `FEATURES.md`, `ARCHITECTURE.md`, `PITFALLS.md` (this directory) + +> **Provenance note (#222 self-heal):** the synthesizer's `Write` call was blocked by a harness +> report-file restriction. It returned the finished document inline and the orchestrator persisted +> it verbatim. Content is the synthesizer's; only this note and the heading structure are the +> orchestrator's. + +> **Unusual ordering:** REQUIREMENTS.md and ROADMAP.md were written and COMMITTED before this +> research ran, and survived a five-lens adversarial review (52 findings triaged). This research is +> a deliberate after-the-fact check, so its primary deliverable is the change list in section 3. + +--- + +## 1. Verdict: amend, do not re-run + +**Targeted amendments suffice. Do NOT re-define the requirements and do NOT re-run the roadmapper.** + +**Phase count.** No researcher proposes adding, removing, splitting or merging a phase. +ARCHITECTURE -- the only doc that evaluated build order explicitly -- states "Recommended order: +7 -> 8 -> 9 -> 10 -> 11 -> 12. The committed roadmap's sequence is correct." The two phases a re-run +would most plausibly restructure are the two the roadmap already defended: Phase 10's eleven +requirements are one indivisible change (RETAIN-04's same-commit rule plus OBS-05's before-CORR-02 +rule make any split ship a strictly worse half-state), and Phase 12's three exist BECAUSE the 11->12 +boundary IS the mandatory ordering. Research strengthens both. Phase 11 gains implementation work it +was not scoped for -- that is plan capacity inside a phase, not a boundary problem. + +**Phase order.** Every sequencing constraint survives; three are now better evidenced than when +written. LINT-01 before PARITY-01 is confirmed at Rust source level (`ProjectConfiguration` emitted +for every task on both branches of `gather_self_inputs`). RETAIN-04 same-commit-as-CORR-02 confirmed +by reading `cleanup.ts:89` -- the filter is the only gate on the delete path. OBS-05 before CORR-02 +confirmed via `read-back.ts:37` plus `publish-mirror.ts:262-266`. One JUSTIFICATION weakens without +changing order: ARCHITECTURE 6.5 finds the mechanical argument for 9-before-10 weak in both +directions (10-first would also be safe), so keep the order for the reason the roadmap actually +gives -- all four TRUST requirements in one phase with verifiable code behind them -- and stop +over-claiming mechanics. Prose edit, not a re-run. + +**Requirement coverage.** 43 mapped, no orphans. Research deletes ZERO, moves ZERO between phases, +and adds five new IDs plus one conditional clause -- all landing in phases that already own their +subject matter. Post-amendment: 48/48, distributed 8 / 7 / 11 / 12 / 7 / 4. Phase 10 goes 11 -> 12 +(already flagged intentionally heavy, for reasons that still hold); Phase 12 goes 3 -> 4 +(strengthens its thin-by-design profile). **No phase changes shape.** A re-derivation reproduces the +same six phases in the same order with the same owners, and costs the 52-finding adversarial review +the current text already survived. + +**Cost of amendment:** 11 requirement-text edits, 5 new REQ-IDs, ~20 success-criteria edits, 6 +recorded decisions. Two decisions are BLOCKING for planning and must be settled at amendment time: +O3's proof shape, and Phase 12's ordering mechanism. + +**The one thing that would change this verdict:** if the zstd probe returns NONE on +`windows-11-arm`, XOS-05 is unreachable at the library level, Phase 12 loses its primary +deliverable, and a real scope decision is needed (`choco install zstandard` step vs deferring O4 to +v0.0.3). A contingency, not a present reason to re-run -- but cheap to foreclose. **Run the probe +before Phase 9 planning**, not at Phase 12 where it currently sits implicitly inside VER-05. + +--- + +## 2. Key findings + +### The store was the outlier, and the ecosystem agrees with the direction + +Zero of seven inspectable Nx remote-cache implementations namespace by OS, and the Nx wire protocol +carries no OS field at all (`http_remote_cache.rs` sends only the hash). v0.0.1's OS-namespaced +store was the ecosystem outlier. But "trust the hash" is the norm for the STORE, not the SYSTEM: +Bazel puts platform in the action digest, ccache/sccache capture it via hashed content, Gradle +requires a `@CacheableTask` annotation. Only Turborepo genuinely assumes. The honest framing is +**"trust the hash, having first made the hash trustworthy"** -- which is exactly CORR-02 plus +CORR-04. + +### Nx has no mechanism-B safety net, and that is the strongest argument for declare-first + +ccache tolerates cross-OS sharing because the things that differ (compiler binary, system headers) +are themselves inside its hash, so its documented consequence is "few or no cache hits" -- a MISS +problem. Nx hashes nothing outside the workspace unless a `runtime` input names it. Under Nx an +undeclared platform dependency is a WRONG ARTIFACT, not a miss. DOCS-07 must not import ccache's +relaxed posture. + +### Nobody detects portability violations, and the reason is structural + +Every detector that exists re-executes the task: Nix `nix-store --realise --check` (dedicated exit +code 104), reprotest's double-build plus diffoscope, Develocity's out-of-band scripted experiments. +A cache that re-runs tasks is not a cache. This upgrades the committed out-of-scope row's +justification from proportionality to structural necessity, and independently corroborates the +"green O4 CI is circular evidence" note already in REQUIREMENTS.md. + +### Two barriers are being removed and the third was never a barrier + +v0.0.1 had three overlapping separations: incidental Nx hash divergence, the `@actions/cache` +version, and the `-` asset name. v0.0.2 removes two deliberately and fixes the third's +accidental divergence so it can no longer separate either. Afterwards the MISS-not-wrong-result +invariant rests on exactly ONE declared input (CORR-04) plus the CORR-05 platform-agnosticism claim. +Every Phase 8-12 gate is ultimately a gate on that one invariant. + +### Nx's own documentation endorses the recipe's ordering + +`configure-inputs.mdoc`: "Nx errs on the side of caution when using inputs... **Start safe and +fine-tune your inputs when there are clear opportunities to improve the cache hit rate.**" That is +DOCS-07's mandated declare-first structure, stated by the framework. Quoting it converts the recipe +from this project's opinion into the framework's guidance. + +--- + +## 3. Required changes to the committed artifacts + +### 3.1 BLOCKING -- wrong or unsatisfiable as written + +Eleven findings. Six were anticipated; five were not. + +**B-1. `VER-04` asserts the wrong variable (CRITICAL).** *PITFALLS B1 + ARCHITECTURE 2.1 h2 + +STACK 2.3 -- three-way.* `@actions/cache` never reads "the Nx workspace root". Four anchors: glob +expansion -> `process.cwd()`; tar-manifest relativization -> `GITHUB_WORKSPACE ?? cwd`; `tar -C` -> +`GITHUB_WORKSPACE ?? cwd`; our `readFile`/`writeFile` -> `cwd`. They coincide only when +`cwd === GITHUB_WORKSPACE`. A container action, a step-level `working-directory:`, or a sidecar +started from a subdirectory breaks the identity while cwd is still "a" workspace root. Failure: +restore HITs, extracts under `$GITHUB_WORKSPACE`, `readFile` ENOENTs under `$CWD`, `server.ts` +`handleGet` catches it -> 404. **Permanent silent all-MISS while `@actions/cache` logs +`Cache hit for:`.** Fix: assert the conjunction -- cwd is the workspace root AND +(`GITHUB_WORKSPACE` unset OR `resolve(GITHUB_WORKSPACE) === resolve(cwd)`), resolved and +case-normalised. Assert ONCE at `createActionsCacheBackend()` construction; a per-request check is +swallowed by `handleGet` and becomes another silent MISS. Keep `cacheArchivePath` a pure string +function. Record the asymmetry: the same fault is LOUD in `publishMirror`, SILENT in `serve`. + +**B-2. `TEST-09`/`XOS-03`'s proof method inverts after Phase 9 (CRITICAL, unsatisfiable).* +*ARCHITECTURE 6.1.* After VER-01/VER-03 the version is identical across OSes, so +`restoreCache([path], 'nx-cache-')` on `windows-11-arm` HITs. Asserting a 404 asserts a +property this milestone deliberately destroyed -- and if it DID 404, the likeliest cause is +compression-method divergence, exactly the "passes for the pre-change reason" failure TEST-09 +exists to prevent, inverted. Fix -- three-part Nx-hash proof: (1) cite CORR-03(b)'s build-gating +record that `H_linux != H_win`; (2) show the Windows `integration` task EXECUTED (no +`[remote cache]`) in a run where `nx-cache-` demonstrably existed; (3) positive control in +the same job via a scripted authed GET on a known-present key, extending `ci.yml:391-407`. XOS-03's +outcome stays true but must be re-read as a statement about Nx HASHES, not cache storage. + +**B-3. `TRUST-11` names the wrong arbitration point (HIGH).** *ARCHITECTURE 4.4; PITFALLS B6 +asserts the opposite -- adjudicated in section 4.* 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 verbatim without re-executing. The real arbitration is at `saveCache`, once XOS-04 puts +build/typecheck/test on a Windows leg and two jobs compute the same H. That race IS +ordering-dependent. XOS-06 is satisfied because no requirement DEPENDS on the winner, not because +the race does not exist. Fix: relocate to `saveCache`; note it does not exist until Phase 12 adds a +second producer -- **this moves TRUST-11's residual risk into the XOS-05 write decision**. Keep the +month-shard clause verbatim. Add: cross-OS restore is byte-faithful (tar-in-tar, inner entry names +forward-slash-normalized), so the out-of-scope file-mode question applies to the Nx client's +extraction of the INNER tar, not our transport. Correct BEFORE the Phase 10 audit. + +**B-4. `LINT-01`'s "covered by the pinned-deps guard" is false (HIGH).** *PITFALLS E6 + +ARCHITECTURE 5.1 + STACK 1.7.* `pinned-deps.spec.ts` asserts exact specifiers for a hard-coded name +list (`@actions/cache`, `@actions/core`, `@octokit/rest`, `@octokit/plugin-retry`, +`@octokit/plugin-throttling`, `esbuild`). It cannot enforce "every dep is exact" -- the workspace +deliberately carries ranges. Pinning without adding NAMES leaves the deps unguarded, and a later +`npm install eslint@latest` passes every check. Fix: LINT-01 states the new names are ADDED to the +spec, and the ROBUST-03-class decision is recorded in the spec comment (`esbuild` is in the list, +`prettier` is not -- the precedent is genuinely ambiguous). + +**B-5. `PARITY-02` is missing a fourth axis: warm vs cold `.nx/workspace-data` (HIGH).** +*PITFALLS B4, VERIFIED from `260725-w3s-RESULTS.md` s4.* On one Windows box at one commit, varying +only graph freshness, all four targets compute different hashes. **Both values STATE.md attributes +to "ubuntu CI" vs "windows CI" are reproducible on one Windows machine by varying nothing but graph +freshness.** CI is always COLD; the O1 workstation is WARM. So CORR-03(c) can be permanently green +while O1 misses, and nothing fails. Fix: (a) PARITY-02's three points each record graph state, with +the Windows workstation measured in BOTH -- four values per target; (b) PARITY-04 gains graph state +as a fourth recorded attribute; (c) "warm local hash == cold CI published hash" becomes a separate +NAMED acceptance question; (d) do not silently resolve it with `nx reset`, which clears +`.nx/workspace-data` too and forces COLD -- convenient for the proof, misleading as evidence of the +everyday developer experience. + +**B-6. `CORR-05` has four violation sites, not three, and one survives CORR-02 (HIGH).** +*ARCHITECTURE 2.2 Gap 1.* `cache-archive-path.spec.ts:1,26` (VER-02, Phase 9); +`releases-backend.spec.ts:38` and `release-asset-name.spec.ts:39` (CORR-02, Phase 10); and +`release-asset-name.spec.ts:60`, removed by NOTHING -- OBS-03 deliberately keeps `cachePlatform`, so +the default-argument test stays meaningful and stays an ambient read. CORR-05 cannot become true in +Phase 10 as written. Fix: name four sites; Phase 10 makes an explicit call on `:60` (recommend +moving it to `public-server.integration.spec.ts`, where LINT-02 allows it). **Unwritten sequencing +consequence:** after Phase 7 all four sites FAIL lint, and LINT-03 requires them confirmed CAUGHT +while they still exist. Phase 7 must therefore land a described `eslint-disable-next-line` at each, +and LINT-06's `reportUnusedDisableDirectives: 'error'` forces each out with its violation in Phases +9/10. That is the mechanism working as designed, but it is in no success criterion. + +**B-7. `LINT-02`'s literal glob set inverts the rule for `.mts`/`.cts` (MEDIUM-HIGH).** +*PITFALLS E5.* `vitest.integration.config.mts` includes `{ts,mts,cts}`; the mandated `ignores: +['**/*.integration.spec.ts']` does not. An `*.integration.spec.mts` would be linted as a UNIT spec +and its LEGITIMATE platform read would fail lint -- the rule inverted against the class it exempts. +Symmetrically a `*.spec.mts` unit spec slips the ban. Fix: mirror the full extension set in both +globs, plus a drift spec asserting the ESLint globs and the two vitest configs agree. + +**B-8. One rule cannot enforce the ban list (MEDIUM-HIGH).** *STACK 1.5 + PITFALLS E4.* +`no-restricted-syntax` 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 needed; both are core rules. LINT-03's RED +fixture must cover evasions, not only the four extant sites. + +**B-9. `OBS-03`'s "producing OS" is not derivable at the publish site (MEDIUM-HIGH).** +*ARCHITECTURE 4.6.* The label derives from the PUBLISHING leg's `cachePlatform()`; +`listCacheEntries` returns `{ key }` only. Publisher-OS equals producer-OS only because restore is +same-OS -- **and Phase 9 is what breaks that identity.** After VER-03 the ubuntu leg mirrors a +Windows-produced entry and labels it `linux`. OBS-03 would produce a label that is WRONG in exactly +the cross-OS case it exists to serve. Fix: label `mirrored-by: ` and record the limitation. +Explicitly RETRACT the stronger claim that the label answers "whose bytes did the developer get". +Also: OBS-03 needs a seam widening no requirement mentions -- +`uploadReleaseAsset(releaseId, name, bytes)` gains `label`, plumbed through `action/index.ts` and +every fake in `publish-mirror.spec.ts`. + +**B-10. `VER-05`'s value is not readable from `@actions/cache` (MEDIUM).** *PITFALLS B9.* The +exports map is `{".": ...}` only; `getCompressionMethod` is internal -- the same +`ERR_PACKAGE_PATH_NOT_EXPORTED` wall VER-02 documents. So VER-05 means an independent +re-implementation, which will disagree if written naively: upstream runs `zstd --quiet --version`, +collects stdout AND stderr, swallows throws to `''`, and branches on +`versionOutput === '' ? Gzip : Zstd` -- **the parsed semver is computed and then not used**, so a +broken-but-present zstd still selects zstd. Fix: mirror the command, the capture and the +empty-string rule exactly; comment-lock to the pinned version; keep it advisory, never gating; add +"re-read `getCompressionMethod`" to the bump checklist. + +**B-11. `VER-06`'s presence-only read-back is vacuous by construction (MEDIUM).** *PITFALLS B8.* +The seed key is `nx-cache-` -- one key per RUN, not per OS. VER-06 is a valid +cross-OS proof only while there is NO Windows `dogfood-seed` leg; the moment one exists the Windows +verify restores the Windows-written entry and passes even if cross-OS restore is completely broken. +This is precisely the failure class OBS-05 closes on the Releases side; the Actions-cache side has +no mirror-image guard. Fix: assert PROVENANCE not presence (extend `dogfoodBody` to encode the +producing OS; assert the Windows leg read a linux-produced body), and write the vacuity condition +into the job comment. + +### 3.2 NEW requirements + +**`PARITY-06` (Phase 9)** -- register `{workspaceRoot}/.github/workflows/ci.yml` as a `test` input +and comment-lock `nx.json`'s explicit list. `nx.json` lists `cleanup.yml` and NOT `ci.yml`, so any +new spec asserting on `ci.yml` serves a stale cached PASS. Consumers: Phase 9 DOCS-08, Phase 10 +OBS-05/XOS-06/XOS-07, Phase 12 DOCS-07. The same edit must record WHY the explicit list exists: +`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`**. We are safe by accident and nothing records it. Placed in +Phase 9 rather than Phase 10 so its hash rotation collapses into VER-01's existing window. + +**`VER-07` (Phase 9)** -- the archive directory exists and the literal stays gitignored. `put()` +does `writeFile` before anything creates `.nx/cache` -> ENOENT -> 500, failing the build; one +`mkdir(recursive)` at construction. The read path self-heals (`extractTar` runs `io.mkdirP`); the +write path does not. `.gitignore` covers `.nx/cache`, NOT `.nx/` -- a later tidy to +`.nx/github-cache/` would put a transient multi-MB file into Nx's workspace file map, producing a +self-referential intermittent hash perturbation. Comment-lock the literal as chosen because +GITIGNORED. `nx reset` deletes `.nx/cache`, so Phase 11's ordering is reset FIRST, then sidecar. + +**`ROBUST-04` (Phases 9, 10; 7 if autofix touches those files)** -- run `npm run build:action` in +the SAME COMMIT as any `serve()`-reachable edit. The committed bundle inlines both comment-locked +helpers and `getCacheVersion`'s `windows-only` branch, and four sidecar jobs run the COMMITTED +bundle from the git ref. Drift means the sidecar writes at V_old while publish restores at V_new -- +the mirror silently stops receiving anything, surfacing only as the all-restore-MISS warning OBS-04 +has just told everyone to expect once. + +**`RETAIN-05` (Phase 10, same commit as CORR-02)** -- (a) ~50 PoC-era `.tar.gz` assets match +NO filter before or after RETAIN-04; they are permanent occupants of the 1000-asset cap. Decide and +record. (b) Assert the two filter branches mutually exclusive directly -- non-overlap is currently a +property of the last-`-` split, not of the design. (c) `CACHE_KEY_PREFIX` becomes QUADRUPLY +load-bearing (Actions key, `isServerProducedKey`, asset name, cleanup filter); changing it orphans +the mirror and the legacy branch would not cover the orphans. + +**`XOS-08` (Phase 12)** -- a producer-to-consumer ordering for the O4 proof. The `integration` +matrix precedent does NOT transfer: its legs compute different hashes, so parallelism is harmless; +the new legs compute the SAME hash, so in parallel they both MISS and race `saveCache`. Once +PARITY-06 lands, the commit that ADDS the Windows legs invalidates the `test` hash, so the +cross-push option needs a second no-op push. Recommend `needs:` on the corresponding ubuntu jobs. + +**Conditional clause on `XOS-05` (Phase 12)** -- a scheduled `--skip-nx-cache` `windows-11-arm` job +ONLY if the write decision is "they write". See the adjudication in section 4. + +### 3.3 Roadmap success criteria that must change + +**Phase 7** -- pinned-deps wording (B-4); full `{ts,mts,cts}` set plus drift spec (B-7); name BOTH +rules (B-8); **replace the `releaseAssetName(hash, 'win32')` example with `cachePlatform('win32')`** +-- the `platform` parameter is dead after Phase 10 and `fallow` will flag it, while OBS-03 preserves +`cachePlatform`; four sites each carrying a described disable so Phase 7 lands green (B-6); LINT-04's +three lint-specific instances (`eslint.config.*` and its imports are inputs; every file ESLint reads +is hashed, not just `src/**`; NO type-aware linting -- no rule needs `projectService` and it would +widen inputs to the whole TS program). New SC: enumerate the `lint` target's inferred nodes for +Phase 8, and CORR-03 treats `lint` as a fourth target. + +**Phase 8** -- name the instrument correctly: **`nx show target inputs` is blind to +`ProjectConfiguration`** (skipped per `HashPlanInspector.inspectInputs`' own API doc) **and reports +file PATHS not HASHES**, so both of v0.0.1's named suspects are invisible to it and a "no +difference" result is not evidence. PARITY-01 needs the per-node `details` map, which nothing in the +CLI prints. Add `.nx/cache/run.json` as the task-level surface. Graph state on every recorded hash +(B-5). New SC: name the leading hypothesis -- `hash_project_config` is RULED OUT (every hashed field +already forward-slashed and `{projectRoot}`-tokenised), so start at `External`, then +`ProjectFileSet`. + +**Phase 9** -- VER-04 conjunction (B-1) plus mkdir and the gitignored comment lock (VER-07); +provenance not presence (B-11); independent re-implemented probe (B-10); **OBS-04's tripwire gated +on "two consecutive all-miss pushes with no version-affecting change in between"**, and note there +are THREE legitimate rotation windows, not two. DOCS-08's location list is incomplete -- add +`ci.yml:356-360` and `docs/advanced.md:45`; note `README.md:125` and `trust-and-security.md:155` +frame "never a wrong result" as a consequence of FAULT DEGRADATION, which stays true, so the +correction there is ADDITIVE. New SCs: PARITY-06, ROBUST-04, and the zstd/GNU-tar pre-flight probe +recorded before any Phase 9 code lands. + +**Phase 10** -- orphan disposition, branch disjointness, `CACHE_KEY_PREFIX` lock (RETAIN-05); four +sites and the `:60` disposition plus a replacement negative control (B-6); `mirrored-by` (B-9) and +the `uploadReleaseAsset` seam as explicit scope; corrected TRUST-11 input (B-3). Record, not SC: the +Windows publish leg will mirror ZERO real assets after this phase -- the strongest argument for the +deferred collapse; and the Phase 9-to-10 window's 2x shard growth, which is bounded and NOT a +correctness bug. + +**Phase 11** -- the three-part O3 proof (B-2); `nx reset` FIRST then sidecar. **"Proof-only, so MVP +slicing does not apply" is half right** -- MVP slicing still does not apply, but "no code" does not +either: new `ci.yml` probe steps plus new task-graph assertion tooling for TEST-08. New SCs: every +"job was green" claim paired with a count that would differ under the failure hypothesis, named in +the plan not after the run; `ACTIONS_STEP_DEBUG` on for the proving run; a soundness probe before +the measurement; `Cache: n/m hit` recorded and explicitly marked non-discriminating both ways. +**The attribution window closes at Phase 9, not Phase 12.** + +**Phase 12** -- `needs:` (XOS-08); the conditional detector; and the free credibility wins for +DOCS-07: lead with Nx's own "start safe" guidance, present the discriminator as the documented +`sharedGlobals` runtime-input pattern, frame VER-01 as the standard relocatability fix, name +`CACHE_KEY_PREFIX` as the poison-disowning epoch knob, and ADD the anti-import warning that ccache's +"same OS recommended" posture does NOT transfer to Nx. + +--- + +## 4. Disagreements, adjudicated + +1. **Where first-write-wins arbitrates. PITFALLS B6 vs ARCHITECTURE 4.4 -> ARCHITECTURE.** For a + given hash the Actions cache holds one entry; both publish legs restore it and upload verbatim. + The differing-payload race is at `saveCache` and does not exist until Phase 12 adds a second + producer. Both agree on the action: rewrite `publish-mirror.ts:159` in the CORR-02 commit -- + byte-identity survives, its REASON changes. +2. **A sampled detector. PITFALLS B5 vs FEATURES s3 -> RECONCILE BY SCOPE.** FEATURES is decisive + for Phases 7-11 (a cache-defeating job during the milestone proving the cache muddies TEST-08's + attribution). PITFALLS is right for Phase 12 under its own condition. Result: a conditional + clause on XOS-05. Does not reopen the committed out-of-scope row. +3. **Does `pinned-deps` cover new deps? STACK 1.7 heading vs PITFALLS E6 -> PITFALLS.** STACK's body + already agrees; only its heading under-sells. +4. **PARITY-01's capture command. STACK 3.5 (`run.json`) vs PITFALLS B3 (per-node details) -> + COMPLEMENTARY, name both.** Neither alone satisfies both PARITY-01 and PARITY-02/03. +5. **`@nx/eslint` vs an explicit target. STACK s0 vs PITFALLS E2 / ARCHITECTURE 6.4 -> KEEP + `@nx/eslint`**, all three converge on the same mitigation. The dismissal is close -- the + explicit-target alternative would dissolve the LINT-01 -> PARITY-01 constraint entirely -- so put + it in the Phase 7 plan in one line before rejecting it. +6. **Does `windows-11-arm` ship zstd? Two EXTERNAL sources contradict.** `actions/cache#1622`'s last + comment (2026-07-10) says yes; `actions/partner-runner-images` + `arm-windows-11-image.md@20260105.41.1` still lists zstd under "Omitted software". One is stale. + It matters because `compressionMethod` is pushed into the version UNCONDITIONALLY, before and + independent of the `enableCrossOsArchive` branch. Workaround exists (`choco install zstandard`). + **Probe before Phase 9 planning.** + +--- + +## 5. Contradictions with the locked decisions (D2-01..D2-06) + +**None contradicted.** + +- **D2-01** CONFIRMED and strengthened -- v0.0.1 was the ecosystem outlier. +- **D2-02** CONFIRMED with a BETTER basis than YAGNI: every comparator puts the knob in the task + DECLARATION, never in the cache BACKEND. "Wrong layer" does not expire the way "zero adopters" + does. +- **D2-03** not contradicted; its unrecorded consequence is the quadruple single-point-of-failure + (RETAIN-05). +- **D2-04** not contradicted but UNDER-CONSTRAINED -- three researchers independently add + constraints it does not carry (VER-07). A `github-cache/` subdirectory still satisfies it. +- **D2-05** verified verbatim, but its one-line framing under-sells its precondition. Use the longer + form: "trust the hash, having first made the hash trustworthy." +- **D2-06** untouched. + +Two out-of-scope rows: the divergence-detection exclusion is CONFIRMED with its justification +upgradeable from proportionality to structural. The executor-classification exclusion is correct, +but every comparator ships a default POSTURE anyway and v0.0.2's lives only in DOCS-07 -- defensible +with zero adopters, but record it as a deliberate choice and note it is the first thing to harden if +an adopter appears. + +--- + +## 6. Confidence and open gaps + +Overall **HIGH**. Three of the eleven blocking findings were reached independently by two or three +researchers (VER-04: three; the pinned-deps gap: three; the missing `mkdir`: three). Both +single-source findings that CONTRADICT another researcher resolved against the single source once +checked at source level. + +**Open gaps:** (1) `windows-11-arm` zstd -- MUST-MEASURE, gates O4; (2) GNU tar on the same probe; +(3) which hash instruction diverges -- this IS PARITY-01, `ProjectConfiguration` ruled out; (4) +whether `@nx/eslint` infers `lint` identically on both OSes -- CORR-03 settles it; (5) whether Nx +23's local-cache eviction can delete foreign files from `.nx/cache`; (6) closed-source Powerpack / +Nx Cloud key derivation (not load-bearing). + +**Two hard locks that currently hold BY ACCIDENT with nothing recording why** -- both worth a +comment lock during Phase 8: never make `vitest.config.mts`'s `coverage.reportsDirectory` absolute +(`@nx/vitest`'s `normalizeOutputPath` has exactly one OS-sensitive branch and we miss it only +because the value is relative), and never "restore" the inferred `test` inputs (`{ env: 'CI' }` +would make O1 structurally impossible for `test`). + +--- + +## 7. Sources + +`.planning/research/v0.0.2/STACK.md`, `FEATURES.md`, `ARCHITECTURE.md`, `PITFALLS.md`, each carrying +its own verification log. Primary sources across the four include: `@actions/cache` 6.2.0 as +installed and the `actions/cache` local clone; the `nrwl/nx` local clone at tag 23.1.0; the +`bazelbuild/remote-apis` proto; Gradle's build-cache and `@CacheableTask` docs; `vercel/turborepo` +hash structs; `mozilla/sccache` and the ccache manual; `NiklasPor/nx-remotecache-custom` and five +sibling implementations; the npm registry for `@nx/eslint`, `@nx/eslint-plugin` and +`typescript-eslint`; Nix `nix-store --realise --check`; Debian `reprotest`; and the +Reproducible Builds environment-variations catalogue. From 067f5fe16e90cb363bd0bb0a33df4e7ac84896a2 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 23:20:51 +0200 Subject: [PATCH 011/511] docs(research): record cross-OS pre-flight probe results --- .planning/research/v0.0.2/PROBE-RESULTS.md | 143 +++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 .planning/research/v0.0.2/PROBE-RESULTS.md diff --git a/.planning/research/v0.0.2/PROBE-RESULTS.md b/.planning/research/v0.0.2/PROBE-RESULTS.md new file mode 100644 index 00000000..d3bbbe93 --- /dev/null +++ b/.planning/research/v0.0.2/PROBE-RESULTS.md @@ -0,0 +1,143 @@ +# Pre-flight probe results -- v0.0.2 + +**Measured:** 2026-07-26 +**Run:** `probe-crossos` 30220536303, PR #8 (throwaway, closed and deleted after recording) +**Commit probed:** `fe25a3f` (`origin/main`). No hashed input differs between it and local `main`; +the intervening commits touch `.planning/` only. +**Both legs:** success. + +Answers the three MUST-MEASURE gaps left open by `SUMMARY.md` section 6. + +--- + +## Q1 -- zstd and GNU tar on `windows-11-arm`: BOTH PRESENT. O4 is not blocked. + +``` +platform : win32-arm64 +zstd : PRESENT at /c/tools/zstd/zstd -> Zstandard CLI (64-bit) v1.5.7 +tar : /usr/bin/tar -> tar (GNU tar) 1.35 +``` + +`actions/cache#1622`'s last comment is correct; `actions/partner-runner-images`' +`arm-windows-11-image.md` listing zstd under "Omitted software" is STALE. XOS-05 is reachable at +the library level and Phase 12 keeps its primary deliverable. No `choco install zstandard` step +needed. + +**Correction to `.planning/debug/windows-publish-one-asset.md` E9.** That report states the Windows +runner "now ships zstd (Git for Windows bundles it under `usr/bin`)". The bundling claim is FALSE: + +``` +/c/Program Files/Git/usr/bin/tar.exe PRESENT (511486 bytes, Jul 10 15:30) +/c/Program Files/Git/usr/bin/zstd.exe ABSENT +``` + +zstd comes from a separate install at `C:\tools\zstd`, not from Git for Windows. This matters for +risk, not for the verdict: a tool at `C:\tools` is a runner-image provisioning choice, which is +weaker than a Git-for-Windows guarantee. It STRENGTHENS the case for VER-05 surfacing the resolved +compression method rather than assuming it, since the thing supplying it is more likely to move +than previously believed. + +--- + +## Q2 -- `cwd` vs `GITHUB_WORKSPACE`: identity HOLDS on both legs + +``` +windows-11-arm cwd = C:\a\github-cache\github-cache + GITHUB_WORKSPACE = C:\a\github-cache\github-cache + resolved + case-normalised identity: HOLDS +``` + +Consistent with the static read that no job in `ci.yml` sets `working-directory`. VER-04's +assertion now has a measured baseline: it is a guard against future drift, not a fix for a live +break. Keep it -- nothing currently defends the identity, and PITFALLS B1 shows the failure is a +silent permanent all-MISS. + +--- + +## Q3 -- cross-OS hashes with freshness CONTROLLED: the OS axis is real, AND it was confounded + +Both legs ran `nx reset` first, so both are cold. + +| target | ubuntu-24.04-arm | windows-11-arm | match | +|---|---|---|---| +| `build` | `3919282196916976507` | `9351058897283095552` | NO | +| `test` | `13619958981758949695` | `7684434396554539514` | NO | +| `typecheck` | `13760497641595851564` | `12047749006609736502` | NO | +| `integration` | `8865876519165210738` | `1822904335635353663` | NO -- **correct**, declared discriminator | + +`integration` diverging is the DESIGNED behaviour (CORR-04's `{ "runtime": "node -p +process.platform" }`), so O3's mechanism is confirmed working at this commit. + +### The finding that reframes Phase 8 + +Local Windows measurements at the same commit, taken immediately before the probe: + +| state | `build` | `test` | +|---|---|---| +| local Windows, WARM (stale `.nx/workspace-data`) | `3919282196916976507` | `13619958981758949695` | +| **ubuntu-24.04-arm, cold** | **`3919282196916976507`** | **`13619958981758949695`** | +| local Windows, COLD (after `nx reset`) | `9351058897283095552` | `7684434396554539514` | +| **windows-11-arm, cold** | **`9351058897283095552`** | **`7684434396554539514`** | + +**A warm Windows box computes ubuntu's hash exactly. A cold Windows box computes Windows' hash +exactly. Two targets, both directions, exact.** Not coincidence. + +So there are TWO independent axes: + +1. **A real OS axis** -- cold-ubuntu != cold-windows for every target. +2. **A freshness axis that perfectly masquerades as it** -- stale `.nx/workspace-data` on Windows + reproduces the Linux inference result. + +**Every prior cross-OS hash measurement in this repo read a confounded variable**, including the +pair recorded in `STATE.md` and attributed there to "ubuntu CI" vs "windows CI". It also explains +why this was invisible for so long: anyone measuring on an established Windows workstation saw +Linux's number and concluded parity. + +The likely root cause is now much sharper than "cross-OS divergence": a **Windows-specific +inference difference that only manifests on a COLD graph**. That is the `@nx/vitest` / +`@nx/js/typescript` OS-dependent-ProjectConfiguration class recorded in v0.0.1's +`research/PITFALLS.md:329-331`, gated on freshness -- which is why the v0.0.1 fixes appeared to +hold. + +`typecheck` carries a THIRD source of variance: four distinct values across the four measurements, +so local-vs-CI differs for it beyond the two axes above. Plausibly `npm install` vs `npm ci` +reaching it via `dependentTasksOutputFiles` or `externalDependencies`; PARITY-04 already requires +install mode to be recorded. Not root-caused here. + +### Local determinism, established separately + +Three consecutive local runs at HEAD: two independent cold runs produced byte-identical hashes, and +a warm re-run after the reset agreed with them. So the hash is NOT nondeterministic -- cold is a +stable, reproducible state. The divergent values came from a `.nx/workspace-data` that had gone +stale across the day's `nx.json` and docs changes and never self-healed. + +--- + +## Consequences + +| # | Consequence | Lands in | +|---|---|---| +| 1 | XOS-05 / Phase 12 unblocked; no zstd install step needed | Phase 12 | +| 2 | VER-05's rationale strengthened -- zstd's provenance is a runner-image choice at `C:\tools`, not a Git-for-Windows guarantee | Phase 9 | +| 3 | `windows-publish-one-asset.md` E9's "Git for Windows bundles it" claim corrected | debug record | +| 4 | VER-04 is a drift guard, not a live fix -- keep it, and say which it is | Phase 9 | +| 5 | **PARITY-01 must control the freshness axis before attributing anything to the OS**, and must state that prior measurements were confounded | Phase 8 | +| 6 | **PARITY-02's observation points each need a graph state**, and the Windows workstation needs BOTH | Phase 8 | +| 7 | The leading PARITY-01 hypothesis is now specific: a Windows-only inference difference visible only on a cold graph | Phase 8 | +| 8 | `typecheck` has a third variance source beyond OS and freshness | Phase 8 | +| 9 | O3's mechanism confirmed working at this commit -- `integration` diverges by declaration | Phase 11 (cite, do not re-derive) | +| 10 | DOCS-07 needs the "if your local box misses everything, `nx reset`" note; the symptom is a silent MISS with no error | Phase 12 | + +--- + +## Method note + +The probe deliberately did not start the cache sidecar; it measured the environment and the hashes +only. `nx run-many` was `|| true`-guarded so a failing task would still yield hashes. Per-task +hashes were read from `.nx/cache/run.json`, the surface STACK identified -- written unconditionally +by `StoreRunInformationLifeCycle` and overwritten by every `nx` invocation, so it must be read +immediately after the run that produced it. + +`run.json` gives per-TASK hashes only. Naming the diverging hash NODE requires the per-node +`details` map (`TaskHashDetails.details`), which no CLI command prints -- that is PARITY-01's job, +and `nx-target-inputs.spec.ts` is the in-repo precedent for reaching into `nx/src/hasher/*`. From 969de3ebae7f733a03e18b17555c082a65394096 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 23:27:54 +0200 Subject: [PATCH 012/511] docs: amend v0.0.2 requirements after research and the live cross-OS probe --- .planning/REQUIREMENTS.md | 421 ++++++++++++++++++++++++++++++-------- 1 file changed, 332 insertions(+), 89 deletions(-) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index b9a4b26f..953d146e 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -86,12 +86,32 @@ cache, and the public-repo exposure surface, are not deferred. 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`). Three spec files - violate this today -- `cache-archive-path.spec.ts` (asserts `dirname === tmpdir()`), - `releases-backend.spec.ts` (derives a wrong-OS fixture from `process.platform`) and - `release-asset-name.spec.ts` (compares against `process.platform`) -- and all three are - eliminated as a side effect of VER-02 and CORR-02, which remove the platform-dependent - behaviour those specs exist to pin. + `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. - [ ] **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`, @@ -99,9 +119,11 @@ cache, and the public-repo exposure surface, are not deferred. 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: `releaseAssetName(hash, 'win32')` is the - designed test seam ("the platform parameter exists ONLY for test injection"). Only deriving an - expectation from the RUNNING machine is prohibited. + 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) @@ -109,26 +131,49 @@ cache, and the public-repo exposure surface, are not deferred. This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own phase. - [ ] **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. New dev - dependencies are exact-pinned under the existing ROBUST-03 discipline and covered by the - `pinned-deps` guard. -- [ ] **LINT-02**: A `no-restricted-syntax` rule set bans AMBIENT platform reads in unit specs and - ALLOWS them in integration specs, scoped by the partition that already exists -- `files: - ['**/*.spec.ts']` with `ignores: ['**/*.integration.spec.ts']`, mirroring - `vitest.config.mts`'s include/exclude and `vitest.integration.config.mts`'s exclusive include. - 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. `releaseAssetName(hash, 'win32')` is the - designed test seam ("the platform parameter exists ONLY for test injection"). Only deriving an - expectation from the RUNNING machine is prohibited, which is precisely what the three CORR-05 - violations do. -- [ ] **LINT-03**: The rule set is proven RED before GREEN -- a deliberately violating fixture - fails `lint`, and each of the three CORR-05 violations is confirmed caught before it is removed. - A rule that matches nothing is indistinguishable from a rule that is not wired up. + 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. +- [ ] **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. +- [ ] **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. - [ ] **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. + 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. - [ ] **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 @@ -144,20 +189,55 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own ### Nx task-hash parity (PARITY) -- [ ] **PARITY-01**: The current cross-OS divergence for `build`/`typecheck`/`test` is root-caused - node-by-node (native Windows vs Linux) and RECORDED, with the capture command named, before any - fix is applied. -- [ ] **PARITY-02**: `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), and ubuntu-24.04-arm runner. Three values per target, - not two. Enforced continuously by CORR-03(c), not measured once. -- [ ] **PARITY-03**: `integration` computes a byte-identical hash between the native Windows +- [ ] **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. +- [ ] **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. +- [ ] **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. +- [ ] **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. +- [ ] **PARITY-05**: `integration` computes a byte-identical hash between the native Windows workstation and windows-11-arm (O2's precondition). -- [ ] **PARITY-04**: Every measurement records the Nx version, the Node version, and the install - mode (`npm ci` vs `npm install`). 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`). -- [ ] **PARITY-05**: The public-surface guard passes unchanged -- no new env knob, no new action +- [ ] **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`. +- [ ] **PARITY-07**: The public-surface guard passes unchanged -- no new env knob, no new action input, no new package export (D2-02). +- [ ] **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) @@ -178,16 +258,62 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own 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. -- [ ] **VER-04**: The process asserts its cwd is the Nx workspace root, failing loud otherwise. A - relative path plus a wrong cwd is a silent split-brain between the derived version and the file. +- [ ] **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. - [ ] **VER-05**: The resolved `@actions/cache` compression method is surfaced in the publish - summary. It is a third version component sensed at runtime by probing for `zstd`, so a runner - image that loses zstd silently re-partitions the version. Surfaced, NOT gated -- a hard failure - would punish an otherwise-healthy runner. + 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. - [ ] **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. + 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. +- [ ] **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. +- [ ] **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. ### Cross-OS outcomes (XOS) @@ -196,13 +322,31 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own - [ ] **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) -- [ ] **XOS-03**: Windows CI gets a cache MISS for `integration` produced by Linux CI. (O3) +- [ ] **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) - [ ] **XOS-04**: `ci.yml` runs `build`, `typecheck` and `test` on a windows-11-arm leg in addition - to the ubuntu leg, wired through the same sidecar block as the `integration` matrix. Without this - there is no Windows job that could exhibit O4's HIT. + 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. - [ ] **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. (O4) + 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) +- [ ] **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. - [ ] **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 @@ -221,6 +365,17 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own `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. +- [ ] **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) @@ -231,11 +386,23 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own 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. -- [ ] **TRUST-11**: The phase threat model records that C3/TRUST-07's "byte-identical under - CORR-01" premise is now FALSE, not merely re-founded: an Nx cache entry carries captured terminal - output (`ci.yml:652`), which embeds OS-specific paths, so two legs produce different bytes for - the same hash. First-write-wins therefore arbitrates between differing payloads, and the - month-shard newest-first read walk makes the winner shard-dependent. +- [ ] **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. - [ ] **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 @@ -256,10 +423,15 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own `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. -- [ ] **DOCS-08**: `docs/advanced.md:54-57` and `ci.yml:577-583` are corrected -- both currently - assert same-OS restore as a load-bearing invariant, which VER-03 inverts. `README.md`'s - unconditional "never a wrong result" gains its new precondition. The statement that correctness - now depends on the declared Nx input is added to `docs/trust-and-security.md`. +- [ ] **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. ### Verification (TEST) @@ -268,17 +440,49 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own 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 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. -- [ ] **TEST-09**: The O3 negative proof runs AFTER VER-01 and VER-03 have landed, so the MISS is - attributable to the Nx discriminator and not to the removed `@actions/cache` OS salt. It requires - a POSITIVE CONTROL in the same run: the Windows job must MISS the Linux `integration` hash AND - HIT at least one entry through the same code path. A run that MISSes everything is not a valid - proof. Both hashes are recorded and shown to differ. -- [ ] **TEST-10**: The O1/O2 local proofs begin from a cleared local Nx cache (`nx reset`). A HIT - recorded without a preceding reset is not accepted -- a local cache hit short-circuits before the - remote is ever queried. + 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. +- [ ] **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. +- [ ] **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. ### Observability (OBS) @@ -287,16 +491,34 @@ This repo currently has NO linter (no ESLint, no Biome). Adopting one is its own 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. -- [ ] **OBS-03**: Every mirrored asset records its producing OS in Release asset metadata that is - NOT part of the lookup name (the free-form `label` field). The store stays OS-invariant for +- [ ] **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`. - [ ] **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`); - a SECOND consecutive all-miss push is a FAILURE, not warm-up. It stays a warning, not a hard - failure -- the one-time rotation makes a full MISS legitimate exactly once. + 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. - [ ] **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 @@ -311,10 +533,15 @@ Consumed by the roadmapper as phase dependencies. |--------|-------|-----| | 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 the three violations before they are removed, or nothing shows the rule works | -| PARITY-01 | PARITY-02 | Root-cause before fixing | +| 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-02 | XOS-01 | Hash parity is O1's precondition | +| 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 | @@ -327,8 +554,8 @@ Consumed by the roadmapper as phase dependencies. | 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 | Disproportionate. NOTE: "O4's green CI is the portability evidence" is NOT the reason -- that argument is circular, since a restored task does not execute | -| Per-job or per-target OS-invariance flag | D2-02 (no adopters, so no exit is needed yet). NOT forbidden by TRUST-05, which is scoped to RW-vs-RO | +| 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 | @@ -349,24 +576,30 @@ honour table: `.planning/ROADMAP.md`. | LINT-05 | Phase 7 | Pending | | LINT-06 | Phase 7 | Pending | | CORR-06 | Phase 7 | Pending | -| PARITY-01 | Phase 8 | Pending | -| PARITY-02 | Phase 8 | Pending | -| PARITY-03 | Phase 8 | Pending | -| PARITY-04 | Phase 8 | Pending | +| PARITY-01 | Phase 8 | Pending (must control BOTH the OS and freshness axes) | +| PARITY-02 | Phase 8 | Pending (per-node `details` instrument) | +| PARITY-03 | Phase 8 | Pending (four values per target, not two) | +| PARITY-04 | Phase 8 | Pending (warm-local vs cold-CI as a named question) | | PARITY-05 | Phase 8 | Pending | +| PARITY-06 | Phase 8 | Pending | +| PARITY-07 | Phase 8 | Pending | | CORR-03 | Phase 8 | Pending | | CORR-04 | Phase 8 | Pending | +| PARITY-08 | Phase 9 | Pending (must land before any spec asserts on `ci.yml`) | | VER-01 | Phase 9 | Pending | | VER-02 | Phase 9 | Pending | | VER-03 | Phase 9 | Pending | -| VER-04 | Phase 9 | Pending | -| VER-05 | Phase 9 | Pending | -| VER-06 | Phase 9 | Pending | +| VER-04 | Phase 9 | Pending (drift guard -- identity MEASURED to hold today) | +| VER-05 | Phase 9 | Pending (zstd MEASURED present; O4 not blocked) | +| VER-06 | Phase 9 | Pending (asserts provenance, not presence) | +| VER-07 | Phase 9 | Pending (before VER-01's first write) | +| ROBUST-04 | Phase 9 | Pending (also Phase 10; Phase 7 if autofix touches those files) | | OBS-04 | Phase 9 | Pending | -| DOCS-08 | Phase 9 | Pending | +| DOCS-08 | Phase 9 | Pending (four locations, not two) | | CORR-02 | Phase 10 | Pending | | RETAIN-04 | Phase 10 | Pending (same commit as CORR-02) | -| CORR-05 | Phase 10 | Pending (1st of 3 violations removed in Phase 9 with VER-02) | +| RETAIN-05 | Phase 10 | Pending (same commit as CORR-02) | +| CORR-05 | Phase 10 | Pending (4 sites; 1 removed in Phase 9 with VER-02; site 4 needs an explicit Phase 10 call) | | OBS-03 | Phase 10 | Pending | | OBS-05 | Phase 10 | Pending (must land before CORR-02) | | XOS-06 | Phase 10 | Pending | @@ -383,11 +616,21 @@ honour table: `.planning/ROADMAP.md`. | TEST-10 | Phase 11 | Pending | | OBS-02 | Phase 11 | Pending | | XOS-04 | Phase 12 | Pending | -| XOS-05 | Phase 12 | Pending (live-CI only) | +| XOS-05 | Phase 12 | Pending (live-CI only; carries the conditional scheduled-detector clause) | +| XOS-08 | Phase 12 | Pending (`needs:` ordering -- without it the HIT cannot occur) | | DOCS-07 | Phase 12 | Pending | +**Coverage:** 50 requirements, 50 mapped, 0 orphans, 0 duplicates. Distribution: Phase 7 = 7, +Phase 8 = 9, Phase 9 = 11, Phase 10 = 12, Phase 11 = 7, Phase 12 = 4. 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.* From 9c47702ce7e96d62ff898d362b4129c94c0f4a47 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 23:31:19 +0200 Subject: [PATCH 013/511] docs: amend v0.0.2 roadmap success criteria after research and probe --- .planning/ROADMAP.md | 202 +++++++++++++++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 48 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 472d6ace..7d6c4de5 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -139,38 +139,59 @@ 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, CORR-03, CORR-04. +**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 between - native Windows and Linux for `build`/`typecheck`/`test`, names the capture command used, - and is dated BEFORE the first fix commit. It controls for `.nx/workspace-data` freshness on - both sides -- quick 260725-w3s showed both previously-cited hash values are reproducible on - ONE Windows machine by varying only that. (PARITY-01) - - 2. 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. Three recorded values per target, not two. (PARITY-02) - - 3. `integration` yields a byte-identical hash between the native Windows workstation and + 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-03, CORR-04) + its Nx inputs. (PARITY-05, CORR-04) - 4. A build-gating CI job over BOTH matrix legs at one commit FAILS when fewer than two + 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. (CORR-03) + this. It treats `lint` as a FOURTH target, since `@nx/eslint`'s inference is the newest and + least-tested in the workspace. (CORR-03) - 5. Every recorded measurement carries the Nx version, the Node version and the install mode - (`npm ci` vs `npm install`), and the public-surface guard passes unchanged -- no new env - knob, no new action input, no new package export. (PARITY-04, PARITY-05) + 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**: TBD -**Live-CI close**: PARITY-02's windows-11-arm and ubuntu-24.04-arm observation points and -CORR-03's two-leg job exist only on real runners. +**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 @@ -183,15 +204,38 @@ 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**: VER-01, VER-02, VER-03, VER-04, VER-05, VER-06, OBS-04, DOCS-08. +**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 its cwd IS the Nx - workspace root and fails loud otherwise. (VER-01, VER-02, VER-04) + 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 @@ -199,21 +243,31 @@ version is rotated, so a rotation MISS is never confused with a parity MISS). 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. 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. - (VER-06) + 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: it is a third version component sensed at runtime by probing for `zstd`, so a - runner image that loses zstd silently re-partitions the version. (VER-05) + 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 a - SECOND consecutive all-miss push is a FAILURE, not warm-up. `docs/advanced.md:54-57` and - `ci.yml:577-583`, which assert same-OS restore as a load-bearing invariant, are corrected; - `README.md`'s unconditional "never a wrong result" gains its precondition. (OBS-04, - DOCS-08) + 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**: TBD @@ -233,8 +287,8 @@ delta TRUST-12 records -- a single-OS publish leg restoring and mirroring every 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, OBS-03, OBS-05, XOS-06, XOS-07, TRUST-10, -TRUST-11, TRUST-12, TRUST-13. +**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): @@ -246,9 +300,24 @@ TRUST-11, TRUST-12, TRUST-13. shard. `CACHE_OS_VALUES` survives, annotated as intentionally-kept legacy support so `fallow` does not prune it. (CORR-02, RETAIN-04) - 2. No target shared cross-OS has a spec that derives an expectation from the RUNNING machine: - all three CORR-05 violations are gone, removed as a side effect of VER-02 and CORR-02 - rather than relaxed, and Phase 7's lint rule is what stops them coming back. (CORR-05) + 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 @@ -256,9 +325,14 @@ TRUST-11, TRUST-12, TRUST-13. mirrored entry (`build`, `typecheck`, `test`, `integration`), not on `build` alone. (OBS-05, XOS-07) - 4. Every mirrored asset records its producing OS 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. (OBS-03) + 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 @@ -266,7 +340,20 @@ TRUST-11, TRUST-12, TRUST-13. 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. (XOS-06, TRUST-10, TRUST-11, TRUST-12, TRUST-13) + 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**: TBD @@ -310,15 +397,30 @@ discriminator). 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. One Windows CI run MISSES the Linux `integration` hash AND HITs at least one entry through - the same code path in that same run; both hashes are recorded and shown to differ. A run - that MISSes everything is not a valid proof. (XOS-03, TEST-09) + 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**: TBD **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 @@ -331,13 +433,17 @@ producer of the `build`/`typecheck`/`test` hashes and permanently destroys O1's Phase 8 (DOCS-07's portability checklist is derived from PARITY-01's root-cause findings, not prejudged). -**Requirements**: XOS-04, XOS-05, DOCS-07. +**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, wired through the same sidecar block as the `integration` matrix. Without this - there is no Windows job that could exhibit O4's HIT. (XOS-04) + 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 From e2c8876d143971929d57665645f05847b4f013bc Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 23:35:07 +0200 Subject: [PATCH 014/511] docs(state): remove the stale OS attribution of the build hash pair --- .planning/STATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 3bad8c7e..fc32d22f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -191,7 +191,7 @@ Items acknowledged and carried forward: | 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 | From 83ac4fd2027b52a4bb7ef6b9c33ad2fc441bd03c Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 23:48:42 +0200 Subject: [PATCH 015/511] docs: close pjz follow-ups, rename the ADR to THREAT-MODEL.md, document inherited zip-slip protection --- .planning/PROJECT.md | 6 +-- .planning/ROADMAP.md | 11 +++-- .planning/STATE.md | 6 +-- ...CHITECTURE-DECISION.md => THREAT-MODEL.md} | 40 ++++++++++++++----- .planning/codebase/CONCERNS.md | 6 +-- .planning/research/ARCHITECTURE.md | 4 +- .planning/research/FEATURES.md | 4 +- .planning/research/STACK.md | 2 +- .planning/spikes/004-ghcr-hazards/README.md | 2 +- .planning/spikes/MANIFEST.md | 2 +- docs/trust-and-security.md | 35 ++++++++++++++-- .../src/backend/actions-cache-backend.ts | 2 +- 12 files changed, 85 insertions(+), 35 deletions(-) rename .planning/{ARCHITECTURE-DECISION.md => THREAT-MODEL.md} (83%) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 4bd31cc0..a540347c 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). @@ -143,7 +143,7 @@ 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 - the project-level CREEP control ledger C1-C18 backing this and every other trust decision is `.planning/ARCHITECTURE-DECISION.md`; re-read and reconcile it at each milestone Key Decisions audit (updated 2026-07-26) | +| **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) | @@ -178,4 +178,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-07-26 at v0.0.2 milestone start (OS-invariant cross-OS sharing) - 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 ARCHITECTURE-DECISION.md.* +*Last updated: 2026-07-26 at v0.0.2 milestone start (OS-invariant cross-OS sharing) - 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/ROADMAP.md b/.planning/ROADMAP.md index 7d6c4de5..999e95c4 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -12,7 +12,7 @@ write-trust = host-detected fail-closed allowlist; sync gate = `{push, schedule} 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/ARCHITECTURE-DECISION.md`. +`.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 @@ -54,9 +54,12 @@ every local gate AND the verifier and took five live pushes to close. Phases 9-1 is a hard precondition of the Phase 11 proofs (the mirror must be warm under the new scheme). **Granularity:** standard (6 phases). **Mode:** `mvp` is marked on the three phases that build -shippable capability (9, 10, 12); Phase 7 is toolchain adoption, Phase 8 is measurement and -configuration, Phase 11 is proof-only, so MVP slicing does not apply to them. TDD stays globally -on (`workflow.tdd_mode: true`). +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 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 diff --git a/.planning/STATE.md b/.planning/STATE.md index fc32d22f..00647ba7 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ Phase: 7 - Lint Toolchain and the Ambient-Platform-Read Ban (not started) Plan: none yet Status: Roadmap created, awaiting `/gsd:plan-phase 7` Progress: 0/6 phases complete [------] 0% -Last activity: 2026-07-26 - Completed quick task 260726-pjz: extracted and deduplicated ARCHITECTURE-DECISION.md into canonical GSD artifacts (content -43% by bytes; C1-C18 ledger byte-identical and retained; verifier human_needed, 0 blockers) +Last activity: 2026-07-26 - Completed quick task 260726-pjz: extracted and deduplicated THREAT-MODEL.md into canonical GSD artifacts (content -43% by bytes; C1-C18 ledger byte-identical and retained; verifier human_needed, 0 blockers) ## Performance Metrics @@ -92,7 +92,7 @@ Last activity: 2026-07-26 - Completed quick task 260726-pjz: extracted and dedup ### Decisions -Full decision log in PROJECT.md Key Decisions; the CREEP control ledger C1-C18 backing those decisions is .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). @@ -178,7 +178,7 @@ 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/ARCHITECTURE-DECISION.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 (out of scope by scope discipline, not merit; `THREAT-MODEL.md`/`CONTROLS.md` trip `gsd health` W019 identically, so the rename gains nothing there). `.planning/milestones/`, `.planning/spikes/005-cross-os-roundtrip/` and all of `packages/` verified untouched. OPEN (verifier `human_needed`, 0 blockers): residue item 4 (git-native / build-artifact rejection rationale) is in fact covered at `research/STACK.md:75,77` verbatim and was retained anyway because Task 2's gate hard-requires its token -- one labelled duplicate that violates the file's own criterion; four minor removed sentences have neither a coverage row nor a home. | 2026-07-26 | 4699232..27e2cb6 | Needs Review | [260726-pjz-audit-triage-extract-and-deduplicate-arc](./quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/) | +| 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. OPEN (verifier `human_needed`, 0 blockers): residue item 4 (git-native / build-artifact rejection rationale) is in fact covered at `research/STACK.md:75,77` verbatim and was retained anyway because Task 2's gate hard-requires its token -- one labelled duplicate that violates the file's own criterion; four minor removed sentences have neither a coverage row nor a home. | 2026-07-26 | 4699232..27e2cb6 | Needs Review | [260726-pjz-audit-triage-extract-and-deduplicate-arc](./quick/260726-pjz-audit-triage-extract-and-deduplicate-arc/) | ## Deferred Items diff --git a/.planning/ARCHITECTURE-DECISION.md b/.planning/THREAT-MODEL.md similarity index 83% rename from .planning/ARCHITECTURE-DECISION.md rename to .planning/THREAT-MODEL.md index 2d78016b..58ddbe25 100644 --- a/.planning/ARCHITECTURE-DECISION.md +++ b/.planning/THREAT-MODEL.md @@ -1,8 +1,10 @@ -# Architecture Decision Record: CREEP-Safety Control Ledger +# 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. +**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. @@ -22,6 +24,12 @@ artifacts is the single source of truth for its own half: - `.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 @@ -91,24 +99,34 @@ the record was deleted. 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). -- **Why two storage primitives were rejected outright.** git-native storage (a cache branch or - LFS) is out for clone bloat and the absence of clean eviction; Actions build artifacts are out - because they are not `content-keyed` - they are run-scoped. (Both rejections are also recorded, - with the same reasons, in `.planning/research/STACK.md` section 2's primitive-comparison table, - which is the fuller treatment.) - **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 against - `..`, absolute-path, symlink and `hardlink` escape, so a malicious cache server cannot - `zip-slip` the client. This project inherits that protection rather than implementing it. It is - worth a note in the consumer trust docs; that note has not been written. +- **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`. +### 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/*`. diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 26d3b824..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 @@ -389,7 +389,7 @@ here. 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/ARCHITECTURE-DECISION.md` still records for them is the +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:** @@ -413,7 +413,7 @@ control surface each trigger carries (C6/C10/C11/C13/C18, cited in the bullets b **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/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index a5cb67d7..184ac837 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -6,7 +6,7 @@ > This research informs building the cache from scratch on the LOCKED foundation > (`.planning/PROJECT.md` `## Key Decisions`, with the CREEP controls in -> `.planning/ARCHITECTURE-DECISION.md`): one `CacheBackend` read port, a context-derived +> `.planning/THREAT-MODEL.md`): one `CacheBackend` read port, a context-derived > `selectBackend`, a conservative write-trust gate, a separate `{push, schedule}` sync gate, > and reader = GitHub Releases (FOUND-01). It does NOT re-derive them. It answers how the > domain capabilities are built against that architecture, validated against GitHub's 2026-06-26 @@ -237,7 +237,7 @@ PPE gate, distribution + docs + governance). This research feeds those phases; i - Nx enterprise security ("writes only from trusted CI branches", CREEP framing, PR artifacts isolated): - HIGH (corroborates the scope-isolation design; note it is Nx-Cloud marketing framing). - Nx `@nx/azure-cache` plugin overview (comparable adapter shape: single storage backend behind the Nx contract, `localMode`/`ciMode`, OIDC): - HIGH. - CVE-2025-36852 CREEP background: - referenced (already in project context). -- Locked foundation (grounding, not re-derived): `.planning/ARCHITECTURE-DECISION.md` (control ledger C1-C18), `.planning/REQUIREMENTS.md`, FOUND-01 reader spike `.planning/spikes/001-005`. +- Locked foundation (grounding, not re-derived): `.planning/THREAT-MODEL.md` (control ledger C1-C18), `.planning/REQUIREMENTS.md`, FOUND-01 reader spike `.planning/spikes/001-005`. --- *Architecture research for: self-hosted Nx remote cache on GitHub-native primitives* diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md index 8d5102a2..0b5970ca 100644 --- a/.planning/research/FEATURES.md +++ b/.planning/research/FEATURES.md @@ -7,7 +7,7 @@ > Greenfield note: this maps the *feature landscape* of Nx remote caches so the v0.0.1 capability set > can be categorized as the system is built from scratch on the LOCKED foundation (reader = GitHub > Releases; `.planning/PROJECT.md` `## Key Decisions`, with the CREEP controls in -> `.planning/ARCHITECTURE-DECISION.md`). It is not a plan - phase structure lives in +> `.planning/THREAT-MODEL.md`). It is not a plan - phase structure lives in > `.planning/ROADMAP.md`. The v0.0.1 capabilities are mapped to requirement IDs in the last section. ## Feature Landscape @@ -207,7 +207,7 @@ Category: **table stakes for adoption** (the value prop is wasted if external pr - `nx-remotecache-azure` / `nx-remotecache-custom` (independent `read`/`write` flags, Azure lifecycle retention): https://github.com/NiklasPor/nx-remotecache-azure - HIGH - "Exploring Nx self-hosted cache" community write-up (ecosystem history, deprecation, CVE framing): https://emilyxiong.medium.com/exploring-of-nx-self-hosted-cache-5bc39bd2ed7f - MEDIUM - CVE-2025-36852 (CREEP) background: https://nx.dev/blog/cve-2025-36852-critical-cache-poisoning-vulnerability-creep - HIGH -- Locked foundation: `.planning/ARCHITECTURE-DECISION.md` (CREEP control ledger C1-C18), `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, FOUND-01 reader spike `.planning/spikes/001-005` +- Locked foundation: `.planning/THREAT-MODEL.md` (CREEP control ledger C1-C18), `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, FOUND-01 reader spike `.planning/spikes/001-005` - `npmx.dev/package/@nx/azure-cache` - config surface covered via the 21.nx.dev overview instead (page did not render through the fetch chain; not load-bearing). --- diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md index 0cb4ec56..9b8db11c 100644 --- a/.planning/research/STACK.md +++ b/.planning/research/STACK.md @@ -7,7 +7,7 @@ > Greenfield note: this pins the contract/versions the build must target and confirms the storage > primitives are the right ones to build on in 2026. The storage model is LOCKED > (`.planning/PROJECT.md` `## Key Decisions`, with the CREEP controls in -> `.planning/ARCHITECTURE-DECISION.md`): Actions cache = CI RW default; GitHub Releases = the +> `.planning/THREAT-MODEL.md`): Actions cache = CI RW default; GitHub Releases = the > cross-context reader (FOUND-01). This file grounds those choices; it does not re-open them. --- diff --git a/.planning/spikes/004-ghcr-hazards/README.md b/.planning/spikes/004-ghcr-hazards/README.md index c758bfd2..d0b8ff94 100644 --- a/.planning/spikes/004-ghcr-hazards/README.md +++ b/.planning/spikes/004-ghcr-hazards/README.md @@ -22,7 +22,7 @@ that Releases does not. Empirically: overwrote a tag with different content and inspected the resulting versions; attempted a version deletion with the current `gh` token (`write:packages`, no `delete:packages`). Documented facts (>5000 wall; fine-grained-PAT-unsupported) are already -established in `.planning/ARCHITECTURE-DECISION.md` C10/C11 and confirmed here, not +established in `.planning/THREAT-MODEL.md` C10/C11 and confirmed here, not re-litigated. The in-repo `GITHUB_TOKEN` cleanup path (C11-preferred) needs a real Actions token and is proven in the CI leg (005). diff --git a/.planning/spikes/MANIFEST.md b/.planning/spikes/MANIFEST.md index 34ca1a8b..5952adf6 100644 --- a/.planning/spikes/MANIFEST.md +++ b/.planning/spikes/MANIFEST.md @@ -10,7 +10,7 @@ paper unknowns (GHCR atomic create-if-absent unavailable/low-severity; write-tru host-detected fail-closed; Nx PUT floor a hard 200) are resolved and must NOT be reopened. This spike produces a **symmetric** operational + security failure ledger for both readers. -Canonical scope: `.planning/ARCHITECTURE-DECISION.md` (the CREEP control ledger C1-C18), +Canonical scope: `.planning/THREAT-MODEL.md` (the CREEP control ledger C1-C18), `.planning/PROJECT.md` `## Key Decisions` (the locked reader / cross-context adapter row) and `.planning/REQUIREMENTS.md` (FOUND-01 rubric). diff --git a/docs/trust-and-security.md b/docs/trust-and-security.md index dbba0fdf..c5f1abff 100644 --- a/docs/trust-and-security.md +++ b/docs/trust-and-security.md @@ -12,7 +12,7 @@ is brought back in sync with the allowlists. | ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | | Write-trust allowlist (which events may WRITE) | `packages/github-cache/src/lib/trust.ts` (`TRUSTED_EVENTS`, `HOST_GATED_EVENTS`, `isWriteTrusted`) | | Sync/publish gate (which events may PUBLISH) | `packages/github-cache/src/lib/sync-gate.ts` (`SYNC_EVENTS`, `isSyncTrusted`) | -| CREEP control ledger (C1-C18) | `.planning/ARCHITECTURE-DECISION.md` | +| CREEP control ledger (C1-C18) | `.planning/THREAT-MODEL.md` | | The audited, settled model | `.planning/phases/05-trust-widening-ppe-gate/05-SECURITY.md` and `05-VERIFICATION.md` | The threat this model defends is CVE-2025-36852 (CREEP): cache poisoning at @@ -131,7 +131,36 @@ may write. This is not a mode flag a caller can get wrong -- read-write versus read-only is derived from runtime context (`selectBackend`), never from a caller-facing option. -## 9. Mirrored keys are anonymously public on public repos +## 9. Extraction escape is handled by the Nx client, not by this server + +A cache server hands the Nx client a tarball, so a malicious or compromised +server could in principle try to write outside the cache directory -- a +`..` traversal, an absolute path, or a symlink pointing out of the tree +("zip-slip"). **The Nx client defends against this itself, and this project +inherits that protection rather than implementing it.** + +Verified against Nx 23.1.0, the version this package targets: +`packages/nx/src/native/cache/http_remote_cache.rs` extracts through the `tar` +crate's `unpack_in`, which confines every entry to the output directory, and +entries that `unpack_in` skips (`..` traversal) or refuses (symlink escape) are +explicitly REJECTED rather than silently dropped. The behaviour carries its own +test (`extract_rejects_parent_dir_traversal`). + +Two things follow for adopters: + +- You do not need to add extraction sandboxing around this server. It would be + redundant with a protection the client already applies. +- The protection is the CLIENT's, so it travels with your Nx version, not with + this package. If you pin an Nx older than the version above, re-check it + yourself -- we verify this claim only against the Nx version we target, and + our conformance fixture pins that version deliberately (see + `docs/versioning.md`). + +This is defence against a hostile SERVER. It is unrelated to CREEP, which is a +poisoning attack carried out by a trusted PRODUCER before hashing, and is +defended at the write and sync gates instead. + +## 10. Mirrored keys are anonymously public on public repos Mirrored Release assets inherit the repository's visibility. On a PUBLIC repo, every mirrored cache key is **anonymously public** (world-readable) -- treat @@ -140,7 +169,7 @@ that embed secrets or private build outputs on a public repository. The mirror filter admits only server-produced keys (`nx-cache-` plus a valid hash), never arbitrary hex, so unrelated CI artifacts are not swept in (ADR C16). -## 10. Freshness and staleness caveats +## 11. Freshness and staleness caveats Local and read-only reads are best-effort and point-in-time -- they are bounded by a freshness window and are subject to mid-session staleness: diff --git a/packages/github-cache/src/backend/actions-cache-backend.ts b/packages/github-cache/src/backend/actions-cache-backend.ts index 3e2ed969..1eea39a3 100644 --- a/packages/github-cache/src/backend/actions-cache-backend.ts +++ b/packages/github-cache/src/backend/actions-cache-backend.ts @@ -85,7 +85,7 @@ export function createActionsCacheBackend(): CacheBackend { // write did not land and the response must not be a silent 200. // // That absent branch answers 'conflict' (409), not a throw. - // ARCHITECTURE-DECISION.md control C1 states a blocked PR write is a + // THREAT-MODEL.md control C1 states a blocked PR write is a // benign 409/no-op, and the Nx client treats 409 as a graceful no-op -- so // 409 satisfies SRV-05/D-06's actual requirement (no silent 200) without // the build-breaking 500 the throw produced via server.ts's put-fault From 7b451ca0e357eb789a49d61eac843f024998f3c0 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sun, 26 Jul 2026 23:59:24 +0200 Subject: [PATCH 016/511] docs(state): record the resume and fold the checkpoint constraints into STATE.md Resumed v0.0.2 from HANDOFF.json + .planning/.continue-here.md. Both were one-shot artifacts, so the wip commit carrying them was dropped before pushing and the files deleted -- but three blocking constraints and the required-reading order lived only in the checkpoint. They are folded into Session Continuity here so they survive its deletion: - rg over the GSD install false-zeroes without -L (it is a symlink) - a coverage gate with one probe term per section false-greens both directions - .nx/workspace-data staleness changes every task hash and never self-heals, which confounded every prior cross-OS measurement in this repo Also records the branch/push shape (15 commits on gsd/v0.0.2-os-invariant-cross-os-sharing, main reset to origin/main, no PR), the three-surface pre-push hygiene result, and carries HANDOFF.json's surviving non-blocking action (regenerate .planning/codebase/*) into Operator Next Steps. --- .planning/STATE.md | 61 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 00647ba7..069ccb89 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,7 +3,7 @@ gsd_state_version: 1.0 milestone: v0.0.2 milestone_name: OS-invariant cross-OS sharing status: planning -last_updated: "2026-07-26T18:30:00.000Z" +last_updated: "2026-07-26T21:58:22.000Z" last_activity: 2026-07-26 progress: total_phases: 6 @@ -198,6 +198,63 @@ Items acknowledged and carried forward: ## Session Continuity +### 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. @@ -290,3 +347,5 @@ Next: lead verifies the series -> pushes gsd/v0.0.1-greenfield-rebuild + updates - Plan the first v0.0.2 phase with `/gsd:plan-phase 7` (lint toolchain; it must precede the parity work because `@nx/eslint` inference changes `hash_project_config`) - Before Phase 10 executes, capture or cite the pre-rename O2 baseline -- it is unrecoverable once CORR-02 lands +- Decide whether to open a PR for `gsd/v0.0.2-os-invariant-cross-os-sharing` (pushed 2026-07-26, no PR yet). Planning-only content, so there is no CI gate riding on it; note the 5 push-gated jobs stay `skipped` on a feature branch because `on.push` is `branches: [main]` +- Regenerate `.planning/codebase/*` via `/gsd:map-codebase` -- it was mapped 2026-07-22 against v0.0.1 and PROJECT.md already flags it as stale. v0.0.2 invalidates it materially: renamed asset scheme, new archive path, a new inferred `lint` target, ESLint in the toolchain (carried over from the consumed `HANDOFF.json`) From ce1a74b7c2829ed26cf995b752d5aff2acb0bee7 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Mon, 27 Jul 2026 00:20:10 +0200 Subject: [PATCH 017/511] docs(07): capture phase context --- .../07-CONTEXT.md | 524 ++++++++++++++++++ .../07-DISCUSSION-LOG.md | 214 +++++++ 2 files changed, 738 insertions(+) create mode 100644 .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md create mode 100644 .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-DISCUSSION-LOG.md 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..03ad5dde --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md @@ -0,0 +1,524 @@ +# 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. + +- **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. + +- **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. + +### 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. From 4a71286c4655b96a94fca3f434d346c1044435f9 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Mon, 27 Jul 2026 00:20:19 +0200 Subject: [PATCH 018/511] docs(state): record phase 7 context session --- .planning/STATE.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 069ccb89..69361b3d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,10 +1,12 @@ --- gsd_state_version: 1.0 milestone: v0.0.2 -milestone_name: OS-invariant cross-OS sharing +milestone_name: framing +current_phase: 7 status: planning -last_updated: "2026-07-26T21:58:22.000Z" +last_updated: "2026-07-26T22:20:18.746Z" last_activity: 2026-07-26 +last_activity_desc: "Completed quick task 260726-pjz: extracted and deduplicated THREAT-MODEL.md into canonical GSD artifacts (content -43% by bytes; C1-C18 ledger byte-identical and retained; verifier human_needed, 0 blockers)" progress: total_phases: 6 completed_phases: 0 @@ -227,11 +229,13 @@ third is the load-bearing one for Phase 8): 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 @@ -271,8 +275,8 @@ Next: `/gsd:plan-phase 7`. ### Prior session (2026-07-26, quick 260726-gok) -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. +Last session: 2026-07-26T22:20:18.733Z +Stopped at: Phase 7 context gathered 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. @@ -340,7 +344,7 @@ 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: .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-CONTEXT.md 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 From d80cd7d77b5195f81818565db1d6573fe06c48a7 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Mon, 27 Jul 2026 00:54:31 +0200 Subject: [PATCH 019/511] docs(07): add research and validation strategy --- .../07-RESEARCH.md | 1270 +++++++++++++++++ .../07-VALIDATION.md | 143 ++ 2 files changed, 1413 insertions(+) create mode 100644 .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md create mode 100644 .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-VALIDATION.md diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md new file mode 100644 index 00000000..fdd6d846 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-RESEARCH.md @@ -0,0 +1,1270 @@ +# Phase 7: Lint Toolchain and the Ambient-Platform-Read Ban - Research + +**Researched:** 2026-07-27 +**Domain:** ESLint 9 flat-config adoption in an Nx 23.1.0 workspace; AST-level rule authoring +**Confidence:** see per-claim labels. Nothing here is presented as verified that was not. + +> **This document does NOT restate `.planning/research/v0.0.2/STACK.md` sections 0-1.7 or +> `PITFALLS.md` section E.** Those are the dependency table, the v9-vs-v10 call, the inferred-target +> shape, the `hash_project_config` mechanism, the OS-variance audit, the LINT-04 input block, the +> two-rule requirement, LINT-05/06 wiring and the pinning obligation. Read them first. This document +> answers only the gaps a planner cannot get from them, and corrects them where they are wrong. + + +## User Constraints (from 07-CONTEXT.md) + +`07-CONTEXT.md` is the authoritative copy and is NOT duplicated here (it is 525 lines; duplicating +it would guarantee drift). It is REQUIRED reading for the planner. Summary of its binding force: + +### Locked Decisions + +**D-01 through D-36 in `07-CONTEXT.md` are LOCKED.** Nothing in this document reopens one. Where a +decision's supporting FACT turns out to be wrong, the correction is filed under +`## Corrections to Existing Artifacts` and the decision's INTENT is preserved. + +Index, so the planner can map a task to its decisions: + +| Group | IDs | Subject | +|---|---|---| +| Toolchain adoption | D-01..D-06 | `@nx/eslint` inference plugin (USER-SELECTED, CLOSED); five exact-pinned devDeps; ESLint 9.39.5; `pinned-deps.spec.ts` names; linux/arm64 lockfile container; package manifest untouched | +| Lint scope | D-07..D-10 | project-scoped `eslint .` at `cwd=packages/github-cache`; never create a root `src/`/`lib/`; no `.eslintignore`; single root `eslint.config.mjs` | +| Rule set | D-11..D-14 | js+tseslint recommended NON-type-checked; bounded-cleanup rule; `.cjs` scoping; no prettier bridge | +| The ban | D-15..D-18 | two core rules; `{ts,mts,cts}` in BOTH globs; `ignores` beside `files`; `cachePlatform('win32')` is the canonical allowed shape | +| Drift guard | D-19 | superset assertion, not set equality | +| RED before GREEN | D-20..D-23 | permanent programmatic spec via `lintText`; evasion fixtures; four-site table; mutation-test the guard | +| Stale cache | D-24..D-27 | `targetDefaults.lint` full input list + `outputs: []`; `test.inputs` in the SAME commit; extend `nx-target-inputs.spec.ts`; close by differential | +| Opt-out | D-28..D-31 | `reportUnusedDisableDirectives: 'error'`; scoped comments-plugin prefix; `ban-ts-comment`; four described disables in the rules commit | +| CI wiring | D-32..D-34 | `lint` job + root script; no sidecar dogfood; no `--max-warnings`, no `command` override | +| Phase 8 hand-off | D-35, D-36 | record the inferred target's hashed node values; record the all-MISS push in advance | + +### Claude's Discretion (verbatim from CONTEXT.md) + +- 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. + +**This document exercises that discretion** in G2 (a validated selector set), G3 (config-object +ordering, forced by a mechanism), and G4 (a measured-by-proxy baseline). Those are RECOMMENDATIONS +with their evidence attached, not new locks. + +### Deferred Ideas (OUT OF SCOPE, verbatim heads from CONTEXT.md) + +Mechanising `curly` / blank-lines-around-control-flow; the `lint` sidecar dogfood block (D-33); +linting the root-level files the project-scoped target misses; the `eslint@10` bump; regenerating +`.planning/codebase/*`. Also surfaced and NOT owned here: the per-phase-vs-milestone-end PR question. + + + + +## Phase Requirements + +| ID | Description (abridged from REQUIREMENTS.md) | Research support in this document | +|----|---------------------------------------------|-----------------------------------| +| LINT-01 | ESLint v9 flat config + a `lint` target in the CI battery; new devDeps exact-pinned AND their names added to `pinned-deps.spec.ts` | G1 (config resolution works from the package cwd), G3 (what actually gets linted, and the `ignores` block without which the target is nondeterministic), G6 (fallow stays green), G7 (ordering: the config file must exist before the plugin infers anything) | +| LINT-02 | Two rules banning ambient platform reads in unit specs, allowed in integration specs; full `{ts,mts,cts}` set in both globs | G2 (validated selector set P1..P7, plus the `no-restricted-imports` behaviour read from the rule source), G1(b) (the glob base path, which every one of these globs depends on) | +| LINT-03 | Proven RED before GREEN over the evasion shapes AND the four extant CORR-05 sites | G1(c) (`lintText` semantics and its vacuity trap), G2 (per-shape CAUGHT / ceiling verdicts, measured), G8 (mutation-test protocol) | +| LINT-04 | `lint` inputs declared so it cannot serve a stale-cache false PASS | G5 (the literal differential command sequence with expected `Cache:` lines), G3 (the filesystem-walk-vs-Nx-file-map mismatch, a NEW instance of the class), G7 (D-25's same-commit constraint) | +| LINT-05 | Opt-out only via a described disable; bare `@ts-expect-error`/`@ts-ignore` also an error | G4 (baseline: zero existing disables and zero existing ts-comments, so both rules land green), G7 (the four disables land with the rules) | +| LINT-06 | `reportUnusedDisableDirectives: 'error'`; a stale disable FAILS | G2 (only FOUR error positions exist, and `:26` is NOT one of them, so a disable there would itself fail LINT-06) | +| CORR-06 | The strategy is MECHANICALLY enforced; same APIs stay ALLOWED in integration | G1(b)+G1(c) (how `files`/`ignores` resolve and how `lintText` proves both directions), G2 (zero false positives across eight ALLOW controls) | + + + +## Verified Facts + +Confidence labels: **VFS** = verified from source read this session; **VFD** = verified from official +docs; **MEAS** = measured on this repo this session; **REAS** = reasoned from a verified mechanism; +**ASSUMED** = training knowledge, unverified. + +| # | Fact | Label | Source | +|---|------|-------|--------| +| F1 | ESLint 9.39.5 locates the flat config with `findUp(FLAT_CONFIG_FILENAMES, { cwd: fromDirectory })`, i.e. it DOES search ancestor directories upward from `cwd`. | VFS | `eslint-9.39.5/lib/config/config-loader.js:527-556` (`ConfigLoader.locateConfigFileToUse`) | +| F2 | The base path for `files`/`ignores` glob matching is `path.dirname(configFilePath)` -- the CONFIG FILE's directory, not `cwd`. It is passed to `new FlatConfigArray(baseConfig, { basePath, shouldIgnore })`. | VFS | `config-loader.js:547`, `:600-605` | +| F3 | `FLAT_CONFIG_FILENAMES` = `eslint.config.{js,mjs,cjs,ts,mts,cts}`, searched in that order. | VFS | `config-loader.js:43-50` | +| F4 | v9's default config loader is `LegacyConfigLoader`, which resolves ONE config from `cwd` (via the same upward search) and reuses it for every file. Same answer for our layout either way. | VFS | `config-loader.js:703-790` | +| F5 | `lintText(code, { filePath })` resolves `path.resolve(cwd, filePath)` and then checks `configs.getConfigStatus(resolvedFilename)`. If the status is not `"matched"` it does NOT lint; it pushes a warning result only when `warnIgnored` is truthy. | VFS | `eslint-9.39.5/lib/eslint/eslint.js:1081-1176` | +| F6 | The ESLint constructor's `warnIgnored` default is `true`. | VFS | `eslint-9.39.5/lib/eslint/eslint-helpers.js:822` | +| F7 | ESLint's default config globs ONLY `**/*.js`, `**/*.mjs`, `**/*.cjs`, and globally ignores ONLY `**/node_modules/` and `.git/`. `.ts`/`.mts`/`.cts` are NOT globbed by default, and `dist/`, `out-tsc/`, `test-output/` are NOT ignored by default. | VFS | `eslint-9.39.5/lib/config/default-config.js` | +| F8 | The default `linterOptions.reportUnusedDisableDirectives` is `1` (= `"warn"`), confirming D-28's premise. | VFS | `default-config.js` (`linterOptions: { reportUnusedDisableDirectives: 1 }`) | +| F9 | `eslint .` from a directory pushes the glob `/**` and walks the REAL FILESYSTEM (`@humanfs/node` `hfs.walk`), keeping an entry only when `matchesPattern && config !== undefined`. It does not consult git. | VFS | `eslint-helpers.js:516-640` (`findFiles`), `:259-380` (`globSearch`) | +| F10 | Core `no-restricted-imports` in 9.39.5 has visitors for `ImportDeclaration`, `ExportNamedDeclaration`, `ExportAllDeclaration`, `TSImportEqualsDeclaration` ONLY. **There is no `ImportExpression` visitor, so it cannot see `await import('node:os')`.** | VFS | `eslint-9.39.5/lib/rules/no-restricted-imports.js:815-845` | +| F11 | `no-restricted-imports` DOES report a namespace import: `ImportNamespaceSpecifier` maps to the name `"*"`, and when `importNames` is set it reports messageId `everything`/`everythingWithCustomMessage` REGARDLESS of the local binding name. | VFS | `no-restricted-imports.js:785-786`, `:441-462` | +| F12 | `paths[].name` matching is EXACT (`Object.hasOwn(groupedRestrictedPaths, importSource)` on the trimmed source string). `'node:os'` and `'os'` are separate entries and both must be listed. | VFS | `no-restricted-imports.js:393`, `:770` | +| F13 | `typescript-eslint@8.65.0`'s `configs.recommended` is a 3-element array. Element 1 (`typescript-eslint/base`) has **NO `files` key**, so it sets `parser` and `sourceType: 'module'` for EVERY linted file, including `.cjs`. Element 3 (`typescript-eslint/recommended`) also has no `files` key, so its 24 rules apply to every linted file. Only element 2 (`typescript-eslint/eslint-recommended`) is scoped, to `['**/*.ts','**/*.tsx','**/*.mts','**/*.cts']`. | VFS | `@typescript-eslint/eslint-plugin@8.65.0` `dist/configs/flat/{base,recommended,eslint-recommended}.js`, `dist/configs/eslint-recommended-raw.js` | +| F14 | `typescript-eslint/eslint-recommended` turns OFF 19 core rules for TS files (including `no-undef`, `no-redeclare`, `no-unreachable`) and turns ON `no-var`, `prefer-const`, `prefer-rest-params`, `prefer-spread`. `no-unused-vars` is replaced by `@typescript-eslint/no-unused-vars`. | VFS | `eslint-recommended-raw.js` | +| F15 | `@eslint/js@9.39.5` `configs.recommended` enables exactly 61 rules. | MEAS | required the published module and counted non-`off` entries | +| F16 | `@nx/eslint@23.1.0` returns `[]` (no lint targets at all) when no `eslint.config.*` / `.eslintrc.*` file exists anywhere. The config file's EXISTENCE is what creates the target. | VFS | `nrwl/nx` @ `23.1.0` `packages/eslint/src/plugins/plugin.ts` `createNodes`, `if (eslintConfigFiles.length === 0) return []` | +| F17 | The inferred target's `inputs` at 23.1.0 ALSO include `...tsconfigChainOutsideProjectRoot.map(f => '{workspaceRoot}/' + f)`. STACK.md 1.3's quoted shape omits this. For this repo the chain is exactly `tsconfig.base.json`, which `sharedGlobals` -> `default` already covers, so dropping it in D-24's replacement list is harmless. | VFS + MEAS | `plugin.ts` `buildEslintTargets`; `packages/github-cache/tsconfig.json` extends `../../tsconfig.base.json`; `nx.json` `sharedGlobals` | +| F18 | The `isPathIgnored` existence gate runs for this project because `configDir` (`.`) `!== projectRoot` (`packages/github-cache`). D-35's recorded OS-divergence risk is live, not theoretical. | VFS | `plugin.ts` `internalCreateNodesV2`, `if (configDir !== projectRoot \|\| projectRoot === '.')` | +| F19 | The repo's pre-commit battery is EIGHT commands: `format:check`, `build`, `typecheck`, `typecheck:action`, `test`, `fallow:ci`, `check:action`, `pack:check`. `integration` is NOT in it. Phase 7 makes it nine. | MEAS | root `package.json` scripts; `quick/260726-gok-.../260726-gok-SUMMARY.md` "Battery result per commit" | +| F20 | `fallow@3.6.0`'s platform binary contains a built-in config-filename table that includes `eslint.config.{js,cjs,mjs,ts,mts,cts}` adjacent to `vitest.config.*`, `jest.config.*`, `vite.config.*`, and a paired `eslint.config.` + `eslint` string suggesting a config-file-to-package crediting map. | MEAS (binary strings) | `rg -uu -a` over `node_modules/@fallow-cli/win32-arm64-msvc/fallow.exe` | +| F21 | The repo has ZERO existing `eslint-disable` comments and ZERO `@ts-expect-error`/`@ts-ignore` comments in `packages/github-cache/**`. LINT-05's two rules therefore have a zero baseline. | MEAS | `git grep -n "eslint-disable" -- 'packages/github-cache/**'` -> empty; same for the ts-comments | +| F22 | `tsconfig.base.json` sets `noUnusedLocals: true` but NOT `noUnusedParameters`. Unused locals and unused imports are already impossible; unused PARAMETERS are the only residual `@typescript-eslint/no-unused-vars` surface. | MEAS | `tsconfig.base.json` | +| F23 | `packages/github-cache` contains exactly ONE non-TypeScript source file inside the lint scope: `pack-check.cjs`. The two `.mts` vitest configs are TypeScript from ESLint's point of view. There is no `.js`/`.mjs`/`.jsx`/`.html`/`.vue` tracked file in the project. | MEAS | `git ls-files 'packages/github-cache/**' \| rg -v "\.ts$"` | +| F24 | On disk today `packages/github-cache/dist/` holds 30 `.js` + 30 `.d.ts` + 30 `.map`, and `out-tsc/` holds 35 `.d.ts` + 1 `.mts`. All are gitignored. All would be LINTED without an explicit `ignores` entry (F7 + F9). | MEAS | `find` over both directories | +| F25 | `esquery@1.7.0` is already installed in this workspace (transitively). `@babel/parser` is too. That is how G2's selector verdicts below were MEASURED rather than reasoned. | MEAS | `node_modules/esquery/package.json`, `node_modules/@babel/parser` | + +--- + +## G1. Flat-config resolution under `cwd = packages/github-cache` with the config at the root + +**Verdict: D-01 / D-07 WORK AS WRITTEN. No `--config` flag, no per-project config, no blocking +finding.** All three sub-answers are VFS from the ESLint 9.39.5 tarball +(`registry.npmjs.org/eslint/-/eslint-9.39.5.tgz`, streamed and read this session; ESLint is not +installed in this workspace). + +### (a) Ancestor search: YES + +```js +// eslint-9.39.5/lib/config/config-loader.js:527-556 +static async locateConfigFileToUse({ useConfigFile, cwd, fromDirectory = cwd }) { + let configFilePath; + let basePath = cwd; + if (typeof useConfigFile === "string") { // --config + configFilePath = path.resolve(cwd, useConfigFile); + basePath = cwd; + } else if (useConfigFile !== false) { + configFilePath = await findUp(FLAT_CONFIG_FILENAMES, { cwd: fromDirectory }); + if (configFilePath) { basePath = path.dirname(configFilePath); } + } + return { configFilePath, basePath }; +} +``` + +`findUp` is the `find-up` package; it walks `fromDirectory` and every ancestor. With +`cwd = /packages/github-cache` and the config at `/eslint.config.mjs`, the search finds +it on the second hop. The default v9 loader (`LegacyConfigLoader`) calls this with +`fromDirectory = cwd` and caches one array for the whole run (F4), so there is no per-file +divergence to reason about either. + +**Planner consequence:** do NOT add `--config`. D-34 forbids overriding `options.command`, and +`--config` is also actively harmful here -- look at the `useConfigFile` branch: it sets +`basePath = cwd`, which would move every glob's base to `packages/github-cache` and silently change +the meaning of every `files`/`ignores` pattern relative to what (b) describes. The one-line +convenience flag would rewrite the glob semantics the whole rule set is built on. + +### (b) Glob base path: the CONFIG FILE's directory (the workspace root) + +`basePath = path.dirname(configFilePath)` and it is handed straight to the config array: + +```js +// config-loader.js:600-605 +const configs = new FlatConfigArray(baseConfig || [], { basePath, shouldIgnore: ignoreEnabled }); +``` + +**STACK.md 1.5's assertion is CORRECT at 9.39.5.** Every `files` and `ignores` glob in +`eslint.config.mjs` is matched against a path relative to the WORKSPACE ROOT, regardless of the +`cwd` the target runs with. + +This is not a footnote. It dictates the shape of every pattern in the config: + +| Intent | Correct pattern | WRONG pattern, and what it silently does | +|---|---|---| +| all unit specs | `**/*.spec.{ts,mts,cts}` | `src/**/*.spec.ts` -- root-relative, matches nothing (there is no `/src`), so the BAN NEVER FIRES and LINT-03 reads as "the rule does not work" | +| exempt integration specs | `**/*.integration.spec.{ts,mts,cts}` | `src/**/*.integration.spec.ts` -- matches nothing, so integration specs are banned too (the E5 inversion, reached by a different route) | +| ignore build output | `**/dist/`, `**/out-tsc/` (or the explicit `packages/github-cache/dist/`) | `dist/` -- means `/dist/`, which does not exist; `packages/github-cache/dist/**` stays linted (see G3) | + +Because every pattern the phase needs starts with `**/`, the base path is invisible right up until +someone "tidies" one of them into a project-relative form. **Comment-lock the reason in +`eslint.config.mjs`**, in the house style: state that globs are workspace-root-relative because +`basePath = dirname(eslint.config.mjs)` even though the target's `cwd` is the package directory. + +### (c) `lintText(code, { filePath })`: applies `files`/`ignores`, and has a vacuity trap + +```js +// eslint-9.39.5/lib/eslint/eslint.js:1131-1160 (abridged, structure verbatim) +const resolvedFilename = path.resolve(cwd, filePath || "__placeholder__.js"); +const configs = await this.#configLoader.loadConfigArrayForFile(resolvedFilename); +const configStatus = configs?.getConfigStatus(resolvedFilename) ?? "unconfigured"; + +if (resolvedFilename && configStatus !== "matched") { + const shouldWarnIgnored = typeof warnIgnored === "boolean" ? warnIgnored : constructorWarnIgnored; + if (shouldWarnIgnored) { results.push(createIgnoreResult(resolvedFilename, cwd, configStatus)); } +} else { + const config = configs.getConfig(resolvedFilename); + results.push(verifyText({ text: code, filePath: resolvedFilename, configs, cwd, ... })); +} +``` + +So: **YES**, `files`/`ignores` matching is applied against the supplied `filePath`. D-20's +one-mechanism-proves-both-directions design is sound. + +**The trap, and it is the single most likely way this phase ships a vacuous guard.** There are THREE +outcomes, and two of them produce zero rule errors: + +| `configStatus` | Meaning | `messages` | Reads to a naive assertion as | +|---|---|---|---| +| `matched` | some config object's `files` matched the path | the real lint result | correct | +| `ignored` | the path hit an `ignores`-only (global) ignore | `[]`, or one `warning` if `warnIgnored` | "the rule did not fire" | +| `unconfigured` | NO config object with `files` matched the path | `[]`, or one `warning` if `warnIgnored` | "the rule did not fire" | + +A synthetic path typo (`'x.spec.tsx'`, `'/tmp/foo.spec.ts'`, a forgotten extension) lands in +`unconfigured` and the RED-proof spec passes its "no error at the integration path" assertion for +entirely the wrong reason -- while its "error at the unit path" assertion fails loudly, so the +executor "fixes" the wrong thing. + +**Mandatory mitigation, and it costs two lines.** Construct with `warnIgnored: true` (the default, +F6, but set it explicitly) and assert, for EVERY fixture path in both directions, that the result +carries no ignore/unconfigured warning: + +```js +// Non-vacuity control for the RED proof. ESLint returns ZERO messages for a path +// it considers `ignored` or `unconfigured`, which is indistinguishable from +// "the rule did not fire". warnIgnored surfaces that as a warning we can reject. +expect(result.messages.filter((m) => m.severity === 1 && !m.ruleId)).toEqual([]); +``` + +`createIgnoreResult` emits a message with `ruleId: null` and `severity: 1`, which is exactly what +that filter catches. A rule violation has a non-null `ruleId`, so the control cannot mask a real +finding. + +### Exact constructor options for the RED-proof spec + +The spec file lives at `packages/github-cache/src/.spec.ts`, and vitest runs it with +`process.cwd()` at the repo root (Nx invokes `vitest` with `cwd: packages/github-cache`, but +`vitest.config.mts` sets `root: __dirname`; do not rely on either). Pin `cwd` explicitly rather than +inheriting it -- `cwd` participates in `path.resolve(cwd, filePath)` at eslint.js:1131, so an +inherited cwd makes the synthetic paths ambiguous: + +```ts +import { ESLint } from 'eslint'; +import { fileURLToPath } from 'node:url'; + +// Resolve the WORKSPACE ROOT from this module, never from process.cwd() -- the +// repo convention (cleanup-workflow.spec.ts, ppe-action.spec.ts, pinned-deps.spec.ts). +const WORKSPACE_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); + +const eslint = new ESLint({ + cwd: WORKSPACE_ROOT, // makes relative filePaths workspace-root-relative, + // i.e. the same frame the config's globs use (F2) + warnIgnored: true, // explicit; see the vacuity control above + // Do NOT pass overrideConfigFile: the point is to load the REAL root config. + // 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. +}); + +const [result] = await eslint.lintText(source, { + filePath: 'packages/github-cache/src/__fixture__.spec.ts', +}); +``` + +Three notes the planner should carry verbatim into the task: + +1. **`cwd: WORKSPACE_ROOT` makes the upward search terminate at the root config on hop zero.** It + also makes `path.resolve(cwd, filePath)` produce exactly the workspace-relative frame that `files` + globs are matched in, so a fixture path and a config glob can be read side by side. +2. **Use a path under the real project tree, not a bare `foo.spec.ts`.** `getConfigStatus` is a pure + path match (no `existsSync`), so the file need not exist -- but keeping the fixture path inside + `packages/github-cache/src/` means the same fixture also proves the path SHAPE the ban is scoped + to, and matches how the four real sites are addressed in D-22. +3. **`import { ESLint } from 'eslint'` makes `eslint` a genuine import of the root devDependency.** + That resolves the G6 question for the `eslint` package itself (fallow will see a real import) and + is a second, independent reason `{workspaceRoot}/eslint.config.mjs` plus the ESLint + `externalDependencies` must be in `test.inputs` (D-25). + +--- + +## G2. The selector set, MEASURED against the four real expressions and every evasion shape + +**Method (so the planner can re-run it).** `esquery@1.7.0` and `@babel/parser` are already in this +workspace's `node_modules` (F25). Each source shape below was parsed with +`@babel/parser` `{ sourceType: 'module', plugins: ['estree', 'typescript'] }` -- which emits the +ESTree node shapes `@typescript-eslint/parser` emits for all constructs used here +(`MemberExpression`, `VariableDeclarator`, `ObjectPattern`/`Property`, `ImportDeclaration`, +`ImportExpression`) -- and then run through `esquery(ast.program, selector)`. Match counts are +MEASURED, not reasoned. Residual risk: babel-estree and ts-estree could differ on a node this table +does not exercise; the D-20 spec running real ESLint is the authoritative confirmation, and it will +run in-phase. + +### The four real violation sites, read from disk + +| # | File:line | Exact expression | Caught by | Error position | +|---|---|---|---|---| +| 1 | `src/lib/cache-archive-path.spec.ts:1` | `import { tmpdir } from 'node:os';` | `no-restricted-imports` (F12: exact `'node:os'` entry, `importNames` includes `tmpdir`) | line 1, at the `tmpdir` specifier | +| 1b | `src/lib/cache-archive-path.spec.ts:26` | `expect(dirname(path)).toBe(tmpdir());` | **NOTHING, and correctly so** | **no error** | +| 2 | `src/backend/releases-backend.spec.ts:38` | `cachePlatform(process.platform) === 'windows' ? 'linux' : 'win32';` | `no-restricted-syntax` P1 | line 38 | +| 3 | `src/lib/release-asset-name.spec.ts:39` | `releaseAssetName('abc123' as Hash, process.platform),` | `no-restricted-syntax` P1 | line 39 | +| 4 | `src/lib/release-asset-name.spec.ts:60` | `expect(cachePlatform()).toBe(cachePlatform(process.platform));` | `no-restricted-syntax` P1 | line 60 | + +**Blocking correction for D-31 / D-22.** Site 1b is a bare call of a binding whose IMPORT is already +banned. No selector reaches it and none should: in strict ESM a `tmpdir` identifier cannot exist +without an import, and the import is the chokepoint. Measured: the standalone expression +`expect(dirname(p)).toBe(tmpdir());` matches ZERO selectors in the set below. + +Therefore **there are FOUR sites but only FOUR error POSITIONS at lines 1, 38, 39 and 60 -- NOT at +line 26.** If the executor puts an `eslint-disable-next-line` above line 26, that directive is +UNUSED, and D-28's `reportUnusedDisableDirectives: 'error'` fails the build. The phase would ship +red because of its own opt-out discipline. Write this into the plan explicitly. + +Also note: `cache-archive-path.spec.ts:3` (`import { basename, dirname, isAbsolute } from +'node:path'`) is NOT an error -- the banned `node:path` `importNames` are `sep`, `delimiter`, +`win32`, `posix`, and F12's per-name check only reports listed names. + +**Second-order:** a disable comment inserted above line 38 shifts every later line by one, in the +same commit. **The D-22 site table must key on FILE + EXPRESSION TEXT, never on a line number**, and +the guard spec should locate the position by searching the file for the expression. Line numbers in +that table rot in the very commit that creates them. + +### The recommended `no-restricted-syntax` selector set (MEASURED) + +```js +// P1 process.platform / process.arch, the primary shape (3 of the 4 real sites) +"MemberExpression[computed=false][object.name='process'][property.name=/^(platform|arch)$/]" + +// P2 computed evasion: const k = 'platform'; process[k] +"MemberExpression[computed=true][object.name='process']" + +// P3 aliasing AND destructuring in one selector: const p = process / const { platform } = process +"VariableDeclarator[init.name='process']" + +// P4 node:os accessor off a namespace object (backstop; see the ceiling note) +"MemberExpression[computed=false][object.name=/^(os|nodeOs)$/][property.name=/^(tmpdir|EOL|platform|arch|homedir|type|release)$/]" + +// P5 path.sep / path.delimiter / path.win32 / path.posix off a namespace object +"MemberExpression[computed=false][object.name=/^(path|nodePath)$/][property.name=/^(sep|delimiter|win32|posix)$/]" + +// P6 dynamic import -- REQUIRED, no-restricted-imports cannot see it (F10) +"ImportExpression[source.value=/^(node:)?(os|path)$/]" + +// P7 optional: globalThis.process.platform +"MemberExpression[computed=false][object.property.name='process'][property.name=/^(platform|arch)$/]" +``` + +### Measured verdicts, shape by shape + +| Shape | Verdict | Matched by | Note | +|---|---|---|---| +| `process.platform` (sites 2, 3, 4) | **CAUGHT** | P1 x1 each | primary | +| `import { tmpdir } from 'node:os'` (site 1) | **CAUGHT** | `no-restricted-imports` | F12 | +| `tmpdir()` bare call (site 1b) | not matched, by design | -- | import is the chokepoint | +| `const { platform } = process` (D-21) | **CAUGHT** | P3 | | +| `const p = process; p.platform` (D-21) | **CAUGHT** | P3 (at the binding, not the read) | error points at the alias, which is the better location anyway | +| `import { platform } from 'node:os'` (D-21) | **CAUGHT** | `no-restricted-imports` | | +| `import * as os from 'node:os'` (D-21) | **CAUGHT TWICE** | `no-restricted-imports` (F11) AND P4 | | +| `import * as nodeOs from 'node:os'; nodeOs.tmpdir()` | **CAUGHT TWICE** | `no-restricted-imports` AND P4 | | +| `import * as sys from 'node:os'; sys.tmpdir()` | **CAUGHT** | `no-restricted-imports` ONLY (P4 misses) | see the ceiling note below | +| `const k = 'platform'; process[k]` (D-21) | **CAUGHT** | P2 | | +| `await import('node:os')` (D-21) | **CAUGHT** | P6 (and P4 if the binding happens to be named `os`) | `no-restricted-imports` CANNOT see this (F10) | +| `const q = await import('node:path'); q.sep` | **CAUGHT** | P6 | | +| `import * as path from 'node:path'; path.sep` | **CAUGHT TWICE** | `no-restricted-imports` AND P5 | | +| `function f(process) { return process.platform }` | CAUGHT (incidentally) | P1 | a shadowed param named `process` is a false positive in principle; nobody writes it | +| `globalThis.process.platform` | **CEILING unless P7 is added** | P7 if included | see below | + +### False-positive controls (all MEASURED clean) + +`cachePlatform('win32')`; `const cfg = { platform: 'win32' }; cfg.platform`; `process.env.CI`; +`path.join('a','b')`; `import { dirname, basename } from 'node:path'`; +`releaseAssetName(h, 'win32')`; `process.exitCode = 1`; `await import('./local.js')`. +**Zero matches across all eight.** D-18's canonical allowed shape and every adjacent legitimate +pattern in the repo survive the rule set untouched. + +### `MemberExpression[computed=false]` vs the computed form: why both, explicitly + +esquery's bare `MemberExpression[object.name='process'][property.name='platform']` matches only the +non-computed form in practice, because in the computed form `property` is the key EXPRESSION +(`Identifier k` or `Literal 'platform'`), so `property.name` is `k` (a variable name), not +`platform`. So the naive selector does not accidentally cover the computed shape, and it does not +accidentally over-match either. STACK.md 1.5's parenthetical ("`MemberExpression` matches both `a.b` +and the computed form when `[computed=false]` is omitted -- constrain it") is right to constrain, +for a slightly different reason than stated: **writing `[computed=false]` on P1 is what lets P2 be a +separate, deliberately BROAD ban on all computed access to `process`**, which is the only way to +reach `process[k]` where `k` is a runtime value. + +P2 bans `process[anything]`, including `process['env']`. That is the intended blast radius: within a +unit spec, computed indexing of `process` has no legitimate use, and `process.env.CI` (the dotted +form) is measured clean. Say so in the rule `message`. + +### The two ceilings, and what closes each + +1. **`import * as from 'node:os'`.** P4 hardcodes `object.name=/^(os|nodeOs)$/`, so + `sys.tmpdir()` is invisible to it. **This is NOT a coverage hole**, because F11 proves + `no-restricted-imports` reports the namespace import ITSELF (messageId `everything`) regardless of + the binding name. The only way to obtain an `os` namespace object in a spec is an import, and + every static import form is closed by `no-restricted-imports` (F11/F12) while the dynamic form is + closed by P6. P4/P5 are defence in depth. Record it as: + + ```js + // ponytail: P4/P5 hardcode the conventional namespace names (os/nodeOs, path/nodePath). + // A namespace bound to any other name is invisible HERE -- and is still an error, + // because no-restricted-imports reports the namespace import itself regardless of the + // local name (eslint 9.39.5 no-restricted-imports.js:785,441 -- messageId "everything"). + // Upgrade path if that ever stops holding: drop the object.name constraint and add an + // allowlist of legitimate objects instead. Do NOT drop it without one -- measured, the + // unconstrained form false-positives on `cachePlatform(process.platform)` and on a plain + // `cfg.platform`. + ``` + +2. **`globalThis.process.platform`.** Not matched by P1 (its `object` is a MemberExpression, so + `object.name` is undefined). **Recommendation: include P7.** Measured, P7 matches + `globalThis.process.platform` and does NOT double-report the plain `process.platform` form, so it + is additive. Its false-positive surface is `.process.platform` -- e.g. a fixture object + with a `process` key holding a `platform` -- which does not exist in this repo and would be a + bizarre thing to write in a unit spec. If the planner declines P7, it MUST be recorded as a named + `// ponytail:` ceiling with `globalThis.` as the evasion and P7 as the upgrade path, per D-21. + + True remaining ceilings after P7, all recorded rather than closed: + `const g = globalThis; g.process.platform`; a platform read hidden behind a helper in another + module (out of reach for any non-type-aware rule, and out of scope per D-11); `eval`. + +### The `no-restricted-imports` half + +```js +'no-restricted-imports': ['error', { + paths: [ + { name: 'node:os', importNames: ['tmpdir','EOL','platform','arch','homedir','type','release'], message: BAN_MESSAGE }, + { name: 'os', importNames: ['tmpdir','EOL','platform','arch','homedir','type','release'], message: BAN_MESSAGE }, + { name: 'node:path', importNames: ['sep','delimiter','win32','posix'], message: BAN_MESSAGE }, + { name: 'path', importNames: ['sep','delimiter','win32','posix'], message: BAN_MESSAGE }, + ], +}], +``` + +Four entries, not two: F12 proves the match is an exact string lookup, so `'os'` and `'node:os'` are +independent keys. The repo writes `node:`-prefixed specifiers everywhere today, but the bare forms +cost one line each and close the shape a future contributor will reach for. + +### `message` wording (Claude's discretion, D-18-compliant) + +One shared constant, referenced by every `paths[].message` and every `no-restricted-syntax` +`message`, so the guidance cannot drift between the two rules: + +```js +// Single source for the ban's explanation, shared by no-restricted-imports and +// no-restricted-syntax so the two rules can never give contradictory advice (CORR-06). +const BAN_MESSAGE = + 'CORR-06: a unit spec must not derive an expectation from the RUNNING machine. ' + + "Pass the platform in instead -- cachePlatform('win32') -- or move the assertion to " + + 'an *.integration.spec.ts, where these APIs are allowed. Opting out needs a described ' + + 'disable that says why the assertion cannot move (LINT-05/LINT-06).'; +``` + +Note the deliberate absence of `releaseAssetName(hash, 'win32')` (D-18): CORR-02 deletes that +parameter in Phase 10 and `fallow` would then flag the example. + +--- + +## G3. What `eslint .` from `packages/github-cache` actually lints, file by file + +### Mechanism first (F9), because it produces the phase's biggest surprise + +`eslint .` with `stat('.').isDirectory()` pushes the glob `/**` and then walks the **real +filesystem** with `@humanfs/node`'s `hfs.walk`. An entry survives iff +`matchesPattern && config !== undefined`, and a directory is descended iff +`!configs.isDirectoryIgnored(absolutePath)`. + +**ESLint does not consult git.** Nx's `default` input (`{projectRoot}/**/*`) resolves against Nx's +file map, which EXCLUDES gitignored paths. So the lint SCOPE and the lint INPUTS are computed from +two different file universes. That is a fresh instance of exactly the LINT-04 class, and it is not +in STACK.md or PITFALLS.md. + +### The 73 tracked files, classified + +| Files | Count | Matched by | Linted? | Notes | +|---|---|---|---|---| +| `src/**/*.ts` (non-spec) | 29 | `typescript-eslint/eslint-recommended` `files: ['**/*.ts',...]` | **YES** | js.recommended (minus the 19 TS-disabled) + tseslint.recommended | +| `src/**/*.spec.ts` (non-integration) | 31 | same | **YES** | plus the LINT-02 ban block | +| `src/server/public-server.integration.spec.ts` | 1 | same | **YES** | `ignores` beside `files` removes ONLY the ban block (D-17) | +| `vitest.config.mts`, `vitest.integration.config.mts` | 2 | `**/*.mts` | **YES** | see the D-13 correction below | +| `pack-check.cjs` | 1 | default config `**/*.cjs` (F7) | **YES** | the only genuinely non-TS file; see the ordering trap below | +| `action.yml` | 1 | nothing | no | ESLint 9 has no YAML language by default | +| `src/conformance/nx-cache-openapi.v23.1.0.json` | 1 | nothing | no | no JSON language plugin is being added (D-02) | +| `package.json`, `project.json`, `tsconfig*.json` | 5 | nothing | no | same | +| `README.md`, `LICENSE` | 2 | nothing | no | no markdown plugin (D-02) | + +So of 73 tracked files, **64 are linted** and 9 are inert. There is no `.js`, `.mjs`, `.jsx`, +`.tsx`, `.cts`, `.html` or `.vue` file anywhere in the project (F23), so `@nx/eslint`'s +`DEFAULT_EXTENSIONS` list is wider than the tree and nothing else can appear by surprise. + +### UNTRACKED, ON DISK, AND LINTED WITHOUT AN `ignores` BLOCK -- the blocking finding + +| Path | On disk today | Matched by | Would be linted | +|---|---|---|---| +| `packages/github-cache/dist/**/*.js` | 30 files | default config `**/*.js` | **YES** | +| `packages/github-cache/dist/**/*.d.ts` | 30 files | `**/*.ts` | **YES** | +| `packages/github-cache/out-tsc/**/*.d.ts` | 35 files | `**/*.ts` | **YES** | +| `packages/github-cache/out-tsc/**/*.mts` | 1 file | `**/*.mts` | **YES** | +| `packages/github-cache/dist/**/*.map`, `*.tsbuildinfo` | 31 files | nothing | no | +| `packages/github-cache/test-output/**` | absent today, created by `test --coverage` | HTML/JSON, nothing matches | no | +| `packages/github-cache/node_modules/**` | empty today | globally ignored (F7) | no | + +**96 generated files would be linted.** Consequences, all bad: + +1. **`lint` is not deterministic.** `dist/` and `out-tsc/` exist only after `build` / `typecheck`. + `lint` has no `dependsOn`, so on a cold CI runner it lints 64 files and on a warm workstation it + lints 160. Same commit, same hash inputs (Nx's file map ignores gitignored paths), different + result. That is a stale-cache false PASS with an extra twist: the cache entry can be *created* by + the run that saw fewer files. +2. **It would be red.** A compiled `dist/**/*.js` is an ES module full of `process`, `Buffer`, + `console` and `URL` references with `no-undef` ON (F14 only disables `no-undef` for + `**/*.{ts,tsx,mts,cts}`, not for `.js`), so the baseline finding count would be dominated by + generated noise, and D-12's measurement would measure the wrong thing. +3. **It is slow and it inflates the D-12 baseline** the bounded-cleanup rule is supposed to act on. + +**Required in `eslint.config.mjs`, as a standalone `ignores`-only config object** (global ignores, +distinct from the D-17 per-object `ignores`), with workspace-root-relative globs per G1(b): + +```js +{ + // GLOBAL ignores. Standalone `ignores` with no other key = removed from linting + // entirely -- deliberately NOT the D-17 shape, which only narrows one config object. + // Required because `eslint .` walks the FILESYSTEM, not git: dist/ and out-tsc/ are + // gitignored (so Nx never hashes them) but they are on disk and would be linted, + // which makes `lint`'s result depend on whether `build`/`typecheck` ran first. + // Globs are WORKSPACE-ROOT-relative (basePath = dirname(eslint.config.mjs)). + ignores: ['**/dist/', '**/out-tsc/', '**/test-output/', '**/.nx/', '**/coverage/'], +} +``` + +`**/node_modules/` and `.git/` are already covered by the default config (F7) and do not need +restating. `**/.nx/` and `**/coverage/` are cheap insurance against a future run from a wider cwd. + +**Drift-guard opportunity (fits the CONVENTIONS.md single-source pattern):** the ignore list and +`.gitignore`'s `dist` / `out-tsc` / `coverage` entries are two copies of one fact. A one-assertion +spec that every ESLint global-ignore stem appears in `.gitignore` (not the reverse -- `.gitignore` +is legitimately wider) keeps them from diverging. Optional; flag it to the planner as a candidate, +not a requirement. + +### Per-file language-options and rule-scoping needs + +**`pack-check.cjs` -- and a config-ORDERING trap that D-13 does not name.** + +F13 is the load-bearing fact: `typescript-eslint/base` has **no `files` key**, so it applies to every +linted file and sets `languageOptions.sourceType: 'module'` and the TS parser universally. It comes +AFTER the default config in the array, so it **overrides the default config's +`{ files: ['**/*.cjs'], languageOptions: { sourceType: 'commonjs' } }`**. `typescript-eslint/recommended` +(element 3) likewise has no `files` key, so `@typescript-eslint/no-require-imports: 'error'` applies +to `.cjs` too. + +Measured against the real file: `pack-check.cjs` references `require` x2, `__dirname` x1, and +`process` x4. Under `sourceType: 'module'` with no globals declared and `no-undef` ON (it is only +disabled for `**/*.{ts,tsx,mts,cts}`, F14), that is **7 `no-undef` errors + 2 +`@typescript-eslint/no-require-imports` errors = 9 findings from one 172-line file.** + +The override block therefore MUST come after the `tseslint.configs.recommended` spread, or it is +itself overridden: + +```js +// AFTER ...tseslint.configs.recommended -- config objects apply in document order and +// typescript-eslint/base carries NO `files` key, so it sets sourceType:'module' for +// EVERY file, including this one. Ordering is the whole fix. +{ + files: ['**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + // Declared inline rather than importing the `globals` package: `globals` is not + // one of the five approved devDependencies (D-02), is not in the LINT-04 + // externalDependencies list, and would need a fallow entry. Four names beat a + // sixth dependency. Keeping no-undef LIVE (rather than switching it off) is what + // makes a typo in this guard script still fail. + globals: { require: 'readonly', module: 'writable', __dirname: 'readonly', process: 'readonly' }, + }, + rules: { + // pack-check.cjs is a deliberately dependency-free CommonJS guard script that CI + // runs straight after `npm ci`. Rewriting it to ESM to satisfy a TS-oriented rule + // would be the tail wagging the dog (D-13). + '@typescript-eslint/no-require-imports': 'off', + }, +} +``` + +The lazier one-liner is `'no-undef': 'off'` for `**/*.cjs` instead of the globals map. It is one +line shorter and loses the typo check on the only file in the repo where `no-undef` is live. Prefer +the globals map; record the alternative. + +**`vitest.config.mts` / `vitest.integration.config.mts` -- D-13 IS WRONG about these.** They are +NOT in the same treatment class as `pack-check.cjs`: + +- `.mts` IS in `typescript-eslint/eslint-recommended`'s `files` (F13), so **`no-undef` is already + OFF** for them and the `__dirname` reference produces nothing. +- Both use ESM `import`, so `@typescript-eslint/no-require-imports` cannot fire. +- Measured: `vitest.config.mts` and `vitest.integration.config.mts` contain no `require`, and their + only non-ESM global is `__dirname`. + +**They need NO special treatment.** Adding a `.mts` override block would be dead configuration and a +future reader would waste time on it. Correction filed below. + +**`src/**/*.ts`** -- covered entirely by `tseslint.configs.recommended`. No `parserOptions.project`, +no `projectService` (D-11). Nothing else needed. + +--- + +## G4. Expected baseline finding count for the two recommended sets + +D-12 makes this a measurement. ESLint cannot be installed here, so this is an ANALYTIC baseline: +each rule in the two recommended sets that could plausibly fire on this tree was grepped, with +counts. Treat the totals as a PREDICTION the executor checks against, not as the measurement itself +-- **the plan must still run the real count and record it**, and a large divergence from this table +is itself a finding (it would mean a rule fires for a reason this analysis missed). + +**Rule universe.** `@eslint/js` recommended = 61 rules (F15). `typescript-eslint/eslint-recommended` +turns 19 of them OFF for `**/*.{ts,tsx,mts,cts}` and turns ON 4 more (F14). +`typescript-eslint/recommended` adds 24 (F13). The `.ts`/`.mts` surface therefore sees roughly +61 - 19 + 4 + 24 = 70 rules; `pack-check.cjs` sees 61 + 24 = 85 (nothing is disabled for `.cjs`). + +### Predicted findings, with evidence + +| Rule | Scope | Predicted | Evidence | +|---|---|---|---| +| `@typescript-eslint/no-unused-vars` | all | **1** | `src/serve.spec.ts:89` `put: async (hash, _bytes) => {` -- `_bytes` is the LAST param and unused, so `args: 'after-used'` reports it. The other five `_`-prefixed params (`resilient-octokit.ts:44,52`, `publish-mirror.spec.ts:333`, `serve.spec.ts:51,271`) are all followed by a USED param and are NOT reported. Unused LOCALS and unused IMPORTS are structurally impossible: `noUnusedLocals: true` (F22). All 6 `catch (error)` bindings are used, so `caughtErrors: 'all'` finds nothing. | +| `no-undef` | `**/*.cjs` only | **7** | `pack-check.cjs`: `require` x2, `__dirname` x1, `process` x4. Live only because `typescript-eslint/eslint-recommended` scopes its `no-undef: off` to ts/tsx/mts/cts (F14) and `typescript-eslint/base` overrides the default `.cjs` `sourceType` (F13). | +| `@typescript-eslint/no-require-imports` | `**/*.cjs` only | **2** | `pack-check.cjs:32,33`. Named in D-13. | +| `@typescript-eslint/no-explicit-any` | all | **0** | `git grep ": any\|\|as any\|any[]"` over `packages/github-cache/**/*.ts` -> 0 hits | +| `@typescript-eslint/no-namespace` | all | **0** | no `namespace`/`module` declaration in the tree | +| `no-redeclare` | -- | **0** (rule OFF for TS) | F14 | +| `@typescript-eslint/no-empty-object-type` | all | **0** | the 4 `{}` grep hits are object-literal VALUES (`{} as never`, `{} as {...}`), not empty TYPE literals | +| `no-empty` | all | **0** | zero statement-position `{ }` blocks (`if/for/while/try/else/do`) | +| `prefer-const` | ts/mts | **0** | all 33 `let` declarations are counters, accumulators or deferred-resolver slots that ARE reassigned | +| `no-var` | ts/mts | **0** | the single `var` grep hit is inside a prose comment | +| `@typescript-eslint/no-unused-expressions` | all | **0** | no standalone optional-chain or bare-expression statements | +| `no-case-declarations`, `no-fallthrough` | all | **0** | no `switch` with declarations; `noFallthroughCasesInSwitch: true` already gates the second | +| `no-prototype-builtins` | all | **0** | no `hasOwnProperty` call | +| `no-constant-condition`, `no-constant-binary-expression` | all | **0** | no `while (true)`, no `\|\| true` / `&& false` | +| `@typescript-eslint/no-non-null-asserted-optional-chain` | all | **0** | no `?.x!` form | +| `triple-slash-reference`, `prefer-rest-params`, `prefer-spread`, `no-unsafe-function-type`, `no-wrapper-object-types`, `no-this-alias`, `prefer-as-const`, `no-array-constructor`, `no-duplicate-enum-values`, `no-misused-new`, `no-unsafe-declaration-merging`, `no-extra-non-null-assertion`, `no-unnecessary-type-constraint`, `prefer-namespace-keyword` | all | **0** | no `/// /dev/null; \ +npx eslint . --format json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const r=JSON.parse(s);const by={};let n=0;for(const f of r)for(const m of f.messages){by[m.ruleId??'(parse)']=(by[m.ruleId??'(parse)']||0)+1;n++;}console.log('files linted:',r.length);console.log('total findings:',n);console.log(JSON.stringify(by,null,1));})" +``` + +`files linted` is also the G3 determinism check: it must be **64** on this tree (see G5's negative +control). + +--- + +## G5. LINT-04's differential proof, as an exact command sequence + +**Prerequisite:** D-32's root script `"lint": "nx run-many -t lint"` must exist first, because the +`Cache: n/m hit (p%)` summary line is emitted by `run-many`, not by a single `nx run`. The line's +exact wording is MEASURED on this repo in `quick/260726-gok-.../260726-gok-SUMMARY.md` +(`Cache: 0/1 hit (0%)`, `Cache: 1/1 hit (100%)`, `Cache: 2/2 hit (100%)`). + +All commands are Git Bash from the repo root. Record BOTH sides of every pair. + +### Measurement A -- editing a rule in `eslint.config.mjs` re-runs `lint` (D-27, clause 1) + +```bash +# A0 warm the cache (run twice; the second is the baseline replay) +npm run lint # first run: executes. EXPECT Cache: 0/1 hit (0%) +npm run lint # baseline replay. EXPECT Cache: 1/1 hit (100%) + +# A1 perturb ONE rule. Use a real severity toggle, not a comment: a comment-only +# edit still changes the file hash, so it proves the file is an input but NOT +# that the rule set is what the command reads. Toggle P7 off, say. +# (Edit eslint.config.mjs by hand or with the Edit tool -- no heredocs.) + +npm run lint # THE PROOF. EXPECT Cache: 0/1 hit (0%) + +# A2 restore and confirm the pre-edit hash is still in the cache +git checkout -- eslint.config.mjs +npm run lint # EXPECT Cache: 1/1 hit (100%) +``` + +A `Cache: 1/1 hit (100%)` at A1 is the LINT-04 defect, unambiguously. + +### Measurement B -- editing a linted source file re-runs `lint` (D-27, clause 2) + +```bash +npm run lint # EXPECT Cache: 1/1 hit (100%) +# append one line to a linted, tracked source file (NOT a spec, so the ban rules +# stay out of it): e.g. add a trailing comment to packages/github-cache/src/index.ts +npm run lint # EXPECT Cache: 0/1 hit (0%) +git checkout -- packages/github-cache/src/index.ts +npm run lint # EXPECT Cache: 1/1 hit (100%) +``` + +### Negative control 1 -- the mutation that proves the input list is load-bearing + +Measurements A and B both pass on a `lint` target with NO `targetDefaults.lint` block at all, +because `@nx/eslint`'s INFERRED inputs already contain `default` and +`{workspaceRoot}/eslint.config.mjs` (F17). **A and B therefore do not prove D-24 did anything.** +The control that does: + +```bash +# C1 TEMPORARILY delete the "{workspaceRoot}/eslint.config.mjs" entry from +# nx.json 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). +npm run lint # re-warm after the nx.json edit. EXPECT Cache: 0/1 hit (0%) +npm run lint # EXPECT Cache: 1/1 hit (100%) +# C2 now toggle the same rule as in A1 +npm run lint # THE BUG, REPRODUCED. EXPECT Cache: 1/1 hit (100%) +# C3 restore both files +git checkout -- nx.json eslint.config.mjs +``` + +C2 showing a HIT is the proof that the entry is doing work; C2 showing a MISS means something ELSE +is invalidating the hash (most likely `{projectRoot}/**/*` catching a stray edit) and the +measurement is confounded -- stop and find it. This mirrors gok's discipline exactly: it proved its +one-token fix by running the FAILING case on both sides of the change. + +### Negative control 2 -- `lint`'s scope must not depend on gitignored build output (G3) + +This one has no analogue in gok and closes the new hole G3 found. It is the check that the global +`ignores` block is correct: + +```bash +cd packages/github-cache +npx eslint . --format json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log('linted:',JSON.parse(s).length))" +# EXPECT linted: 64 +rm -rf dist out-tsc +npx eslint . --format json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log('linted:',JSON.parse(s).length))" +# EXPECT linted: 64 <- IDENTICAL +cd .. && cd .. && npm run build && npm run typecheck # restore the build output +``` + +**Two different numbers means `lint`'s result depends on whether `build` ran, while its Nx hash does +not. That is a stale-cache false PASS by construction and the `ignores` block is wrong.** + +### Measurement C -- the second-order hole D-25 names + +The `test` target runs the D-20 guard spec, which imports `eslint` and reads `eslint.config.mjs`. +Prove `test` re-runs too, or LINT-03 itself will read a stale PASS: + +```bash +npm run test # warm. EXPECT Cache: 1/1 hit (100%) +# toggle the same rule in eslint.config.mjs +npm run test # THE PROOF for D-25. EXPECT Cache: 0/1 hit (0%) +git checkout -- eslint.config.mjs +``` + +If this shows a HIT, `test.inputs` is missing `{workspaceRoot}/eslint.config.mjs` and the RED proof +is untrustworthy from that moment on. Run this BEFORE trusting any LINT-03 result. + +### What to record + +For each of A, B, C and both negative controls: the command, the `Cache: n/m hit (p%)` line on each +side, and the git SHA. Same evidence discipline as gok. Note that `Cache: n/m hit` is +non-discriminating in isolation (PITFALLS D1) -- it is the BEFORE/AFTER PAIR that carries the proof, +never a single reading. + +--- + +## G6. `fallow` interaction: will `npm run fallow:ci` stay green? + +The gate is `fallow dead-code --fail-on-issues` (`fallow@3.6.0`, verified installed). Its finding +classes include `--unused-files`, `--unused-deps` and `--unlisted-deps`, all of which this phase can +trip. + +### Per-artifact verdict + +| Artifact | Credited by | Verdict | Action | +|---|---|---|---| +| `eslint.config.mjs` (the FILE) | fallow's built-in config-filename table -- the binary contains the literal `eslint.config.mjs` alongside `vitest.config.*`, `jest.config.*`, `vite.config.*` (F20) | **LIKELY auto-credited. MEDIUM confidence.** | verify in-phase; contingency is one `entry` line | +| `@eslint/js` | imported by `eslint.config.mjs` | credited IF the config file is analysed | -- | +| `typescript-eslint` | imported by `eslint.config.mjs` | same | -- | +| `@eslint-community/eslint-plugin-eslint-comments` | imported by `eslint.config.mjs` (the `./configs` subpath, D-29) | same, PLUS a subpath-specifier risk (see below) | -- | +| `eslint` | **imported by nothing in `eslint.config.mjs`**; imported by the D-20 RED-proof spec (`import { ESLint } from 'eslint'`) | **credited by the SPEC, not by the config** | none, provided the spec is in the same commit | +| `@nx/eslint` | referenced ONLY from `nx.json` `plugins[]`, which fallow does not read | **WILL be flagged unused. HIGH confidence.** | **required** `ignoreDependencies` entry | + +### Required `.fallowrc.jsonc` change (the one that is certain) + +`@nx/vitest` already sits in `ignoreDependencies` with the comment "Nx plugin that INFERS the `test` +target via nx.json". `@nx/eslint` is the identical case and gets the identical treatment, in the +house style (trailing-comma JSONC, one comment per entry): + +```jsonc +"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 + ... +] +``` + +### The contingency, and how to decide it in one command + +Run `npm run fallow:ci` as an explicit `verify` line on the commit that adds `eslint.config.mjs`. +Three possible findings and their one-line fixes: + +| If fallow reports | Add to `.fallowrc.jsonc` | +|---|---| +| `eslint.config.mjs` is an unused file | an `entry` line, with a comment in the file's existing voice: `// Root ESLint flat config: consumed by the inferred `lint` target's `eslint .` command and by the LINT-03 guard spec's ESLint Node-API instance; never imported by product code.` | +| `@eslint/js` / `typescript-eslint` / the comments plugin are unused deps | nothing -- fixing the entry above fixes these, because they are real imports of that file | +| the `/configs` subpath is an unresolved or unlisted import | `ignoreDependencies` for `@eslint-community/eslint-plugin-eslint-comments`, with the reason recorded as "consumed via its `./configs` subpath export, which reachability analysis resolves to a different specifier than the package name" | + +Do NOT pre-emptively add all of these. F20 is strong evidence the config file is recognised, and a +speculative `ignoreDependencies` entry suppresses a real future finding -- the exact failure the +existing `.fallowrc.jsonc` comments are careful about ("Both are defensive/documentary"). +**Add `@nx/eslint` now; add the rest only against a measured finding.** + +### Two adjacent facts + +- **No `.prettierignore` entry is needed.** `eslint.config.mjs` is normal source and should be + Prettier-formatted like everything else. `.prettierrc` sets `singleQuote: true`, so the config + must be written with single quotes or `npm run format:check` (battery command 1) fails. +- **`fallow`'s GSD structural pre-pass remains a no-op** (ROADMAP records this). Phase 7's `lint` + target does not replace it and does not interact with it. The gate that matters is the + `fallow:ci` script, invoked directly. + +--- + +## G7. Task and plan sequencing, and every forced same-commit coupling + +### The two facts that drive the whole ordering + +1. **F16: `@nx/eslint` returns NO targets when no `eslint.config.*` exists anywhere.** The plugin + registration alone does not create a `lint` target. So the config file is a hard precondition of + the target, not a companion to it. +2. **`nx.json` `plugins[]` cannot reference an uninstalled plugin** -- Nx throws while building the + project graph, which fails every one of the eight battery commands at once. So the install is a + hard precondition of the registration. + +Combined: **install -> config -> registration.** Never any other order. + +### FORCED same-commit couplings, each with the mechanism that forces it + +| # | These must land in ONE commit | Forced by | +|---|---|---| +| SC1 | `nx.json` `targetDefaults.test.inputs` (the ESLint entries) + the D-20/D-22 guard spec | **D-25.** Without the input, editing a rule replays a cached `test` PASS. Since LINT-03 IS the activity that edits rules, the false PASS surfaces during LINT-03 and reads as "the rule does not fire". Shipped twice before in this repo (`governance-email.spec.ts` T-06-03-02; `typecheck` in quick 260726-gok). | +| SC2 | `eslint.config.mjs` (the rule set) + the FOUR described disables at lines 1 / 38 / 39 / 60 | **D-31**, and independently: any commit where the rules are enforced and the disables are absent is RED, which the repo's bisect-safety standard forbids. See the ordering note below on whether "enforced" starts at the config or at the target. | +| SC3 | The D-22 site-table spec + the four disables | The spec asserts each site currently carries a described disable AND that stripping it produces an error. It cannot pass before the disables exist, and the disables have no reviewable justification without it. | +| SC4 | `nx.json` `plugins[]` + `nx.json` `targetDefaults.lint` | Registering the plugin without the input block leaves the inferred `{externalDependencies: ['eslint']}` hole live for one commit, i.e. one commit ships the exact defect LINT-04 exists to close. Also both are one file. | +| SC5 | `nx.json` `targetDefaults.lint` + the `nx-target-inputs.spec.ts` `lint` probes | The spec indexes `nxJson.targetDefaults['lint'].inputs`; it throws on `undefined` before the block exists. Same coupling gok used for `test`. | +| SC6 | The root `"lint"` script + the `ci.yml` `lint` job + the target | A `ci.yml` job invoking a script that does not exist is a red CI leg; a script invoking a target that does not exist is a red battery command. | +| SC7 | The five devDependency additions + `package-lock.json` + the five `pinned-deps.spec.ts` `it()` blocks | **D-04.** The spec reads the root manifest; the `it()` blocks fail until the specifiers exist. And an install without the guard leaves the pins unguarded, which is D-04's whole point. | +| SC8 | The install + `.fallowrc.jsonc`'s `@nx/eslint` entry | `fallow:ci` is battery command 6. Five new devDependencies that nothing imports is an unused-dependency finding, i.e. a red commit. | +| SC9 | **`npm run build:action` output, IF and ONLY IF a `serve()`-reachable source changes** | Project memory / ROBUST-04. **Verified NOT triggered by this phase's planned edits** -- see below. | + +### SC9, verified rather than assumed + +`start-cache-server/entry.ts:19` imports `serve` from `packages/github-cache/src/serve.js`, and +`esbuild.action.mjs` bundles that entry with `bundle: true`, so every module transitively reachable +from `serve()` is inlined into the committed `start-cache-server/index.js`. + +Phase 7's planned edits touch: `package.json` (root), `package-lock.json`, `eslint.config.mjs` +(new), `nx.json`, `.fallowrc.jsonc`, `.github/workflows/ci.yml`, `pinned-deps.spec.ts`, +`nx-target-inputs.spec.ts`, two new spec files, and three existing SPEC files (the disables). +**No product source under `packages/github-cache/src/**` that is not a `*.spec.ts` is touched, so +nothing `serve()`-reachable changes and `check:action` is unaffected.** D-06 (the package manifest +is untouched) reinforces this. + +**One residual, and it is real.** D-05 mandates regenerating `package-lock.json` in a linux/arm64 +`node:24` container. A lockfile regeneration can re-resolve a TRANSITIVE runtime dependency of +`@actions/cache` / `@actions/core` (both are exact-pinned, but their own dependencies carry ranges), +and any such bump changes the bytes esbuild inlines. **Contingency, not a prediction:** run +`npm run check:action` immediately after `npm ci` on the regenerated lockfile. If it drifts, run +`npm run build:action` and stage `start-cache-server/index.js` **in the same commit as the lockfile** +-- never as a follow-up, or the `action-bundle-drift` CI job fails that commit and every later one +until it is fixed. + +### Recommended commit shape (three commits, battery green at each) + +| Commit | Contents | Battery | Why this boundary | +|---|---|---|---| +| **1. Adopt the toolchain** | five devDeps exact-pinned + regenerated lockfile (SC7) + `pinned-deps.spec.ts` blocks (SC7) + `.fallowrc.jsonc` `@nx/eslint` (SC8) + `eslint.config.mjs` (full rule set) + the four described disables (SC2, SC3) + the D-20/D-22 RED-proof spec + the D-19 drift spec + `nx.json` `targetDefaults.test.inputs` (SC1) | 8 commands, green | ESLint runs here only via the Node API inside `test`. There is still NO `lint` target, so the four real sites are not enforced by any gate and the commit cannot be red for that reason. | +| **2. Wire the target** | `nx.json` `plugins[]` + `targetDefaults.lint` (SC4) + `nx-target-inputs.spec.ts` `lint` probes (SC5) + root `"lint"` script + `ci.yml` `lint` job (SC6) | **9** commands, green | This is the commit that rotates EVERY task hash (D-36). Isolating it makes the rotation attributable, which Phase 8 needs. | +| **3. Record the evidence** | the D-35 hashed-node baseline, the D-27 differential measurements, the D-12 baseline count, the D-36 all-MISS pre-record | 9, green | Docs only; no code. Splitting it keeps commit 2's diff readable. | + +**Why the disables sit in commit 1 and not commit 2.** D-31 says "the same commit as the rules", and +the rules are in commit 1. It is also the only placement that lets SC3's guard spec ship in commit 1. +The cost is that a reviewer sees four disable comments one commit before anything enforces them; the +commit message must say so. The alternative (disables in commit 2, with the target) satisfies the +never-red constraint equally well but breaks SC3 and reads as a weaker fit to D-31. Either is +defensible; commit 1 is recommended. + +**TDD ordering INSIDE commit 1** (`workflow.tdd_mode: true`, and gok's precedent of observing RED +inside a single commit): write the D-20/D-22 spec and run `npm run test` BEFORE adding the two ban +rules to `eslint.config.mjs`. Expect a partial RED -- the "error at the unit-spec path" assertions +fail while the "no error at the integration path" assertions pass on both sides by design (they are +the direction controls, exactly like gok's negative control). **Record which assertions failed and +which passed**, because a RED where the direction controls ALSO fail means the config is not being +loaded at all (the G1(c) `unconfigured` trap), not that the rules are missing. + +### The two ordering constraints that reach outside this phase + +- **LINT-01 before PARITY-01** (ROADMAP): satisfied by Phase 7 preceding Phase 8. Commit 2 is the + hash-rotating event; Phase 8's measurements must be taken after it. +- **LINT-02/03 before CORR-05 removal** (Phases 9 and 10): satisfied by the D-22 site table plus the + disables. The removal schedule is comment-locked in the table so a Phase 9/10 executor deletes the + ROW with the SITE. LINT-06 then fails the build if they delete one without the other, which is the + mechanism doing its job. + +--- + +## Validation Architecture + +`workflow.nyquist_validation: true` and `workflow.tdd_mode: true` in `.planning/config.json` +(verified this session). This section is the input the VALIDATION.md gate reads. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest `~4.1.0` (`@nx/vitest@23.1.0` infers the `test` target) | +| Config file | `packages/github-cache/vitest.config.mts` (unit), `vitest.integration.config.mts` (integration) | +| Quick run command | `npx vitest run ` from `packages/github-cache`, or `npm run test` from the root (`nx run-many -t test`, ~5s warm) | +| Full suite command | `npm run test` from the root; full gate is the NINE-command battery (F19 + `npm run lint`) | +| Baseline today | 32 spec files, 438 tests (gok, 2026-07-26) | + +### Phase requirements to test map + +| Req | Behavior that must be proven | Test type | Automated command | File | +|---|---|---|---|---| +| LINT-01 | the five devDeps are exact-pinned and NAME-guarded | unit | `npx vitest run src/pinned-deps.spec.ts` | EXTEND `src/pinned-deps.spec.ts` (D-04) | +| LINT-01 | a `lint` target exists, is cacheable, and is in the battery | integration-by-command | `npm run lint` (must exit 0 and print `Successfully ran target lint`) | no spec; the battery command IS the assertion (D-34: do not assert on `nx show project` text) | +| LINT-02 | a unit spec reading ambient platform state FAILS; the same code at an integration path PASSES | unit | `npx vitest run src/.spec.ts` | **NEW** (D-20) | +| LINT-02 | the ESLint globs and the vitest partition agree (superset, not equality) | unit | same file or a sibling (D-19 discretion) | **NEW** (D-19) | +| LINT-03 | every D-21 evasion shape has an explicit asserted verdict | unit | same file | **NEW** (D-20/D-21) | +| LINT-03 | each of the FOUR extant CORR-05 sites is CAUGHT while it exists | unit | same file | **NEW** (D-22) | +| LINT-04 | `lint` cannot serve a stale-cache false PASS | (a) unit for the declared inputs, (b) **manual differential** for the behaviour | (a) `npx vitest run src/nx-target-inputs.spec.ts`; (b) the G5 command sequence | (a) EXTEND `src/nx-target-inputs.spec.ts` (D-26); (b) manual, recorded in the phase evidence -- D-27/SC4 say "by differential, not by reading the config", so the spec alone does NOT close it | +| LINT-04 | `test` re-runs when a rule changes (D-25) | unit + manual | `nx-target-inputs.spec.ts` assertion + G5 Measurement C | EXTEND `src/nx-target-inputs.spec.ts` | +| LINT-05 | a bare disable and a bare `@ts-expect-error` are both errors | unit | the RED-proof spec, via `lintText` on a two-line fixture | **NEW**, folded into the D-20 spec | +| LINT-06 | a stale disable FAILS | unit | the RED-proof spec: lint a fixture that carries a described disable over a NON-violating line, assert a `reportUnusedDisableDirectives` error | **NEW**, folded into the D-20 spec | +| CORR-06 | the ban is mechanical, and integration keeps every OTHER rule | unit | the D-20 spec's direction pair, plus one assertion that a DIFFERENT rule still fires at the integration path (proves D-17's `ignores`-beside-`files` semantics, not a global un-lint) | **NEW** | + +### Sampling rate + +- **Per task commit:** `npm run test` (the whole unit suite; it is seconds) plus, from commit 2 on, + `npm run lint`. +- **Per commit, before committing:** the full battery. EIGHT commands today (F19), **NINE** from + commit 2: `format:check`, `build`, `typecheck`, `typecheck:action`, `test`, `lint`, `fallow:ci`, + `check:action`, `pack:check`. gok's standard was "8/8 green before EVERY commit, not just the + last"; this phase inherits it at 9. +- **Phase gate:** full battery green, plus the G5 differential measurements recorded, before + `/gsd:verify-work`. + +### Non-vacuous assertions -- what makes each one able to fail + +This is the section the Nyquist gate exists for, and this phase has three distinct vacuity traps. + +| Assertion | Its vacuity trap | The control that closes it | +|---|---|---| +| "the rule errors at a unit-spec path" | ESLint returns `[]` for an `ignored` or `unconfigured` path (F5), which looks identical to "no violation" | assert `result.messages` contains NO `severity:1, ruleId:null` ignore-warning (G1(c)); `warnIgnored: true` explicitly | +| "the rule does NOT error at an integration path" | passes trivially if the config never loaded, if the path is misspelled, or if the ban rules were never added | pair it with an assertion that a DIFFERENT rule (e.g. `@typescript-eslint/no-explicit-any` on `const x: any = 1;`) DOES fire at that same integration path -- proves the file is linted, only the ban is exempt (D-17) | +| `nx-target-inputs.spec.ts` `lint` probes | `filterUsingGlobPatterns` returns the WHOLE input list when the pattern list is empty, so every `toContain()` passes together on a resolver that resolved nothing (recorded in the spec's own comment) | reuse the existing NEGATIVE control shape: assert `lint` does NOT hash something it genuinely must not. `build` is the existing discriminator; for `lint` the honest negative is a probe path that `default` excludes. If no clean negative exists for `lint`, say so in the comment rather than shipping a positive-only set | +| "the four CORR-05 sites are caught" | rots the instant the disables shift the line numbers (which happens in the SAME commit) | key the site table on FILE + EXPRESSION TEXT, locate the position by searching the file content, and blank-out (do not delete) the stripped disable line so numbering is preserved | +| "the disables are described" | a disable with a `--` and an empty reason still parses | assert the reason text is non-empty AND contains the word `integration` (LINT-06 requires it to state why the assertion cannot move there) | + +### Mutation testing (D-23, the repo standard since gok) + +A guard that cannot fail is worthless. Before declaring the phase done, run each mutation, confirm +the EXACT expected failure set, and restore. Record the observed failure counts. + +| # | Mutation | Expected result | +|---|---|---| +| M1 | delete the P1 selector from `no-restricted-syntax` | the three `process.platform` site assertions and the `process.platform` evasion assertion go RED; the import-shape assertions stay GREEN | +| M2 | delete the `node:os` entry from `no-restricted-imports` `paths` | the site-1 assertion and the named-import / namespace-import evasion assertions go RED; the `process.*` assertions stay GREEN | +| M3 | delete the P6 `ImportExpression` selector | ONLY the `await import('node:os')` / `await import('node:path')` assertions go RED. If nothing goes red, P6 is untested and D-21's dynamic-import shape is a silent gap | +| M4 | change `ignores` to `['**/*.integration.spec.ts']` (drop `mts,cts`) | the D-19 drift guard goes RED. If it stays green it is asserting set EQUALITY of the wrong pair, or restating the globs instead of reading them (D-19) | +| M5 | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.test.inputs` | the `nx-target-inputs.spec.ts` assertion goes RED. This is the D-25 guard's own mutation test | +| M6 | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.lint.inputs` | the `lint` probe assertion goes RED, AND G5's negative control 1 reproduces the stale-cache HIT | +| M7 | remove the global `ignores` block from `eslint.config.mjs` | G5's negative control 2 reports two different `linted:` counts across `rm -rf dist out-tsc`. This is the only control for the G3 finding | +| M8 | replace one described disable with a bare `// eslint-disable-next-line no-restricted-syntax` | `require-description` errors. Proves LINT-05 is live, not just configured | +| M9 | move a described disable one line off its violation | `reportUnusedDisableDirectives` errors AND the underlying rule errors. Proves LINT-06 is live | + +**Each mutation must be applied, observed, and REVERTED before the commit.** Mutation runs are +never committed. gok's precedent (`nx-target-inputs.spec.ts`) is why that guard is trusted; the same +standard applies here or the LINT-03 evidence is worth nothing. + +### Wave 0 gaps + +- [ ] the D-20/D-22 RED-proof spec (NEW file) -- covers LINT-02, LINT-03, LINT-05, LINT-06, CORR-06 +- [ ] the D-19 drift-guard assertions (NEW file or folded into the above, D-19 discretion) +- [ ] `src/pinned-deps.spec.ts` -- five new `it()` blocks (LINT-01) +- [ ] `src/nx-target-inputs.spec.ts` -- `lint` probes and the `test.inputs` ESLint assertion (LINT-04) +- [ ] No framework install needed. Vitest, its config and the ESM/`import.meta.url` reading idiom + all already exist. The only NEW capability is instantiating the ESLint Node API from a spec, + which needs the `eslint` devDependency (SC7) and nothing else. + +--- + +## Security Domain + +`workflow.security_enforcement: true`, `security_asvs_level: 1`, `security_block_on: high`. + +### Applicable ASVS categories + +| ASVS Category | Applies | Why / standard control | +|---|---|---| +| V2 Authentication | no | no auth surface is touched; D-06 keeps the package manifest and public surface unchanged | +| V3 Session Management | no | none | +| V4 Access Control | no | none. THREAT-MODEL.md's C1-C18 CREEP controls are untouched by this phase | +| V5 Input Validation | no | no runtime input path changes | +| V6 Cryptography | no | none | +| V14 Configuration / Dependency | **yes** | five new devDependencies, all exact-pinned (D-02) and NAME-guarded (D-04); lockfile regenerated in a controlled linux/arm64 container (D-05) | + +### Threat patterns for this change + +| Pattern | STRIDE | Mitigation in this phase | +|---|---|---| +| Slopsquatted / typosquatted lint dependency | Tampering | all five names came from `STACK.md` section 0, verified against `registry.npmjs.org` on 2026-07-26 and re-reached this session for `eslint@9.39.5`, `@eslint/js@9.39.5`, `typescript-eslint@8.65.0` (each resolved a real tarball). See the Package Legitimacy Audit below | +| Silent version drift in a build gate | Tampering | exact pins + the `pinned-deps.spec.ts` name list (D-04). The ROBUST-03-class argument is recorded in the spec comment: `lint` is a build gate whose BEHAVIOUR a silent minor can change, which is the same argument that put `esbuild` in the list | +| A lint rule that can be silently disabled | Repudiation | LINT-05 (`require-description`) + LINT-06 (`reportUnusedDisableDirectives: 'error'`) are precisely this control | +| Malicious `postinstall` in a new devDependency | Tampering | see the audit below | + +### Package Legitimacy Audit + +`gsd-tools query package-legitimacy check` is not reachable from this agent context; verification was +done directly against `registry.npmjs.org` this session and against `STACK.md` section 1.1's +2026-07-26 registry pass. + +| Package | Registry | Requested version | Publish date | Source repo | Verdict | Disposition | +|---|---|---|---|---|---|---| +| `eslint` | npm | `9.39.5` | 2026-07-10 | github.com/eslint/eslint | OK | Approved. Tarball fetched and read this session; `engines: ^18.18.0 \|\| ^20.9.0 \|\| >=21.1.0`; no `postinstall` | +| `@eslint/js` | npm | `9.39.5` | in lockstep with `eslint` | github.com/eslint/eslint (monorepo) | OK | Approved. Tarball fetched and read this session | +| `typescript-eslint` | npm | `8.65.0` | 2026-07-20 | github.com/typescript-eslint/typescript-eslint | OK | Approved. Tarball fetched this session; it is a thin meta-package over `@typescript-eslint/{eslint-plugin,parser,utils,typescript-estree}@8.65.0` | +| `@eslint-community/eslint-plugin-eslint-comments` | npm | `4.7.2` | 2026-05-26 | github.com/eslint-community/eslint-plugin-eslint-comments | OK | Approved (STACK 1.1, registry-verified 2026-07-26). **Not independently re-fetched this session** -- the executor should confirm at install time | +| `@nx/eslint` | npm | `23.1.0` | 2026-07-13 | github.com/nrwl/nx | OK | Approved. Same org and exact version as the four `@nx/*` / `nx` packages already installed | + +**Packages removed due to a SLOP verdict:** none. +**Packages flagged SUS:** none. +**Executor obligation at install time:** run +`npm view scripts.postinstall` for all five before `npm i`. None is expected to have one; a +non-empty result on any is a STOP condition requiring a `checkpoint:human-verify`. + +--- + +## Corrections to Existing Artifacts + +Nine corrections. Two are BLOCKING (they change what the plan must contain); the rest are precision +fixes that would otherwise cost the executor time. + +### C1 (BLOCKING) -- `no-restricted-imports` cannot see a dynamic import, so the `ImportExpression` selector is MANDATORY + +- **Where:** `STACK.md` 1.5 ("Also cover `import('node:os')` dynamic form **if any spec uses it**"); + `PITFALLS.md` E4 (lists `await import` implicitly among evasions but prescribes only + "`no-restricted-imports` for `node:os`/`os` ... it closes the whole import family in one line"). +- **Wrong because:** ESLint 9.39.5's `no-restricted-imports` returns a visitor object containing + exactly `ImportDeclaration`, `ExportNamedDeclaration`, `ExportAllDeclaration` and + `TSImportEqualsDeclaration`. **There is no `ImportExpression` visitor** (F10, read from + `lib/rules/no-restricted-imports.js:815-845`). It does NOT close "the whole import family"; it + closes the STATIC import family. +- **Consequence:** D-21 lists `await import('node:os')` as a shape whose verdict must be asserted. + Without an `ImportExpression[source.value=/^(node:)?(os|path)$/]` entry in `no-restricted-syntax`, + that assertion FAILS and the executor will (correctly) conclude the rule set is incomplete -- + after burning time looking in the wrong rule. The selector is P6 in G2; it is measured to work. + +### C2 (BLOCKING) -- there are FOUR sites but only FOUR error positions, and `:26` is not one of them + +- **Where:** `07-CONTEXT.md` D-22's site table and `REQUIREMENTS.md` CORR-05's table both write + "`cache-archive-path.spec.ts:1` (`import { tmpdir }`) **and `:26` (`tmpdir()`)**". +- **Wrong because:** measured -- `expect(dirname(p)).toBe(tmpdir());` matches ZERO selectors, and it + should: in strict ESM the `tmpdir` binding cannot exist without the import, and the import is + already an error. One error is produced for that file, at line 1. +- **Consequence:** if the executor places an `eslint-disable-next-line` above line 26, that + directive is UNUSED and D-28's `reportUnusedDisableDirectives: 'error'` fails the build. **The + phase would ship red because of its own opt-out discipline.** Four disables, at lines 1, 38, 39 + and 60. The table's site 1 row is still correct as a SITE (both lines go away together in Phase 9 + with VER-02); only the error-position reading is wrong. +- **Second-order:** the table must key on FILE + EXPRESSION, not line numbers -- inserting the + disables shifts every later line in the same commit. + +### C3 -- the `import * as ` "ceiling" is smaller than CONTEXT.md and STACK.md imply + +- **Where:** `STACK.md` 1.5 ("`no-restricted-imports` alone cannot ban a specific member of a + namespace import") and `07-CONTEXT.md` D-15 (same claim), which together read as "an + `import * as sys from 'node:os'` slips the ban". +- **Sharpened:** the literal claim is true (it cannot ban a specific MEMBER), but the operative + behaviour is stronger than the claim suggests. F11: when `importNames` is set, + `ImportNamespaceSpecifier` maps to the name `"*"` and the rule reports the whole import with + messageId `everything` -- **regardless of the local binding name**. So `import * as sys from + 'node:os'` IS an error, from `no-restricted-imports`, and P4's hardcoded `object.name` regex is + defence in depth rather than the only line. This does not change D-15 (both rules are still + required, for the reason D-15 gives) -- it changes what must be recorded as a `// ponytail:` + ceiling under D-21. Recorded form is in G2. + +### C4 -- D-13 is wrong about `vitest.config.mts` / `vitest.integration.config.mts` + +- **Where:** `07-CONTEXT.md` D-13: "`vitest.config.mts` and `vitest.integration.config.mts` use + `__dirname` and fall in the same treatment class" as `pack-check.cjs`. +- **Wrong because:** `typescript-eslint/eslint-recommended` scopes `no-undef: 'off'` to + `['**/*.ts','**/*.tsx','**/*.mts','**/*.cts']` (F13/F14), which INCLUDES `.mts`. `__dirname` in an + `.mts` file produces nothing. Both files use ESM `import`, so `no-require-imports` cannot fire + either. **They need no override block.** Adding one ships dead configuration. +- D-13's `pack-check.cjs` half is correct and is the ONLY member of that class (F23). + +### C5 -- the `.cjs` fix is an ORDERING fix, which D-13 does not say + +- **Where:** `07-CONTEXT.md` D-13 prescribes "scope that rule off for `**/*.cjs` with + `sourceType: 'commonjs'` and node globals" without saying where in the array. +- **Sharpened:** `typescript-eslint/base` has NO `files` key (F13), so it sets + `sourceType: 'module'` and the TS parser for EVERY file including `.cjs`, overriding ESLint's own + default `.cjs` block. **The override must come AFTER the `tseslint.configs.recommended` spread** + or it is itself overridden and the fix silently does nothing. Config-object ordering is explicitly + Claude's discretion under CONTEXT.md; this is a mechanism that constrains that discretion. +- Also: `no-undef` is LIVE on `.cjs` (it is only disabled for ts/tsx/mts/cts), which D-13 does not + mention. Measured: 7 `no-undef` findings in `pack-check.cjs` on top of the 2 `no-require-imports` + D-13 predicts. + +### C6 -- PITFALLS E3 clause 2 overstates the lint scope + +- **Where:** `PITFALLS.md` E3.2: "ESLint lints more than `src/**`: config files + (`vitest.config.mts`, `esbuild.action.mjs`), `start-cache-server/entry.ts`, and `*.cjs` helpers." +- **Wrong under D-07:** `esbuild.action.mjs` and `start-cache-server/entry.ts` live at the WORKSPACE + ROOT, outside `cwd = packages/github-cache`, and are not linted at all -- D-07 records this as an + intentional deviation and CONTEXT.md's Deferred Ideas carries "lint the root-level files the + project-scoped target misses" as a follow-on. E3.2 predates that decision. +- **And it points the wrong way.** The real LINT-04 widening runs in the OPPOSITE direction: `eslint + .` walks the filesystem (F9) while Nx's `default` input resolves against a git-derived file map, + so `dist/` and `out-tsc/` are **in the lint scope but NOT hashed**. 96 generated files today (F24). + See G3. This instance is not recorded anywhere in the existing artifacts. + +### C7 -- `STACK.md` 1.3's inferred-target shape is incomplete + +- **Where:** the quoted `buildEslintTargets` output in `STACK.md` 1.3. +- **Missing:** `...tsconfigChainOutsideProjectRoot.map(f => '{workspaceRoot}/' + f)`, between the + eslint-config entries and `{workspaceRoot}/tools/eslint-rules/**/*` (F17). For this repo the chain + resolves to exactly `tsconfig.base.json`, which `sharedGlobals` -> `default` already covers, so + D-24's replacement list needs no change. **Recording it matters because D-24's whole premise is + "the block must restate everything it keeps"** -- a planner comparing the restated list against + STACK's quote would conclude it is complete when the real inferred list is one entry longer. + +### C8 -- the config file's EXISTENCE gates the target, which nothing records + +- **Where:** not stated in `STACK.md`, `PITFALLS.md` or `07-CONTEXT.md`. +- **Fact (F16):** `@nx/eslint@23.1.0`'s `createNodes` short-circuits with + `if (eslintConfigFiles.length === 0) { return []; }`. Registering the plugin in `nx.json` with no + `eslint.config.*` anywhere produces NO lint target, silently -- not an error, just nothing. It + would read as "the plugin does not work". +- **Consequence:** a hard ordering constraint (G7): install -> config -> registration. Never + registration before config. + +### C9 -- `STACK.md` 1.5's glob-base-path claim is CONFIRMED (recorded so nobody re-checks it) + +The prompt flagged this for verification. `basePath = path.dirname(configFilePath)`, passed to +`new FlatConfigArray(..., { basePath })` (F2, read at 9.39.5). STACK.md 1.5 is right. The one +addition: passing `--config ` would set `basePath = cwd` instead and silently rewrite every +glob's frame. D-34 already forbids overriding `options.command`; this is a second, independent +reason. + +### Also noted, already known, restated for the planner + +- `ROADMAP.md` Phase 7 SC3 says "three CORR-05 violations". `REQUIREMENTS.md` and D-22 correct this + to FOUR. Use FOUR. +- `.planning/codebase/CONVENTIONS.md` still states "ESLint is NOT configured in this repository". + This phase falsifies it. Regenerating `.planning/codebase/*` is a Deferred Idea, not a Phase 7 + deliverable, but the verifier should not treat the stale sentence as a contradiction. +- `LINT-05`'s requirement text uses the legacy bare `eslint-comments/` prefix; the flat-config prefix + is `@eslint-community/eslint-comments/` (D-29). Do not copy the requirement text into the config. + +--- + +## Open Questions + +| # | Question | Status | What would settle it | +|---|---|---|---| +| Q1 | Does `@nx/eslint` infer `lint` identically on Windows and Linux? | **UNVERIFIED BY DESIGN** (D-35, STACK 7). F18 confirms the mixed-separator `isPathIgnored` gate genuinely runs for this project layout, so the risk is live rather than hypothetical | Phase 8's CORR-03 two-leg job, treating `lint` as a fourth target. Phase 7's obligation is only to RECORD the hashed node values as the baseline. **Do not reason it closed here.** | +| Q2 | Does `fallow` auto-credit `eslint.config.mjs` and its imports? | **MEDIUM.** F20 (the literal filename is in the binary's config table, paired with `eslint`) is strong but is a binary-strings inference, not documentation | One `npm run fallow:ci` on the commit that adds the file. Contingency and exact remedy are in G6. Zero risk of getting stuck | +| Q3 | The REAL D-12 baseline finding count | **PREDICTED 10 (+0-2 uncertain), not measured.** ESLint cannot be installed in this session | The measurement command in G4. A materially higher number most likely means the G3 `ignores` block is missing -- check that before disabling any rule | +| Q4 | `no-useless-escape` / `no-control-regex` / `no-irregular-whitespace` / `no-misleading-character-class` findings | **LOW confidence, predicted 0-2.** ~25 files contain a regex; this class is the least greppable | The G4 measurement command | +| Q5 | Do babel-estree and `@typescript-eslint/parser` agree on every node shape G2's selectors depend on? | **REASONED yes** for `MemberExpression`, `VariableDeclarator`, `ObjectPattern`/`Property`, `ImportDeclaration`, `ImportExpression` -- all plain ESTree | The D-20 spec running REAL ESLint with the real parser, in-phase. If a selector that measured MATCH here comes back clean there, the node shape is the first place to look | +| Q6 | Can the D-19 drift guard import `eslint.config.mjs` from a `.ts` spec under `module: nodenext`? | **OPEN.** A static `import ... from '../../../eslint.config.mjs'` will fail `typecheck` (no declaration file, `allowJs` is unset). Recommended workaround: `await import(new URL('../../../eslint.config.mjs', import.meta.url).href)` -- a NON-LITERAL specifier, which TypeScript types as `any` and does not attempt to resolve. Untested here | The executor writing the spec. Fallback is the house disk-read pattern (`cleanup-workflow.spec.ts` / `ppe-action.spec.ts`: read the text, strip `//` comment lines, regex the globs) | +| Q7 | Can the drift guard read the vitest configs by IMPORT rather than by text? | **PROBABLY NOT.** `defineConfig(() => ({ root: __dirname, ... }))` returns the function; calling it evaluates `__dirname`, which Vitest's ESM transform does not inject for a `.mts` module imported from a spec. **Recommend the disk-read + comment-strip pattern for the vitest side** and do not burn time on the import | Trying it once; the failure is immediate and loud | +| Q8 | Does `nx run-many -t lint` print `Cache: n/m hit (p%)` in the same form as `test` / `typecheck`? | **ASSUMED yes.** The line is emitted by `run-many`'s lifecycle, not per target; gok measured it for `test` and `typecheck` on this repo | The first G5 run. If the line is absent, fall back to `nx run :lint --verbose` and record the `[local cache]` / execution evidence instead | +| Q9 | Does `eslint@10` still expose `loadESLint` (which `@nx/eslint`'s `resolveESLintClass` calls)? | **NOT CHECKED**, carried from STACK 7 | Irrelevant at 9.39.5 (D-03). Check before the deferred v10 bump | +| Q10 | Will regenerating `package-lock.json` in the D-05 container bump a transitive runtime dep and drift `start-cache-server/index.js`? | **POSSIBLE, not predicted.** `@actions/cache` and `@actions/core` are exact-pinned but their own deps carry ranges | `npm run check:action` immediately after `npm ci` on the regenerated lockfile. If it drifts: `npm run build:action` and stage the bundle IN THE SAME COMMIT (SC9) | + +--- + +## Sources + +**Read from source this session (highest confidence):** + +- `eslint@9.39.5` -- tarball streamed from `registry.npmjs.org/eslint/-/eslint-9.39.5.tgz`: + `lib/config/config-loader.js` (`FLAT_CONFIG_FILENAMES` :43, `locateConfigFileToUse` :527, + `calculateConfigArray` :560, `LegacyConfigLoader` :690-800), `lib/config/default-config.js`, + `lib/eslint/eslint.js` (`lintText` :1081-1176), `lib/eslint/eslint-helpers.js` + (`globSearch` :259, `findFiles` :516, `warnIgnored` default :822), + `lib/rules/no-restricted-imports.js` (`checkRestrictedPathAndReport` :392, `checkNode` :769, + the visitor object :815). +- `@eslint/js@9.39.5` -- `src/configs/eslint-recommended.js` (61 enabled rules, counted). +- `@typescript-eslint/eslint-plugin@8.65.0` -- `dist/configs/flat/{base,recommended,eslint-recommended}.js`, + `dist/configs/eslint-recommended-raw.js`. +- `typescript-eslint@8.65.0` -- `package.json` (the four `@typescript-eslint/*` deps at 8.65.0). +- `nrwl/nx` @ tag `23.1.0` (local clone at `D:\projects\github\nrwl\nx`, read via `git show 23.1.0:`) + -- `packages/eslint/src/plugins/plugin.ts`: `createNodes`, `internalCreateNodesV2`, + `getProjectUsingESLintConfig`, `buildEslintTargets`, `normalizeOptions`, `DEFAULT_EXTENSIONS`. + +**Measured on this repo this session:** + +- `esquery@1.7.0` + `@babel/parser` (both already in `node_modules`) -- 22 source shapes parsed and + matched against 7 candidate selectors; every verdict in G2 is a measured match count. +- `git grep` / `git ls-files` / `find` -- the file inventory (73 tracked, 64 linted), the extension + breakdown, the four violation sites read verbatim, the G4 rule-by-rule baseline greps, the + `dist/` + `out-tsc/` counts. +- `rg -uu -a` over `node_modules/@fallow-cli/win32-arm64-msvc/fallow.exe` -- the built-in + config-filename table containing `eslint.config.{js,cjs,mjs,ts,mts,cts}`. +- `registry.npmjs.org` -- `eslint@9.39.5` metadata (engines, deps, dist), `dist-tags.latest` = + `10.8.0`, and live tarballs for the three packages read above. + +**Repository artifacts read:** + +`nx.json`, `packages/github-cache/project.json`, both vitest configs, +`packages/github-cache/{tsconfig.json,pack-check.cjs}`, `tsconfig.base.json`, root `package.json`, +`.fallowrc.jsonc`, `.prettierignore`, `.gitignore`, `.github/workflows/ci.yml` (job list + the +`format-check`/`fallow` job shapes), `src/{pinned-deps,nx-target-inputs}.spec.ts`, the three +violation-site spec files, `esbuild.action.mjs`, `start-cache-server/entry.ts`, +`.planning/config.json`. + +**Planning artifacts read:** `07-CONTEXT.md`, `ROADMAP.md`, `REQUIREMENTS.md` (85-188), +`research/v0.0.2/STACK.md` (0-1.7, 4, 5, 7), `research/v0.0.2/PITFALLS.md` (section E), +`quick/260726-gok-.../260726-gok-SUMMARY.md`. + +**Not re-verified this session (carried from STACK.md's 2026-07-26 registry pass):** +`@eslint-community/eslint-plugin-eslint-comments@4.7.2` and `@nx/eslint@23.1.0` package metadata. + +--- + +## Metadata + +**Confidence breakdown:** + +| Area | Level | Reason | +|---|---|---| +| G1 config resolution + `lintText` semantics | **HIGH** | read from the exact 9.39.5 sources | +| G2 selector set | **HIGH** | every verdict is a measured esquery match count; the `no-restricted-imports` half is read from the rule source | +| G3 lint scope + the `dist`/`out-tsc` finding | **HIGH** | mechanism read from `eslint-helpers.js`, file counts measured on disk | +| G4 baseline count | **MEDIUM** | analytic. Rule universe is HIGH (read from source); the per-rule counts are grep-derived proxies | +| G5 differential sequence | **HIGH** for the mechanism and the negative controls; **MEDIUM** for the exact `Cache:` wording under `lint` (Q8) | +| G6 fallow | **HIGH** for `@nx/eslint`; **MEDIUM** for the config-file auto-credit (Q2) | +| G7 sequencing | **HIGH** -- every coupling names a verified mechanism; SC9's non-triggering is verified, not assumed | +| Validation architecture | **HIGH** -- builds on the repo's own gok precedent and the measured vacuity traps | + +**Research date:** 2026-07-27 +**Valid until:** 30 days for the ESLint/tseslint source facts (pinned versions, so effectively +indefinite); 7 days for the registry `latest` observations; the `dist`/`out-tsc` file counts are a +snapshot and will change with any build. + +--- +*Phase 7 research. Deliberately narrow: it answers the seven gaps `07-CONTEXT.md` and +`research/v0.0.2/{STACK,PITFALLS,SUMMARY}.md` leave open, and corrects them where measurement +disagreed with them. It does not restate what they already establish.* + diff --git a/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-VALIDATION.md b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-VALIDATION.md new file mode 100644 index 00000000..f9375760 --- /dev/null +++ b/.planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-VALIDATION.md @@ -0,0 +1,143 @@ +--- +phase: 7 +slug: lint-toolchain-and-the-ambient-platform-read-ban +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-07-27 +--- + +# Phase 7 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. +> Derived from `07-RESEARCH.md` `## Validation Architecture`. The per-task map is +> filled in after plans exist; `/gsd:validate-phase` closes it post-execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Vitest `~4.1.0` (`@nx/vitest@23.1.0` infers the `test` target) | +| **Config file** | `packages/github-cache/vitest.config.mts` (unit); `vitest.integration.config.mts` (integration) | +| **Quick run command** | `npx vitest run ` from `packages/github-cache`, or `npm run test` from the root | +| **Full suite command** | `npm run test` from the root; full gate is the NINE-command battery below | +| **Estimated runtime** | ~5 s warm for `npm run test`; battery ~2-4 min cold | +| **Baseline today** | 32 spec files, 438 tests (quick 260726-gok, 2026-07-26) | + +**The battery is EIGHT commands today and NINE from the commit that adds `lint`:** +`format:check`, `build`, `typecheck`, `typecheck:action`, `test`, **`lint`**, `fallow:ci`, +`check:action`, `pack:check`. + +--- + +## Sampling Rate + +- **After every task commit:** `npm run test` (whole unit suite, seconds), plus `npm run lint` + from the commit that introduces the target onward. +- **Before every commit:** the full battery. The repo standard set by quick 260726-4cc and + 260726-gok is "green at EVERY commit, not just the last" — this phase inherits it at nine + commands. +- **Before `/gsd:verify-work`:** full battery green AND the two G5 differential measurements + recorded AND the M1-M9 mutation results recorded. +- **Max feedback latency:** ~5 s (unit suite) / ~4 min (battery). + +--- + +## Per-Task Verification Map + +Task IDs are assigned when plans are written. Requirement-to-proof mapping is fixed now; the +planner binds each row to a task. + +| Requirement | Behavior that must be proven | Threat Ref | Test Type | Automated Command | File | Status | +|-------------|------------------------------|-----------|-----------|-------------------|------|--------| +| LINT-01 | five devDeps exact-pinned AND name-guarded | V14 dep-drift | unit | `npx vitest run src/pinned-deps.spec.ts` | EXTEND `src/pinned-deps.spec.ts` (D-04) | pending | +| LINT-01 | a `lint` target exists, is cacheable, is in the battery | — | command | `npm run lint` exits 0 | none — the battery command IS the assertion (D-34) | pending | +| LINT-02 | unit spec reading ambient platform state FAILS; identical code at an integration path PASSES | — | unit | `npx vitest run src/.spec.ts` | **NEW** (D-20) | pending | +| LINT-02 | ESLint globs and the vitest partition agree (superset, not equality) | — | unit | same file or sibling (D-19) | **NEW** (D-19) | pending | +| LINT-03 | every D-21 evasion shape has an explicit asserted verdict | — | unit | same file | **NEW** (D-20/D-21) | pending | +| LINT-03 | each of the FOUR extant CORR-05 sites is CAUGHT while it exists | — | unit | same file | **NEW** (D-22) | pending | +| LINT-04 (a) | the declared `lint` inputs are correct | — | unit | `npx vitest run src/nx-target-inputs.spec.ts` | EXTEND `src/nx-target-inputs.spec.ts` (D-26) | pending | +| LINT-04 (b) | `lint` cannot serve a stale-cache false PASS | — | **manual differential** | the G5 command sequence | recorded in phase evidence — D-27/SC4 require differential, so (a) alone does NOT close it | pending | +| LINT-04 | `test` re-runs when a rule changes (D-25) | — | unit + manual | `nx-target-inputs.spec.ts` + G5 Measurement C | EXTEND `src/nx-target-inputs.spec.ts` | pending | +| LINT-05 | a bare disable and a bare `@ts-expect-error` are both errors | V14 repudiation | unit | RED-proof spec via `lintText` | **NEW**, folded into D-20 spec | pending | +| LINT-06 | a stale disable FAILS | V14 repudiation | unit | RED-proof spec: described disable over a NON-violating line | **NEW**, folded into D-20 spec | pending | +| CORR-06 | the ban is mechanical, and integration keeps every OTHER rule | — | unit | D-20 direction pair + one assertion that a DIFFERENT rule still fires at the integration path | **NEW** | pending | + +*Status: pending / green / red / flaky* + +--- + +## Non-Vacuous Assertions + +This phase has five distinct vacuity traps. Each assertion below is only admissible with its +control attached. + +| Assertion | Vacuity trap | Control that closes it | +|-----------|--------------|------------------------| +| "the rule errors at a unit-spec path" | ESLint returns `[]` for an `ignored` or `unconfigured` path — identical to "no violation" | assert `result.messages` contains no `severity:1, ruleId:null` ignore-warning; set `warnIgnored: true` explicitly | +| "the rule does NOT error at an integration path" | passes trivially if the config never loaded, the path is misspelled, or the ban rules were never added | pair with an assertion that a DIFFERENT rule (e.g. `@typescript-eslint/no-explicit-any` on `const x: any = 1;`) DOES fire at that same path — proves the file is linted and only the ban is exempt (D-17) | +| `nx-target-inputs.spec.ts` `lint` probes | `filterUsingGlobPatterns` returns the WHOLE input list when the pattern list is empty, so every `toContain()` passes together on a resolver that resolved nothing (recorded in the spec's own comment) | reuse the existing NEGATIVE-control shape: assert `lint` does NOT hash something it genuinely must not. If no clean negative exists for `lint`, say so in the comment rather than shipping a positive-only set | +| "the four CORR-05 sites are caught" | rots the instant the disables shift line numbers — which happens in the SAME commit | key the site table on FILE + EXPRESSION TEXT, locate position by searching file content, and blank out (do not delete) the stripped disable line so numbering is preserved | +| "the disables are described" | a disable with `--` and an empty reason still parses | assert the reason text is non-empty AND contains `integration` (LINT-06 requires it to state why the assertion cannot move there) | + +--- + +## Mutation Testing (D-23 — repo standard since quick 260726-gok) + +A guard that cannot fail is worthless. Each mutation must be applied, observed, and **REVERTED +before the commit**. Mutation runs are never committed. Record observed failure counts. + +| # | Mutation | Expected result | +|---|----------|-----------------| +| M1 | delete the P1 selector from `no-restricted-syntax` | the three `process.platform` site assertions + the `process.platform` evasion assertion go RED; import-shape assertions stay GREEN | +| M2 | delete the `node:os` entry from `no-restricted-imports` `paths` | site-1 + named-import/namespace-import evasion assertions go RED; `process.*` assertions stay GREEN | +| M3 | delete the P6 `ImportExpression` selector | ONLY `await import('node:os')` / `await import('node:path')` go RED. If nothing goes red, P6 is untested and D-21's dynamic-import shape is a silent gap | +| M4 | change `ignores` to `['**/*.integration.spec.ts']` (drop `mts,cts`) | the D-19 drift guard goes RED. If it stays green it is asserting equality of the wrong pair, or restating the globs instead of reading them | +| M5 | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.test.inputs` | the `nx-target-inputs.spec.ts` assertion goes RED (the D-25 guard's own mutation test) | +| M6 | remove `{workspaceRoot}/eslint.config.mjs` from `targetDefaults.lint.inputs` | the `lint` probe assertion goes RED, AND G5 negative control 1 reproduces the stale-cache HIT | +| M7 | remove the global `ignores` block from `eslint.config.mjs` | G5 negative control 2 reports two different `linted:` counts across `rm -rf dist out-tsc`. The only control for the G3 finding | +| M8 | replace one described disable with a bare `// eslint-disable-next-line no-restricted-syntax` | `require-description` errors — proves LINT-05 is live, not merely configured | +| M9 | move a described disable one line off its violation | `reportUnusedDisableDirectives` errors AND the underlying rule errors — proves LINT-06 is live | + +--- + +## Wave 0 Requirements + +- [ ] the D-20/D-22 RED-proof spec (NEW file) — covers LINT-02, LINT-03, LINT-05, LINT-06, CORR-06 +- [ ] the D-19 drift-guard assertions (NEW file, or folded into the above — D-19 discretion) +- [ ] `src/pinned-deps.spec.ts` — five new `it()` blocks (LINT-01) +- [ ] `src/nx-target-inputs.spec.ts` — `lint` probes + the `test.inputs` ESLint assertion (LINT-04) + +**No framework install needed.** Vitest, its config, and the `import.meta.url` disk-read idiom all +already exist. The only NEW capability is instantiating the ESLint Node API from a spec, which +needs the `eslint` devDependency and nothing else. + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| `lint` re-runs instead of replaying a cached PASS after a RULE edit | LINT-04 | SC4 requires proof "by differential rather than by reading the config". A spec asserting the declared inputs is necessary but explicitly not sufficient | G5 Measurement A: run `npm run lint` twice (second is a cache HIT), edit a rule in `eslint.config.mjs`, run again — must re-run. Record the `Cache: n/m hit` line on both sides | +| `lint` re-runs after a linted SOURCE file edit | LINT-04 | same | G5 Measurement B, same shape | +| `test` re-runs after a rule edit (D-25) | LINT-04 | same defect class one level up; this is the second-order hole | G5 Measurement C | +| M1-M9 mutation results | LINT-03, LINT-04, LINT-05, LINT-06 | mutations must be reverted, so they cannot live in a committed test | apply, observe, record, revert | +| the five packages carry no `postinstall` | LINT-01 (V14) | pre-install supply-chain check | `npm view scripts.postinstall` for all five BEFORE `npm i`. Non-empty on any is a STOP condition requiring human verification | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or a Wave 0 dependency +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags (`vitest run`, never bare `vitest`) +- [ ] Feedback latency < 300 s (battery) +- [ ] Every non-vacuous control from the table above is present in the shipped assertions +- [ ] M1-M9 applied, observed, recorded, and reverted +- [ ] G5 Measurements A, B, C recorded with their `Cache: n/m hit` lines +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending From d1978a16db80af4b752ca02ebb98204fcc8a1539 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Mon, 27 Jul 2026 01:00:53 +0200 Subject: [PATCH 020/511] docs(07): add pattern map --- .../07-PATTERNS.md | 772 ++++++++++++++++++ 1 file changed, 772 insertions(+) create mode 100644 .planning/phases/07-lint-toolchain-and-the-ambient-platform-read-ban/07-PATTERNS.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