From 0a5e703a7012f27b18da35513ecceff5bcfa920e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 16 Jul 2026 16:11:06 -0500 Subject: [PATCH] test(mosaic): gate #797 ledger upgrade-survival + harden manifest parity (#791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the Mos-elevated #797 Runtime-Ledger survival sentinel into #791 PR1 and harden the .txt-format parity test per the accepted-format conditions. - framework-manifest.txt: annotate the existing fleet/run/** operator carve-out to name the #797 ledger (fleet/run/sessions/) so it reads as load-bearing. The glob already matches the #797 spec exactly — no location divergence. - HARD GATE (test-upgrade-manifest-guard.sh): seed a populated ledger (events.ndjson journal + ledger.json projection, 0600 under 0700) as an operator sentinel; assert byte-identical + mtime-unchanged + dir-perms unchanged after a keep-mode upgrade. Relabel the prune check as the explicit negative control. 48 -> 58 checks. - Parity (manifest-parity.spec.ts): add format-edge fixtures driven through BOTH resolvers via MANIFEST_FILE — comments/blanks/whitespace, duplicate and overlapping globs (deny-wins), section/glob-ordering independence, and an explicit UNKNOWN->operator negative probe; add ledger probe paths. - manifest.spec.ts: isolate the carve-out's load-bearing value with a resolver red->green — under a hypothetical fleet/** framework glob, the ledger is pruned WITHOUT the fleet/run/** carve-out and protected WITH it (deny-wins). Gates: typecheck, lint, format:check green; mosaic vitest 1069 passed; HARD GATE 58/58; migration 21/21. Commits forward on 34e55d4a (no rebase). Part of #791 Co-Authored-By: Claude Opus 4.8 --- .../791-upgrade-config-protection.md | 36 +++++ .../mosaic/framework/framework-manifest.txt | 5 + .../scripts/test-upgrade-manifest-guard.sh | 41 ++++- .../src/framework/manifest-parity.spec.ts | 150 +++++++++++++++++- .../mosaic/src/framework/manifest.spec.ts | 40 +++++ 5 files changed, 265 insertions(+), 7 deletions(-) diff --git a/docs/scratchpads/791-upgrade-config-protection.md b/docs/scratchpads/791-upgrade-config-protection.md index 1196d9a..21a7e32 100644 --- a/docs/scratchpads/791-upgrade-config-protection.md +++ b/docs/scratchpads/791-upgrade-config-protection.md @@ -87,3 +87,39 @@ and asserts byte-identical ownership vs the TS resolver over 34 probe paths span - `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check` ✓ - Full mosaic vitest: 1062 passed (cli-smoke needs `pnpm build` first — build-artifact dep, not this change). - HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8. + +### PR opened + reported (2026-07-16) +- **PR #802** http://git.mosaicstack.dev/mosaicstack/stack/pulls/802 — base `main`@`9745bc3f`, + head `34e55d4a` (commit `feat(mosaic): manifest-owned upgrade guard…`). 15 files, +1160/-142. +- Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy). + Standing by for the independent-review commission at head `34e55d4a`. +- **TWO items flagged to MS-LEAD for decision (awaiting reply):** + 1. Deviation `.txt` vs `.json` — confirm accept (parity-tested) or convert to `.json`+jq. + 2. `pr-create -i 791` appended `Fixes #791` → would auto-close the tracking issue on PR1 merge + while PR2/PR3 remain. Recommended edit to `Part of #791`; awaiting go-ahead to patch PR body. +- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time). + +### MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16) +MS-LEAD ruled both my decisions: (1) `.txt` format ACCEPTED (parity must be strict/merge-blocking incl. +format edge cases + negative probe); (2) trailer `Fixes #791`→`Part of #791` APPROVED (patched PR #802 +body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker +(spec `~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md`): #797 Runtime Session Ledger +must survive upgrade. Two coupled deliverables landed in PR1: +- (i) Carve-out: `fleet/run/**` was ALREADY an explicit `[operator]` entry — glob matches the spec's + pinned `fleet/run/**` EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment + to name the ledger (`fleet/run/sessions/` events.ndjson + ledger.json) so it is unmistakably load-bearing. +- (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen, + 0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged + + dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly. + HARD GATE now 58/58 (was 48). +- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping + globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven + through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3). +- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the + ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The + carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to `fleet/**`) is + isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUT `fleet/run/**` operator + entry + hypothetical `fleet/**` framework → ledger resolves framework and planPrune DELETES it (RED); + WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18). +- Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58 + · migration 21/21. Committing FORWARD on the branch (NOT rebasing 34e55d4a out from under review). diff --git a/packages/mosaic/framework/framework-manifest.txt b/packages/mosaic/framework/framework-manifest.txt index 7ffe1a5..8c328cf 100644 --- a/packages/mosaic/framework/framework-manifest.txt +++ b/packages/mosaic/framework/framework-manifest.txt @@ -75,6 +75,11 @@ tools/_lib/credentials.json fleet/roster.yaml fleet/roster.json fleet/agents/** +# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/ +# (events.ndjson journal + ledger.json projection). This carve-out is the +# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would +# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh) +# proves a populated ledger survives byte-identical + mtime-unchanged. fleet/run/** fleet/backlog/** fleet/roles.local/** diff --git a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh index a27c1aa..5f2883d 100644 --- a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh +++ b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh @@ -27,7 +27,8 @@ SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a' seed_home() { local H="$1" mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \ - "$H/fleet/agents" "$H/harvester" "$H/unknown-operator-dir" "$H/guides" + "$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \ + "$H/unknown-operator-dir" "$H/guides" printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode printf 'MODEL=opus\n' > "$H/agents/coder0.conf" printf '# operator policy\n' > "$H/policy/custom.md" @@ -39,6 +40,23 @@ seed_home() { printf '# harvester SOP\n' > "$H/harvester/sop.md" printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x" printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml" + + # #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a + # populated ledger under fleet/run/sessions/ must survive the upgrade — a + # runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as + # #797 writes it: a non-empty append journal + a non-empty compacted + # projection, files 0600 under a 0700 dir. + printf '%s\n%s\n%s\n' \ + '{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \ + '{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \ + '{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \ + > "$H/fleet/run/sessions/events.ndjson" + printf '%s\n' \ + '{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \ + > "$H/fleet/run/sessions/ledger.json" + chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions" + chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json" + # A retired framework file inside a shipped subtree (absent from source) — must be pruned. printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md" echo 3 > "$H/.framework-version" @@ -55,6 +73,9 @@ OPERATOR_SENTINELS=( "harvester/sop.md" "unknown-operator-dir/x" "fleet/my-fleet.yaml" + # #797 Runtime Session Ledger — populated journal + projection must survive. + "fleet/run/sessions/events.ndjson" + "fleet/run/sessions/ledger.json" ) run_matrix() { @@ -69,6 +90,10 @@ run_matrix() { stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt" done + # Snapshot the ledger directory permission bits (#797 assert: perms unchanged). + local before_dirperm after_dirperm + before_dirperm=$(stat -c %a "$H/fleet/run/sessions") + # The upgrade under test (keep + sync-only = the `mosaic update` reseed path). MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1 @@ -84,10 +109,18 @@ run_matrix() { "[ '$before_mt' = '$after_mt' ]" done - # Positive controls: the framework tree still updates, retired file pruned. - chk "[$label] framework file present after upgrade (guides synced)" \ + # #797 assert 7: the ledger directory's permission bits are unchanged. + after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING) + chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \ + "[ '$before_dirperm' = '$after_dirperm' ]" + + # Positive controls / negative controls — prove the test discriminates: the + # upgrade DOES write and prune framework-owned paths, so the operator sentinels + # (incl. the #797 ledger) survive because of the manifest, not because the + # upgrade is a no-op. + chk "[$label] positive control: framework file present after upgrade (guides synced)" \ "[ -f '$H/guides/E2E-DELIVERY.md' ]" - chk "[$label] retired framework file inside a subtree is pruned" \ + chk "[$label] negative control: retired framework file inside a subtree IS pruned" \ "[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]" chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]" diff --git a/packages/mosaic/src/framework/manifest-parity.spec.ts b/packages/mosaic/src/framework/manifest-parity.spec.ts index 89dfd6e..e58a47e 100644 --- a/packages/mosaic/src/framework/manifest-parity.spec.ts +++ b/packages/mosaic/src/framework/manifest-parity.spec.ts @@ -1,9 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { afterAll, describe, it, expect } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadManifest, resolveOwnership, frameworkSubtreeRoots } from './manifest.js'; +import { + loadManifest, + parseManifest, + resolveOwnership, + frameworkSubtreeRoots, +} from './manifest.js'; /** * Bash ↔ TS parity (#791, §6.1). @@ -34,6 +40,14 @@ function bashResolve(relPath: string): string { }).trim(); } +/** Drive the bash resolver against an arbitrary manifest file (MANIFEST_FILE override). */ +function bashResolveWith(manifestFile: string, relPath: string): string { + return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], { + encoding: 'utf-8', + env: { ...process.env, MANIFEST_FILE: manifestFile }, + }).trim(); +} + function bashSubtreeRoots(): string[] { return execFileSync('bash', [MANIFEST_SH, 'subtree-roots'], { encoding: 'utf-8' }) .split('\n') @@ -75,6 +89,9 @@ const PROBE_PATHS = [ 'fleet/roster.json', 'fleet/agents/coder0.env', 'fleet/run/coder0.hb', + // #797 Runtime Session Ledger — must resolve operator on both paths. + 'fleet/run/sessions/events.ndjson', + 'fleet/run/sessions/ledger.json', 'fleet/backlog/data.db', 'fleet/roles.local/custom.md', // unanticipated → operator (fail-safe) @@ -106,3 +123,130 @@ describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => { expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort()); }); }); + +/** + * Format-safety parity (#791, Decision 1 — the `.txt` line-oriented format is + * accepted only because both resolvers agree on the format edge cases a hand- + * edited text file invites: comments, blank lines, stray whitespace, duplicate + * and overlapping globs (where deny-wins must resolve), and section ordering. + * Each fixture is driven through BOTH resolvers (bash via MANIFEST_FILE, TS via + * parseManifest) and must agree AND land on the expected ownership. Any + * divergence here means the format itself is unsafe and must be fixed/converted. + */ +describe.skipIf(!hasBash)('bash ↔ TS manifest format-edge parity (§6.1, Decision 1)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'mf-parity-')); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + let fixtureSeq = 0; + function writeFixture(text: string): string { + const file = join(tmp, `manifest-${fixtureSeq++}.txt`); + writeFileSync(file, text); + return file; + } + + // Both resolvers must agree, and on the expected value, for every probe. + function expectParity(text: string, cases: ReadonlyArray): void { + const file = writeFixture(text); + const manifest = parseManifest(text); + for (const [path, expected] of cases) { + const ts = resolveOwnership(manifest, path); + const bash = bashResolveWith(file, path); + expect(bash, `bash disagrees with TS on ${path}`).toBe(ts); + expect(ts, `ownership of ${path}`).toBe(expected); + } + } + + it('tolerates comments, blank lines, and leading/trailing whitespace identically', () => { + // Entries and headers are padded with spaces/tabs; comments and blanks are + // interleaved. Both resolvers must trim and ignore them the same way. + const text = [ + '# leading comment', + ' ', + '\t[framework] ', + ' tools/** ', + '# mid-section comment', + '', + '\tguides/**\t', + ' [operator] ', + '\ttools/_lib/credentials.json ', + '*.local.md', + '', + ].join('\n'); + expectParity(text, [ + ['tools/git/pr-create.sh', 'framework'], + ['guides/E2E-DELIVERY.md', 'framework'], + ['tools/_lib/credentials.json', 'operator'], // deny-wins carve-out inside tools/** + ['SOUL.local.md', 'operator'], + ['nowhere/unknown.md', 'operator'], // negative probe: matches NO rule → operator + ]); + }); + + it('resolves deny-wins for overlapping and duplicate globs identically', () => { + // Framework claims tools/** (twice) and the overlapping tools/git/**; + // operator carves out tools/_lib/**. Operator must win the overlap on both. + const text = [ + '[framework]', + 'tools/**', + 'tools/**', // duplicate — must not change resolution + 'tools/git/**', // overlaps tools/** + '[operator]', + 'tools/_lib/**', + ].join('\n'); + expectParity(text, [ + ['tools/git/pr-create.sh', 'framework'], + ['tools/other.sh', 'framework'], + ['tools/_lib/credentials.json', 'operator'], // deny-wins over both framework globs + ['tools/_lib/nested/deep.json', 'operator'], + ]); + }); + + it('is independent of section and glob ordering', () => { + // Same rule set, operator section first and entries reordered. Resolution + // must be identical because deny-wins checks all operator globs before any + // framework glob — order within or between sections cannot matter. + const forward = [ + '[framework]', + 'guides/**', + 'tools/**', + '[operator]', + 'tools/_lib/credentials.json', + '*.local.md', + ].join('\n'); + const reversed = [ + '[operator]', + '*.local.md', + 'tools/_lib/credentials.json', + '[framework]', + 'tools/**', + 'guides/**', + ].join('\n'); + const probes: ReadonlyArray = [ + ['tools/git/pr-create.sh', 'framework'], + ['guides/E2E-DELIVERY.md', 'framework'], + ['tools/_lib/credentials.json', 'operator'], + ['SOUL.local.md', 'operator'], + ['unanticipated/path.md', 'operator'], + ]; + expectParity(forward, probes); + expectParity(reversed, probes); + // And the two orderings agree path-for-path on both resolvers. + const fFile = writeFixture(forward); + const rFile = writeFixture(reversed); + for (const [path] of probes) { + expect(bashResolveWith(fFile, path)).toBe(bashResolveWith(rFile, path)); + } + }); + + it('defaults an unmatched path to operator on both resolvers (UNKNOWN → operator)', () => { + // A manifest that names only a narrow framework slice. Everything else — + // including paths under no rule at all — must fail safe to operator. + const text = ['[framework]', 'guides/**', '[operator]', 'agents/**'].join('\n'); + expectParity(text, [ + ['guides/x.md', 'framework'], + ['agents/coder0.conf', 'operator'], + ['totally/unlisted/file.txt', 'operator'], // negative probe + ['fleet/run/sessions/ledger.json', 'operator'], // unlisted → operator + ['README.md', 'operator'], + ]); + }); +}); diff --git a/packages/mosaic/src/framework/manifest.spec.ts b/packages/mosaic/src/framework/manifest.spec.ts index 928fedd..5db13bf 100644 --- a/packages/mosaic/src/framework/manifest.spec.ts +++ b/packages/mosaic/src/framework/manifest.spec.ts @@ -154,6 +154,46 @@ describe('planPrune (pure prune planner)', () => { }); }); +// The #797 Runtime Session Ledger lives at fleet/run/sessions/. Today it is safe +// twice over: it matches the explicit `fleet/run/**` operator carve-out AND, even +// without it, the UNKNOWN→operator fail-safe. This test isolates the CARVE-OUT's +// load-bearing value by simulating a future framework author who broadens fleet +// ownership to `fleet/**`: without the operator carve-out the ledger would resolve +// framework and be pruned; deny-wins is what keeps it protected. If deleting the +// `fleet/run/**` line ever stops turning this test red, the carve-out has silently +// stopped mattering — which is exactly the #797 regression we are gating against. +describe('fleet/run/** carve-out is load-bearing for the #797 ledger (deny-wins)', () => { + const LEDGER = ['fleet/run/sessions/events.ndjson', 'fleet/run/sessions/ledger.json']; + // A framework that (hypothetically) ships all of fleet/** as a subtree. + const withoutCarveOut: FrameworkManifest = { + framework: ['fleet/**'], + operator: [], + }; + const withCarveOut: FrameworkManifest = { + framework: ['fleet/**'], + operator: ['fleet/run/**'], + }; + + it('RED without the carve-out: the ledger resolves framework and is pruned', () => { + for (const p of LEDGER) expect(resolveOwnership(withoutCarveOut, p)).toBe('framework'); + const del = planPrune({ manifest: withoutCarveOut, targetPaths: LEDGER, sourcePaths: [] }); + expect(del.sort()).toEqual([...LEDGER].sort()); + }); + + it('GREEN with the carve-out: deny-wins makes the ledger operator and unprunable', () => { + for (const p of LEDGER) expect(resolveOwnership(withCarveOut, p)).toBe('operator'); + const del = planPrune({ manifest: withCarveOut, targetPaths: LEDGER, sourcePaths: [] }); + expect(del).toEqual([]); + }); + + it('the SHIPPED manifest reserves fleet/run/** so the ledger is operator-owned', () => { + const shipped = loadManifest(FRAMEWORK_ROOT); + for (const p of LEDGER) expect(resolveOwnership(shipped, p)).toBe('operator'); + // And it is structurally unreachable by pruning even if it were in a subtree. + expect(planPrune({ manifest: shipped, targetPaths: LEDGER, sourcePaths: [] })).toEqual([]); + }); +}); + describe('frameworkSubtreeRoots', () => { it('returns only the /** subtree roots, not single-file entries', () => { const m = parseManifest(SAMPLE);