queue move ID in-review posts the review request as a Gitea comment and review record reads verdicts back, so reviews stop being files in docs/plans/reviews/. On a comment round, in-review to waiting-on-jason now needs every listed reviewer's approval for the current round, the same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads the raw per-seat token files (lead decisions 37 to 39): config built and checked before curl starts, export attribute cleared, fixed base URL. test-queue.sh skips its live checks outside the canonical root. Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302) and corrected the plan (293747cd). Rocko reviewed the helper (e896192f, 2096b0a3), and Sage's lead check passed under decision 38. Manifest b402fb38, 19 files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
128 KiB
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 existrefusal 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.jsonin 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 aspackages/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, plusrender --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.mjsandtests/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 manualnode --testcovers them, so "commit only after suites are green" does not cover them either.
Changed:
scripts/mosaic: today it isexec node packages/seat/src/cli.mjs "$@"with no dispatch. It needsqueue) exec node packages/queue/src/cli.mjsand a fallthrough to seat.launchandseat taskbehavior must stay byte-identical, because everyagents/*/launch.shandscripts/test-{darkwing,rocko}-launch.mjsgo 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, whichafterreplaces (Q8). The parked table's items becomeparkedrows (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 intopackages/ledger/README.mdand 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.mdlines 112–117 (Cadence) and 190 (pointer).agents/{darkwing,dewey,filbert,rocko,sage}/CONTEXT.md: each has a line sending seats todocs/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 saysagents/*/AGENTS.md, but no seat has one; the files areCONTEXT.md.- Out of B's scope, noted for Sage:
agents/filbert/SOUL.mdandCONTEXT.mdstill name Darkwing as team lead.
D
packages/queue/src/{queue,cli}.mjs:move ID in-reviewbuilds and posts the request comment, then records the comment id on the row.scripts/gitea-api.shis unchanged if the identity comes fromMOSAIC_GITEA_CREDENTIAL_FILE, which it already honors (see Q4).packages/queue/README.md,docs/TOOLS.md.
E
packages/ledger/src/ledger.mjs(newqueueChecks),src/cli.mjs(section above the weekly table,--jsonkey),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.jsonthrough the queue validator by relative import (../../queue/src/queue.mjs). There are no workspaces, so a bare@mosaic/queueimport would not resolve (R14). - Reads registrations with
loadRegistrationsimported relatively frompackages/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,
iduniqueness,idnever reused after deletion (the CLI never deletes a row; test that no verb can),blockedReasonrequired iff blocked, unknown field refused, owner outside the allowed set refused,afternaming a missing id or itself refused, a dependency cycle refused,requireda 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.
jasonandsagemay move any row (Q5).coordinatoras an owner may move only its own rows. A missing identity (noMOSAIC_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 whoseafterdependencies are not done (Q8); never returnsparked; printsnothingwith 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 launchandseat taskproduce the same argv/env capture as before (reuse the capture pattern inscripts/test-darkwing-launch.mjs). - C:
add --brief missing/path.mdrefused,add --brief docs/plans/X.md#Sectionaccepted when the file exists (the section is not verified; say so in the README). scripts/test-queue.shruns the above plusrender --checkagainst the realdocs/plans/queue.json, so the committed queue is validated by a suite.
B
grep -n "CURRENT.md" agents/*/CONTEXT.mdshows no "what now" pointer. AGENTS.md cadence names the command.agents/<seat>/launch.sh --checkstill 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-reviewposts 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 underdocs/plans/reviews/oragents/*/work/. - Identity: the comment is posted with the credential file the ruling names.
The test asserts that the default
~/secrets/mosaic.gitea.jsonis 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=opencall fails the run (Q7). Violations appear above the table in text and under aqueuekey 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):
- One real row in
briefedstate, 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. - The seat starts with no prior session:
agents/<seat>/launch.sh --freshin its tmux pane. The Pi launcher exportsMOSAIC_AGENT_NAMEand 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 -afrom 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.
mosaic queue nextresolves to the wrong program (Q1). Verified on this host.- Gate G assumes a launcher identity that T3 seats lack (Q2). Verified:
MOSAIC_AGENT_NAMEis set only byscripts/agent.sh,scripts/agent-host-dev.sh,agents/rocko/launch.shand the discord engine child. requiredas 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: trueis a boolean on the row, alongside the normal state machine, andrequiredis no longer a state.parkedis refused while the flag is set. Proposed detail: onlyjasoncan clear it, following QUEUE.md's existing "only Jason moves" rule for required rows.- 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 throughwaiting-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 agateOwnerfield or awaiting-on-jasonrequirement flag. blocked→previousneeds storage. AddblockedFrom, set on entry and cleared on exit.queuedis unreachable ifaddrequires a brief. Statequeuedmeans "no brief yet", but C makesaddrefuse a missing brief. Fix:--briefis optional onadd, and must exist if given. queued→briefed requires an existing brief.- Parked rows "never change" vs "needs Jason to reopen" (QUEUE.md's
parked table). Fix: parked→queued/briefed with
--by jasononly. --by jasonis a claim, not proof. The CLI cannot check who typed it. Every agent commits as the same git user.packages/seathandlestaskSetBythe 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 itsupdatedBy, for Jason to confirm weekly. A real check needs a signed approval, which is out of this brief's scope.- Rendered view with hand edits. Nothing stops a hand edit between the
markers, and the next write silently erases it. Fix:
render --checkinscripts/test-queue.shfails on drift. - 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.
- 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) andissues: [int].nextfor a reviewer returns rows inin-reviewwhere they are the reviewer. That is how Filbert's review work would reach Filbert through the command. - 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
noteis 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. - Piece D's pass criterion is already trivially met. Since 2026-09-13,
review receipts have gone to
agents/darkwing/work/*/r*-review.md, notdocs/plans/reviews/(161 entries, last change ea00ec66/11659cf2). The side channel moved; it did not close. Fix: the criterion counts new review files anywhere, includingagents/*/work/. - 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. - 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.jsonlis the record of operations,queue.jsonis the validated snapshot, and the rendered table is a view.listandnextread onlyqueue.json, and only after verify. No reader ever takes QUEUE.md as current. - Revision stamps. Each snapshot carries
revisionand 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-testtherefore share one lock, and nothing new is added at the repository root. Every verb, includinglist,nextand standalonerender, takes the lock before reading. - Stale locks. The owner file records the PID and its
/proc/<pid>/statstart 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:
- Verify the current state (R3).
- Append the journal entry and fsync.
- Write the
queue.jsontemp file, fsync, rename, fsync the directory. - Render the table and rename it into place.
- 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.
addrefuses when an identical non-terminal row exists (same piece, owner and brief) and prints that row's id. Amoveto 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 spawningflock(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.shpasses 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, andrender --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 verifyreplays the journal from its genesis entry (the reviewed migration, R13) and requires the replay to equalqueue.jsonbyte 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.jsonor 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, thequeue.jsonrevision, 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
messageKindis 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:
messageKindalso 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.
movechecks theafterand authority predicates under the lock, the same predicatesnextuses. A seat that bypassesnextis refused. - Claims. Moving to in-progress records the claim: seat plus
MOSAIC_LAUNCH_INCARNATIONwhen present. in-progress→in-progress is refused.nextreports a row claimed by another incarnation asresume (claimed by <incarnation>)and does not return it as fresh work. - Action role.
nextreturns an action with the row:implement,resume,revieworwait. An author whose row is in-review getswait. A reviewer getsreviewonly when it has no receipt for the current round (R12). - Dependency conditions.
afterentries carry a condition. The default isdone, andsettledmeans done or blocked. Row 9 isafter: [{id: 6, when: "settled"}], which matches the brief's "Gate F or blocked". Sage decidedafter, 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.
aftercannot 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 useragainst 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/Nfor 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-issuesprintsqueue 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 SEATand printed),missing,invalid,stale(dead PID, or a PID alive with a different start time) andlive. - Matching uses repository root, layout
repoand 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
createdAtandrequiredSince(UTC). - E ages required rows from
requiredSinceand only while non-terminal, sonoteno 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.
--briefstays required onadd(the brief's rule is kept).queuedmeans the brief exists but is not accepted, andbriefedmeans accepted. That changes QUEUE.md's definition of queued ("no brief yet"), so it needs Jason. - Blocking:
any→blockedapplies 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 withnote. This is a reading, not a change. - 5.10, issue closure: rows gain
closesIssue: trueon 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
reviewerfield becomesreviewers: [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:
--candidatemust be a commit SHA present in the repository, or a regular, repository-contained manifest file./tmpand 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 ofissues, and is explicit when there are several) andreview: {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#Headingis 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, andnode scripts/test-darkwing-launch.mjsandnode scripts/test-rocko-launch.mjsfor 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:
requiredas a flag. It alters the brief's schema text but keeps its semantics.- in-review→in-progress.
- Q5–Q9 as amended.
- The
settleddependency 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/mosaicdispatchesqueueto the queue CLI and passes everything else through unchanged.scripts/test-queue.shrunsnode --test packages/queue/tests/, thenscripts/mosaic queue verify.scripts/queue-commit.shis the lead's commit procedure (8.12).scripts/git-hooks/pre-commitis the queue guard, whichqueue-commit.sh --install-hookinstalls (8.12).docs/plans/queue.jsonis 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.mjsby 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 ofagents/{dewey,filbert,darkwing,sage}/CONTEXT.mdand line 15 ofagents/rocko/CONTEXT.md("Read docs/plans/CURRENT.md to reconcile ownership and existing gates"). Line 30 ofagents/darkwing/CONTEXT.mddescribes 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.mjsand thereviewverbs. It posts only throughscripts/gitea-api.shwithMOSAIC_GITEA_CREDENTIAL_FILE, set to the per-seat token file Jason ruled on (Q4, 8.9). - E:
packages/ledger/src/queue-checks.mjsand the ledger CLI hook. It imports../../queue/src/queue.mjsand 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,
\nline 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}.
argsis canonical.byis the claimed actor.semanticsis the rule version this entry was checked under.viewShais 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.jsonexists in the working tree or in HEAD;- the witness file exists (below);
--rootdiffers from the realpath of the toplevel, found as in 8.3;--branchdiffers 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;
canonicalRootandbranch(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, equalscanonicalRoot. - 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-dirand--git-common-dirare the same path. A linked worktree fails this. git symbolic-ref -q HEADnames the genesisbranch. 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 isverify --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:
-
Create
<canonicalRoot>/.git/mosaic-queue.lock.<pid>.<random>.tmpwithO_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.
-
link()the temp file to the lock path.- EEXIST means the lock is held. That is the same exclusivity as an
O_EXCLopen. - Any other
linkerror refuses. - On success,
statthe 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 literalO_EXCLopen (2026-09-26). An empty or unparsable lock file, which the CLI can't produce, counts asinvalid. - EEXIST means the lock is held. That is the same exclusivity as an
-
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 stats 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.
- Create the gate
<canonicalRoot>/.git/mosaic-queue.unlockwith 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.
- With the gate held, classify the lock.
live,unknownorinvalid: refuse and remove the gate.deadormismatch: unlink the lock.
- 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,
unlockclassifies later, sees the writer's published live record, and refuses. - If the check came after, the writer sees the gate and releases.
unlocknever 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:
- Order. Read the witness first, then
queue.json. The writer publishesqueue.jsonbefore 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. - 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. - 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 suggestsaccept-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
linkerror 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-gatesaysmismatch; - 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:
-
Checks. Run the canonical checks (8.3). Read
queue.json, and keep its bytes and itsstatresult (dev, ino, size, mtime_ns). The file must parse, match the schema, re-serialize byte for byte and replay torows.log[0]must be the only genesis entry, op ids must be unique andrevisionmust equal the lastrev. Any failure refuses. -
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
logDigestand 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: fsyncqueue.json, fsyncdocs/plans/, write the witness, then continue. - Otherwise refuse, and name the tail ops and
queue sync.
- If this call is a retry of an op in that tail, or is
- 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, whichsyncaccepts.
- It matches (same revision and
-
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.
-
View. Check that the QUEUE.md table is current. If it is stale or unknown, refuse the new op (see "Outcomes").
-
Compute. Compute the new rows and the log entry, and validate both.
-
Temp file. Create
queue.json.<op>.tmpwithO_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. -
Unchanged check.
statand hashqueue.jsonagain. 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. -
Rename. Rename the temp file over
queue.json. The op is now visible. A rename failure unlinks the temp file and refuses. -
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.
- print
-
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. -
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.
-
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 syncruns or the op is retried.syncprintsdurable 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:
-
Copy the current
queue.jsonbytes toagents/<lead>/work/queue-recovery-<date>/before touching anything. Restoring HEAD would discard every op written since the last queue commit. -
Find the lost ops. They are the entries after the last committed revision, taken from:
- the preserved bytes;
git stash listand the reflog;- the receipts that seats hold;
- issue comments carrying
mosaic-queue-opmarkers.
-
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.
-
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.
-
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 commitand never--no-verify, so the queue guard runs (8.12). - Merge, rebase, cherry-pick, revert and
amskip that guard. On the queue branch they are the lead's. - Nobody disables, replaces or overrides the guard. That means no
core.hooksPathin 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 orsyncconfirms 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
syncnames 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.jsonbetween steps 1 and 7: step 7 refuses;git stashof a valid newer pair, restoring an older valid pair: history lost, every verb refuses, andaccept-historyworks only with--yesand 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-historyin progress;
- a hand edit to
queue.jsonthat 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;
verifyandrender --checkleave 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
.outcomeis 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
addreturns 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
addretried 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
owneris itself. It may setpiece,brief(required, 8.13),issuesandnote. - 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,reviewersandrequiredonadd.
"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 | with D built, on a comment round: refused while any attempt is unresolved, and it needs the approving receipts of every listed reviewer for that round, the same checks as in-review→done (8.9). Before D, or on a round with request: none: no condition |
| 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
requiredare Jason-only. - J4: only
jasonunparks, and a parked row returns toqueued. - J5: in-review→done is allowed where
gateOwneris 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:
resume: an in-progress row claimed by SEAT.review: an in-review row where SEAT is a listed reviewer and has no receipt for the current round.start: a briefed row owned by SEAT whoseafteris satisfied. If the working brief no longer matches the pinned blob (8.13),nextstill names the row, markedbrief differs from pinned blob; ask the lead to re-pin, andstartrefuses.wait: only when nothing above matches and SEAT owns an in-review row.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.shreads it. - D only
stats 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 holdwrite:issue. - It is unverified whether
write:repositoryalone lets a token post an issue comment. If it doesn't, the POST gets a 403, which isfailed: 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 isfailedorabandoned.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:
-
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,uncertainorconflict, 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.
- If any attempt on the row is
-
Pre-send checks, without the lock:
- the credential file is set, is not the default, and passes the
statchecks above; GET usermatches the expected login: the seat's own for a seat, andjarviswhen 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. - the credential file is set, is not the default, and passes the
-
POST. Run
gitea-api.shin 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 andHTTP <code>on stderr, and it keeps the token off argv, stdout and stderr. D records only the status and the comment id.HTTP 201with 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.
-
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
postedX, transport posted X) is recorded as confirmation; - disagreement (for example,
abandonedwhile the transport reports 201) setsconflictand records both.
- agreement (resolved
- If the lock can't be taken, print
uncertain REQOP: transport said <x>, not recorded, and exit 3. The attempt staysrequesting.
- If the attempt is still
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 becomesposted. 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 attemptabandonedand the roundduplicateRisk: true. For that round, the report makes no at-most-one claim.- Terminal states. Resolving an attempt that is
posted,failedorabandonedrefuses. Aconflictaccepts 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[].
On a comment round, the receipts gate both moves out of in-review toward
done: in-review→done, and in-review→waiting-on-jason, which is the route
for a Jason-gated row. Each needs an approval from every listed reviewer
and no unresolved attempt.
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
requestingoruncertain: 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 --postedwith the same id and with a different id; resolve --postedwith the wrong issue, marker, round or candidate;- a pre-send
GET usermismatch, and aGET usertimeout; - the lead: a fake-transport success with login
jarvisand actor lead, and a refusal when the login is anything else,sageincluded; - the credential
statchecks: 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
GETthat 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 amissing,invalidorpid-goneowner, 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-unknownowner; - an undecidable legacy age.
- an issue check that was
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.
-
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.jsonrevision; - the brief blob.
-
Freshness (F4, G1). The header having no
parentSessionrules 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._persistkeeps 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
timestampfalls 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.
- the listings of
-
Linear file. The header is session identity, not a node in the entry chain. The first entry after the header has
parentIdnull, and every later entry'sparentIdis 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.
-
-
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.
-
Input audit. Any user-role entry anywhere in the file after the instruction and before the cutoff fails the gate, whatever its format.
-
Execution trace. The audited chain must show, in order, with ordinary reads between the steps allowed:
- the
nextcall: atoolCallrunningscripts/mosaic queue nextwith the canonical root as its working directory, and itstoolResult(matched bytoolCallId,isErrorfalse) naming the row; - the brief read: a successful read of the brief named in that result;
- the start transition: a
toolCallwhose actual command isscripts/mosaic queue move <row> in-progress --op <OP> …in the canonical root, with a successfultoolResultcarrying 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.
- the
-
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 (
parentSessionpresent); - 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-commitbyqueue-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.hooksPathis 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.hooksPathselects a different hook while the checked file stays the same. So on every invocation,queue-commit.shchecks all of these:-
.git/hooks/pre-commitis a regular file, not a symlink, owned by the user and executable, and its bytes equal HEAD'sscripts/git-hooks/pre-commit; -
git config --show-scope --get-all core.hooksPathis 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-commitmust 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.hooksPatheach gave 1 on the clean run.
The checks run at step 1, and again just before
update-refin 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 commitis refused by the guard until step 8 reconciles those two entries. - A commit whose guard ran before
update-refloses at its own HEAD update.git commitupdates 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-reffails.
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):
- The queue implementation lands through normal, review-gated source
integration:
packages/queue, thescripts/mosaicdispatch,queue-commit.sh, the guard andtest-queue.sh. HEAD then has the code and noqueue.json. queue-commit.sh --install-hook, which is privileged.queue genesis(8.2) in the working tree.queue-commit.sh --genesis -m MSG, the first queue commit.- It is allowed only when HEAD has no
docs/plans/queue.jsonand the snapshot's log holds only the genesis entry. Genesis'scanonicalRootandbranchmust match 8.3, and its migration-map blob id must exist in HEAD. --genesiswith 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).
- It is allowed only when HEAD has no
Steps:
-
Guard.
- Run the active-guard checks above: file, mode, bytes,
core.hooksPathand 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 whetherH:docs/plans/queue.jsonexists (git cat-file -e). Absent requires--genesis; present forbids it.
- Run the active-guard checks above: file, mode, bytes,
-
Snapshot.
scripts/mosaic queue snapshot --out DIRtakes 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 newmktemp -doutside the repository), and releases the lock. The two files come from one revision because they are read under the lock. -
Base (F3). The script writes the base into DIR from the canonical object database. Unless
--genesisis set, that isgit -C <root> cat-file blob H:docs/plans/queue.json > DIR/base.json. It also writesDIR/base.idwith H and H's tree id. The validator reads only files in DIR. It runs no git, and resolves nothing from its working directory. -
Verify with HEAD's code. Unpack
git -C <root> archive Hinto a new temp directory. There, run itsnode --test packages/queue/tests/and itsqueue verify --snapshot DIR --base-file DIR/base.json, or--base-absentfor genesis. It runs withGIT_CEILING_DIRECTORIESset, 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. -
Blobs.
B1=$(git hash-object -w DIR/queue.json), andB2the same forDIR/QUEUE.md. -
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 $Tlists only those two paths. -
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-treeruns no hooks, the guard included. There are no other hooks today.
-
Reconcile the shared index.
- Check again that
git rev-parse HEADis 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.jsonmust 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.
- Check again that
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 $Tmust pass on that tree beforecommit-tree.- The suites run from
git archive $Tunpacked, 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.lockis 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.shrefuses; - a hook with the same bytes but no executable bit, a symlinked hook, and
a newly set
core.hooksPathin the local and the global scope. Each makesqueue-commit.shrefuse beforeupdate-ref; - the guard deactivated between step 1 and step 7: refuse at the step-7 recheck;
- pause right after
- bootstrap (F3):
- an implementation-only HEAD, then genesis, then the
--genesiscommit, then a later extending commit; --genesiswith a base present refuses;- an absent base without
--genesisrefuses; - an op after genesis but before the first commit refuses;
- the archived validator, run in a directory outside any repository;
- an implementation-only HEAD, then genesis, then the
- 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-commiton 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,
syncandaccept-history(8.5); - the claim lifecycle,
adddefaults and J4/J5 cases (8.7); - the working-brief check (8.13);
- the
queue-commit.shtests, including the guard and bootstrap (8.12); - the unlocked-read race tests (8.4, 8.5);
- byte-stable render;
nextordering among competing rows.
scripts/test-queue.shpasses. 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, andnode --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
nextormainlater is a reviewed migration. accept-historyis privileged, needs--yesand a reason, and gives up deduplication for the lost range (8.5).- No
not-postedresolution. A privilegedabandonrecords the risk of a duplicate instead (8.9). - The failed-4xx allowlist: 400, 401, 403, 404 and 422. Everything else is uncertain (8.9).
adddefaults:gateOwnerisjason, andreviewersis empty (8.7).- The op suffix
.outcomeis 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.shusescommit-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
--genesisexception 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-treeskips the guard on purpose, and 8.12 step 8 plus the guard cover that gap. - D only
stats 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 nonot-posted, a temporary-index commit procedure, Gate G tied to its execution trace, E result levels with no full pass, and theadd, 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:repository403 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 runcanary (G2), checked in a scratch repository; - the lead's expected login is
jarvis. 8.19 maps the findings.
-
2026-09-27: correction from Filbert's Piece D review round 1 (C1), ruled by Sage. The transition table let in-review→waiting-on-jason pass with no reviewer approvals, so a Jason-gated row could close without its reviewers. That move now carries the in-review→done checks on a comment round; the table and 8.9's receipts paragraph say so.