feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791) #802
Reference in New Issue
Block a user
Delete Branch "feat/791-upgrade-config-protection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
#791 PR1 — Manifest-owned upgrade guard (root-cause fix)
Closes the root cause of #791. Inverts the framework updater from a denylist
("framework owns everything unless preserved") to an explicit allow-list manifest
("operator owns everything unless framework"). A path the manifest never anticipated
resolves to operator-owned by the fail-safe default, so it is structurally
unreachable by any write or prune.
This is PR1 of the ratified 3-PR split (b→a→d). PR2 (snapshot/restore, secrev) and
PR3 (
fleet regen+ docs) depend on this landing.Root cause
mosaic updatere-seeds viainstall.shkeep-mode, whosersync -a --delete+hand-maintained
PRESERVE_PATHSdenylist wiped operator paths the denylist forgot:agents/*.conf,policy/*.md,*.local.md, harvester SOP,tools/_lib/credentials.json, and any unanticipated fleet file.Change
framework-manifest.txt— single SSOT ([framework]/[operator], deny-wins,UNKNOWN ⇒ operatorfail-safe), consumed by both installers.src/framework/manifest.ts— pure resolver (parse / matchGlob / resolveOwnership /frameworkSubtreeRoots / planPrune) — the testable seam.
tools/_lib/manifest.sh— bash resolver (globs compiled once → fork-freemanifest_is_framework), sourced byinstall.sh; parity-tested vs the TS resolver.install.shkeep mode is now manifest-driven (no--delete): overlay-copyframework files, scoped-prune only retired framework files inside shipped subtrees.
Operator + unknown paths are never written or deleted. Overwrite mode unchanged.
file-ops.syncDirectorygains anisOperatorOwnedguard;file-adapterderivesit from the shared manifest, deleting the drifted hardcoded
preservePaths.Note on the manifest format
The design doc referenced
framework-manifest.json; this shipsframework-manifest.txt(line-oriented) to keep the bash installer free of a
python3/jqdependency. The"ONE shared file, parity-tested" requirement is honored regardless of extension — see
manifest-parity.spec.ts.HARD GATE (§2.4) — merge-blocking
test-upgrade-manifest-guard.sh: 10 operator sentinels — including an unanticipatedunknown-operator-dir/x,harvester/sop.md,fleet/my-fleet.yaml— survive a keep-modereseed byte-identical + mtime-unchanged; a retired framework file is pruned; the operator
secret value is absent from installer output. Runs both rsync + reduced-PATH environments.
Red → green:
31 failedagainst the old installer →48 passedafter the fix.Wired into CI as the merge-blocking
upgrade-guardstep.Tests
test-upgrade-manifest-guard.sh(HARD GATE)test-install-migration.sh(F6 flipped: unanticipated operator file MUST survive)manifest-parity.spec.ts(§6.1, bash↔TS over 34 paths + subtree roots)manifest.spec.ts(incl. planPrune property test + §6.2 shipped-tree completeness)file-adapter.test.ts(real manifest fixture)Gates
pnpm typecheck✓ ·pnpm lint✓ ·pnpm format:check✓ · no--no-verify.🤖 Generated with Claude Code
Part of #791
Invert the framework updater from a denylist ("framework owns everything unless preserved") to an explicit allow-list manifest ("operator owns everything unless framework"). A path the manifest never anticipated resolves to operator-owned by the fail-safe default, so it is structurally unreachable by any write or prune. Root cause (#791): `mosaic update` re-seeds via `install.sh` keep-mode, whose `rsync -a --delete` + hand-maintained PRESERVE_PATHS denylist wiped operator paths the denylist forgot (agents/*.conf, policy/*.md, *.local.md, harvester SOP, tools/_lib/credentials.json, unanticipated fleet files). - framework-manifest.txt: single SSOT ([framework]/[operator], deny-wins, UNKNOWN=>operator fail-safe), read by BOTH installers. - src/framework/manifest.ts: pure resolver (parse/matchGlob/resolveOwnership/ frameworkSubtreeRoots/planPrune) — the testable seam. - tools/_lib/manifest.sh: bash resolver (compiled globs, fork-free hot path), sourced by install.sh; parity-tested against the TS resolver. - install.sh keep mode is now manifest-driven (no --delete): overlay-copy framework files, scoped-prune only retired framework files inside shipped subtrees. Operator + unknown paths are never written or deleted. - file-ops.syncDirectory gains an isOperatorOwned guard; file-adapter derives it from the shared manifest, replacing the drifted hardcoded preservePaths. Tests (TDD, red->green): - HARD GATE test-upgrade-manifest-guard.sh: 10 operator sentinels (incl. an unanticipated one) survive a keep-mode reseed byte-identical + mtime-unchanged; retired framework file pruned; secret value absent from output. RED 31 fail on the old installer -> GREEN 48 pass. Wired merge-blocking into CI. - manifest-parity.spec.ts (§6.1): bash<->TS agree on 34 paths + subtree roots. - manifest.spec.ts: 18 tests incl. planPrune property test + shipped-tree completeness (§6.2). - test-install-migration.sh F6 flipped: an unanticipated operator fleet file now MUST survive keep-mode reseed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Second- and third-round independent-review reliability fixes on the keep-mode upgrade rollback path, plus accurate abort messaging. All fixed red-first with self-verifying controls in the rollback gate. Round 2 (blockers A/B, should-fix C): - install.sh: `trap 'restore_snapshot; exit 1' ERR INT TERM` so an INT/TERM mid-sync terminates instead of resuming past the interrupt and reporting success (a bash signal handler that only returns does not terminate). - manifest.{ts,sh}: reject a degenerate [framework] section whose entries are all empty or bare-dot (`/`, `./`, `.`, `..`) — it passed the non-empty guard yet yielded zero usable globs, silently resolving everything to operator. Parity via a shared `[^/.]` usable-glob test; TS throws ManifestError. - finalize.ts: classify the sync-abort message — a ManifestError is a pre-sync validation abort ("no files were changed"); any other error may be partial. Round 3 (blockers D1, D2): - install.sh: enumerate framework files with a checked temp file (_scan_or_die) instead of `< <(find …)` — process substitution discards find's exit status, so an EACCES/I/O failure mid-scan would truncate the file list yet leave the loop exiting 0, committing a partial upgrade as success (ERR trap never fires). - install.sh: guard the `rm -rf; mkdir -p` target reset inside restore_snapshot — a bare reset failing under set -e exits silently after partial deletion, never printing the snapshot-recovery pointer. Now checked like the cp -a restore: on failure it preserves the snapshot and tells the operator where. Tests: rollback gate 14→28 (Parts C/D/E with disabled-guard controls); new finalize-sync-abort.spec.ts (3). No secret value is ever emitted; snapshots stay 0700. Gates green: typecheck, lint, format:check, full mosaic vitest 1094, HARD GATE 193, rollback 28, migration 21. Refs #791 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Independent Reviewer-of-Record — exact-head verdict: APPROVE
PR #802 (issue #791) — manifest-owned upgrade config protection
af627e758399bdbe5b07ad50ee73f32440fe36dfc1a198df062a5aabc0f45f39a0de3707023d89d3· parent0a5e703a7012f27b18da35513ecceff5bcfa920e#1877@af627e75= success (all 8 steps incl the newupgrade-guardgate)Verdict per concern (all FIXED / verified)
trap 'restore_snapshot; exit 1' ERR INT TERM—; exit 1is sequenced so it always terminates non-zero;restore_snapshotdisarms the trap first (no re-entrancy).set -Eeuo pipefailpropagates ERR into the per-file loop andsync_framework_keep(). No resume-to-success path._scan_or_diewritesfind -print0to a temp and checks status,return 1on failure → ERR trap → restore, for BOTH copy and prune scans. No< <(find …)process-substitution status loss.rmfailure, preserves the snapshot, and surfaces the recovery pointer (path only). Every emitting path prints directory paths only — never file contents. Snapshot ismktemp -d0700. No secret value reaches stdout/stderr on any path.[^/.]usable-glob test in bashmanifest_loadand TSisUsableFrameworkGlob; hand-traced norm/regex parity — degenerate set (/,./,.,..) rejected; legit globs (**,tools/**,.hidden) accepted. No false-accept/reject.ManifestErroris provably pre-write (loadManifest before first write); finalize maps it to "no files were changed", every other error to "may be partially applied". Cannot mislabel a partial apply.-Estrip,; exit 1strip, D1-guard neuter, D2-message delete, carve-out RED/GREEN). HARD GATE proves operator sentinels survive byte-identical + mtime/inode-stable.upgrade-guardstep runs manifest-guard + rollback + migration suites with nofailure: ignore;typecheckdepends_on it. No--no-verify, no required check made non-blocking.SUPER-SECRET-*is a test sentinel asserted absent from output; no committed snapshots/secrets/session.lock.Execution evidence (lead first-hand, at exact head
af627e75)The static reviewer had no shell tool; MS Team Lead executed the suites at the exact head to close that caveat:
test-upgrade-rollback.sh→ 28 passed, 0 failed (exit 0)test-upgrade-manifest-guard.sh(HARD GATE) → 193 passed, 0 failed (exit 0)#1877'supgrade-guardstep.Disposition: APPROVE at exact head
af627e758399bdbe5b07ad50ee73f32440fe36df. Merge to proceed via queue guard + squash (MS Team Lead), per Mosaic gates.