# Row 232 (I1): stack-release external units and bundle copies Brief: `docs/plans/2026-10-20_installer-rows.md` section I1, blob `f35e7e57daee` (commit ad6f0be7a). Gitea #1734, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage. Design input: row 195 DESIGN r6, `agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66 (approved in duckworth-0055). Where the brief and r6 differ, the brief wins. sage-1561 ruled where the stack user's `stack-release.sh` runs: from its own clone of the release bundle, so `check_target`, the plan's commit list and status drift are unchanged and nothing adds `safe.directory`. ## Files Base: cb5fdb064 (row 199's landing, on top of row 200's 9e1085816). Branch `darkwing/row232-r1`. The first build, 0af0c73c6 on `darkwing/row232`, sat on 00d4cc1f6 (row 190); row 200 landed first, so this one is rebased onto it (see D9 and D10). - `scripts/stack-release.sh`: `--units user|external`, the `sc` and `jctl` backend, `confirm`, `revert`, `gate --since`, `rollback --to`, `--plan --expect-from`, the external gate (start floor, PM check from `launches list`), `external-pm`, and the `awaiting units:` status line. - `scripts/stack-copy.sh`: `check --units external`, `copy --bundle FILE SHA`, `wrapper ID --create`. - `packages/cli/tests/stack-release-external.test.mjs` (new, 25 tests). - `packages/cli/tests/stack-release-fixture.mjs` (new): the helpers `stack-release.test.mjs` defined, moved so both files share them. See deviation D1. - `packages/cli/tests/stack-release.test.mjs`: the helpers leave and one import line comes in. The 74 tests have the same names and the same bodies as on the base. - `packages/cli/tests/stack-copy.test.mjs`: three new `row 232:` tests and a wider `node:fs` import. - `docs/TOOLS.md`: the stack-copy usage block and three paragraphs, the stack-release usage block, and a new subsection "Root-owned units (`--units external`, row 232)". - `packages/cli/README.md`: one paragraph. - This file. The worktree's `node_modules` symlink is not part of the candidate. ## User mode is unchanged Every `systemctl` call goes through `sc` and every `journalctl` call through `jctl`. Under `user` they are `systemctl --user "$@"` and `journalctl --user "$@"`. Under `external`, `sc` allows only `show`, `is-active` and `list-units`, and `jctl` returns 1, so an external run can't start, stop, install or read the journal even through a path the tests miss. Two tests hold the brief's "every command line and every byte of output": - `user mode: every systemctl call carries --user, and activate, rollback and check make the base's calls`. A fixture repo gets the base's two scripts from `git show cb5fdb064:`. The same activate, rollback and `stack-copy.sh check` run from both trees, and the recorded argv (with the fixture root and port replaced by placeholders) must match line for line. Every call carries `--user`. - `user mode: a default activate --plan prints the base's bytes`, for four plans. The base output is produced live from the base blobs, not stored as a fixture file. See deviation D3. ## r6 section 7, test by test All in `stack-release-external.test.mjs` unless named. | r6 :742-812 item | Test | |---|---| | `units-running` (N4), store, log and TAKEN left | `units-running (N4) refuses while a unit is active...`; also checks that `failed` counts as down and that the Console is checked first | | activate leaves the pointer, writes `AWAITING-UNITS` and a backup | `activate stages: ...`, which also checks the set's file list, `UNITS.sha256`, no `--user`, no journal call and `status` | | `open-activation` with zero, two, the wrong copy, one not open | `confirm and revert refuse open-activation ...` | | `confirm` polls: 3 s fails at `--timeout 1`, passes at `--timeout 10` | `confirm polls: bus status good after 3 s fails ...` and `confirm polls to a pass ...` | | `stale-start` floor with `started` at .900 | `stale-start: @1792540799 fails ...` and `stale-start is a gate code ...` | | `confirm` pass writes pointer, ok, removes marker; timeout writes `FAILED` and exits 1 | the two polling tests | | `activation-failed` when `FAILED` exists | `confirm polls: ... FAILED then refuses activation-failed` | | lines carry the started op, from, to, activation and backup | `sameAsStarted` in every confirm test, and sequence 7 | | `gate` (N2) with and without `--since` | `gate (N2, A2f): ...` and the usage test | | `--plan --expect-from` (N5), activate and `rollback --to` | sequence 7 | | A3 order: capture-failed, marker-failed, wrapper failure after migrate | `A3 order: ...` and sequence 2 | | refusals log no activation; `interrupted` unchanged | `open-activation` test and sequence 5 | | PM check: one, none, two, early, not live, other business, other instance; no journal call | `the external PM check reads launches list ...` (modes '', coder, none, twolive, early, dead, other) and `journalCalls` in every external test | | `external-pm` | `pm-on and activate --pm-after-harness refuse external-pm` | | `revert` restores TAKEN only after a migration | `revert puts the wrapper back and leaves the store ...` and sequences 2 and 8 | | `confirm --rollback` per cause, `unrecorded`, `rollback-failed`, mismatch | sequences 1, 2, 3, 5 and 8, and `confirm --rollback refuses rollback-mismatch, and logs rollback-failed ...` | | sequences 1-8 | `sequence 1:` to `sequence 8:`, each ending with the `closed` check (one started and one result line, empty `interrupted`, no marker or `FAILED`, a following `--plan` that doesn't refuse `interrupted-run`) | | `rollback --to` refuses not-previous; `rollback --restore` finds an external backup | sequence 7, and `confirm --rollback after a --restore swap ...` | | `gate` writes nothing | the `gate (N2, A2f)` test and sequence 4 compare the log, the pointer and the state tree | | `confirm`, `revert`, `gate`, `--to` refuse without `--units external` | `confirm, revert, gate, --to and --expect-from need --units external ...` | | default path: every call carries `--user`; default `--plan` bytes | the two user-mode tests above | The brief's additions, in `stack-copy.test.mjs`: - `row 232: copy --bundle copies a SHA the bundle's refactor reaches, byte-identical to copy SHA, and refuses the rest`: a short SHA, a missing file, a symlink, a bundle that fails `git bundle verify`, a bundle with no `refs/heads/refactor`, a SHA not in the bundle, and a SHA the bundle carries on another branch that refactor doesn't reach. The bundle copy's tree matches `copy SHA`'s byte for byte, and the `release:` and `bundle sha256:` lines are checked. - `row 232: wrapper --create writes the first wrapper only, the same bytes wrapper ID writes, and refuses over any file`, a symlink included. Without `--create` a missing wrapper still refuses. - `row 232: check --units external reads the system units, the default reads the user units`. ## Mutants `mutants.sh` in my scratch (not committed, it edits the worktree), 17 mutants: r6's re-pin list (:817-838) minus the three `case1-repin.sh` ones, plus the brief's two. Each one applies exact replacements after a checked backup, runs `bash -n`, runs its named killers, restores the file and checks its sha256. Two controls run first with no change: the whole external file, and stack-copy's `row 232:` tests. | Mutant | Killers | |---|---| | m01 `confirm` writes the pointer on a gate failure | `confirm polls: bus status` | | m02 `gate_once` in place of `gate` | `confirm polls to a pass`, `stale-start is a gate code` | | m03 PM check counts any instance | `external PM check` | | m04 PM check drops the `startedAt` bound | `external PM check` | | m05 `confirm` takes the newest `AWAITING-UNITS` | `open-activation` | | m06 `rollback --to` skips the comparison | `sequence 7:` | | m07 the default path drops `--user` from one call (`is-active`) | `user mode: every systemctl` | | m08 the timeout writes r4's interim `rollback pending` line | `confirm polls: bus status`, `sequence 1:`, `sequence 5:` | | m09 `AWAITING-UNITS` last, after the wrapper | `sequence 2:` | | m10 a refusal names the open activation | `sequence 5:`, `open-activation` | | m11 `stale-start` is a refusal | `stale-start is a gate code` | | m12 `revert` restores without `MIGRATING` | `revert puts the wrapper back` | | m13 `confirm` logs op `confirm` | `sequence 7:`, `confirm polls to a pass` | | m14 `MIGRATING` after the migration | `sequence 8:` | | m15 `gate` takes its floor from the log without `--since` | `gate (N2` | | m16 `copy --bundle` skips the reachability check | stack-copy `row 232:` | | m17 `wrapper --create` overwrites | stack-copy `row 232:` | ## Deviations and choices D1. The shared helpers moved to a new file, `packages/cli/tests/stack-release-fixture.mjs`. The brief's files list names `stack-release.test.mjs` "where a shared helper moves" but not a destination. Importing from the other test file would run its 74 tests twice, so the helpers need a module of their own. D2. The fixture's T17 check (what a run may never remove) now exempts a rollback set's `AWAITING-UNITS` and `FAILED`. `confirm` removes both when it closes the activation (r6 A5 and R4). User mode writes neither, so user-mode runs check exactly what they did. D3. The user-mode argv and `--plan` tests read the base's scripts with `git show cb5fdb064:` rather than storing their output as a fixture file. A later row that changes user-mode output on purpose moves the `BASE` constant in the test to its own base, or the test fails. I chose this over a stored fixture because a stored copy can drift from the base without anyone noticing. D4. `marker-failed` is reached by making `state/stack-rollback` unwritable. r6 says "an existing `$RS`", but `next_activation` never picks a taken name, so no test can make the run meet an existing `$RS`. The exclusive `mkdir` is still what refuses. D5. The stale-start polling case turns the enter time to `@1792540800`, not r6's `@1792540805`, with the started line at 1792540800.900. That also checks that the floor is the whole second, so .900 doesn't push it to 1792540801. D6. No fake `sudo` or `case2-unit.sh`. r6's sequences ran under them because r6 had `case1-repin.sh` drive the steps, and that script is I3's. Here the test plays root: it stops and starts the fake system units and installs with the existing `bus-service.sh` and `webui-service.sh` stand-ins, between the real `stack-release.sh` calls, on one log. D7. The fake marker in the `open-activation` test is named `20000101T000000Z-` so it sorts before the real one. A marker that sorted after it would leave m05 (newest marker) alive. D8. m10 sets `ACT` inside `open_activation`, where r6's wording ("ACT set before the checks") lands in this code. `ACT` is set only after every check in `confirm`. D9. Row 200's `--sso` is a usage error under `--units external`, with the proof flags, `--no-proof` and `--no-send`. It is a Console flag, and root's Console unit carries the Console flags; the script under external renders no Console line and never calls `console_for_target`, so `sso_checks` doesn't run there either. The usage text, the TOOLS.md bullet and the external usage test name it. D10. Row 200's `config_checks` runs under `--units external` too, after `plugin_checks`, in both the `--plan` and run paths of `activate` and `rollback --to`. It checks the target's loader against the stack user's `config.json`, which the bus on a VM still reads, so a `console` key the target refuses would break root's bus start the same way. The cost is one more refusal (`config-console-key`) an external run can print; I took it over a bus that fails after root's start. The rebase changed no user-mode line: the conflicts were the `SSO=0` globals line, the `plan` body (row 200's `SSO_PLAN` line sits in the user branch) and the two `--plan` lines that gained `config_checks`. In the tests, row 200's fixture lines (the loaders, `sso-config.mjs` and the `--sso` cli.mjs line for C1 and C) moved into `stack-release-fixture.mjs` with the rest of the copy setup, and the `ALGS` import stays in `stack-release.test.mjs`, which T57 uses. ## duckworth-0055's notes on r6 (brief line 169) Duckworth's r6 approval (REVIEW-r5.md at 23f5520b5) carried two notes. - N1, A2's stop timeout under `gate --since`. A unit that never stopped keeps its old `ActiveEnterTimestamp`, so `gate --since` fails it with `stale-start`, and the run ends at exit 2 with root stopping the units. That is intended: a unit that won't stop is a fair reason to stop. In I1 this is the gate code and its polling (`stale-start: @1792540799 fails ...`, `gate (N2, A2f)`, m11). The run sheet's A2f line and the re-pin test that points "exits 1 with OLD's gate passing" at an A3 exit 3 belong to I3, which owns `scripts/stack-upgrade.sh` and `docs/guides/host-run-sheet.md`. I'll name both in the I3 build. - N2, the claude path under `/opt/mosaic-stack` (gosalyn-0058, sage-1522). I1 doesn't touch it. Under `--units external` the script checks no claude pin of its own (`no-claude-pin` applies only to user mode), because root's unit carries it. The `--claude` value, the managed-settings reasoning, F6 and the run sheet's claude step go to I2 and I3. ## Run environment Worktree `/mnt/storage/scratch/darkwing-r232`, `TMPDIR=/mnt/storage/scratch/tmp/r232`, node from PATH. One external file run takes about 94 s. ## Results Singles on the rebased candidate, before the gate: | File | Tests | Pass | Fail | |---|---|---|---| | `packages/cli/tests/stack-release-external.test.mjs` | 25 | 25 | 0 | | `packages/cli/tests/stack-copy.test.mjs` | 16 | 16 | 0 | | `packages/cli/tests/stack-release.test.mjs` (row 200's T57 included) | 192 | 192 | 0 | | `packages/cli/tests/stack-instances.test.mjs` | 23 | 23 | 0 | | `packages/webui/tests/pinned-copy.test.mjs` | 10 | 10 | 0 | | `packages/cli/tests/channel-recovery.test.mjs` (imports `channel-fixture.mjs`, which names the scripts) | 1 | 1 | 0 | | `packages/cli/tests/host.test.mjs` (reads `packages/cli/README.md`) | 42 | 42 | 0 | These are every test file that reads `stack-release.sh` or `stack-copy.sh` (a grep of `packages/*/tests` and `scripts/`). None of them pins a sha256 of a changed file. `packages/cli/tests/host.test.mjs` reads `packages/cli/README.md` for a unit file name; this row only adds a paragraph there. The grep and the runs were repeated on the rebased tree on 2026-10-21; the reader set is the same. A dry run of `mutants.sh` matches each of the 17 replacements once on the rebased scripts. ### Gate, 2026-10-21, Sage's slot (sage-1618) Candidate a0ffabc3f on `darkwing/row232-r1`, run by `~/darkwing-scratch/r232/gate.sh`: the cli suite, then every `scripts/test-*.sh`, one after another, stopping at the first failure. busy.sh passed at 01:07:49Z after Launchpad's single `stack-release.test.mjs` run ended. The gate stopped at the first one, so the chain waited for that PID to exit and checked again. The EXIT trap removed the compose network; no container was left. | Step | Time (UTC) | Result | |---|---|---| | `packages/cli`, `node --test tests/*.test.mjs` | 01:07:50 to 01:17:53 | 561 pass, 0 fail, 0 skipped | | `scripts/test-auth.sh` | 01:17:53 to 01:17:54 | 15 passed, 0 failed | | `scripts/test-conductor.sh` | 01:17:54 to 01:18:00 | 17 passed, 0 failed | | `scripts/test-config.sh` | 01:18:00 to 01:18:02 | 31 passed, 0 failed | | `scripts/test-discord.sh` | 01:18:02 to 01:18:12 | 78 passed, 0 failed | | `scripts/test-extension-package.sh` | 01:18:12 to 01:18:13 | 18 passed, 0 failed | | `scripts/test-foundation.sh` | 01:18:13 to 01:19:01 | 44 passed, 0 failed | | `scripts/test-land-work.sh` | 01:19:01 to 01:19:14 | 86 passed, 0 failed | | `scripts/test-queue.sh` | 01:19:14 to 01:19:37 | 156 node tests pass, queue suite 28 passed, 0 failed | | `scripts/test-release.sh` | 01:19:37 to 01:19:51 | 14 passed, 0 failed | | `scripts/test-task.sh` | 01:19:51 to 01:20:44 | 98 passed, 0 failed | ### Mutants, 2026-10-21, same slot `~/darkwing-scratch/r232/mutants.sh`, right after the gate. Both controls (the external file whole, and stack-copy's `^row 232: ` tests) exited 0 on the unmutated tree. Each mutant was applied after a checked backup and a syntax check, ran its killers from the table above, and was put back with its sha256 checked. The script stops on any restore mismatch, and it ran to its end. | Mutant | Killer exit | Result | |---|---|---| | m01 pointer on gate failure | 1 | killed | | m02 gate once | 1 | killed | | m03 PM from any instance | 1 | killed | | m04 no startedAt check | 1 | killed | | m05 newest marker | 1 | killed | | m06 --to not compared | 1 | killed | | m07 one call without --user | 1 | killed | | m08 interim pending line | 1 | killed | | m09 marker written last | 1 | killed | | m10 refusal names the open activation | 1 | killed | | m11 stale-start refuses | 1 | killed | | m12 revert always restores | 1 | killed | | m13 confirm op | 1 | killed | | m14 MIGRATING after migrate | 1 | killed | | m15 gate floor from the log | 1 | killed | | m16 bundle reachability unchecked | 1 | killed | | m17 --create overwrites | 1 | killed | 17 of 17 killed, 01:22:16 to 01:24:32Z. I read the logs for m01 and m09, two of the quick kills: each failed on its named test's assertion (m09 on `AWAITING-UNITS` in sequence 2), not on a load error. ## Round 2: rebase onto 8fede5fc6 (sage-1673) Rebase only. Before the landing gate, a check of BASE..HEAD on the manifest paths found that row 230 (c8bc5cf4c) had changed one of them after the round 1 base, cb5fdb064. Sage ruled a re-round with a scoped review, the same shape as row 210 round 3. Branch `darkwing/row232-r2`: the two round 1 commits, a0ffabc3f and 84f1a91e6, cherry-picked onto 8fede5fc6 with no conflict, then this section. No code or test changed. - `git merge-tree --write-tree origin/refactor darkwing/row232-r1` (refactor at 8fede5fc6) gives tree 837bde79236c with no conflict. The cherry-picked tip before this section has the same tree. - `packages/cli/README.md` is a clean 3-way merge. Row 230's hunk is at lines 710-717 (the session credential paragraph); this row's is a new paragraph at 773. With `git patch-id --stable`, the README diff from r1 to r2 is 02b679e2, the same as row 230's README diff `cb5fdb064..8fede5fc6`. This row's README diff is c395ecd2 on both bases (`cb5fdb064..r1` and `8fede5fc6..r2`). - The seven code, test and doc paths other than the README hash the same as in manifest 7a3b4ea1, which Filbert approved in round 1: `docs/TOOLS.md`, `packages/cli/tests/stack-copy.test.mjs`, `packages/cli/tests/stack-release-external.test.mjs`, `packages/cli/tests/stack-release-fixture.mjs`, `packages/cli/tests/stack-release.test.mjs`, `scripts/stack-copy.sh`, `scripts/stack-release.sh`. This file also matched until this section was appended. Its other lines are unchanged. - The one test that reads the README, `host.test.mjs:855`, looks for the unit's `EnvironmentFile=` line. That line is unchanged context in row 230's hunk. - No row 230 test reads `stack-release.sh` or `stack-copy.sh`, and no test of this row reads a row 230 file. Nothing was run for this round. The landing gate runs on the r2 tip on Sage's slot. Follow-up, doc-only (sage-1673): `scripts/case2-unit.sh:108` and `scripts/case2-precheck.sh:157` cite `scripts/stack-copy.sh:8` and `:22` for `COPIES` and `copy_root`. With this row those lines are `:10` and `:26`. Only comments are affected and no test reads them. Row 233 doesn't touch those scripts, so the fix goes on the #1732 carry list.