Files
stack/docs/plans/2026-10-18_stack-release.md

18 KiB

Stack release, rollback and rehearsal (D1, D2), 2026-10-18

Status: written by Sage, lead, for decision 96's D track (Q97). Decision 96 orders S7, then M1-M2, then D1-D2 beside I1, then I2-I6. I1 is written (docs/plans/2026-10-18_containerized-install-design.md), so D1 and D2 come before row 157 (I2). It follows docs/plans/BRIEF-TEMPLATE.md. Two rows. Line numbers are at 7dfda5182.

Background

Every live change since row 85 goes out as a new copy and a re-pin (docs/plans/2026-10-12_one-copy-stack.md). The bus, the Console and the human CLI all run from one read-only copy:

  • scripts/stack-copy.sh copy (:34-60) makes the copy;
  • bus-service.sh install --copy (:132-170) and webui-service.sh install --copy (:242-281) write the units;
  • stack-copy.sh wrapper (:61-74) switches the CLI;
  • stack-copy.sh check (:87-98) confirms all three point at the same copy.

What happens between those steps is manual. docs/TOOLS.md:343-381 ("Pinned stack update") lists seven operator steps. Rollback means repeating steps 2-6 with the earlier id, and nothing records which copy is live or which copy came before it. The bus has no health endpoint. The Console's /healthz returns a fixed 200 (packages/webui/src/serve.mjs:355). A schema change has no backup in product code: packages/bus/README.md:678 says only "after a consistent backup". The store migrates forward only (packages/bus/src/store.mjs:21-50), and there is no down-migration.

The real windows filled these gaps with plan-local scripts. The de352dfaf window (agents/sage/work/window-plan/repin-de352dfaf/) had these:

  • capture-rollback.sh: the old wrapper and unit files, with sums;
  • stopped-check.sh: no writer.lock holder and no bus process;
  • backup.sh: an owner-only copy of the stopped store, with sums;
  • rehearse.sh: the migration run on disposable copies of the backup;
  • restore.sh: the backup put back, with the post-attempt store moved aside and nothing deleted;
  • verify-live.sh and verify-new.sh: the effective paths.

scripts/release.sh already has the shape decision 96 names for the worker image:

  • a health gate before the switch (release.sh:41-57);
  • an atomic pointer at state/active.json, written to a temp file and renamed (:74-80);
  • an append-only state/activation-log.jsonl;
  • rollback to the last different tag through the same gate (:133-143).

D1 gives the stack that shape. D2 makes the rehearsal a product step.

Stack activate and rollback, health-gated (D1)

Problem

A re-pin is a hand-run sequence of stops, installs, a wrapper switch and starts, checked by eye. The window plans carry the backup, restore and path checks as one-off scripts. The live stack has no pointer and no log, so "what is live and what was before it" lives in a plan file. A bad start has no gate that puts the old copy back.

Owner and reviewer

  • Owner: darkwing (Sage, 2026-10-18).
  • Reviewer: filbert.

Files owned

  • scripts/stack-release.sh (new): verbs adopt, activate, rollback and status
  • scripts/stack-copy.sh: only to share the copy-id and copies-directory helpers, if the owner factors them out; copy, wrapper and check keep their behavior
  • packages/cli/tests/stack-release.test.mjs (new)
  • docs/TOOLS.md, the "Pinned stack update" section, and packages/cli/README.md, the paragraph on the pinned stack
  • the packet agents/<owner>/work/queue-<row>/

What ships

  1. A pointer and a log, the same shape as release.sh:

    • <dataRoot>/state/stack-active.json holds { copy, business, pm, claude, console, schema, activation, at }. It is written to a temp file and renamed. claude is the bus unit's --claude pin, and console is the Console unit's flag list (--port, --business, --board, --send and the public-host, auth-header and allow-user set). webui-service.sh saves its flags nowhere else, so the pointer is their record (Darkwing's survey, 2026-10-18).
    • <dataRoot>/state/stack-activation-log.jsonl is append-only. Each line has at, op, from, to, backup, result and, on a refusal or a failed gate, code. op is one of adopt, activate or rollback.
    • Both are files under state/, which AGENTS.md's data map already gives to the release pointer and its logs.
  2. adopt BUSINESS records the stack that is running now as the first pointer, and changes nothing. It refuses unless stack-copy.sh check passes and the bus unit's copy is the Console's and the wrapper's. The live stack is already on one copy (52f35e7a0), so no mixed-setup path is needed. A pointer that already exists refuses already-adopted. adopt reads claude and console from the effective units with systemctl --user show -p ExecStart -p Environment, the way bus-service.sh status reads --pm (:200-209).

  3. activate ID BUSINESS [--pm] moves the stack from the pointer's copy to copy ID. It refuses, with a code, a log line and no change, when any of these holds:

    • there's no pointer, no-pointer;
    • the copy is missing or isn't reachable from refactor and origin/refactor, using the rule stack-copy.sh already enforces;
    • any instance is busy, instance-busy, using the check bus-service.sh install_guard uses (:100-130);
    • a bus drop-in overrides ExecStart or WorkingDirectory, drop-in-override. It is named, and the operator removes it as in TOOLS.md step 3;
    • the target is already the pointer's copy, already-active;
    • the effective units' --pm, --claude or Console flags differ from the pointer's, unit-flags-drift. The difference is printed. A hand edit between runs is never carried forward or reverted without anyone seeing it; the operator settles it first.
  4. The order inside activate is as follows. Each step is logged, and a failure at any step goes to item 6.

    1. Capture the rollback set under <dataRoot>/state/stack-rollback/<activation>/, mode 0700: the installed bus and Console unit files, their drop-in directories, and the wrapper's bytes and mode, with SHA256SUMS. This is capture-rollback.sh as product code.
    2. Stop the Console, then the bus. Wait for both to be inactive and for the stopped check to pass: no writer.lock holder and no bus process for the data root (stopped-check.sh).
    3. Read the stopped store's schema version without writing. The owner picks the read, for example a read-only SQLite open of the version the store records, and the packet names it. If the version is the new copy's SCHEMA_VERSION, there's no backup. If it's older and the run has no --migrate, refuse migration-needs-flag through item 6. With --migrate, back up the stopped store to <dataRoot>/backups/stack/<activation>/ (backup.sh: 0700, sums, a source and copy comparison, the stopped check again after the copy), then run the new copy's migrate-schema.mjs. If it's newer than the new copy's, or not in the copy's chain, refuse store-newer. The stack is stopped by then, so the refusal goes through item 6 and starts the old stack again.
    4. Install the bus unit with bus-service.sh install --copy ID, with exactly the --pm and --claude pin the pointer recorded. Then install the Console with webui-service.sh install --copy ID with exactly the pointer's console flags. Then switch the wrapper with stack-copy.sh wrapper ID.
    5. Start the bus, then the Console.
  5. The health gate runs after the start and passes only when all of these hold within a timeout (default 60 s, --timeout):

    • systemctl --user is-active reports both units active;
    • the copy's scripts/mosaic bus status --json exits 0 and shows a live host for BUSINESS whose pid is the bus unit's MainPID, with the boot status ok (packages/cli/src/cli.mjs:499-514). The store opens only at its own schema, so a running host means the schema matches;
    • the Console's /healthz answers 200 on its configured port;
    • stack-copy.sh check passes;
    • with --pm, scripts/mosaic launches list shows the PM.

    On a pass, the pointer is renamed into place and the log says ok. The pointer changes only on a pass.

  6. A failed gate, or a failure after step 2, rolls back inside the same run. It stops the Console and the bus, then puts back the rollback set's unit files, drop-ins and wrapper bytes and runs daemon-reload. If step 3 migrated, it restores the backup the way restore.sh does: the current bus/ is renamed to bus.post-attempt-<stamp>, nothing is deleted, and the restore is checked against the backup's sums. Then it starts the old stack and runs the same gate against the pointer's copy. The log records gate-failed with the failing check and the outcome of the rollback. If the rollback's own gate fails, the script stops, prints both results and leaves the stack stopped, with a rollback-failed line. It never retries.

  7. rollback BUSINESS moves to the copy the log names as the one before the pointer's, the same way release.sh rollback finds it. It runs steps 4.1-4.5 and the gate with that copy as the target. If the store's schema is newer than that copy's, it refuses schema-differs unless --restore ACTIVATION names the backup taken when the store was migrated. With --restore, it prints the post-attempt path it keeps and how many bus events were written after the backup's stamp, then restores. If that count is above zero, it refuses post-backup-events unless --events N repeats the printed count.

  8. activate ID BUSINESS --plan and rollback BUSINESS --plan print the target, whether a migration or a restore is expected and the flags that would be installed. The store schema comes from a read-only read of the store the running bus holds, or of the stopped store when the bus is down. With no store file it falls back to the pointer's schema and says so (sage-0745, mos-0741; this replaced the first draft's "pointer only, no store read"). The target side is the copy's SCHEMA_VERSION. They change nothing and write no log line, so the window knows beforehand whether Jason must be present.

  9. status BUSINESS prints the pointer, the last five log lines, the live units' copies, the result of stack-copy.sh check, bus status and the drift from refactor. It only reads.

  10. Who runs it. Decision 96 covers the script stopping and starting the live units under four conditions (Mos, mos-0716), and the header comment states all four:

  • every run of adopt, activate or rollback happens on Mos's go, and Jason or Mos runs it;
  • a run that migrates the store, an activate --migrate, happens with Jason present;
  • a rollback --restore with events written after the backup waits for Jason, which is why it needs --events N;
  • the script never prunes backups, rollback sets, post-attempt directories or copies.

Decisions 94 and 102 still decide who starts the Console. webui-service.sh keeps having no start or stop verb (:50). The release script is the one path that stops and starts. status and --plan are open to every seat.

Tests run with a stand-in systemctl and systemd-analyze on PATH (install_guard also calls systemd-analyze --user unit-paths), temporary XDG_CONFIG_HOME and XDG_DATA_HOME, a temporary data root, and fake copies whose scripts/mosaic and Console are small stubs, as packages/cli/tests/stack-copy.test.mjs and packages/webui/tests/service.test.mjs do. The cases:

  • adopt writes the pointer and one log line only when the check passes, with claude and console read from the stand-in's show output. A second adopt refuses.
  • activate installs exactly the pointer's --pm, --claude and Console flags, and a stand-in unit whose flags differ refuses unit-flags-drift with no change.
  • Each refusal in item 3 leaves the units, the wrapper, the pointer and the store unchanged, and it writes one log line with its code.
  • An activate with no schema change: the stand-in sees the stops, the installs and the starts in that order. The pointer names the new copy, and no backup directory exists.
  • An activate with a schema change and no --migrate refuses migration-needs-flag and starts the old stack. With --migrate it makes the backup before the migration. A fixture migration that fails restores the backup and starts the old stack. The post-attempt directory exists, and the restored files match the backup's sums.
  • Each check in item 5, failing on its own, rolls back. The pointer is unchanged, the old unit files and wrapper bytes come back byte-for-byte, and the log names that check.
  • A failing rollback gate stops with rollback-failed and starts nothing more.
  • rollback finds the previous copy from the log, refuses schema-differs without --restore, and restores with it. With post-backup events, --restore without a matching --events N refuses post-backup-events.
  • --plan reports the expected migration or restore and the flags, and leaves the units, pointer, log and store unchanged.
  • The store is never opened for writing outside migrate-schema.mjs and the restore, and nothing under backups/, state/stack-rollback/, bus.post-attempt-* or the copies directory is removed.

Mutants: one per behavior above, with the killing test in the packet.

Out of scope

  • Any live run of adopt, activate or rollback. The first adopt and the first activate happen in a Mos window, with Mos's go.
  • The Discord unit (docs/plans/2026-10-14_discord-pinned-copy.md). It joins in a follow-up once D1 lands.
  • Pruning copies, rollback sets or backups.
  • A down-migration, any change to the store's schema chain, and any change to migrate-schema.mjs.
  • A health endpoint in the bus. The gate uses bus status.
  • Any change to the human proof (packages/bus/src/human.mjs) or to packages/queue.
  • CI, which is D4.

Gate

Filbert approves on the row's issue, naming the candidate manifest. The cli, bus and webui node suites and every scripts/test-*.sh pass on Sage's gate rerun. The landing changes nothing live. Sage sends Mos the commit and stack-release.sh status output from a scratch root. The first live adopt waits for Mos's go.

Stack rehearsal on a scratch root (D2)

Problem

Decision 96's D2 is staging: trying a candidate before live takes it. Today the trial is rehearse.sh, a plan-local script that checks only the migration on copies of the backup. Nothing starts the new copy's bus host and Console against real data before the live window, so a failure that shows only at start is found with the live stack stopped.

Owner and reviewer

  • Owner: sage (row 163, briefed 2026-10-20).
  • Reviewer: filbert.

Files owned

  • scripts/stack-release.sh: the rehearse verb
  • packages/cli/tests/stack-release.test.mjs: the rehearsal cases
  • docs/TOOLS.md, the same section as D1
  • the packet agents/<owner>/work/queue-<row>/

What ships

  1. rehearse ID BUSINESS --backup ACTIVATION|PATH builds a scratch stack under a new 0700 directory, <dataRoot>/rehearsals/<stamp>/:

    • a data root holding copies of the backup's store files;
    • the business file, roles and organization file copied from the live config directory, read only;
    • a scratch config that names the scratch data root.

    The live store, live config and the backup are never written. The backup's sums are checked before and after.

  2. On the scratch root it runs the de352dfaf rehearsal checks with the new copy: the new code refuses the unmigrated store at a normal open, migrate-schema.mjs succeeds, a second run is a no-op, and the old copy refuses the migrated store. Those checks apply only when the schema differs.

  3. It starts the new copy's bus host in the foreground on the scratch root with launches disabled, so no role session starts and no model is called. It starts the new copy's Console on a free loopback port with no send. Then it runs D1's gate checks against both, except the PM check. It stops both and prints one PASS or FAIL line per check, then REHEARSAL_PASS or REHEARSAL_FAIL.

  4. "Launches disabled" is a host flag the owner adds if the host lacks one. Its default is off, and the live units never pass it. If the host can't start without launching, the row stops and Sage takes the design back to Mos. No rehearsal calls a model.

  5. The rehearsal directory stays for reading. Nothing prunes it, and it is 0700 because it holds bus data.

  6. Who runs it. rehearse reads a backup of the live store, so only Jason or Mos run it, as the window's pre-step (the old P-steps). No development seat runs it against live data. Tests use fixture stores.

Tests:

  • The rehearsal passes on a fixture store and copy, and the scratch root holds the expected files.
  • The live store, the live config files and the backup are byte-identical before and after.
  • A migration that fails, a new copy whose host doesn't start, and a Console that doesn't answer each give REHEARSAL_FAIL naming the check.
  • No role session starts: the fixture's launcher stub records no start.
  • A scratch port that is in use refuses and picks no other port that could be the live Console's.

Mutants: one per behavior above.

Out of scope

  • A standing staging stack with its own units, Console, business and model turns. That needs a second system config beside ~/.config/mosaic-dev/config.json (AGENTS.md invariant 2) and model spend, so it's Jason's call. It is not briefed here.
  • Everything D1's out-of-scope list names.

Gate

As D1. D2 lands after D1, because both edit stack-release.sh and its test file.

D2 also lands after the Part B window (P19 385) has run. The Part B window file names the stack-release.sh blob, and any change to that blob means Mos checks the window again (mos-0958, mos-0959). The build and review can go ahead before then.