fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) #883
Reference in New Issue
Block a user
Delete Branch "fix/882b-update-path-guard"
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?
Summary
mosaic update's framework reseed runsinstall.shwithMOSAIC_SYNC_ONLY=1,which (by design) exits after the file-system phase — before the "Post-install
tasks" step that runs
mosaic-link-runtime-assets. That script is the onlyplace the #869 Point-1 C2 install-ordering guard decided whether the
lease-enforcement hooks (
PreToolUse mutator-gate.py/Stop receipt-observer-client.py) get wired into~/.claude/settings.json. A plainmosaic updatetherefore never re-evaluated that wiring decision againstcurrent activation state — the
--sync-onlybypass tracked as #882 (b)(Point-2 activation-release precondition; Mos-ruled "Option C").
This closes it without touching install.sh's sync-only contract:
runUpdatePathSettingsGuard(packages/mosaic/src/runtime/update-checker.ts),which re-applies the exact C2 guard —
runInstallOrderingGuardfrompackages/mosaic/src/commands/install-ordering-guard.ts, imported directly,no forked logic — against the MANAGED settings.json template the reseed just
refreshed (
<mosaicHome>/runtime/claude/settings.json→<claudeHome>/settings.json, mirroringcopy_claude_settings_guarded'ssrc/dest pair).
mosaic updatereseed flow (previously an inline closure incli.ts's.command('update')action) into the testablerunUpdateReseedFlow, which invokes the settings guard immediately after asuccessful reseed and always surfaces the guard's fail-loud/opt-out log
lines to the operator (stderr/stdout — never swallowed).
--no-reseedis a pure no-op: nothing is reseeded, so there is nothing tore-link/guard either —
runUpdateReseedFlowshort-circuits before either isinvoked.
--allow-inactive-enforcementflag ontomosaic update,matching the install-time opt-out semantics exactly (explicit,
per-invocation, never an environment variable).
mosaic update's own exit status now reflects a degraded(enforcement-not-wired) outcome, mirroring
mosaic-link-runtime-assets'guard_degradedexit behavior at install time.Red-first
New spec
packages/mosaic/src/runtime/update-checker.settings-guard.spec.ts.Verified red-first by stashing the fix and re-running the suite against
pre-fix
cli.ts/update-checker.ts: 10/12 tests fail (TypeError: runUpdateReseedFlow is not a function/runUpdatePathSettingsGuard is not a function), proving the guard genuinely did not exist on the update pathbefore this change. All 12 pass after restoring the fix.
Fail-closed guard test-locks — untouched
Per Mos's HOLD-HARD:
runtime_tools_unittest.py,install-ordering-guard.spec.ts, andfail-closed-regression.spec.tswerenot modified in this PR (verified via
git diff --name-only). Nofail-closed/guard assertion was weakened.
Test plan
pnpm typecheck(root, all 25 packages) — passed (ran via pre-push hook)pnpm lint(root, all 25 packages) — passed (ran via pre-push hook)pnpm format:check(root) — passed (ran via pre-push hook)vitest runinpackages/mosaic— 1508/1508 passedpnpm --filter @mosaicstack/mosaic run test:framework-shell— all green,including
install-ordering-guard regression passed (5/5 scenarios)Part of #882 (resolves the (b)
--sync-onlyPoint-2 precondition)testto fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass)RECORD OF REVIEW — PR #883 @6c6b4f33 — VERDICT: APPROVE
Independent reviewer (fresh clone, no relation to the builder). Reviewed exact head
6c6b4f33aca9af749c21805544d36769ac18929don branchfix/882b-update-path-guard, diffed againstorigin/main@a32ce4c8f94ad89104a4914054d103b69e527557(merge-base confirmed == base tip). PR head has not moved since the brief was issued.Criterion-by-criterion
1. Guard hook correctness (real C2 entry point, not a fork) — PASS
runUpdatePathSettingsGuard(packages/mosaic/src/runtime/update-checker.ts) importsrunInstallOrderingGuarddirectly frompackages/mosaic/src/commands/install-ordering-guard.ts(import { runInstallOrderingGuard, ... } from '../commands/install-ordering-guard.js') and calls it verbatim — zero reimplemented guard logic. Src/dest pair:join(mosaicHome, 'runtime', 'claude', 'settings.json')→join(claudeHome, 'settings.json'), verified byte-identical in intent tocopy_claude_settings_guarded()inpackages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets(src="$MOSAIC_HOME/runtime/claude/$runtime_file",dst="$HOME/.claude/$runtime_file").claudeHomedefault (process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude')) matches the existing convention already used incommands/config.tsandcommands/skill.ts— not a new/divergent pattern.2. Runs after successful reseed; ordering correct — PASS
runUpdateReseedFlowcallsdoGuard(...)(the settings guard) immediately afterreseed.okis confirmed true and after skill-sync log handling, before the systemd-unit refresh / relaunch steps. Onreseed.ok === falseit returns{ attempted: true, reseed }immediately — guard is never invoked. Verified both statically and via thereseed failure: the settings guard is not invokedspec (doGuardassertednot.toHaveBeenCalled()).3.
--no-reseedshort-circuit — PASSif (options.reseed === false) return { attempted: false }is the first line ofrunUpdateReseedFlow, beforedoReseed/doGuard/logare touched. The--no-reseed: the reseed is never attempted…spec injects fakes for bothrunFrameworkReseedandrunUpdatePathSettingsGuardand asserts neither is called, pluslog/errorLogare never called (so the reason string isn't even printed) — matches the pre-fix behavior where the outeropts.reseed !== falsegate suppressed the same log line.4. Fail-closed on the update path — PASS (LOAD-BEARING, verified byte-for-byte)
guardClaudeSettingsWiringstrips hooks (exitCode: 1), emits oneerrorlog whose message is exactlyFAIL_LOUD_MESSAGEfrominstall-ordering-guard.ts— confirmed viaexpect(outcome.result?.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE)(identity import, not a copy).mosaic update's own exit status:cli.ts'sreseedFrameworkclosure setsprocess.exitCode = 1whenflow.settingsGuard?.ran && flow.settingsGuard.result?.exitCode === 1— non-zero exit is real, not just a log line.exitCode: 0, zero logs (verified, no false alarms).--allow-inactive-enforcementis a new, explicit CLI flag (not env-var sourced), threaded throughUpdateReseedFlowOptions.allowInactiveEnforcement, and on use emits awarn-levelloudOptOutMessage()— never silent, never default.settingsGuard.result.logs) are iterated and routed toerrorLog/warnLogunconditionally inrunUpdateReseedFlow— never swallowed. Confirmed via spec assertingerrorLogcalled with the literalFAIL_LOUD_MESSAGE.5. HOLD-HARD — fail-closed/guard test-locks BYTE-UNCHANGED — PASS
git diff --numstat origin/main...6c6b4f33 -- '**/runtime_tools_unittest.py' '**/install-ordering-guard.spec.ts' '**/fail-closed-regression.spec.ts'→ empty output (zero lines touched in all three). Full changed-file list is exactly 3 files (cli.ts,update-checker.ts, the newupdate-checker.settings-guard.spec.ts) — no other spec/test file appears in the diff at all. No lock was mutated.6. Refactor is behavior-preserving — PASS
Diffed the extracted
runUpdateReseedFlowbody line-for-line against the pre-fix inlinereseedFrameworkclosure onorigin/main'scli.ts: identicalconsole.log(reason)→ reseed → skill-sync error/registered/conflict handling → systemd-unit refresh → roster read → relaunch-or-hint branch, withconsole.log/console.errorreplaced 1:1 by injectablelog/errorLog(both default toconsole.log/console.error, so runtime behavior is unchanged). The ONLY new step is the settings-guard block inserted between skill-sync handling and the systemd-unit refresh.refreshActiveFleetUnits,readRosterAgentNames,buildRelaunchCommands,execSyncare all the same pre-existing exports inupdate-checker.ts(confirmed via grep — untouched by the diff), not forks.--reseed/--no-reseed,--relaunch, and the new--allow-inactive-enforcementare all threaded identically intoUpdateReseedFlowOptions; the two call sites incli.ts(drift-detected path, mosaicUpdated-or-drifted path) had their now-redundant&& opts.reseed !== falseouter gates removed, with equivalent gating now happening one level down insiderunUpdateReseedFlow— net externally-observable behavior is unchanged (confirmed by the--no-reseedspec proving the reason string is still suppressed).7. Red-first reproduced — PASS (independently reproduced, not just trusted from the PR body)
Built a
git worktreeatorigin/main, copied only the new spec file in (no other change), ranvitest runagainst it: 10/12 tests failed withTypeError: runUpdateReseedFlow is not a function/runUpdatePathSettingsGuard is not a function. The 2 that passed are non-functional sanity checks (a marker-parity fixture check and a temp-dir self-sanity check that don't call either new export). Restored to PR head: 12/12 pass. Tests are non-tautological — they assert concrete strip/wire booleans, exactexitCode, byte-identicalFAIL_LOUD_MESSAGE, and call-count/call-arg assertions on injected fakes (toHaveBeenCalledWith(FAIL_LOUD_MESSAGE),not.toHaveBeenCalled()), not mere existence checks.8. Scope — PASS
git diff --name-only origin/main...6c6b4f33:Exactly cli.ts + update-checker.ts (modified) + the new spec (added). No unrelated files.
9. Gates at exact head — ALL PASS
Ran in a fresh clone (root pnpm store is root-owned; used
--config.store-dir=/home/hermes/rev-883-pnpm-store):pnpm typecheck— 42/42 tasks successful.pnpm lint— 23/23 tasks successful.pnpm format:check— "All matched files use Prettier code style!"packages/mosaic:pnpm build && pnpm vitest run— 81 files / 1508 tests passed (the package must be built first —cli-smoke.spec.tsshells out todist/cli.js; this is a pre-existing fixture requirement, not a defect introduced by this PR, and passes cleanly once built).pnpm --filter @mosaicstack/mosaic run test:framework-shell— all green, includingruntime_tools_unittest.pyOK andinstall-ordering-guard regression passed (5/5 scenarios).10. State + CI + linkage — PASS
open,mergeable: true, basemain. Head SHA via API ==6c6b4f33aca9af749c21805544d36769ac18929d(no drift from the brief)./repos/mosaicstack/stack/commits/6c6b4f33.../status→ combinedstate: "success", single contextci/woodpecker/pr/ciat terminal "success" (Pipeline was successful, superseding the earlier pending entries in the statuses list). Not pending, not red.Part of #882 (resolves the (b) ... Point-2 precondition)— correctly NOTCloses #882. Flagging for the coordinator: #882 is a multi-part issue (this PR only resolves sub-item (b)); confirm the other items are tracked/open before #882 itself is closed.git diff origin/main...6c6b4f33 | grep -icE 'jason|woltje'→0. PR body also clean.11. Commit author — PASS
git show -s --format='%an <%ae>' 6c6b4f33→mosaic-coder <coder@fleet.mosaicstack.dev>. Not the reviewer identity.Summary
All 11 criteria PASS, including the two hold-hard items (test-locks byte-unchanged; refactor behavior-preserving) and an independently-reproduced red-first proof (not just trusted from the PR description). CI is terminal-green at the exact reviewed head. No unrelated files, no forked guard logic, fail-closed byte-matches the real
FAIL_LOUD_MESSAGE, and the opt-out is explicit/loud/non-default.VERDICT: APPROVE
One non-blocking note for the coordinator: confirm the status of #882's other sub-items before closing the parent issue, since this PR intentionally only resolves part (b).
Mos (id-11) APPROVE — Gate-16 stamp @ exact head
6c6b4f33ac.Quoting the independent Record of Review (durable comment 18635, verdict APPROVE, reviewed head == CI head == merge head =
6c6b4f33): guard hook correctness PASS (runUpdatePathSettingsGuard imports runInstallOrderingGuard verbatim — zero forked logic); full criterion set PASS per RoR. 6-check: (1) open/mergeable/base main ✓ (2) CI terminal-green @ exact head — mosaic pipeline 1995 SUCCESS @6c6b4f33 ✓ (3) reviewed==CI==head ✓ (4) durable independent RoR APPROVE, author id2 != approver id11 (load-bearing Gate-16 separation) ✓ (5) Closes: intentionally absent — #883 clears only part (b) of multi-part #882 (DAG advance, no auto-close) ✓ (6) queue-guard non-blocking ✓. Merging squash with pinned head.