# Row 166 (#1663): stack-release.sh hardening after D1 Owner: Darkwing. Reviewer: Filbert. Gate owner: Sage. Brief: `docs/plans/2026-10-18_stack-release-hardening.md`, from Filbert's notes on row 162 round 1 (#1659 comment 28997). Base: 139e28673. origin/refactor is c3cd27345, and `git diff 139e28673 c3cd27345 -- scripts packages/cli docs/TOOLS.md` is empty. Worktree: `~/darkwing-scratch/r166/wt`. ## Files | File | Change | |---|---| | `scripts/stack-release.sh` | items 1 to 4 | | `packages/cli/tests/stack-release.test.mjs` | T28 to T31, a `bus-stops-after` stand-in fault, and `run()` takes `e.sr` | | this packet | | No verb's wording changes, so TOOLS.md stays as it is. No new refusal code. ## Items **1. Unreadable drop-ins refuse in the precheck.** `drop_in_override` used to skip a unit path or drop-in directory that `[ -d ]` rejected and a `.conf` that `grep 2>/dev/null` couldn't read. `install_guard` in `bus-service.sh` dies on the same path after the stop. The precheck now refuses `drop-in-override` when: - a unit path exists but isn't a readable, searchable directory ("cannot inspect unit path P"); - a drop-in directory exists but isn't a readable, searchable directory ("cannot inspect drop-ins D"); - `cat` can't read a `.conf` ("cannot read drop-in F"). A dangling symlink counts as existing (`-e || -L`), as in `install_guard`. A unit path that isn't absolute refuses too, since `install_guard` refuses it. **2. The is-active to read race.** After a held-store read, `plan_store` and the `--restore` event count each check `is-active` again. If the bus stopped meanwhile: - `--plan` prints `store schema: unknown (the bus stopped during the read, so the store state changed; run --plan again)` and leaves out the note, migration and restore lines; - rollback `--restore` refuses `restore-mismatch` ("the bus stopped while the events after STAMP were counted, so the store state changed; nothing was stopped"). **3. The copy check names the target.** The gate's `copy-check` step reads the check's `bus:` line and fails with `copy-check` unless it is `$COPIES/$ID`, the copy this run installed. The gate message says what it saw: "the bus, Console and CLI agree on X, not Y". **4. A quoted `Environment=` or `ExecStart=` is unreadable.** `read_units` returns 1 when the bus `Environment=` line or either unit's `ExecStart` argv holds `"`, `'` or `\`. Callers refuse as they do for an unreadable unit: activate and rollback with `unit-flags-drift`, adopt with `check-failed`, and status prints `unit flags: cannot read the units`. Nothing parses systemd quoting (out of scope in the brief). ## Tests | Test | Item | What it checks | |---|---|---| | T28 | 1 | three subtests, each a mode 000 path: the unit path, the bus drop-in directory and a drop-in file. Each `activate` exits 3 with the named refusal, no start or stop, both units active, the fingerprint unchanged and one more log line. Round 2 adds a fourth: a relative unit path (`systemd/user`) refuses "invalid systemd unit path", with the same checks | | T29 | 2 | the stand-in `is-active` answers the real state once, then inactive. `--plan` prints the exact plan without migration lines and writes nothing; rollback `--restore` refuses `restore-mismatch` with no start, stop or post-attempt dir | | T30 | 3 | a repo copy whose `stack-copy.sh check` reports agreement on the pointer's copy. `activate C1` fails the gate with `copy-check`, names both copies, rolls back to B and logs `gate-failed`, `copy-check`, rollback ok | | T31 | 4 | five unit forms (`Environment="..."`, `Environment='...'`, `Environment=/a\ b`, the bus `ExecStart ... "--pm"` and, from round 2, the Console `ExecStart ... --business "acme"`). Each: activate refuses `unit-flags-drift`, status prints the unreadable line, adopt refuses `check-failed`, and no error names `/a` | T30 copies the fixture repo, so the check stand-in replaces `stack-copy.sh` for that test only. T28's relative path comes from a `unit-path` fault file: the `systemd-analyze` stand-in prints its text as one more unit path. The fingerprint is left out there because the copy's `webui-service.sh` writes its own `--checkout`. ## Mutants `~/darkwing-scratch/r166/mutants.py`: each test is run with `--test-name-pattern`. CONTROL makes no edit and must pass all four tests. | Mutant | Removes | Test | |---|---|---| | 1a | the unit path read check | T28 | | 1b | the drop-in directory read check | T28 | | 1c | the `cat` read, back to `grep 2>/dev/null` | T28 | | 1d | the relative unit path refusal (round 2) | T28 | | 2a | `plan_store`'s recheck | T29 | | 2b | the `--restore` count's recheck | T29 | | 3 | the copy compare with `$COPIES/$ID` | T30 | | 4a | the `Environment=` quote check | T31 | | 4b | the bus `ExecStart` argv quote check, the Console one kept (round 2 narrows it) | T31 | | 4c | the Console `ExecStart` argv quote check, the bus one kept (round 2) | T31 | ## Slot run (sage-0952, round 1) In Sage's slot after row 165 round 2, in `~/darkwing-scratch/r166/wt` (139e28673 plus the candidate), `TMPDIR=~/darkwing-scratch/r166/tmp`. | Run | Result | |---|---| | `stack-release.test.mjs`, the whole file | 71 of 71 pass, T28 to T31 among them | | `stack-copy.test.mjs`, the other test that reaches `stack-release.sh` by name | 12 of 12 pass | | awk sum over both logs | pass 83, fail 0 | | `mutants.py` | CONTROL passes (T28 4, T29 3, T30 1, T31 5 subtests and parents), all 8 mutants killed | Logs: `~/darkwing-scratch/r166/out/full-20261018T232350Z` and `~/darkwing-scratch/r166/out/mutants-20261018T232619Z`. Mutant 2b took 63 s against about 3 s for the others, because without the recheck the rollback goes on with the restore. Its log shows the store moved to `bus.post-attempt-...` and the backup restored, and the test fails on exit 2 where it expects 3. I didn't time the steps inside that run. The script was byte-equal to its backup after the run, and the sweep found no process. A grep of `packages`, `scripts` and `tools` for `stack-release` finds the test file, the script itself, two comments in `stack-copy.sh` and `packages/cli/README.md:648`. No test pins a hash of either manifest file. Correction (round 2): round 1 said the grep found only the test file, `stack-copy.sh` and the script, and left out the README line Filbert named in 29045. Its text describes the verbs, and no verb's wording changes, so it still holds. ## Round 2 (Filbert 29045) Filbert approved round 1 on `d0c58a37...` with three non-blocking notes. Round 2 takes the two test notes and the grep correction. It changes only the test file. `scripts/stack-release.sh` is still `0cfdfa4f...` and its diff is still `stack-release-sh.diff` (`490d6c99...`), the script Mos read. | Note | Round 2 | |---|---| | 1. T31 has no Console `ExecStart` form, so dropping `&& unquoted "$console_argv"` would survive | T31 gets a fifth form, the Console unit's `--business "acme"`. Mutant 4b now drops only the bus check and the new 4c drops only the Console check. Each is killed | | 2. The relative unit path refusal is untested | T28 gets a fourth subtest. A `unit-path` fault makes the `systemd-analyze` stand-in print `systemd/user` as a second unit path, and activate refuses `drop-in-override` "invalid systemd unit path systemd/user" before any stop. The new mutant 1d removes the refusal and is killed | | 3. `docs/TOOLS.md:389-390` doesn't say the copy check must name the target | Doc-only, left as a follow-up (decision 137) | | The grep list leaves out `packages/cli/README.md:648` | corrected under "Slot run". The line is 648 at 139e28673 and 693 after row 156 (Filbert 29054); the text is unchanged | ### Round 2 slot run (sage-0959) In `~/darkwing-scratch/r166/wt`, `TMPDIR=~/darkwing-scratch/r166/tmp`: | Run | Result | |---|---| | `stack-release.test.mjs` | 73 of 73 pass | | `stack-copy.test.mjs` | 12 of 12 pass | | awk sum over both logs | pass 85, fail 0 | | `mutants.py` | CONTROL passes (T28 5, T29 3, T30 1, T31 6 subtests and parents), all 10 mutants killed | Logs: `~/darkwing-scratch/r166/out/full-20261018T235407Z` and `~/darkwing-scratch/r166/out/mutants-20261018T235640Z`. Mutant 2b again took about a minute (64 s), for the reason under "Slot run". The script was byte-equal to its backup after the run, the worktree matched the round 2 manifest and the sweep found no process. Row 156 landed as 4f45cadc0 meanwhile. `git diff 139e28673 4f45cadc0` touches none of the two files, `stack-copy.sh` or `bus-service.sh`. It changes `docs/TOOLS.md` and `packages/cli/README.md`, but no +/- line there names `stack-release`, `stack-copy`, `bus-service` or drop-ins. The round 2 `build.patch` applies with `git apply --cached --check` on a 4f45cadc0 index. ## The candidate 139e28673 plus 2 changed files. | File | sha256 | |---|---| | `candidate-manifest.sha256` (the 2 files) | `4fc0613f2f9dad09d9a2a4ed424f4cde807485c0cee3eb536fec5c768468aced` | | `build.patch` (the whole diff against 139e28673) | `ecfe96dd22191810468007d83267a2e8b779d1329a608f6b454b565fb05e5575` | | `stack-release-sh.diff` (the script part only, for Mos, unchanged) | `490d6c9910648e74b56f9343a9381ad4ad977cbc5f7e13aa9ff8c434cabf88d3` | `scripts/stack-release.sh` is `0cfdfa4f...` and the test file is `123555d6...`. Round 1 was manifest `d0c58a37...` and build.patch `f7c22a97...`; the packet keeps them as `candidate-manifest-r1.sha256` and `build-r1.patch`. `git apply --check -R build.patch` is clean on the worktree. The commit message is `commit-msg.txt`. ## Which script the 8e7ea89a1 window runs The canonical checkout's, not the pinned copy's. Traced from `agents/sage/work/window-plan/repin-8e7ea89a1.md` (blob ca3baaf1, last commit 53f3f29a3): - lines 21-24 set REPO to `/mnt/storage/src/mosaic-stack` "at or after row 162's landing commit `7e7f8e208`" and say "`stack-release.sh` runs from REPO, not from a copy"; - W0 (line 82) is `cd $REPO && scripts/stack-copy.sh copy 8e7ea89a1...`, and W2, W2b, W3 and W5 (lines 84-88) run `scripts/stack-release.sh` by that relative path, so from REPO's working tree; - the copy under `copies/8e7ea89a1...` is only the install target (lines 23-24). At a618528b2 the canonical `scripts/stack-release.sh` is blob 4b16e848, the same as at 7e7f8e208, and the working tree has no change to it. So the window runs row 162's script as long as row 166 hasn't landed. If row 166 lands first, the window runs row 166's script, because "at or after 7e7f8e208" covers it. What that changes for the window's own steps: - W3 `--plan` prints the same lines unless the bus stops during the store read, so the scratch plan in `agents/darkwing/work/queue-162/scratch-plan-8e7ea89a1.txt` still matches. - W5's gate also checks that the bus, Console and CLI agree on `copies/8e7ea89a1...`, the copy W0 makes, rather than on any copy. - W2, W3 and W5 refuse if a live drop-in path can't be read, or if the bus `Environment=` or either `ExecStart` holds a quote. This row doesn't read live state. ## Landing order (mos-0834, via sage-0956) Mos ruled that row 166 is reviewed now and lands only after the 8e7ea89a1 window is done, so the window runs blob 4b16e848, the script Mos rehearsed. Mos read the live units: neither has a quote in `Environment=` or `ExecStart`, and neither has a drop-in. Mos reruns that check after the window, before 166 lands. No fresh P2 is needed. If the window lands anything on `stack-release.sh` first, I rebase and request review again. ## Notes for Mos and the live window - A live unit whose `Environment=` or `ExecStart` holds a quote now makes activate refuse `unit-flags-drift`. `systemctl --user show mosaic-bus@BUSINESS.service -p Environment -p ExecStart` shows it before a window. - The copy compare assumes the copy dir is `$COPIES/$ID`, which `stack-copy.sh` makes.