# Queue as data (#1508, QUEUE rows 9–13): build plan Author: Filbert (T3 thread 9cb9731e), for Sage (lead). Planning only; no source edits. Written 2026-09-26 against HEAD 43d7574d plus the shared uncommitted tree. Brief: `docs/plans/2026-09-13_queue-as-data.md`. Evidence was gathered read-only. Two read-only Gitea calls were made (`GET user`, `GET issues/N` for the issues the queue references). > **Section 8 is the active specification (round 6).** Where sections 0–7 > differ from it, section 8 wins; sections 0–7 are kept as the record of > how the design got here. ## 0. Status (updated 2026-09-26) **Round 6:** Rocko's round 5 (`agents/rocko/work/queue-as-data-adversarial-r5-2026-09-26.md`, 3b031a70…177e) returned "revise" with two medium findings. G1: Gate G pinned session evidence that Pi hasn't written yet. G2: matching hook bytes don't prove the guard runs. Section 8 answers both (8.11, 8.12), and 8.19 maps them. No owner ruling was needed. **Round 5:** Rocko's round 4 (`agents/rocko/work/queue-as-data-adversarial-r4-2026-09-26.md`, fcb8933d…efcff) returned "revise", with the lead choices accepted and five residuals, F1–F5. Section 8 answers them, and 8.18 maps each one. Jason's per-seat token ruling for D is in 8.9. No new owner ruling was needed. **Round 4:** Rocko's round 3 (`agents/rocko/work/queue-as-data-adversarial-r3-2026-09-26.md`, 13a32804…a274) returned "revise", with findings T1–T8 and the architecture and rulings accepted. Section 8 now answers them, and 8.17 maps each one. Lead-level choices for Sage are listed at the end of 8.15. No new owner ruling was needed. **Round 3:** Rocko's round 2 (`agents/rocko/work/queue-as-data-adversarial-r2-2026-09-26.md`, b3a2d72a…e70f4) returned "revise". Section 8 applies Sage's direction: fail closed, manual recovery verbs, one host, canonical checkout only. One departure is stated in 8.0: the log lives inside `queue.json`, so there is no journal file to tear. Sage, deciding as lead on Jason's instruction, has ruled on every open item, Q1–Q4 and J1–J9 (8.15). The round-2 text below about nine items, the journal file and incarnation is superseded. Sage (lead) reviewed this plan and decided Q5–Q9 and the transition fixes in 5.3 and 5.4 (see section 4, "Decided"). Q1–Q4 are the open set. Sage is holding them for Jason until his next-phase target arrives, since that target may settle Q2. Build assignment: not yet made. Rows 9–13 wait for row 6 to close and for Jason's target. When they open, the brief's split stands: Darkwing builds, Filbert reviews. Sage may move Filbert to author if Darkwing is still on row 6. What each open question gates: | Open | Gates | Buildable before the ruling | |---|---|---| | Q1 Gate G wording | Gate G only | A, B, C, D, E | | Q2 T3 identity and runtime | Gate G; `next` with no seat argument | A (refuse without identity), B, C, E | | Q3 automatic commit | A's write path | A with no commit step (recommendation changed, section 7) | | Q4 posting identity | D | A, B, C, E | **Round 2 (2026-09-26):** Rocko's adversarial review (`agents/rocko/work/queue-as-data-adversarial-2026-09-26.md`, SHA-256 df433f93…2cbf5c, reviewed this plan at 59d5a22f…) returned "revise" with 14 findings. Section 7 disposes of each. Sage amended Q6 (per R9) and Q7 (per R8). **The Q3 recommendation has changed: no automatic commit.** Section 7.2 lists nine further items that need Jason (J1–J9), plus the two Sage is already holding for him (reduced liveness gate, E before D). ## 1. Build order and dependencies ``` row 6 closes (Gate F or blocked) └─ A queue record + CLI + render + migration (row 9) ├─ C brief template + `add` brief refusal (row 11, ships inside A's round) ├─ B AGENTS.md + CONTEXT.md point at the command (row 10) │ └─ Gate G demonstration (verifies A and B) ├─ D review request posted by `move in-review` (row 12; needs identity ruling Q4) └─ E ledger queue section (row 13; Q6, Q7 decided) ``` - **A first.** Every other piece reads or writes `queue.json`. - **C with A.** The `--brief must exist` refusal is A's code. The template is one file. Shipping them separately would mean a second review round on the same CLI for one check. - **B after A, before Gate G.** Gate G tells the seat the command directly, so it could technically pass without B. Running it after B also verifies B, which the brief says Gate G does. - **D and E are independent of each other** in code. The brief says A to E ship in order, so E follows D unless Jason allows E first while Q4 holds D (R14, needs Jason). E's rulings (Q6, Q7, as amended) are decided. - **A is larger than the brief's "one commit-sized scope"** after round 2: the journal, lock and verify (7, R1/R3) roughly double it. It may land as two commits under one row: A1 = journal, lock, CLI and verify; A2 = migration, render and dispatch. Sage's call. - **Migration is part of A.** The 25 current rows and the parked table have to become `queue.json` in the same commit that makes QUEUE.md a rendered view. Otherwise there are two sources of truth. ## 2. Files and packages per piece (as the code stands today) ### A (+ C) New: - `packages/queue/package.json`: same shape as `packages/seat/package.json` (`type: module`, `engines.node >=24`, `test: node --test tests/`, no deps). - `packages/queue/src/queue.mjs`: schema validation, transition table, `next`, `render`, and the journal/lock/verify protocol in 7.1 (R1, R3). This replaces the earlier "atomic write under a lock file". - `docs/plans/queue.log.jsonl`: the append-only operation journal (7.1). - `packages/queue/src/cli.mjs`: `list`, `next`, `add`, `move`, `assign`, `note`, `render`, plus `render --check` (see 5.9). Exit codes follow seat: 0 ok, 1 operation failed, 2 invalid data/refused transition, 4 usage. - `packages/queue/tests/queue.test.mjs` and `tests/fixtures/`. - `packages/queue/README.md`. - `docs/plans/queue.json`: the migrated rows. - `docs/plans/BRIEF-TEMPLATE.md` (C). - `scripts/test-queue.sh`: see 3.A. Package tests for seat, ledger, control-board and webui are in no suite script today. Nothing but a manual `node --test` covers them, so "commit only after suites are green" does not cover them either. Changed: - `scripts/mosaic`: today it is `exec node packages/seat/src/cli.mjs "$@"` with no dispatch. It needs `queue) exec node packages/queue/src/cli.mjs` and a fallthrough to seat. `launch` and `seat task` behavior must stay byte-identical, because every `agents/*/launch.sh` and `scripts/test-{darkwing,rocko}-launch.mjs` go through it. - `docs/plans/QUEUE.md`: marker comments around the Pieces table. The hand-written header stays above the markers, minus the "Current owner priority" paragraph, which `after` replaces (Q8). The parked table's items become `parked` rows (Q9). The log of table changes stays below the markers, frozen as history (5.12). - Row 7 (weekly ledger run) leaves the queue (Q9). A's scope excludes `packages/ledger`, so A leaves a one-line hand-written pointer to the weekly routine below the markers. E moves the routine into `packages/ledger/README.md` and removes the pointer. - `docs/TOOLS.md`: usage lines. This file carries other owners' uncommitted changes; add a scoped patch the way #1511 did. ### B - `AGENTS.md` lines 112–117 (Cadence) and 190 (pointer). - `agents/{darkwing,dewey,filbert,rocko,sage}/CONTEXT.md`: each has a line sending seats to `docs/plans/CURRENT.md` "to reconcile ownership and existing gates" (darkwing/dewey/filbert/sage line 18, rocko line 15). Researcher has no such line. The brief says `agents/*/AGENTS.md`, but no seat has one; the files are `CONTEXT.md`. - Out of B's scope, noted for Sage: `agents/filbert/SOUL.md` and `CONTEXT.md` still name Darkwing as team lead. ### D - `packages/queue/src/{queue,cli}.mjs`: `move ID in-review` builds and posts the request comment, then records the comment id on the row. - `scripts/gitea-api.sh` is unchanged if the identity comes from `MOSAIC_GITEA_CREDENTIAL_FILE`, which it already honors (see Q4). - `packages/queue/README.md`, `docs/TOOLS.md`. ### E - `packages/ledger/src/ledger.mjs` (new `queueChecks`), `src/cli.mjs` (section above the weekly table, `--json` key), `tests/ledger.test.mjs`, `README.md` (counting rules for the new section, and the weekly routine that was row 7). - One more Gitea call, `state=open`, one page; a full page fails as today (Q7). Referenced issues absent from that list count as closed. - Reads `docs/plans/queue.json` through the queue validator by relative import (`../../queue/src/queue.mjs`). There are no workspaces, so a bare `@mosaic/queue` import would not resolve (R14). - Reads registrations with `loadRegistrations` imported relatively from `packages/control-board/src/scan.mjs`, the way scan.mjs already imports `../../seat/src/seat.mjs`. It is not moved, so no ownership expands. Liveness classification is E's own code (R9, 7.1). ## 3. Tests that prove each piece ### A - Schema: every field and type, `id` uniqueness, `id` never reused after deletion (the CLI never deletes a row; test that no verb can), `blockedReason` required iff blocked, unknown field refused, owner outside the allowed set refused, `after` naming a missing id or itself refused, a dependency cycle refused, `required` a boolean, `state: "required"` refused. - Transitions: a table-driven test with one fixture per allowed edge and one per refused edge. Refusals must write nothing (file hash unchanged) and print exactly one stderr line with exit 2. Includes in-review→in-progress (allowed), and parking a row with `required: true` (refused). - Authority: a seat moving another seat's row is refused. `jason` and `sage` may move any row (Q5). `coordinator` as an owner may move only its own rows. A missing identity (no `MOSAIC_AGENT_NAME`, no `--by`) is refused. - `next`: picks in-progress, then in-review, then briefed, each by id, not file order (shuffle the fixture array); skips rows whose `after` dependencies are not done (Q8); never returns `parked`; prints `nothing` with exit 0; the reviewer case (5.11). - Render: byte-stable (render twice, compare), idempotent over an unchanged file, `|` and newlines in text escaped. Only the marker region changes, so a hash of the header and footer bytes is unchanged. - `render --check`: exits 2 when the table between the markers was edited by hand. - Concurrency and recovery: superseded by 7, R1 and R3 acceptance lists (races through both checkout paths, kill at every step, dead lock holder, disk errors, hand-edited valid JSON, forks). - Migration: a fixture of today's QUEUE.md table migrates to a valid `queue.json`. The rendered table matches a reviewed golden file. - Dispatch: `scripts/mosaic launch` and `seat task` produce the same argv/env capture as before (reuse the capture pattern in `scripts/test-darkwing-launch.mjs`). - C: `add --brief missing/path.md` refused, `add --brief docs/plans/X.md#Section` accepted when the file exists (the section is not verified; say so in the README). - `scripts/test-queue.sh` runs the above plus `render --check` against the real `docs/plans/queue.json`, so the committed queue is validated by a suite. ### B - `grep -n "CURRENT.md" agents/*/CONTEXT.md` shows no "what now" pointer. AGENTS.md cadence names the command. - `agents//launch.sh --check` still passes for every seat, since context files feed the launch snapshot. ### D - A fake Gitea tool, injected the way `readIssues(root, range, tool)` already allows in ledger. `move in-review` posts exactly one comment, built from row fields and `--candidate`. A post failure leaves the row unchanged, exits 1 and prints one stderr line. No file is created under `docs/plans/reviews/` or `agents/*/work/`. - Identity: the comment is posted with the credential file the ruling names. The test asserts that the default `~/secrets/mosaic.gitea.json` is refused. ### E - Fixtures: queue.json, an open-issue list and a registrations dir. One test per rule, both pass and fail, including a multi-row issue (5.10). A seat with no registration appears on its own labeled line and is not counted as a violation (Q6). A full page from the `state=open` call fails the run (Q7). Violations appear above the table in text and under a `queue` key in `--json`. Zero violations prints a "queue: 0 violations" line, not nothing, so an absent check is distinguishable from a clean one. ### Gate G demonstration Setup (Sage or Darkwing, before Jason watches): 1. One real row in `briefed` state, owned by the test seat, with an existing brief written from BRIEF-TEMPLATE, and no other row that seat owns in in-progress, in-review or briefed. 2. The seat starts with no prior session: `agents//launch.sh --fresh` in its tmux pane. The Pi launcher exports `MOSAIC_AGENT_NAME` and writes the registration, so the board shows the seat. A T3 thread does neither (Q2). Run: Jason sends exactly `run \`scripts/mosaic queue next\` and do it` (Q1). Pass evidence: superseded by 7, R5. The earlier version relied on the ledger's Table 2 count, the git log and "first file read is the brief". Rocko showed that all three can pass with extra human help or no work. ## 4. Questions ### Open, for Jason (Q1–Q4; Sage is holding these until the next-phase target) **Q1. The `mosaic` name collides.** On this host, `mosaic` on PATH is `~/.npm-global/bin/mosaic` 0.0.50-next, the estate CLI. It already has a `queue` command group ("Manage Mosaic job queues": list, drain, pause, stats…), so `mosaic queue next` prints `error: unknown command 'next'` and `mosaic queue list` would run the estate's job-queue listing. The repo CLI is reachable only as `scripts/mosaic` (its own header says so). *Recommend:* Gate G's sentence becomes ``run `scripts/mosaic queue next` and do it``. It needs no PATH change and does not touch `~/.mosaic` or the npm-global install. Renaming the repo CLI is the alternative, but it is larger and gains nothing until the estate CLI is retired. **Q2. Which runtime runs Gate G?** Development runs in T3 now. T3 threads set no `MOSAIC_AGENT_NAME`, write no registration, and are not on the board. This thread confirms it: the variable is absent. `queue next` with no seat argument therefore cannot know who is asking. *Recommend:* run Gate G on a Pi launcher (`agents//launch.sh --fresh`), which sets identity and registers, so Jason can watch from the board as the brief says. Separately, decide whether T3 seats should export an identity. Until then, `next` with no seat and no env refuses rather than guessing. **Q3. Who commits `queue.json`?** The brief says the file is committed, and the CLI writes it on every verb. In one shared checkout, an uncommitted `queue.json` gets swept into whichever seat commits next. That is the exact failure the brief cites. QUEUE.md is dirty in the tree right now. *Recommendation changed in round 2 (R2): no automatic commit.* The first version recommended `git commit -- docs/plans/queue.json docs/plans/QUEUE.md` after each write. It was withdrawn for three reasons: - A path-limited commit takes the whole file, so another author's uncommitted edit to QUEUE.md's hand-written header goes into the queue's commit. - An ordinary `git commit -a` from another seat does not honor the queue lock and can still take the queue files between a write and its commit. - My claim that "the validator means invariant 8 holds" was false. Schema validation is not the applicable suite. New recommendation: the CLI never commits. The lead commits the queue files (`queue.json`, `queue.log.jsonl` and the rendered table) at gate points, by explicit path, after `scripts/test-queue.sh` passes, as QUEUE.md is committed today. The journal (7.1) keeps every operation between commits, so no history depends on commit timing. If Jason still wants automatic commits, R2's preconditions are the minimum: - the queue paths are clean against the last queue revision; - nothing is staged on them; - no merge or rebase is in progress; - the branch is `refactor`; - HEAD is unchanged since the read; - test-queue.sh passes on the exact prospective content. Even then, other seats' ordinary commits cannot be prevented. That option needs Jason's yes. **Q4. Whose Gitea identity posts review requests (Piece D)?** `scripts/gitea-api.sh` defaults to `~/secrets/mosaic.gitea.json`, which authenticates as `jason.woltje` (checked with `GET user`). Earlier rounds on #1507 and #1509 established "no Jason-default issue writes". Seat tokens live under `~/.mosaic/fleet/agents//secrets/`, the fleet tree being retired, and Jason's jarvis permission covers git, not issue comments by arbitrary seats. *Recommend:* D posts only with an explicit per-seat credential file passed through `MOSAIC_GITEA_CREDENTIAL_FILE`. Where that comes from is Jason's call (new repo-native seat tokens, or read-only use of the fleet token files). Until he rules, D refuses to post with the default file. D is blocked on this; A, B, C and E are not. ### Decided by Sage as lead, 2026-09-26 (Q5–Q9) Recorded from Sage's message to Filbert's T3 thread. Each question keeps its original reasoning, followed by the decision. **Q5. Who may move any row?** The brief says `coordinator` and `jason`. `coordinator` was the Claude #1509 session. Sage has led since 2026-09-26. *Decided:* privileged identities are `jason` and `sage`. `coordinator` stays an owner label, for the #1509 rows, with no override authority. **Q6. Piece E's "live registration" check and T3 seats.** Every seat now working in T3 has no registration, so the check would report every T3 row as a violation. *Decided:* "no registration" goes on its own labeled line and is not counted as a violation. No T3 liveness probe; the T3 guide forbids building Mosaic features on the T3 tools. *Amended by Sage per R9 (2026-09-26):* E keeps five states separate: explicitly unsupported runtime (T3), missing expected registration, invalid record, stale record, and live matched record. Only T3 gets the labeled exemption. E prints a coverage line. Zero violations with liveness untested is not a full pass. Accepting that reduced gate is Jason's call (7.2). **Q7. Piece E's dates and fetch rule.** "First run Monday 2026-09-21" has passed. The ledger's one-call rule (issues updated since the start date, one page of 50) misses open issues that were not updated in range, such as #1503. *Decided:* the first run is the Monday after E is approved. E adds one more call (`state=open`, one page, a full page fails as today). ~~Referenced issues absent from that list count as closed.~~ *Amended by Sage per R8 (2026-09-26):* an issue absent from the open list is **unknown**, not closed. A done row needs positive closure evidence; without it, E reports incomplete evidence, never success. `--no-issues` makes the issue checks unknown, not zero violations. **Q8. The owner-priority override.** QUEUE.md's header carries a "Current owner priority" paragraph that overrides row order. That is state in prose, which the brief exists to remove. Rows 9–13 also "start when row 6 is done", a dependency the schema cannot express. *Decided:* add `after: [ids]` to the schema. `next` skips a row until its dependencies are done, and orders in-progress, then in-review, then briefed, each by id. No free-text priority field. **Q9. Rows the schema cannot hold.** Row 7 (weekly ledger run) is recurring: it is never done and must never change. The parked table's five items have no owner or issue. *Decided:* row 7 moves to `packages/ledger/README.md` as the weekly routine (sequenced across A and E; see 2.A). The parked items become rows with state `parked`, owner `unassigned` and issue null. ## 5. Where the brief contradicts the code or itself Sage decided the fixes in items 3 and 4 (in-review→in-progress, `required` as a flag), and they are marked **Decided**. Items 1 and 2 are Q1 and Q2. After round 2 (R11), items 4 (remainder), 6, 7, 10 and 13 change owner requirements and are in 7.2 for Jason; they are not for A's round. Items 5, 9, 11, 12 and 14 are revised in section 7 and are builder detail. 1. **`mosaic queue next` resolves to the wrong program** (Q1). Verified on this host. 2. **Gate G assumes a launcher identity that T3 seats lack** (Q2). Verified: `MOSAIC_AGENT_NAME` is set only by `scripts/agent.sh`, `scripts/agent-host-dev.sh`, `agents/rocko/launch.sh` and the discord engine child. 3. **`required` as a state loses information.** Rule: required→in-progress only. After that move, nothing records that the row was required. E's "required row older than 14 days" can then only see rows never started, and "required rows cannot be parked" is enforced only by the absence of a path. **Decided (Sage):** `required: true` is a boolean on the row, alongside the normal state machine, and `required` is no longer a state. `parked` is refused while the flag is set. Proposed detail: only `jason` can clear it, following QUEUE.md's existing "only Jason moves" rule for required rows. 4. **No way back from review.** The listed chain queued→briefed→in-progress→in-review→waiting-on-jason→done has no in-review→in-progress edge. Every changes-requested round (#1511 R1→R2, #1512 R1 provenance) would need `blocked`. It also forces every piece through `waiting-on-jason`, while rows 14–20 closed without an owner step. **Decided (Sage):** add in-review→in-progress. Still proposed: in-review→done where the row's gate is not Jason's, made per-row with a `gateOwner` field or a `waiting-on-jason` requirement flag. 5. **`blocked→previous` needs storage.** Add `blockedFrom`, set on entry and cleared on exit. 6. **`queued` is unreachable if `add` requires a brief.** State `queued` means "no brief yet", but C makes `add` refuse a missing brief. *Fix:* `--brief` is optional on `add`, and must exist if given. queued→briefed requires an existing brief. 7. **Parked rows "never change" vs "needs Jason to reopen"** (QUEUE.md's parked table). *Fix:* parked→queued/briefed with `--by jason` only. 8. **`--by jason` is a claim, not proof.** The CLI cannot check who typed it. Every agent commits as the same git user. `packages/seat` handles `taskSetBy` the same way and says so ("authorizes nothing"). Iron-clad point 5 ("only Jason moves them") is not enforceable by the CLI. *Fix:* state this in the README, and have E list every change to a required or parked row, with its `updatedBy`, for Jason to confirm weekly. A real check needs a signed approval, which is out of this brief's scope. 9. **Rendered view with hand edits.** Nothing stops a hand edit between the markers, and the next write silently erases it. *Fix:* `render --check` in `scripts/test-queue.sh` fails on drift. 10. **E's issue rules break on multi-row issues.** "Every open issue with #N in a row has that row not done": #1509 is open (checked) and rows 14, 15, 17, 19 and 20 are done. That is five violations on day one, and the same shape exists for #1503 (rows 1, 22) and #1508 (rows 9–13). *Fix:* per issue, open ⇔ at least one row not done, and closed ⇒ all rows done. 11. **Owner and issue fields hold one value; today's rows hold several.** Examples: "dewey; filbert reviews", "#1511 code phase; #1512 pilot". *Fix:* add `reviewer` (seat or null) and `issues: [int]`. `next` for a reviewer returns rows in `in-review` where they are the reviewer. That is how Filbert's review work would reach Filbert through the command. 12. **State cells are paragraphs.** Rows 6, 15, 17 and 23–25 carry 375–775 characters of history in State; seven more rows exceed 150. The schema's `note` is one line. *Fix:* migration keeps a short note, and the history stays where it already is (CURRENT.md, issue comments). The journal is the change log, and its genesis entry keeps each original row verbatim (7, R13; this replaces "Q3's per-write commits"). The hand "Log of table changes" is frozen as history. 13. **Piece D's pass criterion is already trivially met.** Since 2026-09-13, review receipts have gone to `agents/darkwing/work/*/r*-review.md`, not `docs/plans/reviews/` (161 entries, last change ea00ec66/11659cf2). The side channel moved; it did not close. *Fix:* the criterion counts new review files anywhere, including `agents/*/work/`. 14. **The review request needs a candidate identity the row does not have.** Current practice pins a frozen snapshot plus a SHA-256 manifest. *Fix:* `move ID in-review --candidate `, required, included verbatim in the comment. 15. **Ownership text is stale.** The brief names "Coordinator: the Claude session" and darkwing as builder. Sage leads now and has confirmed the brief's split for when the rows open (section 0). ## 6. Scope held No source, record or queue edits were made for this plan, and nothing was committed or pushed. The only repository writes this session are one line appended to `docs/SESSIONS.md` (registration) and this file. ## 7. Disposition of Rocko's adversarial review (round 2) Source: `agents/rocko/work/queue-as-data-adversarial-2026-09-26.md`, SHA-256 `df433f932f6e8687fdb2ad6d707d133b633a245ac39293362ce6bde6942cbf5c`, verified before reading. It reviewed this plan at `59d5a22f…1fdb`. I reproduced Rocko's two probes. `messageKind` classifies the T3 `[from: sage (…) -> to: …]` header as `human`, and `import.meta.resolve("@mosaic/seat")` throws `ERR_MODULE_NOT_FOUND`. I also checked R13's fresh-clone concern against today's rows. Five briefs the queue points at are untracked in git: relaunch-activity (row 6), task-attribution (row 6), internal-development-bootstrap (row 16), discord-board-row (row 18) and board-attention-status (row 22). **No finding is rejected.** Ten are accepted as written and four (R1, R3, R6, R7) are accepted with a modification, each stated below. "Needs Jason" marks a change to an owner requirement or gate. The items are collected in 7.2. ### 7.1 Findings **R1 (High), two-file write without a crash contract: accept-modified.** Rocko is right that an atomic rename protects each file, not the pair or the sequence. Plan change: - *Truth and views.* The journal `docs/plans/queue.log.jsonl` is the record of operations, `queue.json` is the validated snapshot, and the rendered table is a view. `list` and `next` read only `queue.json`, and only after verify. No reader ever takes QUEUE.md as current. - *Revision stamps.* Each snapshot carries `revision` and the SHA-256 of its journal tail. The rendered table's begin marker carries the same revision and hash, so anyone can see a stale view. - *Lock.* The lock is a directory at `$(git rev-parse --git-common-dir)/mosaic-queue.lock`, realpath-resolved. The checkout and its symlink `~/src/mosaic-stack-dev-test` therefore share one lock, and nothing new is added at the repository root. Every verb, including `list`, `next` and standalone `render`, takes the lock before reading. - *Stale locks.* The owner file records the PID and its `/proc//stat` start time. A lock is stale only when that PID is dead, or alive with a different start time. It is never stale because a deadline passed. Waits are bounded (10 s), then the verb refuses and prints the owner. - *Write order under the lock:* 1. Verify the current state (R3). 2. Append the journal entry and fsync. 3. Write the `queue.json` temp file, fsync, rename, fsync the directory. 4. Render the table and rename it into place. 5. Acknowledge on stdout. - *Recovery on entry, under the lock:* - Journal tail present, snapshot equal to the tail's parent: roll forward by replaying the tail. - Snapshot current, view stale: re-render. - Anything else: refuse, and print the verify output with a recovery instruction. - *Retries.* `add` refuses when an identical non-terminal row exists (same piece, owner and brief) and prints that row's id. A `move` to the state the row already holds reports the earlier journal entry and exits 0. A retry after an unacknowledged crash therefore applies at most once. - *Durability.* The README states that only acknowledged operations survive a host crash, and only because of the fsyncs above. Modification: I use provable stale-owner detection and do not introduce a kernel `flock`, because Node has none built in, and spawning `flock(1)` would tie the lock to the bash wrapper and not to the CLI the tests call. Acceptance: Rocko's list as written (add/add, move/note and render/write races through both paths; a kill after each step; injected disk errors; a dead lock holder; a reused PID). **R2 (High), Q3's path-limited commit takes other authors' work: accept. This changes my Q3 recommendation to "no automatic commit"** (see Q3). Plan change: - A has no commit step, not even behind a flag. - The lead commits queue files by explicit path after `scripts/test-queue.sh` passes on the exact content being committed. - The invariant-8 claim is withdrawn. The checks that apply to a queue-data commit are `scripts/test-queue.sh`: `node --test packages/queue/tests/`, verify, and `render --check`. A commit that also changes code adds that code's suites. - Package tests are not exempt because they lack a shell wrapper; test-queue.sh runs them. If Jason chooses automatic commits anyway, R2's preconditions and acceptance list apply as written. **R3 (High), checks validate appearance, not provenance: accept-modified.** Plan change: - `queue verify` replays the journal from its genesis entry (the reviewed migration, R13) and requires the replay to equal `queue.json` byte for byte. Each entry is checked against its parent for authority (R4), legal transitions, immutable ids and no deletions. - The high-water id is the maximum id ever issued, and retired ids stay as tombstones. Row 7 is recorded as retired in genesis. - Every mutation runs verify first and refuses to overwrite unexplained drift in `queue.json` or between the markers. Missing or duplicate markers are refused. - A fork (two entries with the same parent, for example after a merge or from a second clone) blocks writes until the lead reconciles it with a new journal entry. A second-clone writer is out of scope and is detected, not supported. - A fresh clone can run verify from committed files alone. Modification: the honest boundary goes into the README and the #1508 gate comment in Rocko's words: this is a cooperative writer with drift detection. Code running as the same host user cannot stop that user rewriting both the data and the journal. No hooks. Acceptance: Rocko's list as written. **R4 (High), identity attribution used as authorization: accept. Needs Jason (trust model and the required-row rule).** Plan change: a permission matrix is added to A. "Claimed" means from `--by` or `MOSAIC_AGENT_NAME`, recorded in the journal as a claim. | Verb or field | Who (claimed) | |---|---| | `add` | any identified seat for its own rows; privileged (`jason`, `sage`) for any owner | | `move` (ordinary) | row owner or privileged | | `assign`, `reviewers`, `after`, `gateOwner`, `issues` | privileged only; `after` on a required row: `jason` only | | `required` set | privileged; `required` clear: `jason` only | | park / unpark | `jason` only, refused while `required` | | `note` | owner, a reviewer of the row, or privileged; never on a done row | | done rows | immutable; nothing changes them | Also: `assign` cannot be used by a seat on its own row to launder a move. A non-required row cannot be added to a required row's `after` (so required work cannot be subordinated, R6). "Only Jason moves required rows" (brief point 5) conflicts with the brief's own "required→in-progress". I read "moves" as reorder or park, which matches QUEUE.md's header ("cannot be parked or reordered below queued rows; only Jason moves it"). **Needs Jason:** confirm that reading. **Needs Jason:** on this host every seat runs as the same Unix user, and the default Gitea credential authenticates as jason.woltje. No human gate is available that the CLI could verify. Recommend: Jason accepts the cooperative trust model for #1508, with detection through the journal and E's weekly list of every protected change, as the brief's scope. Enforcement would need a separate credential or user boundary, which is outside #1508. No `roles/*.json` change is implied. **R5 (High), Gate G can pass with extra help or no work: accept.** Plan change, replacing the old pass evidence: - *Pin before the run:* the fresh session file path, the launch snapshot under `.pi/state//launches/` and its hash, the `queue.json` revision, the brief's hash, and the start time. - *Coaching audit:* diff the launch snapshot against committed SOUL, CONTEXT and USER inputs. Any row- or task-specific text is a fail. Generic governance context is allowed. - *Input audit:* after Jason's instruction, **any** further user-role entry in that session, in any format, fails the gate until Jason's pass or fail. That covers board replies, agent relays, the T3 header and tmux sends. The ledger's `messageKind` is not used for the count: it classifies the T3 header as human and a board-routed correction as board. - *Claim check:* the journal entry moving the row to in-progress must carry the pinned launch's `MOSAIC_LAUNCH_INCARNATION`. - *First work action:* at least one concrete action from the brief's "What ships" in that session (an edit or a command), not only reads. Normal prerequisite reads before the brief are allowed. - *Verdict:* Jason's observed pass or fail stays authoritative. A commit is not required and not evidence. - *Checker:* `packages/queue/src/gate-g.mjs`, a read-only evidence checker, with negative-control fixtures that must fail: a board correction, a relayed hint, a resumed session, a helper moving the row, and reads with no work action. - *Selection* among competing eligible rows is tested in unit tests, not by the single-row demo. Follow-up outside #1508, for Sage: `messageKind` also counts the T3 header as human in the weekly Table 2 number (row 7). That is a ledger bug in its own right. **R6 (High), dependencies and `next` do not stop unauthorized starts: accept-modified. Needs Jason (the row-9 start condition, only as a brief wording check).** Plan change: - *Predicates at transition time.* `move` checks the `after` and authority predicates under the lock, the same predicates `next` uses. A seat that bypasses `next` is refused. - *Claims.* Moving to in-progress records the claim: seat plus `MOSAIC_LAUNCH_INCARNATION` when present. in-progress→in-progress is refused. `next` reports a row claimed by another incarnation as `resume (claimed by )` and does not return it as fresh work. - *Action role.* `next` returns an action with the row: `implement`, `resume`, `review` or `wait`. An author whose row is in-review gets `wait`. A reviewer gets `review` only when it has no receipt for the current round (R12). - *Dependency conditions.* `after` entries carry a condition. The default is `done`, and `settled` means done or blocked. Row 9 is `after: [{id: 6, when: "settled"}]`, which matches the brief's "Gate F or blocked". Sage decided `after`, so this is a refinement of that decision. - *Ordering.* The in-progress > in-review > briefed ordering (Sage, Q8) differs from AGENTS.md's "first row you own in any of those states". B updates the cadence text in the same commit, so the two cannot disagree. - *Limitation.* `after` cannot rank two rows that are both active. The README says so, and no priority field is added (Q8). **R7 (High), post-then-record cannot promise one review request: accept-modified.** Plan change: no exactly-once promise is made, because a remote side effect cannot be rolled back. D promises at most one request per operation id, with reconciliation: - The operation id is formed from row, revision, round and candidate digest. - An intent entry is written to the journal and fsynced before the POST. - The comment embeds ``. - On an uncertain outcome, D looks up the issue's comments for that marker before any retry. If the lookup fails, it refuses and prints the recovery step. No blind retry. - After Q4, D compares `GET user` against the account expected for the seat before posting; a different path alone is not proof of identity. - No token is read or provisioned before Q4. - Local-commit failure no longer applies (R2). Acceptance: Rocko's list, with a fake transport that can accept a POST and then drop its response. **R8 (High), absent from the open list does not mean closed: accept (Sage amended Q7).** Needs Jason (the call budget). Plan change: - Absent means unknown. - Positive evidence comes from `GET issues/N` for each referenced issue absent from the open list. That is currently 3 (#1504, #1505, #1506). The response must be an issue, not a PR, and must exist. - The lookups are bounded at 20, then E reports incomplete. - A done row without positive closed evidence is reported as incomplete. - `--no-issues` prints `queue issue checks: not run`. The per-issue lookups break the ledger's one-call boundary from the #1506 brief. **Needs Jason:** allow the bounded extra lookups, or accept "incomplete" as the permanent result for done rows. Acceptance: Rocko's list as written. **R9 (High), the liveness exemption can turn missing evidence green: accept (Sage amended Q6).** Needs Jason (the reduced gate; Sage is adding it to his list). Plan change: - *Five states:* `exempt-runtime` (T3, declared per run with `--unsupported-runtime SEAT` and printed), `missing`, `invalid`, `stale` (dead PID, or a PID alive with a different start time) and `live`. - *Matching* uses repository root, layout `repo` and seat, never seat name alone. Duplicates across layouts are reported. - *Errors.* Scanner errors propagate as `invalid`. - *Coverage line:* `liveness: N live, N exempt, N missing, N invalid, N stale`. With any exempt or untested seat, E says "reduced gate". Acceptance: Rocko's list as written. **R10 (Medium), history E needs is not in the schema: accept.** Plan change: - Rows gain `createdAt` and `requiredSince` (UTC). - E ages required rows from `requiredSince` and only while non-terminal, so `note` no longer resets the age. - The journal (R1) is the history. E lists every protected change from it, not from `updatedBy`. - Violation first-observed evidence is the dated E run posted on #1508. Remediation is the journal entry's UTC timestamp on the same day. - None of this depends on commits (R2). Corrections are new journal entries. - The journal lives in `docs/plans/`, not in BUILD-LOG, SESSIONS or any runtime log. **R11 (High), some section-5 fixes change owner requirements: accept.** Plan change: - Section 5's intro no longer says "settle in A's round" for these items. They are listed in 7.2 with the original rule, the proposed rule, the authority and the acceptance change. - *5.6:* I take Rocko's reading. `--brief` stays required on `add` (the brief's rule is kept). `queued` means the brief exists but is not accepted, and `briefed` means accepted. That changes QUEUE.md's definition of queued ("no brief yet"), so it needs Jason. - *Blocking:* `any→blocked` applies to non-terminal rows only. done and parked stay immutable, which keeps the brief's "never change". blocked→blocked is refused; the reason is updated with `note`. This is a reading, not a change. - *5.10, issue closure:* rows gain `closesIssue: true` on the row whose completion gates closure (for example row 22 targets #1503 while row 1 keeps broader MVP acceptance, so row 1 would carry it). Where rows and issue state disagree, E reports a mismatch for disposition. It does not demand closure. Needs Jason. - *Reviewers:* the single `reviewer` field becomes `reviewers: [seat]` with per-reviewer receipts, so row 6's Darkwing and Dewey lanes migrate intact. **R12 (Medium), no immutable review candidate or receipt lifecycle: accept.** Plan change: - `--candidate` must be a commit SHA present in the repository, or a regular, repository-contained manifest file. `/tmp` and other out-of-repository paths are refused. - The journal and the comment record the resolved content digest. - The row gains `reviewIssue` (defaults to the first of `issues`, and is explicit when there are several) and `review: {round, requests[], receipts[]}`. Prior rounds are kept. - Test: request, changes requested, new candidate, approval, with exact pins in each round. - The brief's criterion (no new files under `docs/plans/reviews/`) stays as written. My stronger "no review files anywhere" (5.13) is a separate proposal for Jason, and it does not replace durable receipts. The receipts are the issue comments. **R13 (Medium), brief validation and migration too shallow: accept. Needs Jason (row 8 has no brief).** Plan change: - *Briefs:* a brief must be a regular file (symlinks refused) whose realpath is inside the repository and which is tracked by git (`git ls-files --error-unmatch`). - *Five current briefs are untracked* (listed above). They must be committed before migration, or their rows cannot be migrated as `briefed`. That commit is Sage's to schedule. - *Anchors:* `path#Heading` is validated. The heading must occur exactly once in the file. - *Migration map:* a reviewed row-by-row map preserves owner, reviewer lanes, gate, required, dependencies, the historical id and every open boundary. It lives in `agents//work/`. The journal genesis entry keeps each original QUEUE.md row verbatim (`legacy`), so no history depends on having been copied elsewhere. - *Parked items* map explicitly, and their "Where" references become briefs. - *Row 8* ("none yet") has no brief, which breaks iron-clad point 3. **Needs Jason:** accept a stub brief written by the lead, or keep row 8 out of the queue until it has one. **R14 (Medium), package reuse and acceptance wiring: accept.** Plan change: - Relative imports only (2.E updated). No workspaces and no lockfile change. - Test commands: `node --test packages/queue/tests/`, `node --test packages/ledger/tests/`, `scripts/test-queue.sh`, and `node scripts/test-darkwing-launch.mjs` and `node scripts/test-rocko-launch.mjs` for the dispatch change. - *C* acceptance adds two owner-accepted briefs, recorded on #1508. - *D* acceptance is one full review round (R12). *E* acceptance is a dated ledger receipt with remediation evidence. - *B* acceptance: inspect a fresh launch snapshot for the new cadence text, then Gate G. - *Order:* C-with-A is the brief's own wording ("as part of Piece A's review round"). D/E parallel departs from "A to E in order", so section 1 now follows the brief. **Needs Jason** only if Q4 holds D and E is wanted first. ### 7.2 Needs Jason (in addition to Q1–Q4) | # | Original rule | Proposed rule | Acceptance change | From | |---|---|---|---|---| | J1 | Iron-clad point 5: only Jason moves required rows | Confirm "moves" means reorder or park; seats may start and progress required rows they own | Permission matrix in R4 | R4 | | J2 | CLI enforces authority | Accept a cooperative trust model with journal detection and E's weekly list; enforcement needs a boundary outside #1508 | README and gate comment state the boundary | R4, R3 | | J3 | QUEUE.md: queued = "no brief yet" | queued = brief exists, not accepted; `add` always requires a brief | States text in QUEUE.md header | R11 (5.6) | | J4 | Brief: parked rows never change; QUEUE.md: Jason reopens | Unpark by `jason` only, refused while required | Transition table | R11 (5.7) | | J5 | Chain passes through waiting-on-jason | in-review→done allowed where `gateOwner` is not Jason | Transition table | R11 (5.4 remainder) | | J6 | E: open issue ⇒ its rows not done; done ⇒ closed | `closesIssue` row gates closure; disagreement is a mismatch for disposition | E rules and fixtures | R11 (5.10) | | J7 | D: no new files under docs/plans/reviews/ | Optionally also no review files under agents/*/work/; receipts are issue comments | D pass criterion | R11, R12 (5.13) | | J8 | Ledger one-call boundary (#1506) | Bounded per-issue lookups (≤20) for positive closure evidence, else "incomplete" | E fetch budget | R8 | | J9 | Every row has a brief that exists | Row 8: stub brief by the lead, or leave it out of the queue | Migration map | R13 | Also held for Jason by Sage: the reduced liveness gate (R9), and E before D if Q4 holds D (R14). Decided by Sage and **not** needing Jason: - `required` as a flag. It alters the brief's schema text but keeps its semantics. - in-review→in-progress. - Q5–Q9 as amended. - The `settled` dependency condition, a refinement of Q8 that matches the brief's own "Gate F or blocked". ## 8. The active specification (round 6) Round 6 answers Rocko's round-5 report, `agents/rocko/work/queue-as-data-adversarial-r5-2026-09-26.md`, SHA-256 `3b031a707555960dc69cc274fef6c39c0d8c0ebad539a5e8ae0c0c2545f4177e`, verified before reading. It reviewed round 5 at `889f2566…ae3d3`, found F2, F3 and F5 resolved, and raised two medium findings, G1 and G2. 8.19 maps them. Round 5 answered Rocko's round-4 report, `agents/rocko/work/queue-as-data-adversarial-r4-2026-09-26.md`, SHA-256 `fcb8933d515bcf98c509d17cb7c5bb384f2bce844b2b87eab6f83e67658efcff`, verified before reading. It reviewed round 4 at `14dccfd0…e63f`. He accepted the lead choices and raised five residuals, F1–F5. 8.18 maps each one. Round 5 also folds in Jason's credential ruling for D (8.9). Round 4 answered Rocko's round-3 report, `agents/rocko/work/queue-as-data-adversarial-r3-2026-09-26.md`, SHA-256 `13a328045fafc3195305b8ae44f0524620a13d5d06b9e86866c59b3956c6a274`, verified before reading. It reviewed round 3 at `cfdaa3fe…1ff21f`, plus the delta to `124b6f9e…0d26`. He accepted the architecture and Sage's rulings, and raised T1–T8. 8.17 maps each finding to the text that answers it. Round 3 answered Rocko's round-2 report, `agents/rocko/work/queue-as-data-adversarial-r2-2026-09-26.md`, SHA-256 `b3a2d72ae32775edc835ab6399f99dc870fe17a4833d76b6972a72e1da0e70f4`, verified before reading. It reviewed this plan at `94922cc5…a27cc5`. Sage's direction for this round: this is a queue for one host and a handful of seats. It fails closed, and recovery happens only through explicit manual verbs, modelled on the Discord connector's run lock (#1509 reclaim-race rounds 3–5, `packages/discord/src/journal.mjs`). There is no automatic recovery. **This section is the specification.** Where sections 1–7 differ from it, this section wins. They remain only as the record of how the design got here (S11). A builder reads the brief and this section, nothing else. ### 8.0 What round 3 removes, and one departure from Sage's direction Removed: - The separate journal file. The operation log moves inside `queue.json` (8.2). - All automatic recovery: roll-forward, re-render, stale-lock reclaim. - Worktree and clone support, and fork reconciliation (S6). - `MOSAIC_LAUNCH_INCARNATION`, and any process-start comparison for registrations (S8). - Retries matched by tuple equality (S4). - The ban on a required row depending on a non-required row (S7). - The optional stronger D criterion (J7, which Sage dropped). - The Gate G checker package. Gate G now uses a checklist (8.11). - The separate generated-table file idea. The brief's markers in QUEUE.md stay. **Departure, stated plainly.** Sage directed `queue repair` for a torn journal tail (S2). I propose no journal file at all. Operations are recorded inside `queue.json`, which is written only by temp file, fsync and rename. - A file replaced by rename cannot be torn. The partial-append case does not arise, so there is nothing for a repair verb to handle. - It is also closer to the brief's "One file, `docs/plans/queue.json` … It is the queue." Sage's other S2 rules carry over unchanged (8.5): - invalid data refuses every verb; - acknowledgement means a printed receipt; - an op that was recorded but never acknowledged stands and is flagged. **Decided by Sage 2026-09-26:** accepted. There is no journal file and no `queue repair`. An invalid `queue.json` refuses every verb. Recovery is a manual, reviewed procedure (8.5), not a plain `git restore`. Round 4 (T1) narrows one round-3 claim. A rename makes a new version visible, but it isn't durable until the directory fsync succeeds, and it isn't acknowledged until the receipt prints. 8.5 now keeps those three points separate. ### 8.1 Files - **A, with C inside it.** - `packages/queue/`: `package.json`, `src/queue.mjs` (data, checks, transitions, lock), `src/cli.mjs`, `README.md`, `tests/`. - `scripts/mosaic` dispatches `queue` to the queue CLI and passes everything else through unchanged. - `scripts/test-queue.sh` runs `node --test packages/queue/tests/`, then `scripts/mosaic queue verify`. - `scripts/queue-commit.sh` is the lead's commit procedure (8.12). - `scripts/git-hooks/pre-commit` is the queue guard, which `queue-commit.sh --install-hook` installs (8.12). - `docs/plans/queue.json` is created by genesis (8.2). - `docs/plans/QUEUE.md`: the table between the two markers becomes generated. The header stays hand-written and loses its priority paragraph (Q8). - `docs/plans/BRIEF-TEMPLATE.md` (C). - The lock's process-identity helpers are imported from `packages/discord/src/journal.mjs` by relative path: `processStart`, `bootId`, `pidAlive`, `validStart`, `validBoot`. The control board already imports that module the same way, and nothing in the discord package changes. - **B:** the AGENTS.md cadence line, changed to name `scripts/mosaic queue next` (8.8). The context files that point at CURRENT.md for what to do next change as well: line 18 of `agents/{dewey,filbert,darkwing,sage}/CONTEXT.md` and line 15 of `agents/rocko/CONTEXT.md` ("Read docs/plans/CURRENT.md to reconcile ownership and existing gates"). Line 30 of `agents/darkwing/CONTEXT.md` describes what CURRENT.md records, not what to do next. It stays unless the review of B says otherwise. That makes one commit, which a fresh launch snapshot verifies (8.14). - **D:** `packages/queue/src/review.mjs` and the `review` verbs. It posts only through `scripts/gitea-api.sh` with `MOSAIC_GITEA_CREDENTIAL_FILE`, set to the per-seat token file Jason ruled on (Q4, 8.9). - **E:** `packages/ledger/src/queue-checks.mjs` and the ledger CLI hook. It imports `../../queue/src/queue.mjs` and the control board's registration loader by relative path. ### 8.2 Data: one file, one log `docs/plans/queue.json` holds `{version, canonicalRoot, revision, rows, log}`. The brief's "array of rows" becomes the `rows` member. This file is new, so no reader of the old shape exists. **Serialization is deterministic:** - UTF-8, `\n` line endings, one trailing newline; - two-space indentation; - keys in schema order; - rows sorted by id, and log entries in the order they were appended. The CLI refuses any file that does not re-serialize byte for byte, so a formatting-only hand edit is caught too. **Row fields:** | Field | Meaning | |---|---| | `id` | never reused | | `piece` | one line | | `owner` | seat name, `jason`, `coordinator` or `unassigned` | | `issues` | list of issue numbers | | `closes` | the issues this row gates, a subset of `issues` | | `state`, `previousState` | current state; the state `blocked` returns to | | `required`, `requiredSince` | flag; ISO time or `"unknown"` | | `gate`, `gateOwner` | the gate sentence and who holds it | | `brief` | `{path, anchor, blob}` | | `after` | list of `{id, when: "done" \| "settled"}` | | `reviewers` | list of seats | | `review` | `{issue, rounds: [...]}` or null | | `claim` | `{seat, op}` or null | | `note`, `blockedReason` | text | | `createdAt`, `updatedAt`, `updatedBy` | times are ISO; `createdAt` may be `"unknown"` | **Log entry:** `{rev, op, verb, args, by, at, semantics, result, viewSha}`. - `args` is canonical. - `by` is the claimed actor. - `semantics` is the rule version this entry was checked under. - `viewSha` is the SHA-256 of the table body rendered for that revision. **Genesis** is `log[0]`, and it is the only entry of its kind. It is created by `queue genesis --op ID --root PATH --branch NAME --map PATH`, which only a privileged actor may run. Genesis refuses if any of the following holds: - `docs/plans/queue.json` exists in the working tree or in HEAD; - the witness file exists (below); - `--root` differs from the realpath of the toplevel, found as in 8.3; - `--branch` differs from the branch HEAD currently names; - the map is not a committed blob in HEAD. Genesis is the only verb that runs before `canonicalRoot` exists, and these arguments are its reviewed input. It records: - the QUEUE.md table rows and parked entries verbatim; - the starting rows as normalized in the reviewed migration map, with that map's blob id (`agents//work/queue-migration-map.md`); - row 7 retired, and the id high-water mark; - `canonicalRoot` and `branch` (8.3). Genesis writes through 8.5, so it is durable once the directory fsync succeeds and it has written the witness. **Genesis is committed before anything else happens (F3).** Until HEAD contains `docs/plans/queue.json`, every mutating verb except genesis refuses with `genesis not committed`. The exceptions are `sync` and `render`, which log nothing. The first queue commit is therefore genesis alone (8.12). **Witness (T2).** `/.git/mosaic-queue.head` holds `{revision, logDigest, fileSha, at}` for the last write whose directory fsync succeeded. `logDigest` is the SHA-256 of the canonical serialization of `log[0..revision]`. The witness is a check value, not a second store: it holds no rows and no operations, and nothing can be rebuilt from it. It is there so that git replacing `queue.json` with an older valid file is detected. Replay alone cannot see that (Rocko's Schedule B). A missing witness refuses (8.5). Legacy `createdAt` and `requiredSince` come from cited evidence or are `"unknown"`. Migration time never stands in for them, and no approval is invented: legacy done rows carry no receipts. **Replay** starts from genesis. It applies each entry under that entry's `semantics` version, checks the actor and transition against the state before it, and requires the result to equal `rows`. - Replay never reads briefs, the roster or registrations. A row's brief identity is the blob recorded at write time. - Whether today's briefs still hold is a separate check (`verify --current`, 8.13). - There is no rotation or archive in #1508. At about 40 rows and a few hundred entries a year, the file stays small. ### 8.3 Canonical checkout only (S6) Every verb, reads included, refuses unless all four of these hold: - The realpath of `git rev-parse --show-toplevel`, run from the process's working directory, equals `canonicalRoot`. - Every later git query runs as `git -C rev-parse --path-format=absolute …`, so paths never resolve against the wrong working directory (git 2.55 here). After realpath, `--git-dir` and `--git-common-dir` are the same path. A linked worktree fails this. - `git symbolic-ref -q HEAD` names the genesis `branch`. A detached HEAD or another branch refuses. Moving the queue to another branch is a reviewed migration, like moving the root. - The CLI's own file lies under `canonicalRoot`. The one exception is `verify --snapshot` (8.12), which reads only the bytes it is given. The symlink `~/src/mosaic-stack-dev-test` resolves to the root and works. A worktree, or a clone at any other path carrying the same committed `queue.json`, refuses. The tests cover a linked worktree and a second clone. No configured canonical root exists today. `config.json` has only `configVersion`, `environment`, `dataRoot` and `execution`, and it is user-authored. So the root is recorded in the reviewed genesis entry, and no config change is needed. Moving the checkout later needs a reviewed migration. It is out of scope. Only one host is supported. The lock records the host name and the boot id, and a lock from another host classifies as `unknown` (8.4). ### 8.4 Lock (S1, T3) The lock is the file `/.git/mosaic-queue.lock`. It is not committed, and it is not a root file. **Owner record:** `{pid, start, boot, host, op, verb, at}`. `start` and `boot` use the same syntax and validation as the Discord connector. If this process's identity cannot be read from `/proc`, the CLI refuses to take the lock. **Acquire:** 1. Create `/.git/mosaic-queue.lock...tmp` with `O_CREAT|O_EXCL|O_WRONLY`, mode 0600. - Write the whole record, looping on short writes and checking the total. Then fsync and close. - Read the file back and compare it with the record. Any mismatch or error removes the temp file and refuses. 2. `link()` the temp file to the lock path. - EEXIST means the lock is held. That is the same exclusivity as an `O_EXCL` open. - Any other `link` error refuses. - On success, `stat` the temp file and keep its `(dev, ino)`. After a hard link, that is also the lock's inode. - Remove the temp file whatever the result. Sage accepted `link()` in place of a literal `O_EXCL` open (2026-09-26). An empty or unparsable lock file, which the CLI can't produce, counts as `invalid`. 3. If the unlock gate (below) exists, release the lock and refuse. **Classification (T3).** The lock record and the gate record are classified the same way. The tests are taken in this order, and the first one that applies decides: | # | Test | State | |---|---|---| | 1 | Record missing, empty or unparsable, or `start`/`boot` fail validation | `invalid` | | 2 | This process can't read its own `/proc` start or `boot_id` | `unknown` | | 3 | `host` is not this host | `unknown`, whatever the local pid says | | 4 | `boot` is not the current boot id | `mismatch`: a previous boot on this host, so the owner can't be running | | 5 | Same boot, and the pid is not alive | `dead` | | 6 | Same boot, pid alive, but its `/proc` start is unreadable | `unknown` | | 7 | Same boot, pid alive, and its start differs | `mismatch`: the pid was reused, and the process holding it now is never signalled | | 8 | Same boot, pid alive, and its start matches | `live` | This order differs from the Discord helper's `ownerState`, which looks at pid death before anything else and doesn't know about hosts. The queue imports only the low-level readers (`processStart`, `bootId`, `pidAlive`, `validStart`, `validBoot`). It does not import `ownerState`. **On EEXIST:** retry every 100 ms for up to 10 s, then classify and refuse: | Owner state | Result | |---|---| | `live` | "held by `` `` since ``; retry the same op later" | | `dead` or `mismatch` | "run `scripts/mosaic queue unlock` once nothing is running" | | `unknown` | refuse; `unlock` refuses too; diagnose by hand | | `invalid` | refuse; inspect by hand | Nothing is ever removed because of its age or because of an earlier inspection. If the host is renamed, locks from before the rename classify as `unknown`, so the queue stays unavailable until someone diagnoses it. That is accepted. **Release:** the holder `stat`s the lock path and reads the record. It unlinks only if the `(dev, ino)` and every record field equal what it acquired. Otherwise it leaves the file alone and reports it. Only `unlock` removes another process's lock, and `unlock` refuses a live owner. **`queue unlock`** is manual, any actor may run it, and it never tries to take the lock itself. 1. Create the gate `/.git/mosaic-queue.unlock` with the same temp-and-`link()` steps, using the unlocker's own identity record. - If the gate exists, refuse, classify the gate record, and print the result. 2. With the gate held, classify the lock. - `live`, `unknown` or `invalid`: refuse and remove the gate. - `dead` or `mismatch`: unlink the lock. 3. Remove the gate, and print the record that was removed. Why two actors can't both win: - A writer publishes its lock, then checks for the gate. - If that check came before the gate existed, `unlock` classifies later, sees the writer's published live record, and refuses. - If the check came after, the writer sees the gate and releases. - `unlock` never acts on an inspection made before it held the gate, and two unlockers cannot both hold the gate. **A stale gate** from a killed unlocker blocks writers with a message that names it. `queue unlock --check-gate` is read-only: it classifies the gate record by the table above and prints the state. A person removes the gate by hand, only after `--check-gate` says `dead` or `mismatch`, and only once no queue command is running. `unknown` and `invalid` gates are left for diagnosis. **Who takes the lock:** every mutation, `render`, `verify` and `sync` (8.5). `list`, `next` and `show` read without the lock. A rename replaces each file whole, so each read sees one complete revision. Two files read one after the other are not an atomic pair, though (F2). Unlocked reads therefore follow this rule: 1. **Order.** Read the witness first, then `queue.json`. The writer publishes `queue.json` before the witness (8.5 steps 8 and 10). So a file read after the witness is never older than it unless history really was lost. A normal write in progress cannot make an unlocked reader see the file behind the witness. 2. **Ahead.** A file ahead of the witness prints `rev N visible, not confirmed durable`. An unlocked reader can't tell a writer that is still publishing from a crash leftover, so it calls it neither. 3. **Adverse.** Any other result takes the lock and repeats 8.5 steps 1–2 before it reports anything. That covers an invalid file, a file that doesn't extend the witness, and a missing witness. This includes `accept-history`, which writes the file before the witness. If the lock can't be taken, the read refuses as 8.4 does, naming the holder. An unlocked read never reports lost history on its own, and never suggests `accept-history`. Only a locked caller reports an unconfirmed tail as something to fix, because under the lock no writer can still be publishing. Nothing holds the lock across network I/O (8.9) or across git (8.12). **Tests:** - a kill between the temp write and the link: no lock appears; - a short or failed temp write: refused, no lock; - a `link` error other than EEXIST: refused; - a paused holder: others wait 10 s, then refuse `live`; - two concurrent unlockers: one refuses on the gate; - a writer publishing during an unlock, in both orders; - a reused pid within one boot: `mismatch`, and the process is never signalled; - the same pid and start on a different boot: `mismatch`; - a foreign host with no such local pid: `unknown`, and unlock refuses; - unreadable `/proc`: `unknown`; - a stale gate whose pid is now reused: `--check-gate` says `mismatch`; - a delayed release by a dead owner, after unlock and a new owner: the inode check keeps the new lock. ### 8.5 Write path, durability, acknowledgement and manual recovery (S2, S3, T1, T2) **Supported platform.** Linux, with `docs/plans/` and `.git/` on a local ext4, xfs or btrfs filesystem (tmpfs is allowed for tests). The CLI checks `fs.statfsSync(...).type` and refuses anything else. This checkout is ext4. Temp files are created in the same directory as their target, so a rename never crosses filesystems. It is assumed that rename replaces the target atomically on these filesystems, and that fsync of a directory fd persists the renamed entry. **Three points, kept separate (T1):** | Point | When | Meaning | |---|---|---| | visible | the rename returns | readers see the new revision | | durable | the directory fsync succeeds and the witness is written | the revision survives a host crash, within the platform assumptions | | acknowledged | the receipt prints | the caller may treat the op as done | **A mutation runs these steps under the lock:** 1. **Checks.** Run the canonical checks (8.3). Read `queue.json`, and keep its bytes and its `stat` result (`dev, ino, size, mtime_ns`). The file must parse, match the schema, re-serialize byte for byte and replay to `rows`. `log[0]` must be the only genesis entry, op ids must be unique and `revision` must equal the last `rev`. Any failure refuses. 2. **Witness.** Compare the file with the witness: - It matches (same revision and `logDigest`): continue. - It extends the witness, meaning the prefix up to the witness revision has the same `logDigest` and later entries follow: this is an unconfirmed tail. Some earlier op became visible but was never confirmed durable. - If this call is a retry of an op in that tail, or is `queue sync`: fsync `queue.json`, fsync `docs/plans/`, write the witness, then continue. - Otherwise refuse, and name the tail ops and `queue sync`. - It does not extend the witness (a lower revision, or a different prefix): history was lost or replaced. Refuse every verb except `accept-history` (below), and name the witness revision. - No witness: refuse, and name `queue accept-history`. The one exception is a file holding only genesis, which `sync` accepts. 3. **Lookup (8.6).** If the op id is in the log, print its recorded receipt and stop. - This happens before any view check, so a retry is answered even while the table is stale, with a one-line warning (T4). - Reads and lookups never fsync. A lookup reaches this step only after step 2 has confirmed durability. 4. **View.** Check that the QUEUE.md table is current. If it is stale or unknown, refuse the new op (see "Outcomes"). 5. **Compute.** Compute the new rows and the log entry, and validate both. 6. **Temp file.** Create `queue.json..tmp` with `O_EXCL`, write all bytes (looping on short writes and checking the total), fsync, and close. On any failure, unlink the temp file and refuse. Nothing has changed. 7. **Unchanged check.** `stat` and hash `queue.json` again. They must equal step 1's values. If they don't, something wrote outside the lock, git most likely: unlink the temp file and refuse. A window remains between this check and the rename. A lock that git ignores cannot close it, and the README says so. 8. **Rename.** Rename the temp file over `queue.json`. **The op is now visible.** A rename failure unlinks the temp file and refuses. 9. **Directory fsync.** fsync `docs/plans/`. **The op is now durable.** If the fsync fails: - print `uncertain rev N: visible, durability not confirmed ()` and exit 3; - print no receipt, and write neither the witness nor the view; - never write an older version back. The next call finds the unconfirmed tail at step 2. 10. **Witness.** Write the witness: temp file, fsync, rename, directory fsync. If that fails, print `uncertain rev N: durable, witness not updated`, exit 3, and print no receipt. 11. **View.** Render the table body. Write QUEUE.md by temp file, fsync, rename and directory fsync, replacing only the bytes between the markers. - Everything outside the markers is copied from the bytes read at step 4. - If QUEUE.md has changed since step 4, refuse the view write. The op stays recorded, and the view is stale. - The same check-to-rename window as step 7 applies, and is stated. 12. **Receipt.** Release the lock and print `ok rev N row R →`. **Only now is the op acknowledged.** **Recorded but not acknowledged:** - **Killed between step 8 and step 10.** The next call finds an unconfirmed tail and refuses new ops until `queue sync` runs or the op is retried. `sync` prints `durable now, never acknowledged: by at