# Two stack-release.sh rows after the merged B+C window Sage, 2026-10-20. Both rows come out of the merged B+C window and its rehearsals, and both change `scripts/stack-release.sh`. Row A goes first. Row B starts from row A's landing, so neither one builds on a stale base (the row 68 drift rule). Line numbers are at c5ae4d7ac. Nothing here runs against the live stack. Every test uses the stand-in `systemctl`, units, stores and data root that `packages/cli/tests/stack-release.test.mjs` already builds. Q173 makes the live stack a test system, but these rows don't need it. # Brief: activation ids and plan-mode refusals (row A) Vikunja P19 391, plus N1 and N2 from Dewey's row 176 review. Owner Darkwing, reviewer Dewey, gate owner Sage. Base: the refactor head when the row moves to briefed. ## Items 1. Unique activation ids within one second (P19 391). `new_activation()` (:662) names the activation `$(date -u +%Y%m%dT%H%M%SZ)-<12-char copy prefix>`, and the post-attempt store path uses the same second. An activate followed by a pm-on on the same copy in the same second makes the second run's `mkdir -m 0700 "$RS"` (:373) fail, and capture refuses `capture-failed` (exit 3). It fails closed, but it failed row 176 gate run 1 in T34 (stack-release.test.mjs:931, subtest :955), and a pinned clock reproduces it every time. The fix keeps the id sortable by time and keeps the current shape as its prefix, so the ids already in `stack-rollback/`, `backups/stack/` and the pointer still parse. Either add milliseconds or add a suffix on collision. A suffix is chosen while holding the lock, against both `stack-rollback/` and `backups/stack/`. The POST path follows the same rule. Adopt's id (:680) uses the same helper. `--restore ACTIVATION` accepts both the old and the new form, and `docs/TOOLS.md` describes the new one. Test: a `date` stand-in on the test PATH that prints one fixed second. Activate, then pm-on on the same copy, both pass with distinct ids, rollback dirs and POST paths. A `--restore` of an old-form id from the fixture still works. 2. Plan-mode refusals in `console_for_target()` (N1). Under `--plan`, `valid_tester_port ... || usage` (:636) and `refuse tester-port-target` (:637) end the run at the first problem. Every other precheck under `--plan` goes through `check_refuse` (:265), so the plan lists all refusals. With PLAN=1, both lines report through `check_refuse` with a named code. A bad port uses a new code, `tester-port-invalid`, and gives the same reason text usage gives today. Without `--plan`, behavior doesn't change. Test: `--plan --tester-port 80` and `--plan` against a copy whose Console lacks `--tester-port` each list their refusal next to a second, independent refusal from the same run, and exit as a plan with refusals does today. The same flags without `--plan` give today's output byte for byte. 3. Tester port length before zero stripping (N2). `tester_port_ok()` in `scripts/webui-service.sh` (:123) strips leading zeros and only then checks for five digits, so `render --tester-port 007333` passes and `serve` refuses the same value. Check the raw length first (at most five digits), then strip and compare, which is what `valid_tester_port` (:62) in stack-release.sh already does. Test: in packages/webui/tests/service.test.mjs, `render --tester-port 007333` refuses with the usage text and `07333` still renders. ## Acceptance - Each item has the test above and a mutant that the gate kills (mos-0862: any gate test may kill it). - Suites green on Sage's gate, run from a git worktree of the candidate. - Dewey approves the manifest. - Touched files: `scripts/stack-release.sh`, `scripts/webui-service.sh`, their test files, `docs/TOOLS.md`, and the packet under `agents/darkwing/work/queue-/`. Anything else gets named in BUILD.md with the reason. ## Out of scope The restore guard (row B). Any change to the pointer format, the backup layout or a refusal code other than the new `tester-port-invalid`. Any run against `~/.mosaic-dev` or the live units. # Brief: the restore guard counts every row a restore drops (row B) Vikunja P19 390. Owner Darkwing, reviewer Filbert, gate owner Sage. Base: row A's landing. The row waits on row A done. ## Why `rollback --restore` refuses `post-backup-events` unless `--events N` names the number of events written after the backup STAMP (:875 before the stop, :805 again in the run). The count in STORE_JS (:289, `count` and `heldcount` at :298) reads only `events WHERE at > ?`. Some writers skip events. `usage_records` (usage.mjs:92) and `instance_names` (broker.mjs:2237) do, per mos-0838 D3. Darkwing's P3 rehearsal on 8e888014d ran a `name --tester` after the stamp. It got instance_names rowsAfter 1 and eventsAfter 0. The restore printed "0 event(s)" and dropped the row from the live store with no refusal, and only the post-attempt copy kept it. The merged window stopped before any restore on a nonzero snapshot rowsAfter as a stand-in for this fix. ## Items 1. Find every table a restore can lose rows from without an event. Read the writers in `packages/bus/src/` at the base and list, in BUILD.md, each table that has rows written without an event in the same transaction, with the writer's file and line. usage_records and instance_names are on that list. The threads tables from rows 175 and 177 get checked too. The list goes in stack-release.sh as one constant next to STORE_JS. For every counted table, BUILD.md also says whether any writer UPDATEs a row without moving its time column, with the writer's file and line. An update like that escapes an `at > STAMP` count, so a restore drops it silently. This row records each such gap and doesn't have to close it (mos-0927 R1). 2. Count them. STORE_JS `count` and `heldcount` return the rows after STAMP for events and for each listed table, keyed by table name, from the same read in the same open as today (immutable on a stopped store, readOnly on a held one, with the row 166 is-active recheck kept). A listed table the store doesn't have counts 0, because stores before 3g have no instance_names and stores before 3h have no usage_records. A table that exists but can't be read is `restore-mismatch`, as a failed count is now. Test: a fixture store with one event, one usage_records row and one instance_names row after the STAMP gets counts 1, 1 and 1 from both modes. A 3f fixture store with no usage_records table counts 0 for it and doesn't refuse. 3. Refuse on any of them. The guard refuses `post-backup-events` unless every count is zero or `--events N` equals the total. The message names each nonzero table, for example `1 event(s), 1 usage_records row(s), 1 instance_names row(s) were written after the backup`. The flag and the code keep their names, so no caller breaks. TOOLS.md and the usage text say that N counts rows in all listed tables. The in-run recount (:805) compares the same total and refuses `post-backup-events` the same way. Test: instance_names rowsAfter 1 with events 0 refuses before any `systemctl stop` in the stand-in log, the stack stays up, and `--events 1` then restores. The same holds for one usage_records row. T14 (:565) passes unchanged for an events-only store. 4. A guard test for every table (mos-0927 R1). A test reads every table in the target store's schema from `sqlite_master`, whatever its columns. Each table has to be in exactly one of four lists, and the test fails on a table in none of them or in two: 1. events; 2. the counted constant from item 1; 3. always written with an event, each with the writer's file and line in BUILD.md (deliveries, for example, if its writer shows it); 4. not counted by time, each with the reason: a time column other than `at` (projects has `updated`) or no time column at all (meta and the cursor tables). At the head, the migrations create 17 tables and 14 of them have `at TEXT NOT NULL`, so lists 3 and 4 can't be empty. A later schema row that adds a table then has to classify it. ## Acceptance - Each item 2 to 4 has its test and a killed mutant: one per listed table that drops it from the count, one that compares only events in the in-run recount, one that adds a table to the fixture schema without classifying it, and one that lists a table in two of the four lists. - Suites green on Sage's gate, run from a git worktree of the candidate. - Filbert approves the manifest. - Touched files: `scripts/stack-release.sh`, `packages/cli/tests/stack-release.test.mjs`, `docs/TOOLS.md`, and the packet. Anything else gets named in BUILD.md with the reason. ## Out of scope Restoring the dropped rows into the restored store, or merging the post-attempt copy back. A new refusal code or flag name. Any change to the bus writers so they also write events. Any run against `~/.mosaic-dev` or the live units.