Files
stack/agents/filbert/work/queue-as-data-plan-2026-09-26.md
T
jason.woltjeandClaude Opus 5.5 1c5f6bc3a0 docs(queue): queue-as-data plan round 6, Rocko approved (#1508)
Plan 282fabbb (Filbert) and the six adversarial rounds (Rocko, r6 80cde839).
Lead item 15: Gate F first, then A1 and A2 as separate reviewed commits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:05:16 -05:00

2405 lines
127 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<seat>/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/<seat>/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/<seat>/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/<seat>/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 <commit-or-manifest-path>`,
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/<pid>/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/<seat>/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 <incarnation>)` 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 `<!-- mosaic-queue-op: <id> -->`.
- 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/<author>/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/<author>/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).** `<canonicalRoot>/.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 <toplevel> 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 `<canonicalRoot>/.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 `<canonicalRoot>/.git/mosaic-queue.lock.<pid>.<random>.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 `<verb>` `<op>` since `<at>`; 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 `<canonicalRoot>/.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.<op>.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 <op> rev N: visible, durability not confirmed
(<errno>)` 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 <op> 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 <op> rev N row R
<from>→<to>`. **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: <op> by <seat> at <time>`
for each tail op.
- **Killed between step 10 and step 12.** The op is durable, and the stale
view flags it: "rev N (op X by S at T) is recorded but the table shows rev
N−1; it may never have been acknowledged. Tell S, then run `queue
render`."
- **Killed after the view but before the receipt.** Nothing flags it. The
caller learns the outcome by retrying the same op id or by running `queue
show`.
- **Unlocked reads.** They follow the order and recheck rule in 8.4. A
read that sees a revision past the witness prints `rev N visible, not
confirmed durable`.
- **D.** Its network step starts only after its own intent reached step 10
(8.9).
**Outcomes:**
| Condition | Effect | Fix |
|---|---|---|
| All checks pass | proceed | — |
| `queue.json` invalid | every verb refuses, reads included | the manual recovery below |
| unconfirmed tail | new ops refuse; retries of tail ops and `sync` confirm durability first | `queue sync`, or retry the op |
| history lost (the file doesn't extend the witness) | every verb refuses except `accept-history` | the manual recovery below |
| view stale (the body matches the `viewSha` of an earlier log entry, a genuine older render) | new ops and `verify` refuse and name the unshown ops; retries answer; reads warn and work | a person looks, then runs `queue render` |
| view unknown (the body matches no logged render; markers missing, duplicated or out of order) | every verb that writes QUEUE.md refuses, `render` included | a person restores the table with git, or re-applies the edit as CLI ops, then runs `render` |
**Manual recovery for an invalid file or lost history (T2).** No verb
repairs anything. The steps, in order:
1. Copy the current `queue.json` bytes to
`agents/<lead>/work/queue-recovery-<date>/` before touching anything.
Restoring HEAD would discard every op written since the last queue
commit.
2. Find the lost ops. They are the entries after the last committed
revision, taken from:
- the preserved bytes;
- `git stash list` and the reflog;
- the receipts that seats hold;
- issue comments carrying `mosaic-queue-op` markers.
3. Account for external effects. For every lost review request, check
whether its comment exists. A lost op id is no longer deduplicated, and
reusing it could post again.
4. Put a valid file in place: the restored version with the lost ops
re-applied under new op ids, or a reviewed hand-built file.
5. Run `queue accept-history --op ID --reason TEXT`.
- Only a privileged actor may run it.
- It appends an entry recording the old witness (revision and
`logDigest`, or "absent"), the new revision and the reason, then
rewrites the witness.
- It is the only verb allowed while history is lost, or while the
witness is missing.
- Before running, it prints the lost revision range and the warning
"ops in that range are no longer deduplicated", and it needs `--yes`.
A reset accepted this way does not keep the at-most-once guarantee for
the lost range. The log entry says so.
**The git side of the protocol (T2).** This part is cooperative, stated in
the README and in B's cadence text. While the working `queue.json` has ops
that are not yet committed, or any review attempt is unresolved (8.9), no
one runs `checkout`, `stash`, `restore`, `reset` or a branch switch that
touches `docs/plans/queue.json` or `QUEUE.md`. Only the lead does such an
exceptional restore, and only by the recovery steps above.
Commits follow the same cooperative rule (F1):
- Seats commit with plain `git commit` and never `--no-verify`, so the
queue guard runs (8.12).
- Merge, rebase, cherry-pick, revert and `am` skip that guard. On the queue
branch they are the lead's.
- Nobody disables, replaces or overrides the guard. That means no
`core.hooksPath` in any scope and no `-c core.hooksPath=` on a command.
It also means no environment that changes which config git reads
(`GIT_CONFIG_*`), no edits to `.git/hooks/pre-commit`, and no change to
its mode (G2).
- "Lead's" and "nobody" are protocol, not enforcement. The witness detects
lost history afterwards; it can't prevent a same-user git write. That
trust limit is accepted.
Step 7 catches ordinary interference. The witness catches rollback after
the fact. Neither can stop an uncooperative git command, because git
doesn't honour the lock. That limit comes with the trust model, and
nothing here pretends otherwise. A passing replay shows the file is
consistent with itself. It does not show that no history was lost. The
witness is the only thing that speaks to that.
**`queue render`** takes the lock, runs steps 1–2, and rewrites the body
only when the view is stale. When the view is current it does nothing. It
never overwrites an unknown view.
**`queue sync --op ID`** takes the lock, runs steps 1–2 (which confirm any
unconfirmed tail), prints what became durable, and logs nothing.
`render --check` and `verify` never write, and neither do `list`, `next` or
`show`. Hand edits stay refused.
**Tests.** File operations go through an injectable layer, so each fault
can be injected separately:
- a short write, ENOSPC, and a file fsync failure: nothing visible, temp
file removed;
- a rename failure;
- a directory fsync failure: `uncertain`, exit 3, no receipt, witness and
view untouched, and a later retry or `sync` confirms it;
- a witness write failure;
- a kill at each step boundary, using a child killed with SIGKILL:
- before the rename, nothing is recorded;
- after the rename, before the witness: the tail refuses new ops, and
`sync` names the op;
- after the witness, before the view: the stale refusal names the op;
- after the view, before the receipt: a retry returns the receipt;
- a same-op retry while the view is stale: it answers;
- git interference:
- `git checkout -- docs/plans/queue.json` between steps 1 and 7: step 7
refuses;
- `git stash` of a valid newer pair, restoring an older valid pair:
history lost, every verb refuses, and `accept-history` works only with
`--yes` and a reason;
- a deleted witness: refuses, after the locked recheck;
- unlocked reads racing a writer (F2):
- a reader paused between the witness read and the file read while a
writer completes steps 8–10: no lost-history report;
- a test hook forcing the file-then-witness order: the locked recheck
prevents a false report;
- a writer paused before and after the witness rename;
- a true rollback, reported only after the locked recheck;
- an `accept-history` in progress;
- a hand edit to `queue.json` that is still valid JSON: replay mismatch;
- a formatting-only edit;
- a genuine stale view compared with an edited view that carries the same
old marker;
- a current marker over a changed body;
- missing or duplicate markers;
- a header edit during a write;
- `verify` and `render --check` leave bytes and mtimes unchanged.
SIGKILL tests exercise process death only. Power loss is not tested. The
host-crash claims rest on the platform assumptions above, and the README
says so.
### 8.6 Operation ids (S4)
Every mutating verb requires `--op ID`, matching
`^[a-z0-9][a-z0-9._-]{7,71}$`, so at most 72 characters. Every op id in the
log matches `^[a-z0-9][a-z0-9._-]{7,79}$`. The 8 characters in between
leave room for `REQOP.outcome` (8.9, F5). The CLI never makes one up. The caller
chooses the id before the first attempt and passes the same id on every
retry, for example `--op dewey-row9-start-2026-09-26`. Because the id is
written into the caller's own command, a killed CLI cannot lose it.
| Op id in the log? | Result |
|---|---|
| No | a new operation, checked against the current state |
| Yes, same verb and canonical arguments | no change; print the recorded receipt with "already recorded at rev N", exit 0, whatever has happened since |
| Yes, different verb or arguments | refuse, exit 2 |
Identity is the op id alone. No state or tuple is compared. The lookup
runs at step 3 of 8.5, after the durability checks and before any view
check. A retry is therefore answered even while the table is stale (T4).
Op ids ending in `.outcome` are reserved for the outcome entries that D
writes itself (8.9), and callers may not use them. Tests:
- a 72-character request op records its 80-character outcome;
- a 73-character caller op is refused;
- a caller op ending in `.outcome` is refused.
- A new operation asking for in-progress→in-progress is refused as an
illegal transition. R1's "moving to the current state succeeds" is
withdrawn.
- A retried `add` returns the id it first allocated, even after the row was
reassigned or finished.
**Tests:**
- Rocko's S4 schedule: a lost result, then another writer changes the row,
then the retry returns the recorded receipt and opens no second round;
- an `add` retried after reassignment and after done;
- a reused id with a different payload;
- a missing `--op`.
### 8.7 States, transitions, permissions: one matrix (S7, R4)
The states are the brief's. `required` is a flag (Sage). `queued` means a
brief exists but has not been accepted, and `briefed` means it has been
accepted (J3, Sage). Every `add` needs a brief, and it creates a `queued`
row.
**What `add` may set (T8).**
- **An ordinary seat** adds only rows it owns, and `owner` is itself. It
may set `piece`, `brief` (required, 8.13), `issues` and `note`.
- **Defaults** for everything else:
- `closes` = `issues`;
- `gateOwner` = `jason`, so the brief's route through waiting-on-jason
applies unless a privileged actor changes it;
- `after` = `[]`;
- `reviewers` = `[]`;
- `required` = false.
- **A privileged actor** may also set `owner`, `gateOwner`, `after`,
`reviewers` and `required` on `add`.
"Privileged" means `jason` or `sage` (Q5). Actors are claimed through `--by`
or `MOSAIC_AGENT_NAME`, and a verb with neither is refused. This is the
cooperative trust model (J2).
| Transition | Who | Condition |
|---|---|---|
| queued→briefed | privileged | — |
| briefed→in-progress | owner | every `after` entry satisfied; records `claim` |
| in-progress→briefed (`release`) | claimant, or privileged | clears `claim` |
| in-progress→in-review | claimant | once D is built, for a row with `reviewers`, this move is the review request, and it takes `--candidate` (8.9). Before D, or with no reviewers, it opens the round with `request: none` |
| in-review→in-progress | owner, or privileged | changes requested (Sage) |
| in-review→waiting-on-jason | owner, or privileged | — |
| waiting-on-jason→done | `jason`; or `sage` with `--evidence` citing Jason's approval | the evidence reference is logged |
| in-review→done | the row's `gateOwner`, or privileged | refused when `gateOwner` is `jason` (that path runs through waiting-on-jason). `--evidence` must name the current round and its candidate digest: with D built, the approving receipts of every listed reviewer for that round (8.9); before D, a comment id together with the round's candidate digest, which must match. Logged (J5) |
| any non-terminal→blocked | owner, or privileged | reason required; records `previousState`; blocked→blocked refused (update the reason with `note`) |
| blocked→`previousState` | owner, or privileged | — |
| queued or briefed→parked | `jason` | refused while `required` |
| parked→queued (unpark) | `jason` | the brief is re-accepted through queued→briefed (J4) |
| done→anything | refused | — |
Field edits, on `add` as well as afterwards:
| Field | Who |
|---|---|
| `owner` | privileged. An ordinary seat may add only rows it owns. |
| `gateOwner`, `after`, `reviewers` | privileged |
| `issues`, `brief` | set on `add` by the adder; afterwards privileged only (a brief change is a re-pin, 8.13) |
| `closes` | set to `issues` at genesis and on `add`; privileged may narrow it only with `--reason`, which is logged (J6) |
| setting `required` | privileged; refused on a parked row |
| clearing `required`; changing `after` on a required row | `jason` |
| `note` | owner, a listed reviewer, or privileged; never on a done or parked row |
| review receipt | a listed reviewer, for the current round, citing that round's candidate (8.9) |
**Claim lifecycle (T8).** Invariant: `claim` is null, or `claim.seat`
equals `owner`.
| Event | `claim` |
|---|---|
| briefed→in-progress | set to `{seat: owner, op}` |
| in-progress→in-review, in-review→in-progress, in-review→waiting-on-jason | kept |
| →blocked, and blocked→`previousState` | kept unchanged |
| `release` (in-progress→briefed) | cleared |
| `assign` by a privileged actor on a claimed row | `owner` and `claim.seat` both become the new seat, and `claim.op` becomes the assign op. The review round continues |
| →done, by any allowed actor | cleared |
| parked, unparked | never claimed: parking happens only from queued or briefed |
Permission comes from this matrix alone. A claim adds no refusal of its own,
so a gate owner who isn't the row's owner can complete it through the J5
edge. "Claimed by X" is only the wording of the refusal a non-owner gets
when they attempt an owner transition.
**Prerequisites.** `after` holds owner-approved prerequisites of any shape,
including a required row depending on a non-required one. Row 9 is
`after: [{id: 6, when: "settled"}]`, where settled means done or blocked.
Prerequisites are checked only on the move into in-progress. Later
transitions do not recheck them, so a parent that leaves `blocked`
afterwards strands nothing.
**Rulings in this matrix** (Sage, 2026-09-26, 8.15):
- J1: owners progress their required rows. Reordering, parking and clearing
`required` are Jason-only.
- J4: only `jason` unparks, and a parked row returns to `queued`.
- J5: in-review→done is allowed where `gateOwner` is not Jason.
### 8.8 `next` and claims (S8, R6)
**A claim is the seat name and nothing more:** `claim: {seat, op}`. No
incarnation and no process identity are recorded. The claim lifecycle is in
8.7.
- If another seat attempts an owner transition, the CLI refuses with
"claimed by X". X runs `release`, or a privileged actor reassigns the row.
- Two sessions of the same seat are one claimant. The queue cannot tell them
apart and does not try. This limit is stated in the README.
**`next [SEAT]`.** SEAT defaults to `MOSAIC_AGENT_NAME`. With neither, the
verb refuses (Q2). It returns one action, lowest id first within each class,
in Q8's order:
1. `resume`: an in-progress row claimed by SEAT.
2. `review`: an in-review row where SEAT is a listed reviewer and has no
receipt for the current round.
3. `start`: a briefed row owned by SEAT whose `after` is satisfied. If the
working brief no longer matches the pinned blob (8.13), `next` still
names the row, marked `brief differs from pinned blob; ask the lead to
re-pin`, and `start` refuses.
4. `wait`: only when nothing above matches and SEAT owns an in-review row.
5. `nothing`.
The author's in-review rows are not actionable, so they give `wait` only
when there is nothing else. That is how I apply Q8 to the author's side.
`queued`, `blocked`, `parked` and `done` rows are never returned. B's
cadence text is written to this list.
### 8.9 Review requests, Piece D (S9, S10, R7, R12, T4)
D posts only with an explicit per-seat credential file
(`MOSAIC_GITEA_CREDENTIAL_FILE`) and refuses the default file (Q4).
**Credentials (Jason, 2026-09-26, relayed by Sage).** The live round reads
each seat's own token in place, read-only, at
`~/.mosaic/fleet/agents/<seat>/secrets/gitea-mosaicstack-<seat>.token`
(mode 0600). There are no copies and no writes under `~/.mosaic`. The lead
posts as jarvis under the same rule, reading
`~/.mosaic/fleet/agents/jarvis/secrets/gitea-mosaicstack-jarvis.token` in
place (Sage, 2026-09-26). That is the Gitea API token. The push helper has
its own route, and this plan doesn't change it.
- D never opens the file. `gitea-api.sh` reads it.
- D only `stat`s it: it must be a regular file, owned by the user, mode
0600. The path must name the acting identity: the seat, or jarvis for
the lead.
- Scopes as ruled: darkwing and dewey hold `write:repository`, and filbert
and rocko hold `write:issue`.
- It is unverified whether `write:repository` alone lets a token post an
issue comment. If it doesn't, the POST gets a 403, which is `failed`: a
definite result with nothing posted. So it fails safe. Nobody spends a
real post to check this beforehand (Sage, 2026-09-26). A 403 for darkwing
or dewey in the first live round is an expected finding, not a defect in
D. It is recorded with the round, and the scope question goes to Sage.
The tests use a fake transport and read no token.
**Verbs:**
- `move ID in-review --op OP --candidate <commit|manifest>`. Per the brief,
this move posts the request. It opens round n and its first attempt.
- `review request ID --op OP`: another attempt in the current round, with
the round's candidate. It is allowed only when every earlier attempt in
the round is `failed` or `abandoned`.
- `review resolve ID --op OP --attempt REQOP --posted <commentId>`: the
owner or a privileged actor.
- `review abandon ID --op OP --attempt REQOP --reason TEXT --yes`:
privileged only.
- `review record ID --op OP --verdict approve|changes --comment <id>`.
- `review verify-commit ID <commit|tree>`, which is read-only.
**Candidate.** A candidate is one of:
- a commit reachable from a local branch or tag;
- a manifest listing repository paths and their SHA-256, in the form of
today's `CANDIDATE.sha256`.
The candidate is frozen for the round. A new candidate needs a new round:
in-review→in-progress, then `move in-review` again.
The manifest text goes into the comment and into the log. The queue does
not keep the source bytes of an uncommitted candidate. `verify-commit`
checks that every manifest path has the approved digest in the given commit
or prospective tree (8.12). If the approved bytes have changed or are gone,
integration is blocked, and the fix is a new round. A missing candidate is
never quietly replaced by a new one. A commit candidate stays retrievable
only while some ref keeps it, and the README says so.
**Attempts.** Each attempt is keyed by its request op and lives in
`round.attempts[]`. The candidate digest and the op never change after the
intent is written.
| State | Meaning | New request on this row? |
|---|---|---|
| `requesting` | intent is durable; no outcome recorded yet | refused |
| `posted` | a comment id was confirmed, by the transport or by `resolve` | not needed |
| `failed` | definite non-acceptance (below) | allowed, with a new op |
| `uncertain` | the transport outcome is unknown | refused |
| `abandoned` | a privileged actor gave up on it, accepting the risk of a duplicate | allowed, with a new op |
| `conflict` | a late transport outcome disagrees with an earlier manual resolution | refused |
**Request steps:**
1. **Intent.** Run the full 8.5 path under the lock. The entry, with op
REQOP, moves the row or opens the attempt, recording the candidate
digest, issue, round and `requesting`.
- If any attempt on the row is `requesting`, `uncertain` or `conflict`,
refuse.
- If the write ends `uncertain` (exit 3), stop: nothing is sent. Only a
write that reached 8.5 step 10 goes on to the network.
2. **Pre-send checks,** without the lock:
- the credential file is set, is not the default, and passes the `stat`
checks above;
- `GET user` matches the expected login: the seat's own for a seat, and
`jarvis` when the lead posts. The queue actor stays the lead; only the
login is jarvis. It runs in its own
process group under the same 30 s deadline as the POST, and a timeout
here is a pre-send failure.
A failure here means the POST was never started, so the outcome is
`failed`.
3. **POST.** Run `gitea-api.sh` in its own process group. After 30 s, kill
the whole group with SIGKILL. The helper has no timeout of its own, and
killing its curl child does not undo a request the server has already
received. The body carries `<!-- mosaic-queue-op: REQOP -->`, the round
and the manifest. The helper prints the body on stdout and `HTTP <code>`
on stderr, and it keeps the token off argv, stdout and stderr. D records
only the status and the comment id.
- `HTTP 201` with a parseable comment id: `posted`.
- `HTTP 400, 401, 403, 404 or 422`, meaning the endpoint answered and
refused: `failed`.
- Anything else is `uncertain`:
- the helper's "request failed", because curl's exit does not say
whether the body was sent;
- a kill at the deadline;
- a 5xx or any other code;
- a 2xx without a comment id.
4. **Outcome.** Take the lock and run the 8.5 path. Append the entry
`REQOP.outcome`, referencing the attempt.
- If the attempt is still `requesting`, it takes the new state.
- If a person already resolved it:
- agreement (resolved `posted` X, transport posted X) is recorded as
confirmation;
- disagreement (for example, `abandoned` while the transport reports
201) sets `conflict` and records both.
- If the lock can't be taken, print `uncertain REQOP: transport said
<x>, not recorded`, and exit 3. The attempt stays `requesting`.
**Nothing is resent automatically.** Replay, `verify`, `list`, `next`, a
retry of the same op and a new op all leave an unresolved attempt alone.
The CLI may print the issue URL and the marker as a hint. It never decides.
**Resolution by a person:**
- **`--posted <id>`.** The CLI makes one GET for that comment, under the
same 30 s process-group deadline. It checks the
comment belongs to the row's issue, carries the marker for REQOP, and
names the round's candidate digest. If everything matches, the attempt
becomes `posted`. If anything differs, or the GET fails, the command
refuses and the attempt is unchanged.
- **There is no `not-posted`.** A missing comment doesn't prove that a
delayed request will never arrive. The only definite failures are the
CLI's own pre-send failures and the listed 4xx codes.
- **`abandon`.** Privileged only, with a reason and `--yes`. It marks the
attempt `abandoned` and the round `duplicateRisk: true`. For that round,
the report makes no at-most-one claim.
- **Terminal states.** Resolving an attempt that is `posted`, `failed` or
`abandoned` refuses. A `conflict` accepts one more resolution, by either
verb.
**Receipts.** `review record` is accepted from a listed reviewer, for the
current round, citing the comment id and the candidate digest that was
approved. A mismatch refuses. Every round is kept in `review.rounds[]`.
**Tests** use a fake transport:
- a kill:
- before the POST;
- after the server accepted it, before the outcome write;
- while reacquiring the lock;
- `posted`; each listed 4xx; a 5xx; "request failed"; a timeout kill; a 201
without an id;
- a new-op request while an attempt is `requesting` or `uncertain`:
refused;
- a same-op retry after a stale view: it answers, and nothing is sent;
- a late POST after `abandon`: `conflict`;
- a late outcome after `resolve --posted` with the same id and with a
different id;
- `resolve --posted` with the wrong issue, marker, round or candidate;
- a pre-send `GET user` mismatch, and a `GET user` timeout;
- the lead: a fake-transport success with login `jarvis` and actor lead,
and a refusal when the login is anything else, `sage` included;
- the credential `stat` checks: a wrong mode, another seat's path, the
default file;
- request → changes → new candidate → approval, with exact pins at every
round.
In no path does the fake transport see a second POST for one attempt.
### 8.10 Ledger queue checks, Piece E (R8, R9, S8, S10, J8)
The checks come from the brief:
- a row naming an open issue is not done;
- the owner of an in-progress or in-review row has a registration;
- the issues a done row closes are closed;
- a required row older than 14 days is listed.
**How I read the one-call restriction.** The rule is in the #1506 Piece 3
brief: `2026-09-12_control-board-mvp.md`, Boundaries, "no network beyond
the one Gitea call". It bounds the ledger's metric sources, and the ledger
README calls the full-page refusal "the cost of the brief's one-call
boundary". Piece E, which Jason approved under #1508, asks about "every open
Gitea issue" the queue references. The metric call cannot answer that,
because it returns issues updated in range, and an open issue nobody touched
that week is absent. So E cannot exist inside one call. I read Jason's
approval of E as adding a separate, bounded queue-check budget. The metric
source is unchanged.
**Budget, per run:**
| Calls | Purpose |
|---|---|
| 1 | Metrics, unchanged. |
| 1 | Queue checks: `state=open`, limit 50. A full page makes the queue issue checks "incomplete". |
| up to 10 | Queue checks: `GET issues/N` for issues in some row's `closes` that are neither in the open list nor shown closed in the metric page. Issues beyond 10 are "unknown (budget)", and the run is incomplete. |
That is at most 12 calls. `--no-issues` makes 0 calls and prints `queue
issue checks: not run`.
- An issue absent from the open list is unknown. Closed needs positive
evidence: a closed entry in the metric page, or a `GET` that returns a
closed issue that is not a PR.
- Today the extra calls would be at most 3 (#1504, #1505, #1506; section 7, R8).
**Closure (J6, decided by Sage).** `closes` equals `issues` at genesis and
on `add`, which is the brief's literal rule. An issue is expected closed
once every row whose `closes` includes it is done. The lead may narrow
`closes` only with a logged reason (8.7), for example so that row 22 does
not close #1503 while row 1 remains. Other disagreements print for
disposition.
**Liveness (S8, R9, T7).** A registration carries a pid and `startedAt`,
which is the registration time. It holds no process-start identity, so
nothing is ever reported as "live" or "verified".
| Class | Meaning |
|---|---|
| `exempt` | declared on this run with `--unsupported-runtime SEAT` (T3); printed |
| `missing` | no registration for (canonical root, layout `repo`, seat) |
| `invalid` | fails validation, or the scan errors |
| `pid-unknown` | the registration is valid but its pid is null, missing or malformed |
| `pid-gone` | the recorded pid is not alive |
| `pid-present` | the recorded pid is alive, reported as "pid present (identity not verified)"; a reused pid looks the same |
The coverage line reads: `liveness: N pid-present (unverified), N exempt, N
pid-unknown, N missing, N invalid, N pid-gone`.
**Age.** A row's age runs from `requiredSince`. A legacy row marked
`"unknown"` was required no later than genesis. So once genesis is more than
14 days old, the row is overdue for certain, and it is listed as "age ≥ N
days (legacy lower bound)". Until then its age is undecidable.
**Result (T7).** E prints one of three results, and never a full pass:
- `fail`: any known violation that has not been remediated. That covers a
`missing`, `invalid` or `pid-gone` owner, and any issue or age violation.
- `incomplete`: no known violation, but something couldn't be decided. That
covers:
- an issue check that was `unknown (budget)`, hit a full page, or was not
run;
- a `pid-unknown` owner;
- an undecidable legacy age.
- `reduced pass`: nothing known and nothing undecided. The liveness evidence
is pid presence only, and it may include exempt seats.
A verified full pass would need process identity in registrations. That is
out of scope for #1508, as Sage ruled that incarnation is dropped. Sage's
acceptance of the reduced gate means a `reduced pass` meets E. It says
nothing about verified liveness.
**Remediation (S10).** A violation's identity is (check, row, issue). "Moved
within the day" means a second dated E run on the same UTC day no longer
reports that identity, and both runs are posted on #1508. A row edit alone
is not remediation. A `note` can never clear a registration finding,
because that check reads registrations, not rows.
### 8.11 Gate G (S8, R5, T6)
A Pi seat is launched with `--fresh` through its launcher (Q2) while Jason
watches the board. Jason's instruction is "run `scripts/mosaic queue next`
and do it" (Q1). The check is manual. Sage posts this checklist on #1508,
with evidence, and Jason's observation is the final gate.
1. **Interval and pins.**
- The interval starts at Jason's instruction entry and ends at the first
successful result of the concrete work action (item 4). Input after
the interval ends, such as Jason's verdict or housekeeping, is outside
the audit.
- At the cutoff, pin:
- the session file's path and SHA-256, and its session id;
- the launch snapshot under `.pi/state/<seat>/launches/`;
- the pre-test `queue.json` revision;
- the brief blob.
- **Freshness (F4, G1).** The header having no `parentSession` rules
out a fork, but it doesn't rule out an ordinary resume. So freshness is
proved from the launch itself.
Pinned Pi writes no session file before the first assistant message.
`SessionManager._persist` keeps the header, the setting entries and
the instruction in memory, then writes them all at once with an
exclusive create (`wx`) when the first assistant message arrives
(`dist/core/session-manager.js`, around line 739; Rocko reproduced
this in round 5). So the file cannot be pinned before the instruction.
Nobody seeds it, and nobody sends a warm-up message to make it
appear.
**Before the launch,** Sage records:
- the listings of `.pi/state/<seat>/sessions/` and
`.pi/state/<seat>/launches/`;
- the exact launch command line, with `--fresh`;
- the UTC start time.
**At the cutoff,** Sage pins the session file and the new launch
snapshot. They pass only if, retrospectively:
- the session file is absent from the pre-launch listing;
- the launch snapshot directory is the only new entry under
`launches/`;
- the header's `timestamp` falls after the recorded start time and
before the instruction;
- the header has no `parentSession`;
- the entries before the instruction are only the launch's settings
(`model_change`, `thinking_level_change`), with no message,
compaction or branch summary.
Pinning the file once it first appears is allowed, as long as the
cutoff pin follows.
- **Linear file.** The header is session identity, not a node in the
entry chain. The first entry after the header has `parentId` null, and
every later entry's `parentId` is the previous entry's id. A branched
file fails the gate, so branching is excluded from the demonstration,
and the audited chain is the whole file from the instruction to the
cutoff. All 18 recorded session files under `.pi/state/*/sessions/`
have this shape today.
2. **Context audit.** Take the SHA-256 at the start and at the cutoff of
every context source:
- the launch snapshot;
- the committed SOUL and CONTEXT files;
- `<dataRoot>/user/USER.md`, whose hash is recorded privately and whose
content never goes into git or the issue;
- the loaded skills;
- the brief.
Any change fails the gate. An edit that is made and then reverted inside
the interval is not visible to endpoint hashes. That is covered by the
cooperative rule that nobody edits these files during the test, and the
report states the bound.
Row-specific text in these sources fails the gate, with one exception:
the queue data and the row's brief, which the seat is meant to discover
during the test.
3. **Input audit.** Any user-role entry anywhere in the file after the
instruction and before the cutoff fails the gate, whatever its format.
4. **Execution trace.** The audited chain must show, in order, with
ordinary reads between the steps allowed:
- **the `next` call:** a `toolCall` running `scripts/mosaic queue next`
with the canonical root as its working directory, and its
`toolResult` (matched by `toolCallId`, `isError` false) naming the
row;
- **the brief read:** a successful read of the brief named in that
result;
- **the start transition:** a `toolCall` whose actual command is
`scripts/mosaic queue move <row> in-progress --op <OP> …` in the
canonical root, with a successful `toolResult` carrying the receipt.
The op, row and actor in the command must equal the log entry that
made the transition. That entry's revision must be the first one after
the pinned pre-test revision to touch the row, and its time must fall
inside the interval. A receipt from a retry ("already recorded") shows
a lookup, not the origin, and fails this item. Echoing the op, a dry
run, or a failed command fails it as well;
- **the work:** a concrete action from the brief's "What ships" with a
successful result or a visible effect. Proposing a tool call is not
enough.
If the output was lost or truncated, preserve whatever evidence can be
recovered, and declare the link inconclusive. Inconclusive is not a
pass.
5. **Verdict.** Jason's observation, in one sentence on #1508.
This is cooperative evidence. A helper that runs the same command outside
the session isn't excluded. The checklist does rule out the observable
non-execution cases.
**Positive controls (G1).** The audit must pass a genuinely fresh session
before Gate G relies on it:
- a unit fixture built with the pinned `SessionManager`, with no model: a
new session, then settings, the instruction and a synthetic assistant
message. The freshness and linearity rules pass it;
- the recorded fresh session files under `.pi/state/*/sessions/`. The
linearity and prefix rules pass them.
No pre-instruction model turn is needed for either.
**Negative controls,** reviewed by hand against recorded or constructed
session files. Each one must fail item 4:
- an op that is only echoed;
- a start command with a nonzero or error result;
- a seat reusing an op a helper recorded earlier;
- a forked session (`parentSession` present);
- an ordinary resumed session: no `parentSession`, but it holds earlier
messages or was already in the pre-launch listing;
- a session with a compaction entry before the instruction;
- a branched file, including one where the start sits on an abandoned
branch;
- a file whose first entry after the header points at the header's id, or
at any other entry;
- a brief that was never read;
- a work command that failed.
Selection among competing rows is covered by unit tests, not by this
demonstration.
### 8.12 Committing the queue (S5, T5, F1, F3)
The CLI never commits (Q3). The lead runs `scripts/queue-commit.sh -m MSG`.
It uses a temporary index, as Sage directed, and commits exactly the tested
bytes. Nothing anyone else has staged is swept in. A pre-commit guard stops
ordinary commits from reverting the queue.
**The queue guard (F1).** `scripts/git-hooks/pre-commit` is versioned. It
refuses any `git commit` whose index entries for `docs/plans/queue.json` or
`docs/plans/QUEUE.md` differ from HEAD's (`git diff --cached --quiet HEAD --
<both paths>`). Its message names the fix: `git reset -q --
docs/plans/queue.json docs/plans/QUEUE.md`. Only `queue-commit.sh` changes
those paths in a commit, and it uses `commit-tree`, which runs no hooks.
- The guard is installed as `<canonicalRoot>/.git/hooks/pre-commit` by
`queue-commit.sh --install-hook`, which is privileged.
- It copies HEAD's blob, mode 0755.
- It refuses if a different pre-commit hook exists or `core.hooksPath` is
set.
- **Active, not just present (G2).** Matching bytes are not enough. With
the same bytes but no executable bit, git skips the hook, and Rocko
reproduced the revert from queue 12 to 10. A later `core.hooksPath`
selects a different hook while the checked file stays the same. So on
every invocation, `queue-commit.sh` checks all of these:
- `.git/hooks/pre-commit` is a regular file, not a symlink, owned by the
user and executable, and its bytes equal HEAD's
`scripts/git-hooks/pre-commit`;
- `git config --show-scope --get-all core.hooksPath` is empty in every
scope;
- **the canary.** Git itself runs the hook it would run for a commit.
With a new temporary index read from H, `git hook run pre-commit` must
exit 0. After one queue entry in that index is changed to a different
blob, it must exit nonzero, and its stderr must carry the guard's own
refusal line. A hook git can't find ("cannot find a hook named
pre-commit") fails the clean run, so a missing, non-executable or
redirected hook refuses.
I checked this with git 2.55.0 in a scratch repository. An active hook
gave a clean exit of 0 and a changed exit of 1. A non-executable hook
and an alternate `core.hooksPath` each gave 1 on the clean run.
The checks run at step 1, and again just before `update-ref` in step 7.
If either run fails, the script refuses before publishing. They use the
lead's normal git environment. A seat's per-command overrides aren't
visible to them, which is why 8.5 forbids those overrides.
**Why this closes Rocko's schedule.** After `update-ref` publishes C, the
shared index still holds H's queue entries, and those differ from C's.
- Any ordinary `git commit` is refused by the guard until step 8 reconciles
those two entries.
- A commit whose guard ran before `update-ref` loses at its own HEAD
update. `git commit` updates HEAD with the parent it read as the expected
old value, so it fails with "cannot lock ref 'HEAD': is at C but expected
H".
- The reverse order is safe too: if that commit lands first, the lead's
`update-ref` fails.
I checked all three cases with git 2.55.0 in a scratch repository on
2026-09-26. So there is no window in which an ordinary commit can put the
old queue on top of C.
If step 8 can't run, the guard is the maintenance hold Rocko asked for,
as long as it stays active under 8.5's rule.
Ordinary commits stay refused, each naming the fix, until someone
reconciles. No queue lock is held across tests or git.
**Sage's narrower proposal.** Of its three parts, the pre-commit check is
the one that closes F1, and it is adopted. The other two don't close it,
and they are not adopted:
- **The shared index empty of everything else.** The check runs before
`update-ref`, and a seat can stage and commit after it. Even with an
otherwise clean index, the index's queue entries are H's, so any commit
in the window would revert the queue.
- **Under the queue lock.** Ordinary committers never take the queue lock.
The existing guard in step 1, that no queue path is staged, stays.
**Bootstrap (F3):**
1. The queue implementation lands through normal, review-gated source
integration: `packages/queue`, the `scripts/mosaic` dispatch,
`queue-commit.sh`, the guard and `test-queue.sh`. HEAD then has the code
and no `queue.json`.
2. `queue-commit.sh --install-hook`, which is privileged.
3. `queue genesis` (8.2) in the working tree.
4. `queue-commit.sh --genesis -m MSG`, the first queue commit.
- It is allowed only when HEAD has no `docs/plans/queue.json` and the
snapshot's log holds only the genesis entry. Genesis's `canonicalRoot`
and `branch` must match 8.3, and its migration-map blob id must exist
in HEAD.
- `--genesis` with a base present refuses, and so does a missing base
without `--genesis`.
- No op can come in between, because every mutating verb refuses until
genesis is committed (8.2).
**Steps:**
1. **Guard.**
- Run the active-guard checks above: file, mode, bytes, `core.hooksPath`
and the canary.
- Refuse if the shared index has staged changes to either queue path
(`git diff --cached --quiet HEAD -- docs/plans/queue.json
docs/plans/QUEUE.md`). Seats never stage queue files.
- The branch must be the genesis branch.
- Record `H=$(git rev-parse HEAD)`, and whether `H:docs/plans/queue.json`
exists (`git cat-file -e`). Absent requires `--genesis`; present
forbids it.
2. **Snapshot.** `scripts/mosaic queue snapshot --out DIR` takes the lock
and runs 8.5 steps 1–2. It requires a confirmed tail and a current view,
copies both files' bytes into DIR (a new `mktemp -d` outside the
repository), and releases the lock. The two files come from one revision
because they are read under the lock.
3. **Base (F3).** The script writes the base into DIR from the canonical
object database. Unless `--genesis` is set, that is `git -C <root>
cat-file blob H:docs/plans/queue.json > DIR/base.json`. It also writes
`DIR/base.id` with H and H's tree id. The validator reads only files in
DIR. It runs no git, and resolves nothing from its working directory.
4. **Verify with HEAD's code.** Unpack `git -C <root> archive H` into a new
temp directory. There, run its `node --test packages/queue/tests/` and
its `queue verify --snapshot DIR --base-file DIR/base.json`, or
`--base-absent` for genesis. It runs with `GIT_CEILING_DIRECTORIES` set,
so no repository is found around it. Snapshot verify checks only this:
- the given pair is valid and the table is its render;
- the log extends the base's log, or is genesis alone when the base is
absent.
It does not certify live witness continuity (F2). That is canonical
`verify`'s job, under the lock. Dirty working copies of the queue code
play no part.
5. **Blobs.** `B1=$(git hash-object -w DIR/queue.json)`, and `B2` the same
for `DIR/QUEUE.md`.
6. **Tree.** In a temporary index at a path that doesn't exist yet:
- `export GIT_INDEX_FILE=$(mktemp -d)/index`;
- `git read-tree $H`, which creates it;
- `git update-index --add --cacheinfo 100644,$B1,docs/plans/queue.json
--cacheinfo 100644,$B2,docs/plans/QUEUE.md`;
- `T=$(git write-tree)`.
Git 2.55 also accepts an empty file as an index, but the plan doesn't
rely on that. Then check that `git diff-tree -r --name-only $H $T` lists
only those two paths.
7. **Commit.**
- `C=$(git commit-tree $T -p $H -F msgfile)`.
- Run the active-guard checks again. If they fail, refuse; nothing has
been published.
- `git update-ref -m queue-commit refs/heads/<branch> $C $H`. This fails
if the branch has moved since step 1. If it does, nothing is lost:
start again.
- `commit-tree` runs no hooks, the guard included. There are no other
hooks today.
8. **Reconcile the shared index.**
- Check again that `git rev-parse HEAD` is C. If it isn't, which needs a
bypass, stop, report, and don't touch the index.
- Check that the shared index's two entries (`git ls-files -s`) still
equal H's. For genesis, `queue.json` must be absent. If they differ,
someone staged a queue path: stop and report.
- Run `git reset -q -- docs/plans/queue.json docs/plans/QUEUE.md`. It
points those two entries at C's blobs and touches nothing else.
- If the index is locked by another git process, print that command and
exit 3. The guard holds ordinary commits until it runs.
Why not something simpler: `git commit --only -- <paths>` also builds a
temporary index, so it wouldn't sweep in other staged files either. But it
commits the working-tree bytes as they are at commit time, which may no
longer be the tested snapshot, and it runs hooks and the editor.
**Review-gated source** is integrated the same way. The prospective tree
comes from `H` plus the blobs of the approved manifest paths.
- `review verify-commit ID $T` must pass on that tree before `commit-tree`.
- The suites run from `git archive $T` unpacked, not from the working tree.
- If the approved bytes are no longer on disk, integration refuses (8.9).
Ordinary source commits by seats go through the guard. The only way it
refuses them is a queue path that is staged or not yet reconciled.
Committing still needs its own authorization. This procedure doesn't give
it.
**Tests,** in a scratch repository:
- **F1:**
- pause right after `update-ref`, then make an ordinary commit of
unrelated staged source. The guard refuses it. After step 8 it commits,
and the queue stays at C's revision;
- a commit whose guard ran before `update-ref`: its HEAD update fails;
- step 8 while `index.lock` is held: exit 3, and ordinary commits are
refused until the printed command runs;
- HEAD changed before `update-ref`: refuse;
- a shared-index change during the procedure is not committed;
- a queue path staged after `update-ref`: step 8 stops and touches
nothing;
- a missing or different hook: `queue-commit.sh` refuses;
- a hook with the same bytes but no executable bit, a symlinked hook, and
a newly set `core.hooksPath` in the local and the global scope. Each
makes `queue-commit.sh` refuse before `update-ref`;
- the guard deactivated between step 1 and step 7: refuse at the step-7
recheck;
- **bootstrap (F3):**
- an implementation-only HEAD, then genesis, then the `--genesis` commit,
then a later extending commit;
- `--genesis` with a base present refuses;
- an absent base without `--genesis` refuses;
- an op after genesis but before the first commit refuses;
- the archived validator, run in a directory outside any repository;
- **general:**
- an unrelated staged file stays staged and uncommitted;
- a queue write after the snapshot is not committed;
- the committed blobs equal the snapshot bytes;
- a snapshot whose log does not extend the base refuses;
- `verify-commit` on a prospective tree with one changed manifest path
fails.
### 8.13 Brief checks (S10, R13)
On `add` and on any brief change, the brief must be:
- a repo-relative path whose realpath is inside `canonicalRoot`;
- a regular file, not a symlink;
- present in HEAD's tree (`git cat-file -e HEAD:<path>`). A brief that is
only staged is refused.
The anchor heading must occur exactly once in the HEAD blob. The row records
the blob id.
`verify --current` rechecks the briefs of non-terminal rows against HEAD and
reports drift. Replay never does this. The five untracked briefs (section 7,
R13) must be committed before genesis.
**The working brief (T8).** A seat reads the brief from the working tree,
not from HEAD. So `start` computes the git blob id of the working file's
bytes in-process, as SHA-1 over `blob <len>\0` plus the bytes, with no git
write. It refuses if that differs from the pinned blob, and `next` flags the
row (8.8). A re-pin is a privileged brief change. The new version has to be
committed first, and the row then records the new blob.
### 8.14 Acceptance checklist
- **A:**
- tests for every transition and refusal in 8.7;
- the op-id rules in 8.6;
- every lock schedule in 8.4 and write schedule in 8.5;
- the canonical checks in 8.3, with worktree, second-clone, detached-HEAD
and wrong-branch fixtures;
- genesis refusals (8.2), witness, `sync` and `accept-history` (8.5);
- the claim lifecycle, `add` defaults and J4/J5 cases (8.7);
- the working-brief check (8.13);
- the `queue-commit.sh` tests, including the guard and bootstrap
(8.12);
- the unlocked-read race tests (8.4, 8.5);
- byte-stable render;
- `next` ordering among competing rows.
`scripts/test-queue.sh` passes. Genesis runs from the reviewed migration
map. The README states:
- the cooperative trust boundary and the git side of the protocol (8.5);
- the platform assumptions and that power loss is untested;
- the unacknowledged-op rule;
- the same-seat claim limit;
- the check-to-rename windows.
- **C:** `BRIEF-TEMPLATE.md`, plus two owner-accepted briefs recorded on
#1508.
- **B:** the AGENTS.md cadence line and the five CONTEXT.md lines (8.1)
name `scripts/mosaic queue next`. A fresh launch snapshot shows the new
text. Then Gate G (8.11), including the negative controls.
- **D:** the 8.9 tests. Then one full live round on a real issue with the
per-seat tokens read in place (8.9), with zero new files under
`docs/plans/reviews/`.
- **E:** the 8.10 tests with a fake `gitea-api.sh`, covering each result
level and the legacy age bound, and `node --test packages/ledger/tests/`
passing. One dated run with the coverage line and its result level is
posted on #1508.
- **Order:** A with C, then B, then Gate G. D and E come after A, and E may
go before D (Sage, 2026-09-26). D's live round uses the per-seat tokens
Jason ruled on (8.9).
### 8.15 Decisions
Jason told Sage to decide as lead. Every item below is **decided by Sage
2026-09-26**; nothing in this plan waits on Jason except D's live posting
round (Q4). Round 5: Jason ruled on D's credentials the same day (8.9), so
nothing waits on him now.
| Item | Decision | Where |
|---|---|---|
| Q1 | Gate G's instruction is "run `scripts/mosaic queue next` and do it" | 8.11 |
| Q2 | Gate G runs on a Pi launcher with `--fresh`; `next` without identity refuses | 8.8, 8.11 |
| Q3 | the CLI never commits; the lead commits the queue files after `test-queue.sh` | 8.12 |
| Q4 | D posts only with an explicit per-seat credential file (`MOSAIC_GITEA_CREDENTIAL_FILE`) and refuses the default file. Where seat tokens come from is Jason's; the live posting test waits for him, the build does not. Round 5: he ruled that each seat's token is read in place (8.9) | 8.9, 8.14 |
| J1 | owners progress their required rows; reorder, park and clearing `required` are Jason-only | 8.7 |
| J2 | cooperative trust model; claimed actors, detection through replay and E | 8.7, README |
| J3 | briefs mandatory; `queued` = brief exists, not accepted | 8.7 |
| J4 | only `jason` unparks, to `queued` | 8.7 |
| J5 | in-review→done allowed where `gateOwner` is not Jason, with logged evidence | 8.7 |
| J6 | `closes` = `issues`; narrowing needs a logged reason | 8.7, 8.10 |
| J7 | dropped | — |
| J8 | 12 calls at most, 0 with `--no-issues`, "unknown (budget)" past the limit | 8.10 |
| J9 | Sage writes a parked stub brief for row 8, which keeps id 8 | 8.2 |
| Reduced liveness gate | accepted, labelled reduced | 8.10 |
| E before D | allowed | 8.14 |
| 8.0 single file | accepted; no journal, no `queue repair`; directory fsync on every write | 8.0, 8.5 |
| 8.4 `link()` | accepted in place of a literal `O_EXCL` | 8.4 |
| 8.3 root in genesis | accepted; no config key | 8.3 |
Two points where I resolved Sage's wording:
- For J4 and J5, "as proposed in 7.2" replaced the 8.7 build defaults that
referenced the brief (parked terminal; in-review→done refused). The
7.2 proposals are what the matrix now says.
- J4's return state is `queued`, not the pre-park state, so a possibly
stale brief is re-accepted before work resumes.
**Round 4: choices of mine for Sage to confirm.** None of these needs a new
owner ruling. Each is a lead-level specification choice made in answer to
T1–T8:
- **The witness file** `.git/mosaic-queue.head` (8.2). It is a check value,
not a second store. Without it, a rollback that loses ops can't be
detected, which is what you asked for in T2.
- **Pinning the branch** alongside the root (8.3). Moving the queue to
`next` or `main` later is a reviewed migration.
- **`accept-history`** is privileged, needs `--yes` and a reason, and gives
up deduplication for the lost range (8.5).
- **No `not-posted` resolution.** A privileged `abandon` records the risk of
a duplicate instead (8.9).
- **The failed-4xx allowlist:** 400, 401, 403, 404 and 422. Everything else
is uncertain (8.9).
- **`add` defaults:** `gateOwner` is `jason`, and `reviewers` is empty
(8.7).
- **The op suffix `.outcome`** is reserved (8.6).
- **The filesystem allowlist:** ext4, xfs, btrfs, and tmpfs for tests
(8.5).
- **E never prints a full pass.** Its best result is `reduced pass` (8.10).
- **`queue-commit.sh` uses `commit-tree`,** which skips hooks. There are
none today (8.12).
Sage confirmed all of the above on 2026-09-26.
**Round 5: lead-level choices for Sage to confirm.** None of these needs an
owner ruling.
- **The queue guard** is a pre-commit hook in the canonical checkout (8.12).
It runs on every seat's `git commit`, and refuses only when a queue path
is staged or not yet reconciled. Of Sage's proposal, this part is
adopted. The clean-index and queue-lock parts are not, because they don't
close F1.
- **Genesis is committed before any op** (8.2), with a `--genesis`
exception for an absent base (8.12).
- **Unlocked reads** read the witness first and recheck under the lock
before any adverse report. There is no retry loop (8.4).
- **Gate G requires a linear session file** and proves freshness from the
launch (8.11).
- **Caller op ids are capped at 72 characters** (8.6).
- **"There are none today" (above) no longer holds** once the guard is
installed. `commit-tree` skips the guard on purpose, and 8.12 step 8
plus the guard cover that gap.
- **D only `stat`s the credential file,** and never opens it. The lead's
jarvis token follows the seat rule (8.9).
Sage confirmed the round-5 choices on 2026-09-26. She also set the jarvis
Gitea token path, and ruled that a `write:repository` 403 in the first live
round is an expected finding (8.9).
**Round 6.** No new lead-level choice. Two of Rocko's corrections:
- Gate G's pins are taken before the launch and at the cutoff, never on a
session file before the instruction;
- the guard is checked for being active on every invocation.
### 8.16 Where each round-2 finding is resolved
| Finding | Resolved in |
|---|---|
| S1 lock | 8.4 |
| S2 torn append | 8.0 (one file, rename only), 8.5 |
| S3 view drift and hand edits | 8.5 |
| S4 retry identity | 8.6 |
| S5 commit integration | 8.12 |
| S6 one history, genesis, replay | 8.2, 8.3 |
| S7 prerequisites, field permissions, closure | 8.7, 8.10 |
| S8 identity evidence | 8.8, 8.10, 8.11 |
| S9 at most one POST | 8.9 |
| S10 candidates, briefs, remediation | 8.9, 8.13, 8.10 |
| S11 contradictory body | this section is the specification (banner at the top) |
The round-2 modifications:
- R1's lock is replaced by 8.4, which never reclaims automatically.
- R3's cooperative writer stays, restricted to the canonical checkout.
- R6's claims become seat names with no incarnation.
- R7 becomes 8.9's uncertain state, with no resend.
### 8.17 Where each round-3 finding is answered
| Finding | Answered in |
|---|---|
| T1 visibility vs durability | 8.0 note, and 8.5: the platform, the three points, steps 6–10, `uncertain` exit 3, `sync`, the fault-injection tests, and the statement that power loss is untested; D sends only after a durable intent (8.9 step 1) |
| T2 git rollback | 8.2 witness; 8.5 step 7 unchanged check, "history lost" refusal, manual recovery replacing "restore with git", `accept-history`, and the git side of the protocol; 8.3 branch pin; 8.12 step 3 (committed history only grows) |
| T3 lock and gate classification | 8.4: classification order with host before pid, `--check-gate`, release by inode and full record, `unlock` never takes the lock, `O_EXCL` temp with checked writes, other `link` errors refuse |
| T4 requesting and outcomes | 8.9: the attempt states, where `requesting` blocks as `uncertain` does; `REQOP.outcome` entries; `conflict`; `resolve --posted` verified by GET; `abandon` in place of `not-posted`; the 4xx allowlist; process-group kill; `move in-review` as the request. 8.5 step 3 and 8.6: lookup before the view check |
| T5 shared-index commit | 8.12: temporary index, blobs from the tested snapshot, `commit-tree` plus `update-ref` against the expected HEAD, tests from `git archive`, prospective-tree `verify-commit` |
| T6 Gate G execution | 8.11: interval and pins, the ancestry chain, `toolCall`/`toolResult` matching, a first transition inside the interval, the work result, the context exception, hash bounds, the negative controls |
| T7 unverified liveness | 8.10: `pid-unknown`; the results `fail`, `incomplete` and `reduced pass`, with no full pass; the legacy age lower bound |
| T8 internal consistency | 8.7 `add` rules and claim lifecycle, with J5 precedence and settlement; 8.13 working-brief check; 8.2 genesis bootstrap; 8.3 `git -C` with absolute paths; 8.1 and 8.14 B's context files |
### 8.18 Where each round-4 finding is answered
| Finding | Answered in |
|---|---|
| F1 HEAD/shared-index gap | 8.12: the queue guard, why it closes the schedule, step 8 reconciliation with its rechecks, the guard as maintenance hold, the tests. 8.5: the commit side of the cooperative protocol. 8.1: the hook file |
| F2 unlocked reads | 8.4: witness-first order, and a locked recheck before any adverse report. 8.5: the unlocked-reads bullet and the race tests. 8.12 step 4: snapshot verify does not certify witness continuity |
| F3 bootstrap and base | 8.2: genesis committed before any op. 8.12: the bootstrap sequence, `--genesis`, the base written from the object database, the validator isolated from any repository, the temporary index at a fresh path |
| F4 Gate G freshness | 8.11: freshness proved from the launch, a linear file, input audited across the whole file, new negative controls |
| F5 op-id length | 8.6: caller ids ≤ 72, log ids ≤ 80, boundary tests |
| Rocko's T3 note (quiescence) | 8.4: gate removal only once no queue command is running |
| Rocko's T4 note (bounded GETs) | 8.9: `GET user` and `resolve --posted` under the 30 s process-group deadline |
| Jason's credential ruling | 8.9 credentials; 8.14 D |
### 8.19 Where each round-5 finding is answered
| Finding | Answered in |
|---|---|
| G1 Gate G pins evidence Pi hasn't written yet | 8.11: pre-launch receipts (listings, command line, start time), the retrospective audit at the cutoff, the header exempt from the entry chain with the first entry's `parentId` null, positive controls with no pre-instruction model turn, and a new negative control |
| G2 matching bytes don't prove the guard runs | 8.12: file, mode, bytes, every-scope `core.hooksPath` and the `git hook run` canary, at step 1 and before `update-ref`, with tests. 8.5: disabling or overriding the guard added to the forbidden bypasses, and the trust limit stated |
| Lead login (Rocko's note) | 8.9: the expected login is `jarvis` for the lead, whose actor stays the lead, with a success and a wrong-login test |
## Log
- 2026-09-26: first version, sent to Sage.
- 2026-09-26: Sage's decisions folded in (Q5–Q9, fixes 5.3 and 5.4).
Q1–Q4 marked as the open set for Jason. Section 0 added.
- 2026-09-26: round 2. Section 7 disposes of Rocko's R1–R14 (ten
accept, four accept-modified, none rejected); 7.2 lists J1–J9 for
Jason. Q3 recommendation changed to no automatic commit. Q6 and Q7
amended by Sage. Body edits in sections 0–5 point at section 7.
- 2026-09-26: round 3. Section 8 added as the active specification after
Rocko's round 2 (revise, b3a2d72a…e70f4). It follows Sage's direction:
manual recovery verbs, a lock with no automatic reclaim and an unlock
gate, op ids as the only retry identity, canonical checkout only, no
incarnation, at most one review POST. One departure: the log lives inside
queue.json, so there is no journal file and no repair verb (8.0). The J8
budget and reading are in 8.10. Banner and section 0 note added; sections
1–7 are unchanged and superseded where they differ.
- 2026-09-26: Sage's round-3 rulings recorded (8.15): 8.0 single file,
`link()` lock and genesis root accepted, J8 budget accepted, and Q1–Q4,
J1, J2, J4, J5, J6, the reduced liveness gate and E before D decided by
Sage as lead. 8.0, 8.4, 8.7, 8.10 and 8.14 updated to match. J4 and J5
follow 7.2's proposals rather than the earlier build defaults.
- 2026-09-26: round 4. Rocko's round 3 (revise, 13a32804…a274, T1–T8)
answered in section 8: durability kept separate from visibility, a
rollback witness with manual `accept-history`, host-first lock
classification, attempt states for review requests with no
`not-posted`, a temporary-index commit procedure, Gate G tied to its
execution trace, E result levels with no full pass, and the `add`,
claim, brief and genesis consistency fixes. 8.17 maps T1–T8. Lead-level
choices are listed in 8.15.
- 2026-09-26: round 5. Rocko's round 4 (revise, fcb8933d…efcff, F1–F5)
answered in section 8:
- a pre-commit queue guard plus shared-index reconciliation (F1),
checked in a scratch repository;
- witness-first unlocked reads with a locked recheck (F2);
- a genesis-first bootstrap and an isolated validator given its base
(F3);
- Gate G freshness proved from the launch, with a linear file (F4);
- caller op ids capped at 72 characters (F5).
Jason's per-seat credential ruling is folded into 8.9. 8.18 maps the
findings.
- 2026-09-26: round 5 follow-up. Sage confirmed the round-5 choices. 8.9
now names the jarvis Gitea token path under the seat rule, and treats a
`write:repository` 403 in the first live round as an expected finding.
- 2026-09-26: round 6. Rocko's round 5 (revise, 3b031a70…177e, G1, G2)
answered in section 8:
- Gate G freshness proved from pre-launch receipts and a retrospective
audit, because Pi writes no session file before the first assistant
message. The header is out of the entry chain (G1);
- the guard checked as active on every invocation, including a
`git hook run` canary (G2), checked in a scratch repository;
- the lead's expected login is `jarvis`. 8.19 maps the findings.