# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic` - **Issue:** mosaicstack/stack#791 - **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`) - **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:509` `buildReseedCommand()` returns `bash /install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`, `MOSAIC_HOME=`. - The same `install.sh` is the direct/`tools/install.sh` upgrade 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`. **`--delete` prunes every path in `~/.config/mosaic` that is NOT present in the shipped framework source**, unless excluded. - `install.sh:47` — `PRESERVE_PATHS` is the **only** thing standing between `--delete` and 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:223` `find "$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: 1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is **missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This is direct evidence for a single shared SSOT manifest. 2. 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: - **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to match exactly what the framework ships in `packages/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`). - **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree; these **win** over `framework` (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` 1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete. 2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from the current SOURCE** (a genuinely retired framework file). 3. 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: 1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy. 2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree); for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after re-checking it does not match `operatorReserved`. 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: 1. Seed a throwaway `TARGET` with 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-unanticipated `unknown-operator-dir/x`**. 2. Record hash+mtime of every sentinel. 3. Run the upgrade from a `SOURCE` containing none of those operator paths. 4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The `unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case. 5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned. 6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧ in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`. --- ## 3. Fix (a) — Transactional pre-update snapshot [safety net] - **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-/`. **Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo. - **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit `chmod`. Never world-readable. - **Scope:** the operator-owned surface (`operatorReserved` paths 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_RETENTION` override); prune older. - **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp; `--from ` 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/0600` and 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@` 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; `--write` to apply. Idempotent. - **Never restarts agents** (the recovery order forbids restart-before-verify). - Prints the runbook's next step (verify `EnvironmentFile` resolves, 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.json` must never appear in installer or command output. - `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned `tools/**` 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`) 1. **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. 2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared). 3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case (§2.4). 4. **Prune-planner** unit + property tests (§2.4.6). 5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from output. 6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation gate; no secret leakage. 7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write` restores `*.env`; **never** issues a lifecycle/restart call. 8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree 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): 1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from roster SSOT. 2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart: `systemctl --user show mosaic-agent@ -p EnvironmentFile` and confirm the generated env exists and carries the intended `MOSAIC_AGENT_*` runtime/workdir values. 3. **Only then** `systemctl --user restart mosaic-agent@`, one unit at a time. If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from ` 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 regen` reuses 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.