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>
17 KiB
Design — #791: Framework upgrades must not destroy operator-owned config under ~/.config/mosaic
- Issue: Framework upgrades must not destroy operator-owned config under ~/.config/mosaic (mosaicstack/stack#791)
- Branch:
feat/791-upgrade-config-protection(offorigin/main9745bc3f) - Author: ms-791 worker lane
- Status: Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation
- Ratified scope (Mos-approved, not re-litigated): deliver (b) strict ownership separation [PRIMARY] + (a) transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic backup timer is DEFERRED — noted as future work only.
1. Current updater behavior + exact wipe mechanism (evidence)
1.1 What runs on mosaic update
mosaic update re-seeds the framework by invoking the bash installer in sync-only, keep mode:
packages/mosaic/src/runtime/update-checker.ts:509buildReseedCommand()returnsbash <frameworkRoot>/install.shwith envMOSAIC_SYNC_ONLY=1,MOSAIC_INSTALL_MODE=keep,MOSAIC_HOME=<mosaicHome>.- The same
install.shis the direct/tools/install.shupgrade path and the framework-vN migration path.
So the destructive surface is packages/mosaic/framework/install.sh.
1.2 The wipe
sync_framework() (install.sh:177) performs, in keep mode:
rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \
[--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/
install.sh:199—rsync -a --delete.--deleteprunes every path in~/.config/mosaicthat is NOT present in the shipped framework source, unless excluded.install.sh:47—PRESERVE_PATHSis the only thing standing between--deleteand operator data. It is a denylist of exclusions:PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")- The cp-fallback (no rsync) is equally destructive:
install.sh:223find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +then re-copies source, restoring only PRESERVE_PATHS globs.
Root-cause model: "Everything under ~/.config/mosaic is framework-owned and pruneable UNLESS
explicitly preserved." Any operator path the list forgets is destroyed on the next upgrade.
1.3 The exact operator paths wiped
Cross-referencing the issue's operator-owned list against PRESERVE_PATHS:
| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on mosaic update |
|---|---|---|
agents/*.conf (per-agent runtime) |
NO | WIPED |
policy/*.md (operator overlays) |
NO | WIPED |
*.local.md (SOUL/USER/STANDARDS) |
NO | WIPED |
| harvester / SOP artifacts + timers | NO | WIPED |
tools/_lib/credentials.json |
NO (credentials/ dir ≠ this path) |
WIPED |
fleet/agents/*.env |
yes (fleet/agents, added by #631) |
survives |
memory/, fleet/roster.*, fleet/backlog, fleet/roles.local |
yes | survives |
The fleet/agents, memory, fleet/backlog entries were retro-added after prior incidents
(#631). This whack-a-mole is the structural signature of a denylist.
Stale-comment evidence: update-checker.ts:492 claims the reseed preserves
"SOUL/USER/*.local/credentials" — but PRESERVE_PATHS contains no *.local entry. The code
documents protection it does not deliver.
1.4 Second code path (TS) — already non-destructive, but drifted
FileConfigAdapter.syncFramework() (packages/mosaic/src/config/file-adapter.ts:157) →
syncDirectory() (packages/mosaic/src/platform/file-ops.ts:66) is a copy-overlay: it copies
source over target and skips preserved paths, but never deletes target paths absent from source
(file-ops.ts:77-109). It is used by the wizard/init flow, not mosaic update.
Two problems remain:
- Its
preservePaths(file-adapter.ts:164-185) has already diverged frominstall.sh— it is missingfleet/backlogandfleet/roles.local. Two hand-maintained denylists, drifted. This is direct evidence for a single shared SSOT manifest. - Even non-destructive, it will happily overwrite an operator file that collides with a framework-shipped path unless that path is on its (incomplete) preserve list.
1.5 Existing snapshot is inadequate for rollback
make_snapshot()/restore_snapshot() (install.sh:76-87) copy TARGET_DIR to mktemp -d under
/tmp, restore only on ERR/INT/TERM trap, and are deleted on success (cleanup_snapshot,
install.sh:345). Consequences: ephemeral /tmp, no retention, no post-success rollback, and no
mosaic restore. It is crash-safety only, not the transactional safety net #791 requires.
2. Fix (b) — Strict ownership separation [PRIMARY / root cause]
2.1 Ownership model (invert to allow-list)
Replace "framework-owned unless preserved" with "operator-owned unless framework-owned", resolved per target path with operator carve-outs winning inside shared framework subtrees.
Two declared lists, one SSOT data file shipped in the framework
(framework/framework-manifest.json), consumed by both bash and TS:
frameworkglobs — paths the updater is entitled to create / overwrite / prune. Authored to match exactly what the framework ships inpackages/mosaic/framework/(e.g.CONSTITUTION.md,AGENTS.md,STANDARDS.md,TOOLS.md,guides/**,constitution/**,templates/**,tools/**,skills/**,mcp/**,defaults/**,fleet/examples/**,fleet/roles/**,fleet/profiles/**,fleet/roster.schema.json).operatorReservedglobs — NEVER written or pruned, even nested inside aframeworksubtree; these win overframework(deny-wins / most-specific-wins). At minimum:agents/**,policy/**,memory/**,sources/**,credentials/**,*.local.md,tools/_lib/credentials.json,fleet/roster.yaml,fleet/roster.json,fleet/agents/**,fleet/run/**,fleet/backlog/**,fleet/roles.local/**, plus operator harvester/SOP artifacts.
2.2 Ownership resolution for a target path P
PmatchesoperatorReserved→ operator-owned: updater MUST NOT write, MUST NOT delete.- else
Pmatchesframework→ framework-owned: may overwrite; may prune only if absent from the current SOURCE (a genuinely retired framework file). - else (matches neither) → UNKNOWN ⇒ operator-owned by default (fail-safe): never delete.
Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected, because unknown defaults to operator. A denylist can never provide this guarantee.
2.3 Sync mechanism change (the mechanically-critical part)
--delete cannot express "prune only framework-owned" without re-enumerating every operator path
(the denylist trap). So:
- Drop
--deletefrom the bulk sync. CopySOURCE → TARGETnon-destructively (writes/overwrites all framework files; deletes nothing). rsync without--delete, or the existing overlay copy. - Explicit manifest-scoped prune pass. Iterate the
frameworkmanifest (not the whole tree); for each framework path present inTARGETbut absent inSOURCE, delete it — after re-checking it does not matchoperatorReserved. Because the prune iterates only declared framework globs, operator/unknown paths are structurally unreachable by deletion.
This is implemented in both bash sync_framework() and TS syncFramework() from the shared manifest.
A pure prune-planner function (TS) computes the delete-set from
(manifest, sourceListing, targetListing) so the invariant is unit-testable in isolation.
PRESERVE_PATHS becomes redundant (kept as a defense-in-depth alias mapping to operatorReserved, or
removed) — either way the two lists stop drifting because they read one file.
2.4 HARD GATE test — "upgrade touches no path outside the manifest"
Filesystem-observation test in the existing test-install-migration.sh harness pattern (mktemp
MOSAIC_HOME, MOSAIC_SYNC_ONLY=1), plus TS specs:
- Seed a throwaway
TARGETwith a realistic operator mix — one sentinel per operator class:agents/x.conf,policy/p.md,SOUL.local.md,memory/m.md,tools/_lib/credentials.json(with a secret value),fleet/agents/a.env,fleet/roster.yaml,harvester/sop.md, and a deliberately-unanticipatedunknown-operator-dir/x. - Record hash+mtime of every sentinel.
- Run the upgrade from a
SOURCEcontaining none of those operator paths. - Assert: every sentinel exists, byte-identical, mtime unchanged (not even rewritten). The
unknown-operator-dirsurviving proves the fail-safe default — a denylist could not pass this case. - Positive controls: framework files WERE updated; a retired framework file WAS pruned.
- Property test (TS prune-planner): for fuzzed operator paths,
deleteSet ⊆ {matches framework ∧ in target ∧ not in source}anddeleteSet ∩ operatorReserved = ∅.
3. Fix (a) — Transactional pre-update snapshot [safety net]
- Destination:
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/. Outside~/.config/mosaic(so no future sync can sweep it) and outside any repo. - Perms: dir
0700, files0600— enforced withumask 077around the copy and explicitchmod. Never world-readable. - Scope: the operator-owned surface (
operatorReservedpaths that exist) — bounded; does not copy the framework tree. - Timing: taken before ANY mutation in the upgrade flow.
- Post-sync verify + selective restore: after sync, diff the operator surface against the snapshot; since (b) should never touch operator paths, any diff means a manifest bug — restore the affected paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b).
- Retention: keep N most-recent (default 5;
MOSAIC_BACKUP_RETENTIONoverride); prune older. mosaic restore:--list(default, dry-run) enumerates snapshots by timestamp;--from <ts>restores that snapshot over the operator surface, confirmation-gated. Reports counts/paths only.- Secret-safety: snapshot copy and restore never emit file contents; only paths/counts.
Tests assert
0700/0600and that no secret value appears in stdout/stderr.
4. Fix (d) — Regeneration-from-SSOT [recovery]
The incident's live blast radius: fleet/agents/*.env (systemd EnvironmentFile sources) gone →
mosaic-agent@<name> boots unit defaults on restart (because EnvironmentFile=-... is
absent-tolerant) → silent identity/runtime/workdir downgrade.
The SSOT for those .env files is the roster. The reconciler already separates a
regenerate-projections-from-roster projection phase from lifecycle
(packages/mosaic/src/fleet/fleet-reconciler.ts:93,234; env rendering in
generated-env-boundary.ts:149-264).
mosaic fleet regen is therefore a thin recovery-framed wrapper over the existing projection
phase — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5):
- Regenerates derivable config (per-agent
*.env.generated, unit files) from roster SSOT. - Preview-first: dry-run default;
--writeto apply. Idempotent. - Never restarts agents (the recovery order forbids restart-before-verify).
- Prints the runbook's next step (verify
EnvironmentFileresolves, THEN restart).
Alternatively documentable as install.sh --relink per the issue; mosaic fleet regen is preferred
because it reuses the merged reconciler plumbing.
5. Secret-safety approach (secrev surface)
- Snapshots/backups:
0700/0600, outside any repo, never world-readable. (§3) - No secret value ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen —
paths/counts only. Adversarial test: a secret value placed in
tools/_lib/credentials.jsonmust never appear in installer or command output. tools/_lib/credentials.jsonis an explicitoperatorReservedcarve-out inside the framework-ownedtools/**subtree — it is never overwritten or pruned.- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched).
6. Test plan (TDD, tests-first, ≥85% on new code, co-located *.spec.ts)
- Manifest SSOT parity — bash and TS resolve identical framework/operator sets from the one file; a test fails if either path hard-codes a divergent list.
- Manifest completeness — every path shipped in
framework/is covered by aframeworkglob (so a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared). - HARD GATE — upgrade touches nothing outside the manifest, incl. the unanticipated-path case (§2.4).
- Prune-planner unit + property tests (§2.4.6).
- Snapshot — perms
0700/0600, correct destination, retention prune, secret value absent from output. - Restore —
--list/--fromround-trip restores operator surface byte-exact; confirmation gate; no secret leakage. - Regen — roster→env projection deterministic + idempotent; dry-run makes no writes;
--writerestores*.env; never issues a lifecycle/restart call. - Cross-path regression — TS
syncFrameworkand bashinstall.shagree on a shared fixture (closes the current #631-style drift).
Gates before every push: pnpm typecheck && pnpm lint && pnpm format:check + mosaic package tests
green. Never --no-verify.
7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only)
For a currently-wiped fleet EnvironmentFile state — do NOT service-restart while
fleet/agents/*.env is absent (a restart boots unit defaults and silently downgrades identity):
- Regenerate:
mosaic fleet regen --write— rebuild~/.config/mosaic/fleet/agents/*.envfrom roster SSOT. - Verify each unit resolves to the intended runtime/workdir before any restart:
systemctl --user show mosaic-agent@<name> -p EnvironmentFileand confirm the generated env exists and carries the intendedMOSAIC_AGENT_*runtime/workdir values. - Only then
systemctl --user restart mosaic-agent@<name>, one unit at a time.
If config (not just fleet env) was lost, mosaic restore --list → mosaic restore --from <ts> before
step 1.
8. Proposed PR split (reviewable; DAG-ordered)
| PR | Scope | Depends | Review focus |
|---|---|---|---|
| PR1 | PRIMARY — shared framework-manifest.json + ownership resolver + non-deleting sync + scoped prune (bash + TS) + HARD GATE + prune-planner tests |
— | correctness (root fix) |
| PR2 | Safety net — pre-update snapshot (~/.local/state, 0700/0600, retention) + post-sync verify/restore + mosaic restore |
PR1 | secrev (backup/secret) |
| PR3 | Recovery — mosaic fleet regen (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) |
PR1 | correctness + docs |
Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; secrev on PR2 (and PR1's secret-sentinel assertions).
9. Deferred (noted per scope)
(c) periodic backup timer — a systemd user timer snapshotting operator dirs on a cadence (defense-in-depth for non-upgrade losses). Explicitly out of scope now; future phase.
10. Constraints honored
- Framework-PR firewall: manifest + logic are operator-agnostic; no SOUL/USER/operator specifics in framework code; web1 details are issue evidence only.
- Capacity-fill: must not preempt M5-001 or #790;
fleet regenreuses merged FCM-M3 plumbing and does not overlap FCM-M4/M5 migration cards. - Delivery gates: TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent review + secrev, completion = merged PR + descendant-main green + #791 closed.
Requesting MS-LEAD confirmation of: (1) the manifest allow-list + non-deleting-sync + scoped-prune
approach as the (b) root-cause fix; (2) snapshot destination/retention + mosaic restore UX;
(3) mosaic fleet regen as a projection-only wrapper; (4) the 3-PR split. Implementation begins only
on your confirmation.