feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791) #802

Merged
jason.woltje merged 4 commits from feat/791-upgrade-config-protection into main 2026-07-16 23:01:27 +00:00
Owner

#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 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, and any unanticipated fleet file.

Change

  • framework-manifest.txt — single SSOT ([framework]/[operator], deny-wins,
    UNKNOWN ⇒ operator fail-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-free
    manifest_is_framework), sourced by install.sh; parity-tested vs 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. Overwrite mode unchanged.
  • file-ops.syncDirectory gains an isOperatorOwned guard; file-adapter derives
    it from the shared manifest, deleting the drifted hardcoded preservePaths.

Note on the manifest format

The design doc referenced framework-manifest.json; this ships framework-manifest.txt
(line-oriented) to keep the bash installer free of a python3/jq dependency. 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 unanticipated
unknown-operator-dir/x, harvester/sop.md, fleet/my-fleet.yaml
— survive a keep-mode
reseed 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 failed against the old installer → 48 passed after the fix.
Wired into CI as the merge-blocking upgrade-guard step.

Tests

Suite Result
test-upgrade-manifest-guard.sh (HARD GATE) 48/48
test-install-migration.sh (F6 flipped: unanticipated operator file MUST survive) 21/21
manifest-parity.spec.ts (§6.1, bash↔TS over 34 paths + subtree roots) 3/3
manifest.spec.ts (incl. planPrune property test + §6.2 shipped-tree completeness) 18/18
file-adapter.test.ts (real manifest fixture) 8/8
Full mosaic vitest 1062 passed

Gates

pnpm typecheck ✓ · pnpm lint ✓ · pnpm format:check ✓ · no --no-verify.

🤖 Generated with Claude Code

Part of #791

## #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 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`, and any unanticipated fleet file. ### Change - **`framework-manifest.txt`** — single SSOT (`[framework]`/`[operator]`, deny-wins, `UNKNOWN ⇒ operator` fail-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-free `manifest_is_framework`), sourced by `install.sh`; parity-tested vs 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. Overwrite mode unchanged. - **`file-ops.syncDirectory`** gains an `isOperatorOwned` guard; **`file-adapter`** derives it from the shared manifest, deleting the drifted hardcoded `preservePaths`. ### Note on the manifest format The design doc referenced `framework-manifest.json`; this ships **`framework-manifest.txt`** (line-oriented) to keep the bash installer free of a `python3`/`jq` dependency. 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 unanticipated `unknown-operator-dir/x`, `harvester/sop.md`, `fleet/my-fleet.yaml`** — survive a keep-mode reseed **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 failed` against the old installer → `48 passed` after the fix. Wired into CI as the merge-blocking **`upgrade-guard`** step. ### Tests | Suite | Result | |---|---| | `test-upgrade-manifest-guard.sh` (HARD GATE) | 48/48 | | `test-install-migration.sh` (F6 flipped: unanticipated operator file MUST survive) | 21/21 | | `manifest-parity.spec.ts` (§6.1, bash↔TS over 34 paths + subtree roots) | 3/3 | | `manifest.spec.ts` (incl. planPrune property test + §6.2 shipped-tree completeness) | 18/18 | | `file-adapter.test.ts` (real manifest fixture) | 8/8 | | Full mosaic vitest | 1062 passed | ### Gates `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check` ✓ · no `--no-verify`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Part of #791
jason.woltje added 2 commits 2026-07-16 20:51:05 +00:00
Design-only planning artifact for #791. Traces the wipe mechanism
(install.sh:199 rsync --delete gated by PRESERVE_PATHS denylist) and
specifies the ratified (b)+(a)+(d) fix: framework-owned manifest allow-list
with fail-safe unknown=>operator default and manifest-scoped prune, a
transactional pre-update snapshot with mosaic restore, and a projection-only
mosaic fleet regen recovery path. No implementation changes.

Refs #791

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
34e55d4a2e
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>
jason.woltje added 1 commit 2026-07-16 21:11:59 +00:00
test(mosaic): gate #797 ledger upgrade-survival + harden manifest parity (#791)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
0a5e703a70
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 <noreply@anthropic.com>
jason.woltje added 1 commit 2026-07-16 22:35:27 +00:00
fix(fleet): harden #791 upgrade rollback against find/reset failures
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
af627e7583
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>
Author
Owner

Independent Reviewer-of-Record — exact-head verdict: APPROVE

PR #802 (issue #791) — manifest-owned upgrade config protection

  • Exact head reviewed: af627e758399bdbe5b07ad50ee73f32440fe36df
  • Head tree: c1a198df062a5aabc0f45f39a0de3707023d89d3 · parent 0a5e703a7012f27b18da35513ecceff5bcfa920e
  • Reviewer: independent adversarial/security review (Opus), author≠reviewer (author = worker lane ms-791; reviewer is not the author)
  • Exact-head CI: Woodpecker #1877 @ af627e75 = success (all 8 steps incl the new upgrade-guard gate)

Verdict per concern (all FIXED / verified)

  1. Signal/trap integrity (blocker A): trap 'restore_snapshot; exit 1' ERR INT TERM; exit 1 is sequenced so it always terminates non-zero; restore_snapshot disarms the trap first (no re-entrancy). set -Eeuo pipefail propagates ERR into the per-file loop and sync_framework_keep(). No resume-to-success path.
  2. Find exit status (blocker D1): _scan_or_die writes find -print0 to a temp and checks status, return 1 on failure → ERR trap → restore, for BOTH copy and prune scans. No < <(find …) process-substitution status loss.
  3. Restore rm-fail + secret non-emission (blocker D2 + invariant): reset guard short-circuits on rm failure, preserves the snapshot, and surfaces the recovery pointer (path only). Every emitting path prints directory paths only — never file contents. Snapshot is mktemp -d 0700. No secret value reaches stdout/stderr on any path.
  4. Degenerate manifest (blocker B): shared [^/.] usable-glob test in bash manifest_load and TS isUsableFrameworkGlob; hand-traced norm/regex parity — degenerate set (/,./,.,..) rejected; legit globs (**, tools/**, .hidden) accepted. No false-accept/reject.
  5. Finalize classification (blocker C): ManifestError is 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.
  6. Test quality: all controls are genuine RED-first and non-tautological (-E strip, ; exit 1 strip, D1-guard neuter, D2-message delete, carve-out RED/GREEN). HARD GATE proves operator sentinels survive byte-identical + mtime/inode-stable.
  7. CI config: new upgrade-guard step runs manifest-guard + rollback + migration suites with no failure: ignore; typecheck depends_on it. No --no-verify, no required check made non-blocking.
  8. Scope/hygiene: clean; 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.sh28 passed, 0 failed (exit 0)
  • test-upgrade-manifest-guard.sh (HARD GATE) → 193 passed, 0 failed (exit 0)
  • migration 21/21 + mosaic vitest 1094 covered green by CI #1877's upgrade-guard step.

Disposition: APPROVE at exact head af627e758399bdbe5b07ad50ee73f32440fe36df. Merge to proceed via queue guard + squash (MS Team Lead), per Mosaic gates.

## Independent Reviewer-of-Record — exact-head verdict: **APPROVE** **PR #802 (issue #791) — manifest-owned upgrade config protection** - **Exact head reviewed:** `af627e758399bdbe5b07ad50ee73f32440fe36df` - **Head tree:** `c1a198df062a5aabc0f45f39a0de3707023d89d3` · parent `0a5e703a7012f27b18da35513ecceff5bcfa920e` - **Reviewer:** independent adversarial/security review (Opus), author≠reviewer (author = worker lane ms-791; reviewer is not the author) - **Exact-head CI:** Woodpecker `#1877` @ `af627e75` = **success** (all 8 steps incl the new `upgrade-guard` gate) ### Verdict per concern (all FIXED / verified) 1. **Signal/trap integrity (blocker A):** `trap 'restore_snapshot; exit 1' ERR INT TERM` — `; exit 1` is sequenced so it always terminates non-zero; `restore_snapshot` disarms the trap first (no re-entrancy). `set -Eeuo pipefail` propagates ERR into the per-file loop and `sync_framework_keep()`. No resume-to-success path. 2. **Find exit status (blocker D1):** `_scan_or_die` writes `find -print0` to a temp and checks status, `return 1` on failure → ERR trap → restore, for BOTH copy and prune scans. No `< <(find …)` process-substitution status loss. 3. **Restore rm-fail + secret non-emission (blocker D2 + invariant):** reset guard short-circuits on `rm` failure, preserves the snapshot, and surfaces the recovery pointer (path only). Every emitting path prints directory paths only — never file contents. Snapshot is `mktemp -d` 0700. No secret value reaches stdout/stderr on any path. 4. **Degenerate manifest (blocker B):** shared `[^/.]` usable-glob test in bash `manifest_load` and TS `isUsableFrameworkGlob`; hand-traced norm/regex parity — degenerate set (`/`,`./`,`.`,`..`) rejected; legit globs (`**`, `tools/**`, `.hidden`) accepted. No false-accept/reject. 5. **Finalize classification (blocker C):** `ManifestError` is 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. 6. **Test quality:** all controls are genuine RED-first and non-tautological (`-E` strip, `; exit 1` strip, D1-guard neuter, D2-message delete, carve-out RED/GREEN). HARD GATE proves operator sentinels survive byte-identical + mtime/inode-stable. 7. **CI config:** new `upgrade-guard` step runs manifest-guard + rollback + migration suites with no `failure: ignore`; `typecheck` depends_on it. No `--no-verify`, no required check made non-blocking. 8. **Scope/hygiene:** clean; `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) - migration 21/21 + mosaic vitest 1094 covered green by CI `#1877`'s `upgrade-guard` step. **Disposition:** APPROVE at exact head `af627e758399bdbe5b07ad50ee73f32440fe36df`. Merge to proceed via queue guard + squash (MS Team Lead), per Mosaic gates.
jason.woltje merged commit 32a0ffba13 into main 2026-07-16 23:01:27 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#802