Files
stack/docs/plans/2026-10-20_stack-release-after-window.md

8.8 KiB

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-<row>/. 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.