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>
6.2 KiB
Scratchpad — #791 Upgrade config protection (ms-791 worker lane)
Lane: web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly.
Worktree: /home/hermes/agent-work/stack-agents-dir-791, branch feat/791-upgrade-config-protection
off origin/main 9745bc3f (verified exact head).
Mission prompt (verbatim intent)
Protect operator-owned config under ~/.config/mosaic from framework-upgrade wipes. Ratified
combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a)
transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic
timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest.
Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl.
Session 1 (2026-07-16) — Phase 1 design
Evidence gathered (wipe mechanism, file/line)
mosaic update→update-checker.ts:509buildReseedCommand→bash install.sh(MOSAIC_SYNC_ONLY=1,MOSAIC_INSTALL_MODE=keep).- Wipe =
packages/mosaic/framework/install.sh:199rsync -a --delete+PRESERVE_PATHSdenylist (install.sh:47). cp-fallbackinstall.sh:223find ... -exec rm -rf. - Denylist gaps → WIPED:
agents/*.conf,policy/*.md,*.local.md, harvester/SOP,tools/_lib/credentials.json. - Stale comment
update-checker.ts:492claims*.localpreserved — PRESERVE_PATHS has no such entry. - TS path
file-adapter.ts:157→file-ops.ts:66syncDirectory= non-destructive copy-overlay, BUT its preserve list (file-adapter.ts:164) already DRIFTED from install.sh (missingfleet/backlog,fleet/roles.local). Evidence for single shared manifest SSOT. - Existing snapshot (
install.sh:76) = /tmp, crash-trap only, deleted on success → inadequate; nomosaic restore. fleet-reconciler.ts:93,234already hasregenerate-projections-from-rosterphase separate from lifecycle →mosaic fleet regen= thin projection-only wrapper (no restart), no FCM-M4/M5 preemption.
Design decisions
- (b) Invert to allow-list: shared
framework/framework-manifest.json(framework globs + operatorReserved carve-outs); resolve per-path, deny-wins; UNKNOWN ⇒ operator (fail-safe). Mechanism: drop--delete; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests. - (a) Snapshot to
~/.local/state/mosaic/backups/pre-update-<ts>/0700/0600, retention N=5, post-sync verify+restore,mosaic restore --list/--from. No secret values in output. - (d)
mosaic fleet regenprojection-only, preview-first, never restart. - HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default.
- PR split: PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3 regen+docs. PR2/PR3 depend on PR1.
Status
Design doc written: docs/design/791-upgrade-config-protection.md. Sent to MS-LEAD.
Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO
All 4 asks approved. Binding conditions:
- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located
*.spec.ts; never--no-verify. - HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1.
- Manifest-completeness test (§6.2) required.
- Bash+TS read ONE shared
framework-manifest.json; parity test (§6.1) required (closes #631 drift class). - UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE.
fleet regen: NEVER restart; dry-run default,--writeto apply; "never issues restart" test mandatory.- Independent review every PR; PR2 dedicated secrev.
- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission.
Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests).
Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven
Deviation noted to MS-LEAD in PR: manifest is framework-manifest.txt (line-oriented), NOT .json.
Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity-
tested" requirement is honored — manifest-parity.spec.ts drives the bash resolver as a subprocess
and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class.
PR1 artifacts
- SSOT:
packages/mosaic/framework/framework-manifest.txt([framework]/[operator], deny-wins, fail-safe). - TS resolver:
src/framework/manifest.ts(pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/ planPrune) +manifest.spec.ts(18 tests incl. planPrune property test + §6.2 completeness). - Bash resolver:
framework/tools/_lib/manifest.sh(compiled globs → fork-freemanifest_is_framework; CLIresolve|subtree-roots|classify). Sourced by install.sh. - HARD GATE (§2.4):
framework/tools/quality/scripts/test-upgrade-manifest-guard.sh— keep-mode reseed, 10 operator sentinels (incl. unanticipatedunknown-operator-dir/x,harvester/sop.md,fleet/my-fleet.yaml) survive byte-identical + mtime-unchanged; retired framework file pruned; secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed). - install.sh: keep mode now manifest-driven (
sync_framework_keep, no--delete); overwrite unchanged. PRESERVE_PATHS denylist deleted. - TS sync:
file-ops.syncDirectorygainsisOperatorOwnedguard;file-adapter.syncFrameworkderives it fromloadManifest— hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest. - Parity:
manifest-parity.spec.ts(§6.1) — bash↔TS agree on 34 paths + subtree roots. - Migration matrix
test-install-migration.sh: F6 flipped —my-fleet.yamlnow MUST survive (fail-safe). - CI: new merge-blocking
upgrade-guardstep (.woodpecker/ci.yml) runs both bash suites (adds rsync). - update-checker.ts reseed comment corrected to the manifest model.
Gates (all green)
pnpm typecheck✓ ·pnpm lint✓ ·pnpm format:check✓- Full mosaic vitest: 1062 passed (cli-smoke needs
pnpm buildfirst — build-artifact dep, not this change). - HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8.