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) andwebui-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.shandverify-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; rollbackto 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): verbsadopt,activate,rollbackandstatusscripts/stack-copy.sh: only to share the copy-id and copies-directory helpers, if the owner factors them out;copy,wrapperandcheckkeep their behaviorpackages/cli/tests/stack-release.test.mjs(new)docs/TOOLS.md, the "Pinned stack update" section, andpackages/cli/README.md, the paragraph on the pinned stack- the packet
agents/<owner>/work/queue-<row>/
What ships
-
A pointer and a log, the same shape as
release.sh:<dataRoot>/state/stack-active.jsonholds{ copy, business, pm, claude, console, schema, activation, at }. It is written to a temp file and renamed.claudeis the bus unit's--claudepin, andconsoleis the Console unit's flag list (--port,--business,--board,--sendand the public-host, auth-header and allow-user set).webui-service.shsaves its flags nowhere else, so the pointer is their record (Darkwing's survey, 2026-10-18).<dataRoot>/state/stack-activation-log.jsonlis append-only. Each line hasat,op,from,to,backup,resultand, on a refusal or a failed gate,code.opis one ofadopt,activateorrollback.- Both are files under
state/, which AGENTS.md's data map already gives to the release pointer and its logs.
-
adopt BUSINESSrecords the stack that is running now as the first pointer, and changes nothing. It refuses unlessstack-copy.sh checkpasses 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 refusesalready-adopted.adoptreadsclaudeandconsolefrom the effective units withsystemctl --user show -p ExecStart -p Environment, the waybus-service.sh statusreads--pm(:200-209). -
activate ID BUSINESS [--pm]moves the stack from the pointer's copy to copyID. 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
refactorandorigin/refactor, using the rulestack-copy.shalready enforces; - any instance is busy,
instance-busy, using the checkbus-service.sh install_guarduses (:100-130); - a bus drop-in overrides
ExecStartorWorkingDirectory,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,--claudeor 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.
- there's no pointer,
-
The order inside
activateis as follows. Each step is logged, and a failure at any step goes to item 6.- 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 iscapture-rollback.shas product code. - Stop the Console, then the bus. Wait for both to be inactive and
for the stopped check to pass: no
writer.lockholder and no bus process for the data root (stopped-check.sh). - 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, refusemigration-needs-flagthrough 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'smigrate-schema.mjs. If it's newer than the new copy's, or not in the copy's chain, refusestore-newer. The stack is stopped by then, so the refusal goes through item 6 and starts the old stack again. - Install the bus unit with
bus-service.sh install --copy ID, with exactly the--pmand--claudepin the pointer recorded. Then install the Console withwebui-service.sh install --copy IDwith exactly the pointer'sconsoleflags. Then switch the wrapper withstack-copy.sh wrapper ID. - Start the bus, then the Console.
- Capture the rollback set under
-
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-activereports both units active;- the copy's
scripts/mosaic bus status --jsonexits 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
/healthzanswers 200 on its configured port; stack-copy.sh checkpasses;- with
--pm,scripts/mosaic launches listshows the PM.
On a pass, the pointer is renamed into place and the log says
ok. The pointer changes only on a pass. -
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 wayrestore.shdoes: the currentbus/is renamed tobus.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 recordsgate-failedwith 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 arollback-failedline. It never retries. -
rollback BUSINESSmoves to the copy the log names as the one before the pointer's, the same wayrelease.sh rollbackfinds 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 refusesschema-differsunless--restore ACTIVATIONnames 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 refusespost-backup-eventsunless--events Nrepeats the printed count. -
activate ID BUSINESS --planandrollback BUSINESS --planprint 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'sschemaand says so (sage-0745, mos-0741; this replaced the first draft's "pointer only, no store read"). The target side is the copy'sSCHEMA_VERSION. They change nothing and write no log line, so the window knows beforehand whether Jason must be present. -
status BUSINESSprints the pointer, the last five log lines, the live units' copies, the result ofstack-copy.sh check,bus statusand the drift fromrefactor. It only reads. -
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,activateorrollbackhappens 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 --restorewith 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:
adoptwrites the pointer and one log line only when the check passes, withclaudeandconsoleread from the stand-in'sshowoutput. A secondadoptrefuses.activateinstalls exactly the pointer's--pm,--claudeand Console flags, and a stand-in unit whose flags differ refusesunit-flags-driftwith 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
activatewith 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
activatewith a schema change and no--migraterefusesmigration-needs-flagand starts the old stack. With--migrateit 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-failedand starts nothing more. rollbackfinds the previous copy from the log, refusesschema-differswithout--restore, and restores with it. With post-backup events,--restorewithout a matching--events Nrefusespost-backup-events.--planreports 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.mjsand the restore, and nothing underbackups/,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,activateorrollback. The firstadoptand the firstactivatehappen 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 topackages/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: therehearseverbpackages/cli/tests/stack-release.test.mjs: the rehearsal casesdocs/TOOLS.md, the same section as D1- the packet
agents/<owner>/work/queue-<row>/
What ships
-
rehearse ID BUSINESS --backup ACTIVATION|PATHbuilds 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.
-
On the scratch root it runs the
de352dfafrehearsal checks with the new copy: the new code refuses the unmigrated store at a normal open,migrate-schema.mjssucceeds, a second run is a no-op, and the old copy refuses the migrated store. Those checks apply only when the schema differs. -
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_PASSorREHEARSAL_FAIL. -
"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.
-
The rehearsal directory stays for reading. Nothing prunes it, and it is 0700 because it holds bus data.
-
Who runs it.
rehearsereads 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_FAILnaming 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.