# Row 56 (#1545): runs and releases reader module, candidate packet, round 1 Author: Rocko. Reviewers: Darkwing and Filbert. Brief: `docs/plans/2026-10-10_design-implementation.md`, section "Runs and releases reader module". Base: `dc96f87b` (`base.txt`). `build.patch` also applies cleanly to `923957e2` (origin/refactor at packet time). No file in this candidate changed between the two (`out/apply-*.txt`). The packet is uncommitted. I made no commits, pushes or Gitea calls, and read no token or private binding. ## Files (`files.txt`, 15) | File | Change | |---|---| | `packages/runs/package.json` | new: `@mosaic/runs`, private, no dependencies | | `packages/runs/src/errors.mjs` | new: `RunsError(message, exitCode = 4)` | | `packages/runs/src/paths.mjs` | new: `resolveInside` containment, `readJsonObject` | | `packages/runs/src/runs.mjs` | new: `isRunId`, `listRunIds`, `listRunRecords`, `readRunDocument`, `readRunRecord` | | `packages/runs/src/state.mjs` | new: `readActivePointer`, `readActivationLog` | | `packages/runs/src/index.mjs` | new: re-exports | | `packages/runs/README.md` | new: what it reads, the limits, the deliberate deltas | | `packages/runs/tests/*.mjs` | new: 37 tests (runs 22, state 10, task-cli 5) plus helpers | | `scripts/mosaic-task.mjs` | `list` and `show` read through the module, +18/−27 | | `agents/rocko/work/queue-56/{seed,byte-check,delta-check}.sh` | the byte-identity and delta checks | `build.patch` is `git diff --cached --binary dc96f87b` over those files: +1021/−27. `candidate-manifest.sha256` hashes the 15 files. Q14 freeze: nothing under `packages/bus`, `packages/tasks`, `packages/cli`, `scripts/bus-service.sh` or `scripts/mosaic` changes. Apart from the import, `scripts/mosaic-task.mjs` is the only file under `scripts/` that changes. The module covers the runs view's needs, but the view itself, `release.sh` and pruning are untouched. ## Design - **Containment.** `resolveInside` runs `realpathSync` on the data root and the target, then applies a `path.relative` check: - `..`, `../…` or an absolute relative path counts as outside; - the configured data root is trusted even when it is itself a link; - links that stay inside it are followed. - **Outside paths.** - `runs/`, `state/`, a run directory read by `readRunRecord`, `active.json` and the activation log refuse when they resolve outside. - A run document that resolves outside reads as `null`, the way an unreadable one always has. - **Missing versus broken.** ENOENT and ENOTDIR read as missing. Anything else (ELOOP, EACCES) refuses with the error code instead of reading as empty. - **Documents.** A run document is a JSON object or `null`. Run records are not validated further, because older records carry older shapes. - **Release pointer.** `active.json` is strict: exactly the version 1 shape `release.sh` writes. Bad data exits 2, and a read error exits 4. - **Activation log.** The log is lenient per line, like `release.sh rollback`. It returns `{ entries, malformed }`, and `last` keeps the newest entries. - **Errors.** Every refusal is a `RunsError` with exit code 2 or 4. `mosaic-task.mjs` maps it to `fail(exitCode, message)` through `readRuns`. - **Read-only.** No function writes. The "readers write nothing" test and byte-check's before/after snapshot of the data root both check this. ## Gate (`out/gate/`, sequential, scratch worktree of the candidate) | Suite | Result | |---|---| | packages/runs tests | 34/34 at gate time; 37/37 after the mutant round (`out/runs-tests-final.log`) | | test-auth | 15 passed, 0 failed | | test-conductor | 17 passed, 0 failed | | test-config | 24 passed, 0 failed | | test-discord | first run 57 passed, 1 failed; rerun 66 passed, 0 failed (below) | | test-extension-package | 18 passed, 0 failed | | test-foundation | 44 passed, 0 failed | | test-queue | 27 passed, 0 failed | | test-release | 14 passed, 0 failed | | test-task | 98 passed, 0 failed | **test-discord's first-run failure was environmental.** The failing case was "pi binary present at node_modules/.bin/pi for the extension checks". The scratch worktree has no `node_modules`, and the base worktree lacks it too. I symlinked the checkout's `node_modules` into the scratch tree and reran the suite: 66 passed, 0 failed. The extra 8 cases are the extension checks that the missing binary had skipped. Then I removed the symlink (`out/gate/test-discord-r2.log`). Nothing in this candidate touches packages/discord. Sage's rerun in a tree that has `node_modules` should see 66. After the gate, the only changes were tests, the README table row and the check scripts. No `src/` or `scripts/mosaic-task.mjs` change came after it. ## Byte identity (`out/byte-check.txt`) `byte-check.sh BASE CAND WORK` seeds a data root with `seed.sh`. The seed holds: - runs a–f; - a run that is a file; - an internal link and a dangling link; - `r-zz.weird_name-1`; - a non-`r-` name, `.pruned.log` and a stray file. It runs 16 cases from both trees: `list` on seeded, empty and absent roots, and `show` on each run, a missing run, `../escape`, no argument and an absent root. It diffs stdout, stderr and exit codes. ``` byte-check: 16 cases, stdout, stderr and exit identical byte-check: data root unchanged ``` **Correction to my own harness.** My first rerun in this round passed a relative WORK_DIR. Every redirect inside the `cd` subshell failed, both trees recorded exit 1, and the diff still reported "identical": a false pass. Earlier runs used absolute paths and were not affected. Both scripts now make WORK_DIR absolute before doing anything else. `byte-check.sh` also exits 4 if any case leaves no stdout or stderr file. The output above comes from the fixed script. ## Deliberate deltas (`out/delta-check.txt`) These cases fall outside the byte-identity domain: a link out of the data root, a document that is JSON but not an object, a run that is a file, a link loop, and an unreadable directory. Each one changes on purpose. The README table lists them, and `delta-check.sh` prints base against candidate: - run directory linked out: `list` shows `unknown`; `show` refuses, exit 4. - `result.json` linked out: `list` shows `unknown`; `show` prints `(missing or unreadable)`. - `runs/` linked out: `list` and `show` refuse, exit 4. - `result.json` is `5`: base `list` crashed (exit 1); base `show` printed `undefined` fields. The candidate gives `unknown` and `(missing or unreadable)`. - `task.json` is `[]`: base printed the snapshot line; the candidate omits it. - run is a regular file: base `show` crashed in `readdirSync`; the candidate gives `run not found`, exit 4. - link loop: base said `run not found`; the candidate refuses with ELOOP, exit 4. - `runs/` unreadable: base `list` printed nothing, exit 0; the candidate refuses with EACCES, exit 4. ## Mutants (`out/mutants.sh`, `out/mutants.txt`) I ran 32 single-line mutants of `src/` and `scripts/mosaic-task.mjs` against the packages/runs tests. First round: 26 killed, 4 survived and 2 didn't apply. Three of the survivors were real gaps, and I added a test for each: - **M02** dropped the exact `..` check. A link to the data root's parent got through. New test: "a link to the data root's parent is outside it". - **M09** dropped the sort. Node returned this directory already sorted, so no fixture could catch it. New test: it mocks `fs.readdirSync` to return the names reversed. - **M15** read every readdir error on a run directory as missing. New test: "an unreadable run directory refuses instead of reading as missing". Final round: 30 killed, 2 survived. Both survivors are equivalent: - **M04** dropped `!path.isAbsolute(relative)`. On POSIX, `path.relative` between two absolute paths is never absolute, so the branch is unreachable on Linux. I kept it as a guard for other platforms. - **M31** dropped the CLI's `isRunId` pre-check in `show`. `readRunRecord` throws the same message with exit 4, and `readRuns` maps it to the same `fail`. The pre-check stays so that an invalid id refuses before the config loads, as it did before. ## Follow-up (not fixed, out of scope) This defect predates the candidate and is unchanged: `list` crashes with a `padEnd` TypeError when `result.json` is an object without a string `status` or `taskId`. Base and candidate behave the same here, so byte-identity holds. A fix would change `list` output and belongs in its own row. ## Reproduce ```sh git worktree add --detach /tmp/r56-base dc96f87b git worktree add --detach /tmp/r56-cand dc96f87b (cd /tmp/r56-cand && git apply --index /build.patch && sha256sum -c /candidate-manifest.sha256) env -u NODE_TEST_CONTEXT node --test '/tmp/r56-cand/packages/runs/tests/*.test.mjs' bash /tmp/r56-cand/agents/rocko/work/queue-56/byte-check.sh /tmp/r56-base /tmp/r56-cand /tmp/r56-bc bash /tmp/r56-cand/agents/rocko/work/queue-56/delta-check.sh /tmp/r56-base /tmp/r56-cand /tmp/r56-dc ```