Compare commits

...
Author SHA1 Message Date
jason.woltje 631a256ba4 row 206 (193b2, #1704): sage-2818 rulings, only a human principal matches a share
#readable returns false for an agent session, so a `*` share no longer shows a user's message
in an agent's trail. New tests: a sharee's session.list refuses (Q1), and an agent bound to the
shared thread, a chief run and the tester match no share (Q2). BUILD.md records the Q1
follow-up, Q2-Q4 and the live --migrate as Mos's go under Q173. README names the agent rule.
2026-10-22 11:53:19 -05:00
jason.woltje 16b9c6df6f row 206 (193b2, #1704): thread shares, schema 3l→3m
thread_shares (row 193 design 5.2) with its index and append-only triggers; the
3l-3m step only adds them. thread.share, thread.unshare and thread.shares for a
thread's owner; #readable holds design 5.4's four rules, #threadRead serves
thread.messages only, and thread.list {shared:true} adds owner. replies and
trail show shared user threads to the owner and readers. COUNTED, LISTED,
timedTables and the clock list take the table. Tests: thread-shares.test.mjs
and the 3l→3m pins. Packet agents/launchpad/work/queue-206/BUILD.md. Not run
yet: node waits for Sage's CLEAR (sage-2789).
2026-10-22 11:46:10 -05:00
jason.woltje aca16404cf queue: revs 3195-3216. Rows 278, 281 and 264a reviews, notes and done moves (sage, gizmoduck, megavolt, gander, webby, dewey, duckworth); row 264 round 1 request posted as jarvis (sage-2693, #1769) 2026-10-22 09:25:48 -05:00
jason.woltje f7e4ec3df5 row 264a (#1769): BUILD.md records the r13 gate on 2cc34aef8 (phases 1-3 green, M2 thrown at the trigger) and the r13p4 rerun at 84f53540d (M1-M4 killed on assertions, M2's four edits and kill rule), and the limits row gets the measured probe time; doc only (mos-1280 item 1, sage-2701) 2026-10-22 09:23:39 -05:00
jason.woltje b789188504 docs: row 264a landing sweep. BUILD-LOG gets Rocko's V502 rerun line and Sage's V502, rows 278/281 and row 264a landing lines; SESSIONS gets Dewey's, Rocko's and Sage's lines; DEFERRED moves the fire-keeps-grants gap to Done (row 281) and opens row 281 N1/N2 and row 264a N1 (actor_run, binds row 269) and N2 (esbuild skip) (sage-2695, #1769) 2026-10-22 09:22:05 -05:00
jason.woltje 84f53540dd row 264a r13 (#1769): BUILD.md writes M2's four edits before phase 4 reruns: the receive filter, delivery_same_session dropped from 3k-3l.sql and schema.sql, and store.mjs's SCHEMA_SHA256 set to the mutated schema; a kill counts only as a receive-test assertion; doc only (mos-1279, sage-2688, sage-2690, duckworth-0143, duckworth-0144) 2026-10-22 09:08:25 -05:00
jason.woltje 2cc34aef87 row 264a r13 (#1769): threads.test :341 takes the 3l trigger's text, 'only to its session's holder' (delivery_same_session, 3k-3l.sql:153); BUILD.md raises the stack-release limit to 1800 s from the survey's 575 s and records the r10 and r11 reds and the r12 survey with M1 in Runs; test only (mos-1278, sage-2682) 2026-10-22 08:36:56 -05:00
jason.woltje 93f4af12bc row 264a r12 (#1769): sessions.test T2's refused() keeps every table but events equal and pins one action.refused {code: not-holder} event per refused call in call order; the old every-table check could never pass since request()'s catch (broker.mjs:4242) writes the refusal through #refused; BUILD.md records Mos's reading of sage-2257; test only (mos-1277, sage-2676) 2026-10-22 08:21:41 -05:00
jason.woltje 75f049ed23 row 264a r11 (#1769): later3l in account-, gitea- and grant-migration names threads_id_not_session, the 3l trigger on threads (3k-3l.sql:104) the sqlite_master compares missed; test only (mos-1276 option A, sage-2672) 2026-10-22 08:14:15 -05:00
jason.woltje 54f65f9abd row 264a r10 (#1769): rebase on landed 281 (e479521d3), all 16 =; broker.mjs overlap read; cli limit 1800 s from 281's 591 s run (sage-2665, sage-2668, sage-2647) 2026-10-22 08:08:28 -05:00
jason.woltje 7451878b66 row 264a r9 (#1769): rebase on landed 278 (385a7194c), all 15 =; webui read check; run limits at 3x the longest recorded run (sage-2647, mos-1275) 2026-10-22 08:07:43 -05:00
jason.woltje 16889a46be row 264a r8 (#1769): build notes correction for the r7 red (account-migration, sqlite_sequence rows from the 3k-3l inserts), the whole-snapshot audit of every migrating test, the r7 run in Runs (sage-2621, duckworth-0129) 2026-10-22 08:07:43 -05:00
jason.woltje c3f43960eb row 264a r8 (#1769): the six migration tests that compare a whole store with sqlite_sequence after the 3k-3l step leave out the sessions and session_defaults sequence rows and assert them exactly, so any other new row still fails; usage-migration reads sqlite_sequence by name (sage-2621, duckworth-0129); unrun 2026-10-22 08:07:43 -05:00
jason.woltje 0431778465 row 264a r7 (#1769): build notes for the rebase onto landed 277 (980cec88c, sage-2608) and the per-session thread.claims; DEFERRED entry binds row 272 to make 274's user reply check session-scoped (sage-2591) 2026-10-22 08:07:43 -05:00
jason.woltje f3e9b6512c row 264a r7 (#1769): thread.claims reads each session's latest claim, so a thread with two held sessions lists both (sage-2591, duckworth-0124 C4); thread-claims.test threads get their default session and its T5 message names it (C5), T6 two held sessions; unrun 2026-10-22 08:07:43 -05:00
jason.woltje b7a05482bb row 264a r6 (#1769): build notes for the rebase onto landed 274 (0165be266, sage-2588): the four user-principal conflicts, users stay at the ruled seven verbs, and why 274's thread-scoped reply check needs no session clause yet 2026-10-22 08:07:43 -05:00
jason.woltje 4beb6433a4 row 264a r5 (#1769): build notes correction, rounds 1-4 never parsed three test files and no run claimed them; the round 5 gate runs every touched suite (sage-2581) 2026-10-22 08:07:43 -05:00
jason.woltje fbfaab3655 row 264a r5 (#1769): build notes for the rebase onto landed 236 (47a35bf43, sage-2579): the 194 conflicts, message-filter.test as the 19th test file, and the escaped quotes in three DEFAULT_SESSION blocks 2026-10-22 08:07:43 -05:00
jason.woltje 9ff668550f row 264a r5 (#1769): build notes name the op rename to legacy-3l and migration-3l by the step's target version (sage-2415) 2026-10-22 08:07:43 -05:00
jason.woltje 5baaaa447b row 264a r5 (#1769): build notes for the 3k-3l step on row 236 (8f51900b5, sage-2412): the renames, the merged pins and lists, gitea-migration reading both steps, and the in-memory dry run on 236's schema; README op value legacy-3l 2026-10-22 08:07:43 -05:00
jason.woltje c4a12de3d8 row 264a r3 (#1769): build notes say the generation fence is equivalent to the holder_run check it replaced, and M1 shows the refusal is tested (duckworth-0108, sage-2270) 2026-10-22 08:07:43 -05:00
jason.woltje 72fc5a330b row 264a r2 (#1769): generation fence for thread runs, a retired generation's approval, delivery and read refuse and write nothing (sage-2257); #route refuses an unknown session, README default-op note, Row 155 comment reworded, mutants M1-M3 in the build notes (duckworth-0107) 2026-10-22 08:07:43 -05:00
jason.woltje d6301dffce row 264a (#1769, T1 item 7): bus schema 3k to 3l, sessions below a thread
sessions and session_defaults are new and append-only. thread_claims, message_threads and
thread_opens are rebuilt in place with a session column, and thread_claims gains a generation
counted from claim rows. Each existing thread gets a default session with the thread's id; new
threads get a random one. Holder, per-run, release and delivery triggers key on the session,
and two id triggers keep session and thread ids apart (Duckworth checks 1-7, duckworth-0106).

The broker binds a thread run to a session (the default when the record names none), and
thread.open, thread.send and thread.messages take an optional session. New verbs session.list
and session.create (idempotent on op). Tests: sessions.test.mjs for T1 bus half, T2 and T7 with
a pinned 3k fixture; count, version and verb pins; hand-made test threads write the legacy
default session.

Built on row 236 (8f51900b5), which takes 3j-3k, so this step is 3k-3l by landing order
(row264 Q2, sage-2412). The op values are legacy-3l and migration-3l. gitea-migration.test reads
its store after both steps. Not run yet: every run waits for Sage's CLEAR.
2026-10-22 08:07:43 -05:00
jason.woltje ae525c4095 gizmoduck: row 264a bus design in the build notes: sessions and session_defaults, thread_claims, message_threads and thread_opens rebuilt with a session (legacy default id = thread id), generation, broker lookups by session, verbs, tests and pinned lists 2026-10-22 08:07:43 -05:00
jason.woltje b5630ca198 gizmoduck: row 264 build notes record Sage's Q1-Q7 rulings (sage-2235, 5f5b51dd6) 2026-10-22 08:07:43 -05:00
jason.woltje 26380e3f0b gizmoduck: row 264 phase A build notes, code at da9555ca2, consumer audit first pass, five proposed slices and Q1-Q7 to Sage (sage-2233, #1769, Vikunja 463) 2026-10-22 08:07:43 -05:00
jason.woltje e479521d3a row 281 build notes: the manifest-round slot on 7979fb41e is green; Runs has the base, anchors, M1 exit check, dewey-0840 files, bus/business/cli whole and the shells with their limits and named skips; Mutants has M1-M22, 22 killed, M22 fails only T14 (sage-2661, dewey-0845, #1786) 2026-10-22 08:00:45 -05:00
jason.woltje 7979fb41eb row 281 build notes: the gate plan for row281-e, with each scripts/test-*.sh under a limit of at least 3x its longest recorded run (sage-2649, dewey-0840, mos-1275, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 54ebc5d71c row 281 b5: T14 pins that the human's no takes a declined host decision out of the set, after hosts.remove left it with no host (N1); M22 drops the closed check and T14 kills it; test only, not yet run (sage-2653, dewey-0844, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 5e05e21794 row 281 build notes: the mutant table names section 9's killing test and each mutant's failing tests from its log; every mutant fails its named test (dewey-0841, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 6b6eb533e8 row 281 b4: T6b pins step 0's four Gitea reads in order and says 'before any Gitea write'; M1-M21 all killed on b4a 3f29375a8 with the M1 exit check, table in the build notes (dewey-0839, sage-2627, sage-2643, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje f9fcf5d7b3 row 281 b4a: the M1 hang was T6c's held web1:add loop, which only the fire's abort ends; setup's t.after clears held and failing, and T6c and T10 release their Gitea gate; test only, no code change (mos-1273, sage-2636, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje c055dddf44 row 281 b3: the first base run's five red tests were test faults, no code change; T4b reads the manual's revoke id from its event, T5b expects gitea-left:<id>,tokens (N10), T6b allows step 0's GET checks, gitea T8 leaves the call log out, cli T10 makes the 0700 store root; rebase onto 980cec88c noted (sage-2605, sage-2615, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 6d62ef04e0 row 281 build notes: rebase onto 46dedf925 after row 256, range-diff all =, 256's actor threading doesn't change what a delete calls (sage-2597, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 4982fa0adf row 281 b2: T1-T13 tests (fire-revokes-grants, lifecycle-revoke T1, T8 tester refusals, cli T10 pass) and the test choices in the build notes (#1786) 2026-10-22 07:42:10 -05:00
jason.woltje 7c2c876319 row 281 b1: step G in the broker (fire and delete jobs, the grant set, the lock, boot's interrupted check) and build notes (#1786) 2026-10-22 07:42:10 -05:00
jason.woltje 7d5f16ef95 row 281 build notes: Dewey's d6 approval with N7 (T12's step B cause survives step G) and N8 (no decision-consumed for a within-role fire) (dewey-0829, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje a7b4f6785c row 281 design d6: the job's step C without the chief's claim, fire.failed for a step B or C failure, boot closes interrupted jobs, Q1 lines in the set (dewey-0825, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 938378455d row 281 design d5: two requests per granted delete with its five conditions, a job id on every answer, event and message (sage-2575, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 5f4cd2faa6 row 281 design d4: step G detached from the verbs, open decisions and a fired-agent raise check, one step G per agent (dewey-0823, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 6faa0f1479 row 281 design d3: a grant decision approved before a fire and executed after it, tests T6/T6b/T6c (sage-2574, #1786) 2026-10-22 07:42:10 -05:00
jason.woltje 9867a6f322 row 281 design d2: Q1/Q2 rulings (sage-2573), the finding in the Why, T3b/T4b and M8/M9 (#1786) 2026-10-22 07:42:10 -05:00
jason.woltje 15ab01c0c3 row 281 design d1: a fire or a delete revokes the instance's host and Gitea grants (sage-2568, #1786) 2026-10-22 07:42:10 -05:00
44 changed files with 6660 additions and 265 deletions

No files matched your search

+8
View File
@@ -5303,3 +5303,11 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
### 2026-10-22 | Gander | row278 gate run3
Fresh gate on3e4c77ac6 under sage-2648: bus793/0, WebUI345/0/1 (esbuild version skip), all ten shell files425/0, touched files15/0, M1-M6 killed6/6. No timeout; sweep0 run-created leftovers. Runs1/2 void. Seven-path manifest8fa859a0 goes to Webby for final review. No landing or live change.
2026-10-22 11:29:09 UTC | Rocko | V502 carry/rerun on980cec88c: source407c8d7be and packet04a86cf33 match approved8e3c6a79b/9bf4683c6 patch-ids, no conflicts. Dewey0834 carry conditioned on rerun, satisfied under sage2616: stop-run13,processingHTTP6,bustasks9 allgreen (Node28/28),Python4/4,0skips/timeouts. Manifest5758d699,source5/5restored,cwdsweep[]. Doc rerun packet6af85df4b explicitly pushed to rocko/vikunja502-980c-20261022; no live action.
2026-10-22 11:30:17 UTC | Sage | Vikunja 502 (row 191 follow-up, no queue row) LANDED 6af85df4b, a fast-forward from 980cec88c. Before: the processing failure diagnostics and cleanup in stop-run, processing HTTP and bus tasks tests were Dewey-approved on the pre-194 base only (8e3c6a79b/9bf4683c6). After: Rocko's picks 407c8d7be/04a86cf33 carry the same patch-ids (37135ebe/0dbcda54), plus a doc-only rerun record 6af85df4b. The rerun on the new base was green: stop-run 13/13, processing HTTP 6/6, bus tasks 9/9, Python 4/4, 0 skips. Dewey carried the approve (dewey-0836). mosaic-task.mjs is unchanged, so N7 (runs suite) didn't trigger. Overlap with in-review rows 13 and 257 was clean. LIVE is still 86f95bb27.
2026-10-22 13:10 UTC | Sage | Rows 278 and 281 LANDED. Before: refactor at 6af85df4b (V502). Row 278 (#1783, Gander, Webby): the Console Agents view shows thread claims in their own section. It landed as 385a7194c at 12:39Z, ff, 9 commits, manifest 8fa859a0. Run 3 was green: bus 793, webui 345/0/1, ten shells 425/0 under the 3x limits (mos-1275), M1-M6 killed. Runs 1 and 2 were void, on fixture fixes 964b6b184 (placed by Sage) and 9696d1fcb (ruled by Mos, mos-1275). Webby approved (c30588, rev 3200). Done rev 3202, #1783 closed c30589. Row 281 (#1786, Megavolt, Dewey): a fire or a delete revokes the instance's host and Gitea grants. It landed as e479521d3 at 13:06Z, ff, 17 commits, manifest b73d4c81. Slot on 7979fb41e: bus 819, business 231, cli 837, dewey-bus 76 with the Docker gitea-grant test, shells green, M1-M22 killed. The M1 hang was fixed at its cause in b4a (mos-1273). Dewey approved (c30614, rev 3207). Done rev 3209, #1786 closed c30621. Skips on both: test-task's row 244 live-provider pair and test-queue's canonical-root check, plus webui's esbuild rebuild test on 278. After: refactor at e479521d3. DEFERRED moves the grant gap to Done and opens Dewey's N1/N2 text gaps. LIVE is still 86f95bb27.
2026-10-22 14:18:23 UTC | Sage | Row 264a LANDED (#1769, Gizmoduck, Duckworth): sessions per thread, schema 3k-3l. Before: refactor at e479521d3 (row 281). It landed as 84f53540d, ff, 21 commits, manifest 257408e9 (Duckworth approve rev 3214, comment 30640). Gate r13 on 2cc34aef8: 20 touched files, bus 827, cli 837, webui 345/1 named skip, ten shells under 3x limits, mutants M1-M4 killed on assertions. Corrections on the way: the kit stopped on first red, so four reds surfaced one at a time. r10 later3l missed a trigger (mos-1276). T2 had failed since r2 because refusals log action.refused (mos-1277, my sage-2257 wording). threads.test:341 kept the old trigger text (mos-1278). M2 was caught by the store trigger before any assertion, so it now drops the trigger too (mos-1279, four files with the SCHEMA_SHA256 pin). Deferred: actor_run pin binds row 269, esbuild skip to a later gate, session-scoped user reply binds row 272. No live change; LIVE is still 86f95bb27.
+548
View File
@@ -0,0 +1,548 @@
# Row 264 build notes (Gizmoduck)
T1 item 7: independent sessions, compaction counts and clear context. Gitea #1769, Vikunja 463
(design) and 466 (Claude compaction format, T3's dependency, linked and not built here). Brief:
docs/plans/2026-10-21_t1-sessions.md, "T1 item 7: independent sessions, compaction counts and clear
context" (blob ff0be0c79). Reviewer Duckworth, gate owner Sage. Branch gizmoduck/row264 from refactor
da9555ca2. MVP due 10-27.
The brief pins its code citations at cca206cdc. `git diff --stat cca206cdc da9555ca2` over every cited
source file is empty, so F1-F11 still hold line for line. Only docs/prd/mosaic-stack.md moved.
## Phase A: what the code has today
- Bus. `threads` binds one `instance` per thread (schema.sql:240-250). `thread_claims` keys on
`thread` alone (:251-261), and `message_threads` maps a message to a thread (:262-265). Schema is
3j (store.mjs:17). The thread tables first appear in migrations/3d-3e.sql.
- Launcher. `check()` refuses a second run for the same instance and thread
(launcher.mjs:233-240, `registry.running(b.id, instance, thread)`).
- Seat. `threadStore()` makes one `session/` and one `claude-projects/` per thread
(session.mjs:51-74). The registry's running key is business, instance and thread (:215-218).
- Runner. `stop()` sends TERM to the child group, then KILL after 5 s (runner.mjs:402-414). It has
no drain, no idle acknowledgement and no dequeue fence. D7 step 2 needs all three.
## Consumer audit (D9), first pass
`git grep -F` over packages/*/src/* and adapters/* at da9555ca2:
| Name | Files and hits |
| --- | --- |
| `thread_claims` | broker.mjs 12, store.mjs 1 |
| `message_threads` | broker.mjs 9 |
| `thread_opens` | broker.mjs 4, store.mjs 1 |
| `threadStore` | launcher.mjs 4, sandbox.mjs 7, session.mjs 3 |
| `running(` | launcher.mjs 5, session.mjs 1 |
Still to read: the broker holder, agents, end, usage, reply correlation and pending-approval paths
named in D9, the two adapters (F5), both harness plugins, the conversation reader (F8) and the
Console. Each gets a file:line row here before the first code commit.
## Proposed slices
One seat can't build D1-D9 in six days and keep every slice reviewable. I propose five candidates
under row 264, each with its own manifest and Duckworth round:
1. 264a bus. Session tables, claim key `(organization, thread, instance, session)` with a
generation, the D3 default session, the D9 bus mapping, `session.list` and `session.create`, and an
optional session selector on the thread verbs. Tests: T1 bus half, T2 claims, T7 legacy clients and
the rollback refusal.
2. 264b seat and launcher. Per-session native store and workspace, the single-writer fence, the
registry key with session, sandbox mounts of the selected store only, and pinned native ids in both
adapters. Tests: T1 run half, T2 workspace and path mutants.
3. 264c compaction count. Host collector, exact for Pi. Claude shows `unknown` with the 466 reason
until that format is pinned. Tests: T3 Pi half.
4. 264d clear. The D7 journal, the runner drain fence and idle acknowledgement, stop proof, then
retirement and successor in one transaction. Tests: T4, T6.
5. 264e surface. `mosaic session` CLI and the Console chooser, count and clear confirmation. Tests:
T5 for the legacy single human, T7 Console half.
## Phase A rulings (sage-2235)
Q1-Q7 went to Sage in gizmoduck-0154. Sage's answers are in agents/sage/work/queue-264/RULINGS.md at
5f5b51dd6. In short:
- Q1. Five candidates in the order a, b, c, d, e, each with its own manifest, Duckworth round and
landing. Row 264 carries 264a. Sage adds rows for 264b-264e. All five are MVP.
- Q2. 264a builds as 3j-3k.sql. If 236 or 206 lands first, I rebase onto a new branch name, take the
next label, and rerun the whole bus suite, the schema.test.mjs counts and the T46 lists.
- Q3. PRD 1.13 has landed, and REQ-ROLE-2 (mosaic-stack.md:323-329) already carries the session
text. Build to it.
- Q4. Row 194's lifecycle path owns the stop. honker/row194-b5 dfabbbe8a is read-only reference.
264d rebases onto 194's landing. The runner's dequeue fence and idle acknowledgement are new code
and a named review item. No 194 code gets copied.
- Q5. One shared admission hook for every session start, using today's limits and revocation. Of
191 and 264b, whichever lands second wires 191's checks in. No setting turns it off, and a check
that's configured but unavailable fails closed.
- Q6. 264c lands with Pi exact and Claude `unknown` with the 466 reason. The Claude half of T3 stays
open on row 264 and Vikunja 466.
- Q7. Legacy single human only. Collaborator and share paths refuse. A4 and the full matrix go to
whichever of 264 and 193 lands second.
## 264a design: the bus
Line numbers are at da9555ca2 unless they say otherwise.
### Schema 3j-3k
- `sessions`, new and immutable: `id`, `at`, `business`, `thread`, `instance`, `owner`, `name`, `op`
(the creation operation key) and `predecessor` (null until 264d's clear). UNIQUE `(thread, name)`
and `(thread, op)`, so a repeated create with the same key finds the first row. Guard triggers
check that the business, instance and owner match the thread's, and that a predecessor sits in
the same thread.
- `session_defaults`, new and append-only: `thread`, `session`, `at`, `by`. The latest row per
thread is that thread's default (D3). A trigger checks that the session is in the thread.
- `thread_claims`, `message_threads` and `thread_opens` are rebuilt, each with a `session` column,
and `thread_claims` also gets `generation`. Rows are copied with their seq values and every old
column as it was, and the sequence carries over, as 3h-3i.sql did for `usage_records`. That is a
named review item: the copy adds two derived columns and changes nothing else.
- Each existing thread gets one legacy default session with **id equal to the thread id**, the
name `default`, op `legacy-3k`, and the thread's own `at` and owner. Every copied row names that
session. This is D9's immutable mapping. 264b binds it to the existing native store at
`threads/<business>/<thread>/session/`. A new thread's default session gets a random id.
- `generation` is the claim's number within its session, counting from 1. A release or revoke
carries its claim's number. On copy, it's the count of claim rows in the thread up to that row.
- Triggers keyed on the thread now key on the session: one holder, release only while held, only the
holder releases, and a generation check. These two keep their thread meaning: one held session per
run per business, and the business check. New checks: the claim's, message's and open's session
is in the named thread. `delivery_same_thread` becomes `delivery_same_session`, which sends a
threaded message only to its own session's holder.
- A 3j binary refuses a 3k store with `schema-mismatch` (store.mjs:181-186), which covers D9's
rollback refusal. T7 tests it.
### Broker
- Run binding. `bindLaunch` takes an optional `session`. When it's missing, the run gets the
thread's default session, which is the D3 path for today's launcher. The binding refuses a
session that isn't in its thread. The run's `s.session` then drives `role.claim`, `#own`,
`message.receive` and the reply routing.
- Lookups. `#threadHolder(thread)` becomes `#sessionHolder(session)`, which `#recipient`,
`#routeThread`, `#threadHolders`, `#claimEnd`, `threadIdle` and run end all use.
`#threadFor` returns a thread and its session: a thread run's own, or the session of the nearest
threaded message up the reply chain. `#specThread` uses the opened thread's default session.
- Launcher view. `threadsState` and `threadIdle` keep their row shape, one row per thread computed
on its default session. Until 264b, the launcher starts only default sessions. Messages to another
session wait, and nothing reaches the default session's run.
- Verbs:
- The thread verbs take an optional `session`, and leaving it out uses the default (D3).
`thread.create` also makes the default session. `thread.messages` without a session reads the
default session.
- `session.list {thread}` and `session.create {thread, name, op}` (D8: metadata only). The build
dropped `session.get`, `session.open` and `session.send`. `thread.open`, `thread.send` and
`thread.messages` take an optional `session`, which does the same job with one owner check and
one code path.
- All of them are human or tester only, through `#human`/`#thread`, the same owner check as the
thread verbs (Q7: single human, no shares). They go into `TESTER_VERBS`.
- Other readers. `#grantNotice`, `#raisedThread` and the decision-close path (:736, :1500, :2941)
read the holder run's claim row. The rebuilt `thread_claims` keeps that query working, and the
session comes along from the same row.
### Tests and lists
- New file `packages/bus/tests/sessions.test.mjs`:
- T1 bus half: two sessions in one thread, distinct histories, receive and reply isolation, and a
non-thread role refused.
- T2: one holder per session, generation fencing, sibling delivery refused by the trigger.
- T7: legacy clients, the 3j-3k migration from a fixture (default session id = thread id, rows
copied with seq), and the rollback refusal.
- A new `fixtures/schema-3j.sql`.
- Update the schema.test.mjs pins (:519-525), the `'3j'` version pins in the migration tests,
`COUNTED` in scripts/stack-release.sh:404, and the T46 lists in
packages/cli/tests/stack-release.test.mjs:860-934. Also `timedTables` (store.mjs:65-79) and the
broker clock-seed list (:325-336).
### Duckworth's design checks (duckworth-0106)
Duckworth had no objection to the in-place rebuild and asked for seven checks. Where each one is:
1. The cross-table trigger. 3j-3k.sql drops `delivery_same_thread` before any rename and creates
`delivery_same_session` after the new tables.
2. Index names. The step drops `thread_claims_thread` and `message_threads_thread` before the
renames, so the new tables can reuse the names.
3. Per-table triggers. The step drops each guard and append-only trigger and creates it again with
text that matches schema.sql. The digest check at store.mjs:231 enforces that, and the T7
`sqlite_master` comparison shows it.
4. Generation counts claim rows only. T7's fixture is Duckworth's: claim, release, claim, revoke,
giving 1, 1, 2, 2, with t2's claim between them.
5. Sequences. Rows keep their seq values, `sqlite_sequence` carries over, and T7 checks that the
next insert is 41 after 40.
6. Id collisions. A legacy session id is its thread's id. `sessions_id_not_thread` refuses a session
id that names another thread, and `threads_id_not_session` refuses a thread id that names a
session. New ids come from randomUUID.
7. `thread_one_per_run`. The inner `max(seq)` is per session.
### Duckworth's early look (duckworth-0107) and Sage's ruling (sage-2257)
Duckworth read 312cf33a1 with git only and found nothing blocking. Round 2 (gizmoduck/row264-r2,
rebased on dcd8d4dda with no shared paths) answers his notes:
1. Retired-generation replay. Sage ruled it into 264a (sage-2257). `#agent` now states the thread
run's fence in generations: the session's latest claim must have the generation of the run's
own latest claim (`#runGeneration`). This refuses nothing new. The trigger
`thread_claim_generation` gives each claim in a session its own number, so with `h.op` 'claim'
the generations match exactly when `h.holder_run` is the run, which is the check it replaced
(duckworth-0108). 312cf33a1 would most likely refuse the same calls. A new sessions.test case has a released run and a revoked run each try
`decision.withdraw`, `message.receive` and `message.read` after another run claims the next
generation. All refuse `not-holder` and every table's row count stays the same. The workspace
and path halves of T2 stay in 264b.
2. `assistant.stop` keeps returning `{thread, run}`. Adding `session` would change the pins in
tester-threads.test and threads.test for a field no caller reads yet. The Console stop in 264e
is the first reader, so the field goes there.
3. `#route` refuses an unknown session with `session-not-found` instead of a TypeError.
4. The README's session section says the `default` retry answers on new threads only, because a
migrated default has op `legacy-3k` (`legacy-3l` from round 5).
5. The Row 155 comment at the role.revoke end now says "held thread session".
### Mutants
Each runs on a copy of the candidate tree with one edit, and the named test must fail.
- M1 (sage-2257). In `#agent`, replace `h.generation !== this.#runGeneration(s)` with `false`.
Killed by sessions.test "a released or revoked run's retired generation". M1 removes the thread
run's fence entirely, so the kill shows the refusal is tested, not that generations add a check.
A mutant that puts back `h.holder_run !== s.run` survives by equivalence. That isn't a coverage
gap.
- M2 (mos-1279, sage-2688, sage-2690). Four edits, and the restore sha check covers all four files:
1. broker.mjs:5251, in `message.receive`: drop the `mt.session=?` filter (the session clause and
its argument).
2. 3k-3l.sql: drop the `delivery_same_session` trigger.
3. schema.sql: drop the same trigger.
4. store.mjs:16: set `SCHEMA_SHA256` to the sha256 of the mutated schema.sql, with the replace
sessions.test:432 uses. store.mjs:94 checks schema.sql against it at import (duckworth-0144).
`LEGACY_SHA256` stays. The chain check at :117 holds because both SQL files drop the trigger.
Without edits 2-4, the trigger refuses #delivery's INSERT first. request() then returns
storage-refused before any assertion runs. That happened at r13 (duckworth-0143).
A kill counts only as an assertion in a test that exercises receive: sessions.test T1 :89, T7 :422,
or another receive assertion the log names. A failure only at the trigger's own pin (:164-170)
doesn't count, and neither does a thrown error. The killing test is expected to be T1, where a1
would take the second session's message.
- M3. In 3k-3l.sql, count every claim row for `generation` instead of `op='claim'` rows only.
Killed by sessions.test T7, generations 1, 1, 1, 2, 2.
Phase 4 rerun on r13 (mos-1279, sage-2688). The r13 gate's phases 1-3 carry. M1-M4 rerun whole on
2cc34aef8 plus this note, each under `timeout -k 10 300` (Run limits). Any survivor, throw or
non-receive kill goes to Mos unplaced.
### Dry run
Before writing any node test, I ran 3j-3k.sql in an in-memory Python sqlite3 on a 3j fixture
(gizmo-264-out/dry-3k.py, no repo code). The claims were kept with generations 1, 1, 1, 2, 2 in seq
order for the mixed t1/t2 fixture. Seq 40 carried over and the next insert was 41. The migrated
`sqlite_master` matched a fresh schema.sql, 119 rows each. Every guard refused its bad write.
### Hand-made threads in tests
A thread that a test writes by hand has no default session, because only `thread.create` and the
migration write one. The broker refuses such a thread with `session-not-found`, and the launcher view
leaves it out. So every test that inserts a `threads` row now also writes the legacy-shape default
session (`DEFAULT_SESSION` in threads, end-launch, thread-cli, thread-launch and chat tests). That
mirrors the migration's rows rather than inventing a third shape.
### Round 5: on row 236, the step is 3k-3l
Row 236 (8f51900b5, which round 5 is built on) adds `migrations/3j-3k.sql` with the
`gitea_users` and `gitea_grants` tables, and lands first. By the landing-order label (Q2,
sage-2412) 264a's step moves one version. The design above still says 3j-3k; read it as 3k-3l.
- `migrations/3k-3l.sql` is round 4's `3j-3k.sql` with three renames and nothing else: the copy
tables are `*_3k`, and the op values are `legacy-3l` and `migration-3l`. The op values name the
step's target version, as round 4's `legacy-3k` did on 3j-3k (sage-2415). No earlier migration
set a precedent either way.
- store.mjs runs 236's 3j to 3k step, then 3k to 3l. `SCHEMA_VERSION` is `3l`, and
`SCHEMA_SHA256` is the merged schema.sql's, cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a.
schema.sql merged with no conflict: 264a's edits are above 236's two tables at the end.
- The T7 fixture is `fixtures/schema-3k.sql`, 236's schema.sql at 8f51900b5 (SHA-256 aba92408...).
236's `fixtures/schema-3j.sql` is the same blob round 4 added, so that path no longer differs.
- Pins: schema.test 22 tables and 86 triggers; README 22 tables and 87 triggers. Every `'3k'`
version pin is `'3l'`, the comment-migration future step is 3l to 3m, and each copied engine
also copies `3k-3l.sql`.
- account-migration and grant-migration filter both 236's objects and the 3k-3l objects.
gitea-migration.test (236's) now reads its store after both steps: version `3l`, `rows` skips
`sessions` and `session_defaults`, `master` skips the 3k-3l objects, and its failing engine
copies `3k-3l.sql`. Its 3j engine already stripped every step after 3i-3j.
- sessions.test's engine is a 3k engine: 236's schema and steps through 3j-3k, no 3k-3l.
- The restore guard lists merged with no conflict: 236 adds `gitea_users` and `gitea_grants` to
T46's `untimed`, and 264a adds `sessions` and `session_defaults` to COUNTED and LISTED.
Dry run (gizmo-264-out/dry-3l.py, Python sqlite3 in memory, no repo node): 3k-3l.sql on 236's
schema kept every claim with generations 1, 1, 1, 2, 2, kept seq 40 with 41 next, and the migrated
`sqlite_master` matched a fresh schema.sql, 121 rows each. Against round 4's run from 86f95bb27
(dry-3k-r4.py), the output differs only in the op values and the 119 rows that 236's two tables make 121.
### Round 5 on landed 236 (47a35bf43)
Row 236 landed at bd5f11ded and refactor was 47a35bf43 (sage-2579). The prep commits moved from
8f51900b5 to 47a35bf43 by cherry-pick. schema.sql, the migrations, store.mjs and the fixtures are
the same as the prep tip 4b369ddba, so the SHA-256 and the dry run above still hold.
- Two conflicts, both with row 194. In broker.mjs `TESTER_VERBS` keeps 194's `instance.launch`,
`instance.end` and `instance.request` and then 264a's `session.list` and `session.create`, and
the private fields keep 194's block before 264a's claim comment. tester-writes.test pins the
same list in the same order.
- Row 205's message-filter.test (new since 8f51900b5) makes threads and `message_threads` rows by
hand. Its threads now get the default session, as the other hand-made threads do, and its
`message_threads` rows name that session. That makes 19 touched test files, not 18.
- A defect from round 1: edit-264a-6.py wrote `DEFAULT_SESSION` with bare `'` quotes inside
single-quoted strings in threads.test, end-launch.test and chat.test. Those three files could
not parse in any earlier round. The quotes are now escaped. The two cli tests used double quotes
and were fine. Runs below records no run, so no reported result rests on those files.
- Every broker insert into `thread_claims`, `thread_opens` and `message_threads` names a session,
194's new callers included. 194's lifecycle-verbs.test reads `thread_claims` by thread only.
- Pins on this base: schema.test 22 tables and 86 triggers, README 22 tables and 87 triggers,
COUNTED and LISTED carry `sessions` and `session_defaults`, T46's untimed list carries 236's
`gitea_users` and `gitea_grants`.
### Correction (sage-2581)
Rounds 1 to 4 never parsed bus threads.test, bus end-launch.test or webui chat.test. Each had the
bare quotes in `DEFAULT_SESSION` described above, from edit-264a-6.py in round 1. No run claimed
those files: this packet's Runs section was empty through round 4 and the r4 gate was voided
before it started (sage-2412). Round 5 escapes the quotes.
The round 5 gate is wider than the plan above (sage-2581, mos-1200: a rebase carries no result).
Each of the 19 touched test files runs whole on its own, then the bus, cli and webui suites whole,
then every `scripts/test-*.sh`, then the mutants, with every skip named.
### Round 6 on landed 274 (0165be266)
Row 274 (FED R3) landed at 4c485c63b and refactor was 0165be266 (sage-2588). The round 5 commits
moved from 47a35bf43 by cherry-pick. broker.mjs is the only path both rows change.
- Four conflicts in broker.mjs, all from 274's user principal. `USER_VERBS` and `USER_LEVEL` follow
`TESTER_VERBS`, which ends with the session verbs as before. In thread.open, thread.send and
thread.messages each verb keeps 274's `!s.tester && !s.user` check and `#grantedThread`, and adds
264a's optional `session` key, `#threadSession` and the session on `thread_opens` and `#route`.
thread.send keeps 274's return, where a user's message writes no human.input.
- Users stay at the seven ruled verbs (sage-2576): `session.list` and `session.create` are not in
`USER_VERBS`. user-principal.test:133 already checks every verb outside the list refuses a user,
and that set now includes the two session verbs.
- 274's B6 check (a run answers a user only in reply to that user's message in its thread) joins
`message_threads` by thread, not by session. No code change: a user can't call `session.create`,
and `#thread` refuses anyone but the owner, so a user's thread has only its default session and
the two scopes are the same rows. A slice that lets a user add sessions (264e) must make it
session-scoped and test it.
- 274's other writes and tests make threads through the broker, so each gets its default session.
Its tests read `thread_opens` and `message_threads` by thread, and those columns stay.
- schema.sql, the migrations, store.mjs and the fixtures are unchanged from round 5, and so are
the touched test files: 19.
### Round 7 on landed 277 (980cec88c)
Row 277 (thread.claims, #1782) landed at 62f53b1e0, after row 256 (f12441d4f), and refactor was
980cec88c (sage-2608). The round 6 commits moved from 0165be266 by cherry-pick with no conflict, and
the range-diff is `=` for all eleven.
- 256 changes `packages/bus/src/runtime.mjs`, `packages/business/src/instance-settings.mjs` and
`packages/webui/src/public/agents.js`. None is a manifest path and none of their changed lines
names a thread, a session, `message_threads` or `thread_claims`. Three touched tests load these
modules (bus chief-of-staff and tester-writes, cli thread-launch), so the whole-file runs cover
256's code.
- 277's `thread.claims` took the latest `thread_claims` row per thread. With sessions that is the
last claim of any session, so a second held session would hide the first (duckworth-0123 N3,
sage-2591). It now joins `sessions` and takes each session's latest row, as `#threadHolders`
does, ordered by thread then session. The result keeps 277's five keys: two rows of one thread
differ by `holder_run`, and `mosaic agents` prints each as its own thread line. Adding the
session id to the row is left to the slice that shows sessions to the owner.
- thread-claims.test (277's) makes its threads by hand, so each now gets the default session the
migration gives an old thread (C5, :47), and its T5 message names that session (:111). T6 holds
two sessions of one thread, sees both rows, releases one and sees the other (C4). M4 swaps the
query back to 277's per-thread latest row and must fail T6.
- 274's thread-scoped user reply check binds row 272 (264e) in `docs/plans/DEFERRED.md`, written
here (sage-2608): a slice that lets a user add sessions makes it session-scoped.
- 277's other tests: message-filter.test's claim pin reads thread.claims for a thread made with
its default session, and the cli format and tester tests use stub rows or no threads.
- Touched test files: 20 (thread-claims.test joins). Mutants M1-M4.
### Correction: the r7 red (sage-2621, duckworth-0129)
The r7 gate on Sage's CLEAR (sage-2619) stopped on its first file. account-migration.test failed 2 of
3: "3h copy migrates to 3i" at :87 and "a 3h→3i step that fails partway" at :136. Each compares a
whole snapshot of the store after the steps to 3l, `sqlite_sequence` included, with the snapshot
before them, and found two more rows: `session_defaults` and `sessions`, both at seq 0.
The cause is mine. 3k-3l.sql:50-52 fills `sessions` and `session_defaults` with INSERT ... SELECT,
and SQLite writes a `sqlite_sequence` row for an AUTOINCREMENT table on that insert even when it
copies no row. My round 1 schema commit (67fded6af then, 31fcecf7b in r8) changed the file's
version pin, `master` filter and engine copy, but no round ran the file before r7 (Correction above:
no run was claimed). The same gap held in every migration test that dumps `sqlite_sequence`.
What I read in r8, before any run: every touched test (and every bus, cli and webui test that calls
`migrateSchema`) that compares a whole snapshot or whole table across a step to 3l.
`git grep migrateSchema` and `git grep sqlite_sequence` over `packages/*/tests/*` and `scripts/*`
gave the list. A Python sqlite3 dry run of the real steps on each fixture's shape
(gizmo-264-out/dry-seq-r8.py, no repo node) gave the rows after the step.
- account-migration :87 and :136: red as run. Fixed. :132 compares before the 3l step (the failing
3h→3i engine rolls back) and is unchanged.
- gitea-migration :90 (3j to 3l) and grant-migration :95 (3i to 3l): `rows()` leaves out the new
tables by name but keeps `sqlite_sequence`. Same two rows at seq 0. Fixed. gitea :113 and grant
:117 compare after a rolled-back step and are unchanged.
- usage-migration :67 (3g to 3l), name-migration :65 (3f to 3l), spec-migration :61 (3e to 3l):
whole snapshots with `sqlite_sequence`. Their fixtures have one thread, so the two rows are at
seq 1. Fixed. In usage-migration the step also moves the `thread_claims` and `thread_opens` rows
after `instance_names`' (3k-3l.sql:84-86 deletes and writes them again), so its snapshot now reads
`sqlite_sequence` by name, as account-migration's has since row 126 (ca15d73e9).
- Read and not affected: thread-migration :110 and :144 compare `messages` and `deliveries` only;
sessions.test T7 and the D9 refusal compare named 3l tables, 3l against 3l, and :177 lists table
names; each engine-refusal test (account :156, gitea and grant's third test) compares 3l with
3l; comment-migration and chief-of-staff count rows and compare no snapshot; stack-release.test
T46 lists table names from schema.sql, and its `--migrate` tests run stub copies at 3g to 3h.
The fix, in all six files: the compare after the step leaves out the two new rows and asserts them
exactly, names and seq, so an extra row of any other name still fails. account, gitea and grant
leave out the rows `SESSIONS_3L` names (sage-2621); their stores have no thread rows, so nothing
else in that list has a sequence row. usage, name and spec leave out only `sessions` and
`session_defaults`, because their `thread_claims` and `thread_opens` rows must stay in the compare:
the step carries those seqs. Nothing else in the files changes.
### Round 9 on landed 278 (385a7194c)
Row 278 (thread claims in the Console) landed, and refactor moved from 6af85df4b to
385a7194c, fast-forward (sage-2657). r8's 15 commits moved by rebase with no conflict, and the
range-diff is `=` for all fifteen.
- 278 changes packages/webui (`src/cli.mjs`, `src/serve.mjs`, `src/public/bus.js`, README, and the
tests `shell`, `thread-claims` and `thread-claims-browser`), BUILD-LOG.md and its own packet. None
is a manifest path.
- Webui reads by 264a's tests: stack-release.test reads `packages/webui/src/cli.mjs` at :718 (a
`--tester-port` match, still present) and its copy tests at :2475-2573 replace or append the whole
file, so 278's added line doesn't reach them. No 264a test pins a hash of a webui file; the 64-hex
pins in the migration tests and sessions.test are fixture DDL.
- 278's `thread.claims` reader in bus.js renders one table row per result row and checks only that
`thread`, `holder_run`, `harness` and `at` are strings, so 264a's per-session rows (a thread with two
held sessions lists twice, five keys kept) show as two rows. 278's three webui tests use fake bus
objects and never reach the broker's query.
### Round 10 on landed 281 (e479521d3)
Row 281 (a fire or delete revokes the instance's grants) landed, and refactor moved from 385a7194c
to e479521d3, fast-forward (sage-2668). r9's 16 commits moved by rebase with no conflict, and the
range-diff gives all 16 `=`. The r9 run never started (sage-2665), so no result carries over.
- 281's paths over 385a7194c: packages/bus/src/broker.mjs, five tests (bus fire-revokes-grants,
gitea-grant, grant-executor, lifecycle-revoke; cli lifecycle) and its own packet. The one shared
path is broker.mjs, and the hunks don't touch: 264a's at :128, :455-:1081 and :3276-:5537, 281's at
:396-:400 and :1273-:3831 (base lines).
- 264a removes #routeThread, #threadFor and #threadHolder. No line 281 adds calls them, and every
private name the merged broker calls is defined. No line 281 adds names thread_claims,
thread_opens, sessions or session_defaults; its section 3a "claim" is the host-key claim.
- 281's tests name no schema version, migration, sqlite_sequence, thread_claims or 64-hex pin. cli
lifecycle.test's `sessions` are launcher.registry.sessions, which 264a doesn't change.
- No 264a test reads a 281 path as text or pins its hash. The touched tests that import broker.mjs
run against the merged file in this round's gate, which is the test of the pair.
- 281's gate (its BUILD.md, "Whole packages") recorded the cli suite at 591 s, longer than the 535 s
the table used. 3x is 1773 s, so the cli limit goes from 1650 s to 1800 s. Its other times (bus 23 s,
each scripts/test-*.sh at most 36 s) are under the table's, and those limits stand.
### Correction: the r11 red, T2 (mos-1277, sage-2676)
The r11 gate (75f049ed2, ffc49615, CLEAR sage-2674) went red in its first phase at sessions.test T2
"a released or revoked run's retired generation", :204: the events table had 10 rows before the
three refused calls and 13 after. Every other table's count was equal. request()'s catch
(broker.mjs:4242) sends each refusal through `#refused` (:4081-:4096), which writes one
`action.refused` event with body `{code}`. r2's base dcd8d4dda has the same catch, so the
assertion that every table's row count stays the same (round 2, item 1 above) could never pass.
Sage reproduced it at 72fc5a330, ed6773f72 and 75f049ed2, and the three rows are
`action.refused {"code":"not-holder"}`. Mos ruled it a test defect, not a product fault.
Mos's reading of sage-2257: "writes nothing" means a retired generation's approval, delivery and
read write no approval, delivery, read or claim state. The refusal trail stays. Commit 19 (test
only) makes `refused()` say that, for both calls (released and revoked):
- every table's row count stays equal except events;
- each refused call adds exactly one event, kind `action.refused` with body `{"code":"not-holder"}`,
checked after each call in call order (withdraw, receive, read), so events grows by three.
Before the next gate, one survey pass runs the touched files the r10 and r11 runs never reached
(sage-2676 step 3), with M1 against the fixed T2.
### Run limits (sage-2634, sage-2637, sage-2647, mos-1275)
Every run in run-264 goes through `timeout -k 10 LIMIT`, so a hang stops the run instead of holding
the slot. Each LIMIT is at least 3x the longest recorded run of that file, suite or script. The
records are my own gate logs (`ℹ duration_ms` for node runs, the lap lines for scripts), the times
in committed BUILD.md files, and row 278's committed receipts: `SHELL-LIMITS.json` (row 196's
longest per script) and `run3/whole/results.json` in `agents/gander/work/queue-278`. A timeout with no failed test is a hang, not a pass or a
red, and goes to Sage first (sage-2637). A timeout in the shell phase goes to Mos unplaced
(mos-1275).
| Run | Longest recorded | Where | LIMIT |
|---|---|---|---|
| stack-release.test alone | 575 s | the r12 survey, 2026-10-22 (row 234 r3 singles with mutants: 505 s; plain r3: 482 s); 3 x 575 = 1725 (mos-1275 item 3) | 1800 s |
| each other touched file alone | 15 s (thread-launch.test); end-launch, gitea-migration, message-filter, sessions, thread-claims, thread-cli and chat have none | rows 234, 235, 210 logs | 300 s |
| bus suite | 23 s | row 235 run 1b; row 278 run 3: 22.5 s | 900 s |
| cli suite | 591 s | row 281's gate, 2026-10-22 (row 235 run 1: 535 s; Megavolt row 199: 472 s) | 1800 s |
| webui suite | 57 s | row 278 run 3 (row 66 r2: about 60 s) | 600 s |
| each scripts/test-*.sh | 42 s (test-foundation); task 36.8, queue 23.4, land-work 12.6, release 12.2, discord 8.8, conductor 8.1, config 3.2, extension-package 3.0, auth 1.9 | the longest of row 268's gate (2026-10-22 04:40Z), row 196's receipts and row 278 run 3 | 900 s |
| mutant probes M1-M4 (sessions.test, thread-claims.test) | 1 s wall each (longest `ℹ duration_ms` 512, M3) | the r13p4 rerun, 2026-10-22 14:08:43Z to 14:08:45Z | 300 s |
## Runs
- r7 a23afbf95 on 980cec88c, Sage's CLEAR sage-2619, 2026-10-22 11:29:47Z. HEAD and the manifest
digest eb7c1ab3 printed and matched. RED on the first file, account-migration.test 1 pass 2 fail
(Correction above); the run stopped there as ruled, with no retry. The sweep found 0 node
processes in the worktree. Slot returned.
- r10 54f65f9ab on e479521d3, Sage's CLEAR sage-2669, 2026-10-22 13:11Z. RED on the first file,
account-migration.test :106: later3l missed the 3l trigger threads_id_not_session (3k-3l.sql:104).
Mos ruled option A (mos-1276): commit 18 names it in account-, gitea- and grant-migration. On r11
all 8 *-migration files ran whole, 25/25, 0 skipped (2026-10-22 13:14:25Z).
- r11 75f049ed2 on e479521d3, manifest ffc49615, Sage's CLEAR sage-2674, 2026-10-22 13:16:37Z.
Phase 1 was green for account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5,
end-launch 7/7, gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1
and schema 1/1. Then RED at sessions.test T2 :204 (Correction: the r11 red, above), and the run
stopped. 0 node processes left. Mos ruled a test defect (mos-1277): commit 19.
- r12 93f4af12b on e479521d3, manifest 16603087, survey pass (sage-2676 step 3), Sage's CLEAR
sage-2678, 2026-10-22 13:24:09Z to 13:34:07Z, 598 s. Each file whole under `timeout -k 10`, with
no stop on red and no edits, all 0 skipped and 0 todo:
sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3,
threads 16/17 RED, usage-migration 1/1, thread-cli 11/11, thread-launch 11/11, chat 23/23,
stack-release 218/218 in 575 s. M1 against the fixed T2: rc=1, killed on an assertion, file
restored, sessions 6/7 with T2 sage-2257 the only failure ("Missing expected exception: approval"
at :204). Log gizmo-264-out/r12-survey/probes/M1.log. Mos accepted M1 killed by T2 alone at r12
(mos-1278 item 4). The threads.test red: :330 expected `/only to its thread's holder/` (:341,
from row 121, 2c16d6b93) and got the 3l trigger's "only to its session's holder". Mos ruled
option A (mos-1278): delivery_same_thread in the base (schema.sql:300-306 at e479521d3) and
delivery_same_session (3k-3l.sql:147-153) share the WHEN, the ops and the holder check, and
differ only in taking the latest claim per session and in the text, so :330 keeps its strength.
Commit 20 changes :341 to `/only to its session's holder/` and nothing else in that file.
- r13 gate, 2cc34aef8 on e479521d3, manifest 65e04d27, Sage's CLEAR sage-2684. Start
2026-10-22 13:39:53Z, busy count 0, HEAD and digest printed and matched, blobs checked with
`sha256sum -c`. Every count below has 0 fail, 0 cancelled and 0 todo.
- Phase 1, the 20 touched files whole, one at a time, all 0 skipped, wall 600 s: bus
account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5, end-launch 7/7,
gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1, schema 1/1,
sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3,
threads 17/17, usage-migration 1/1; cli stack-release 218/218, thread-cli 11/11,
thread-launch 11/11; webui chat 23/23.
- Phase 2, suites whole: bus 827/827 (23 s), cli 837/837 (593 s), webui 345 pass of 346 with 1
skipped (55 s). The skip is the esbuild 0.28.1 editor rebuild (Duckworth named it in
duckworth-0146; DEFERRED row 264a N2).
- Phase 3, the 10 scripts/test-*.sh, all rc=0: auth 15, conductor 17, config 31, discord 81,
extension-package 18, foundation 44 (36 s), land-work 86, queue 156 node tests and 28 suite
checks, release 14, task 91. The worktree was clean after.
- Phase 4: M1 killed by T2 at sessions.test:204 (6/7). M2 then failed with no assertion: T1
(:89) and T7 (:422) threw `storage-refused` from #refused (broker.mjs:4082) through request()'s
catch (:4242), because the delivery_same_session trigger refused #delivery's INSERT. The run
stopped there at 14:02:47Z, total wall 1374 s, and M3 and M4 did not run. 0 node processes
left. Mos ruled option B (mos-1279): phases 1-3 carry and phase 4 reruns with M2's four edits.
- Logs: gizmo-264-out/r13-run.out, with the per-file, suite and probe logs under
gizmo-264-out/r13/single, r13/suite and r13/probes.
- r13p4, phase 4 rerun under mos-1279 at HEAD 84f53540d (2cc34aef8 plus commit 21, the M2 note
in this file), Sage's CLEAR sage-2688 with the fourth edit confirmed in sage-2690. Start
2026-10-22 14:08:43Z, busy count 0. `git diff --name-only 2cc34aef8..HEAD` was this file only,
and the non-BUILD.md blobs matched manifest 65e04d27. Kit gizmo-264-out/probes-264-r13p4.py
(`--check ok`), each probe under `timeout -k 10 300`, each file restored and its sha checked.
End 14:08:45Z, failures 0, 0 node processes left.
- M1, the #agent generation fence set to `false`: killed, sessions 6/7, T2 sage-2257 at
sessions.test:204 (`assert.throws(fn, { code: 'not-holder' }, label)`).
- M2, four edits: the `mt.session=?` clause and its argument dropped from message.receive
(broker.mjs:5251), delivery_same_session dropped from 3k-3l.sql and from schema.sql, and
store.mjs:16 SCHEMA_SHA256 set to the sha256 of the mutated schema.sql. Killed, sessions 4/7,
by T1 at sessions.test:89 (a1's receive got the second session's message). T7 also failed on
its receive assertion at :422. T2 failed at :168, the trigger's own pin, which doesn't count as
a kill under mos-1279. The kill rule: only an assertion in a test that exercises receive
counts, never a thrown error.
- M3, generation counted over every claim row in 3k-3l.sql: killed, sessions 6/7, T7 at
sessions.test:390 (the thread_claims generations).
- M4, thread.claims back to the latest row per thread: killed, thread-claims 5/6, T6 at
thread-claims.test:133 (two held sessions list as two rows).
- Logs: gizmo-264-out/r13p4-run.out and gizmo-264-out/r13p4/probes/M1.log to M4.log.
+132
View File
@@ -0,0 +1,132 @@
# Row 206 build notes (Launchpad): 193b2, thread shares (#1704)
Brief: docs/plans/2026-10-20_human-users-build.md, "Brief: 193b2, thread shares (schema 3l)"
(blob de3a6310b). Design: agents/morgana/work/queue-193/DESIGN.md at e2d161e2e (sha256 dd7fcb41…),
sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789.
## Base and chain
- Started on aca16404c (sage-2789) on branch launchpad/row206.
- 206 chains on 275 (193b1b, viewer verbs and board refusals), which Morgana is building. I rebase
onto Morgana's 275 sha when it comes, and onto 253's landing under a new branch name.
- At this base a user binds only with a collaborator grant (`#userGrants`, USER_LEVEL). Viewer
sessions arrive with 275, so the viewer column of design 4.3 is not testable here (sage-2818
Q4: the viewer column waits on the 275 rebase; 275 moves once more for Duckworth's B1,
duckworth-0166). On the 275
rebase, USER_VERBS and the viewer gate merge with the three share verbs, and the share verbs
must refuse a viewer (design 4.3: share, unshare and shares are "refused" for viewers).
- The live `--migrate` (3l→3m on the running bus) is not part of this build. It is Mos's go under
Q173, with a backup first and a STATE report after (mos-1310 item 4).
## What changed
- Schema 3l→3m. `thread_shares` as design 5.2, its index and three append-only triggers, in
schema.sql and in the new step `src/migrations/3l-3m.sql` with the same SQL text. SCHEMA_SHA256
d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9, SCHEMA_VERSION 3m.
`tests/fixtures/schema-3l.sql` is the 3l schema.sql (sha256 cd68512212ce…).
- `thread_shares` joins `timedTables` (store.mjs), the broker's clock list, `COUNTED` in
scripts/stack-release.sh and `LISTED` in stack-release.test (T46). It has `at`, so it goes in
the counted list, not the untimed one.
- broker.mjs:
- `#readable(s, t)` holds rules 1-4 of design 5.4. `#shared` reads the latest row per thread and
target. `#covered` reads the access document once per call and covers nothing on a read
failure, as `#userGrants` does.
- `#threadRead` admits the owner (with `#grantedThread`) or `#readable`, and serves only
`thread.messages`. `#thread` keeps the owner check for every write verb.
- `thread.share`, `thread.unshare`, `thread.shares` (design 5.3). `thread.list {shared:true}`
adds `owner`; the plain list keeps its shape (5.5).
- `replies` leaves out user thread messages except those in threads `#readable` admits.
`trail` needed no change: it already filters through `#hiddenMessage`, which calls `#readable`.
- USER_VERBS gains the three share verbs. `thread.shares` joins `#processingGate`'s read list.
- packages/bus/README.md: the verb rows, a "Thread shares" section, and the schema line (v3m, the
new sha, 23 tables, 90 triggers). schema.test counts 89 triggers because it drops one first.
## Choices and departures (for Sage and Duckworth)
1. Label 3l→3m, not 3i-3j (design) or 3l (brief title). 264a took 3k-3l, and labels go by
landing order (sage-2789).
2. A share covers every session of the thread, and `session.list` stays owner-only (sage-2818
Q1, from Sage's Q7 ruling, agents/sage/work/queue-264/RULINGS.md:72-77). broker.mjs keeps its
"no shares (Q7)" refusal: a sharee reads any session it can name but can't list them. A test
holds the refusal (human `thread-not-owned`, reader `read-only`, user `access-refused`).
**Follow-up**, bound to whichever of row 193 or the share wiring lands second: wire the sharee's
`session.list`. The bar is T1 sessions line 7 and REQ-USER-4 (candidate PRD 1509c7cc5,
docs/prd/mosaic-stack.md:1027-1052): read-only, and it needs current grants, the thread
owner's included.
3. A reader session matches shares as the business human (sage-2818 Q2: accepted, for the human
principal only). Design 4.3 says reader sessions "stay owner-only", and 5.5 says "messages in
other users' unshared threads hidden" for human, reader and tester. The reader sees what the
owner sees, shares to the owner included. Q2 also found a gap in the first tip (16b9c6df6): an
agent session has no owner, but it matched a `*` share, so a shared user message showed in an
agent's trail. `#readable` now returns false unless the session is the business human, a
reader or a user. A test binds an assistant run to the shared thread and a chief run with no
thread, and checks that neither (nor the tester) sees the message after a `*` share and a share
to the owner.
4. Access is read per request (sage-2818 Q3). Design 5.6's "30 s timer" and `access.reload` have
no path at this base: runtime.mjs:263 passes `access: () => loadAccess(file, context)`, read on
every request. The tests cover the per-request read (removed, expired, out of scope and
unreadable), which is the stricter case.
5. Rule 3 asks the owner's grant for any level, like rule 2 (design: "still has a live grant
covering t"). A user owner lowered to viewer keeps the thread readable to its sharees.
6. An unshare checks no coverage, so an owner can end a share whose target lost its grant.
7. `thread.shares` for a user goes through `#grantedThread`, so it needs the collaborator grant
to still cover the thread, like share and unshare. Design 4.3 says "own threads" with no grant
note; at this base every user is a collaborator, so the two agree until 275.
## Tests
New: tests/thread-shares.test.mjs, 15 tests: the read flow and the write refusals, `*`, rule 2,
rule 3 (with trail for human and reader), latest-row state, share-not-covered, target checks,
lost owner coverage, tester and reader refusals (18 `action.refused` events, no rows), trail and
replies, sessions, the sharee's `session.list` refusal (Q1), agents and the tester matching no
share (Q2), the 3l→3m migration and the 3l engine's refusal of a 3m store.
Pins moved 3l→3m: account-, chief-of-staff, comment-, gitea-, grant-, name-, spec-, usage- and
thread-migration, sessions, schema (23 tables, 89 triggers) and user-principal (USER_VERBS).
The engine copies in account-, gitea-, grant- and comment-migration gain 3l-3m.sql;
comment-migration's future step becomes 3m→3n. `later3l` in account-, gitea- and grant-migration
also leaves out `thread_shares` objects, and the gitea and grant `rows()` lists leave out the
table, since each compares a store migrated through 3m against an older one.
## Mutants (design section 8 numbers)
Each runs on a copy of the candidate tree with one edit, under `timeout -k 10 300`. The named
test must fail on an assertion; a thrown error or a refusal before the assertion doesn't count.
- M2. In `#readable`, delete the rule 2 line (`if (s.user && !this.#covered(s.user, ...)) return
false;`). Killed by "mutant 2: a share never widens a grant" (bob reads after his grant moves to
p2) and by the `*` test (carol reads).
- M3. In `#readable`, delete the rule 3 line. Killed by "mutant 3" (the human reads A after
alice's grant is removed).
- M4. In `#shared`, replace the latest-row lookup with any share row:
`SELECT op FROM thread_shares WHERE thread=? AND target=? AND op='share' LIMIT 1`. Killed by
"mutant 4" (bob still reads after the unshare).
- M7. In `thread.send`, replace `this.#thread(s, a.thread)` with `this.#threadRead(s, a.thread)`.
Killed by the first test (bob's send to A must refuse `thread-not-owned`).
- M8. In `thread.share`, delete the `share-not-covered` line. Killed by "mutant 8" (the share
to user:carol must refuse).
- M9. In `thread.share`/`thread.unshare`, replace `this.#thread(s, a.thread)` with
`this.#threadRow(s, a.thread)`. Killed by the first test (bob's share of A to `*` must refuse).
- Q2 probe (not a design mutant). In `#readable`, delete the human-principal line
(`if (!s.human && !s.reader && !s.user) return false;`). Killed by "only a human principal
matches a share" (the agent's trail shows the message after the `*` share).
## Run limits
From 264's table (agents/gizmoduck/work/queue-264/BUILD.md, Run limits), each at least 3x the
longest recorded run. A timeout with no failed test goes to Sage first; a timeout in the shell
phase goes to Mos unplaced (mos-1275).
| Run | LIMIT |
|---|---|
| stack-release.test alone | 1800 s |
| each other touched file alone | 300 s |
| bus suite | 900 s |
| cli suite | 1800 s |
| each scripts/test-*.sh | 900 s |
| each mutant probe | 300 s |
## Runs
None yet. Node and https git wait for Sage's CLEAR (Gander holds the slot for row 253 run 5).
+931
View File
@@ -0,0 +1,931 @@
# Row 281 build notes: a fire or a delete revokes the instance's grants (#1786)
Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d6 for Dewey's approval. No code until it is
approved. The results sections are filled in after the runs on Sage's gate.
History: d1 dfebd16cc. d2 c746ce378 added Sage's Q1/Q2 rulings (sage-2573). d3 36c2d98b8 added the
approved-before-fire tests (sage-2574). d4 answers Dewey's CHANGES on d3 (dewey-0823):
- B1: the set takes every grant decision with no outcome, and a raise refuses a fired or firing agent.
- B2: step G runs detached from the verb, so neither caller waits on the network.
- B3: one step G per agent, with named refusals for everything that could race it.
- N1-N3 are folded in.
d5 adds sage-2575: two requests per granted delete, with its five conditions. It also adds a `job` id to every
answer, event and message, and tests T3, T3b and T3c with mutants M16-M18.
d6 answers Dewey's CHANGES on d5 (dewey-0825):
- C1: the job's step C records authority without the chief's claim (`#authorityFor`, as a landing's record
does), processing-halted doesn't hold the job, and a step B or C failure records `fire.failed {job, step,
code}` with its own message. Tests T11 and T12, mutants M19 and M20.
- N4: boot closes a job a restart interrupted, with one message. Test T13, mutant M21.
- N5: section 7 names the refusals the manual verbs gain.
- N6: the set counts Q1 leftover lines, in fire step 3 and in the delete text.
## Sources
- Brief: `docs/plans/2026-10-22_fire-revokes-grants.md`, "Brief: no grant outlives its instance" (sage-2568,
mos-1271, Vikunja 524).
- Code read at 47a35bf43: `packages/bus/src/broker.mjs`, `runtime.mjs`, `credentials.mjs`, `host-grant.mjs`,
`gitea-grant.mjs`, `packages/business/src/instance-settings.mjs`, `tools/host/mosaic-keys-write`.
- Row 256's delete (honker/row256-r2 at 1e761962a, not landed) changes only the delete's checks. Its write still
goes through `lifecycleDelete` and the same `revoke()`, so this design covers it unchanged.
- Row 235 r6 section 6 (host revoke) and #436b r5 section 6 (Gitea revoke and the fire steps).
## 1. Why: what happens today (read from the code, to be pinned by a test before the change)
In short: today a delete of a granted instance unlinks the key files but leaves both grant records, the host
lines and the Gitea token live, and a fire refuses `credential-revoke-failed` (sage-2573 asked for this here
and on #1786). The details:
- **Fire.** `revoke()` (runtime.mjs:151) revokes only the roles entry's `credentials` services, then lists the
agent's store directory and refuses `credential-revoke-failed` if any file is left. A host grant's key is
`<store>/<business>/<agent>/host.<id>` (host-grant.mjs:102), and a Gitea grant's token is `.../gitea`
(gitea-grant.mjs:379). Neither is a roles-entry service, so a fire of an instance with a grant refuses at
step B with "host.<id> still in the store". It doesn't leak, but it can't finish until a human runs
`mosaic hosts revoke` or `mosaic grants revoke` by hand. If the business file also names `gitea` as a
roles-entry credential, `credentials.revoke(instance, 'gitea')` unlinks the granted token file with no
`gitea_grants` change and no Gitea call.
- **Delete.** `credentials.remove(instance)` (credentials.mjs:400) revokes every registered reference,
`host.<id>` and `gitea` included, and unlinks the directory. The `host_grants` and `gitea_grants` records
stay, the host line stays on each host, and the Gitea token, user and login stay live. This is the leak.
The next start then finds a grant record whose key file is gone. What the start does with that isn't read
yet. The T1 test below pins it.
- **An open grant decision.** A fire delists the instance but keeps its roles entry. An approved host or Gitea
grant decision for that agent that hasn't run yet still passes `#hostDefinition` and `#giteaRole`, so it can
still apply after the fire. A delete removes the roles entry, so its executor refuses `unknown-role` or
`gitea-not-declared`.
- **A raise after the fire** (dewey-0823 B1). `#raiseGrant` (broker.mjs:1529) checks only that the caller is
the chief, and the grant bindings check only the roles entry. So the chief can raise a new grant for a fired
agent, and if the human approves it, it applies.
## 2. Where the grant revokes sit (D1, rewritten in d4 for B2)
d3 awaited step G inside the verb. Dewey's B2 showed that neither caller can wait that long:
- **Delete.** The launcher's `host.op` waits `REQUEST_TIMEOUT_MS = 10000` (cli/src/host.mjs:31). On a timeout,
`breakChannel` stops the whole bus host. One SSH remove can take 30 s (host-grant.mjs:231, :311), and each
Gitea call 15 s (`CALL_MS`, gitea-grant.mjs:38).
- **Fire.** server.mjs answers a verb that isn't on its async list synchronously (:154), and the async list
gives 120 s (:150). The chief's runner client times out at 30 s (harness/src/runner.mjs:449). Two hosts
plus the Gitea calls can pass both limits.
So step G never runs inside a verb. The fire and the delete both use the same answer-at-once job, for the same
timeout reason (sage-2575 condition 5). Both verbs stay synchronous and answer at once. Step G runs as tracked
broker work after the answer, as row 236's grant executors do (`#track`, `setImmediate`). No caller waits on
the network, so `server.mjs`, `process.mjs`, `cli/src/host.mjs` and the launcher don't change.
Each job gets an id, `job` (a UUID), when it starts. The verb's answer, the job's start event, its end event
and every inbox message about it carry that id. The human and the chief can then match a later outcome to
the request that started it.
### The fire
1. `write('instance.fireCheck')`, as today.
2. Step A, as today, with one addition. In the same transaction, after `fire.stopped`, the grant set is read
(section 3). Reading it there means a grant raise either committed before step A, and is in the set, or
comes after `fire.stopped` and refuses (section 3d).
3. If the set is empty, steps B and C run synchronously, exactly as today. A fire with no grant answers
today's result with today's events (T7). The set counts everything step G would touch (d6, N6): grant
rows, open decisions, the agent's Gitea leftovers (section 5) and its Q1 leftover host lines (section 5).
Any of them makes the set non-empty and goes to the job.
4. Otherwise the verb takes the agent's step G lock (section 3e), records `fire.revoking {job, grants}` in
step A's transaction, starts the job, and answers at once:
`{...stopped, job, revoking: ['host:<id>' | 'gitea', ...], done: false}`. There's no `transaction`, since
step B hasn't run.
5. The job runs step G, then step B (`write('instance.fire')`) and step C (`#recordAuthority`, below, then
`fire.done {job, grants}`). It releases the lock when it ends, whichever way it ends, `fire.failed`
included.
What the chief sees meanwhile: the instance is "firing". That's an existing state (`#firing`, broker.mjs:645):
`fire.stopped` with no later `fire.done`. Starts already refuse `instance-firing`.
- A repeat fire while the job runs refuses `fire-in-progress`, a new code.
- A repeat after the job finishes takes the delisted path (webby-0094 N1) and answers done.
- A repeat after a refused step G runs step G again (section 5).
The human sees the outcome either way: `fire.done` names the job and the grants, and a refusal sends the
inbox message in section 4.
**Steps B and C in the job (d6, C1).** d4 left "step C without the chief's run" as a build-time check, and
Dewey checked it: it fails.
- `#consumeAuthority` calls `#checkAuthority` (broker.mjs:2852). That runs `#processingWork()` and
`#agent(s)`, and `#agent` refuses `not-holder` once the chief's run no longer holds its claim (:749).
- After the verb answers, the job can take a minute or more. If the chief's run ends meanwhile, step C would
throw after step B had delisted. The instance would be left delisted with no `fire.done`.
So the verb does the full check, and the job only records:
- The verb runs `#checkAuthority(s, action, authority)`, as today, before step A. The chief holds its claim
then.
- The job's step C runs `#authorityFor(s, action, authority)`, the check without the holder test. It then
runs the `#spent` check and the `action.allowed` event, as `#consumeAuthority` does. That's the shape a
landing's record already uses (`recordPublish`, broker.mjs:3174, row 137): the run that asked held its
claim when it asked, and may have ended since. It's one small private method, `#recordAuthority`, shared
with nothing else.
- The job keeps a copy of the session fields `#authorityFor` and `#event` read (business, role, thread,
run). It doesn't keep the live session object.
- `processing-halted` doesn't hold the job. A halt stops agent work. Step G only removes access, and steps
B and C finish a decision the chief made while processing ran. Holding them would leave a delisted or
half-revoked instance "firing" for the length of the halt. The verb itself still refuses during a halt,
through `#checkAuthority`, as today.
**A step B or C failure in the job** gets its own record, not `grant.revoke.refused`, since every grant in the
set was revoked by then:
- It records `fire.failed {job, step: 'B' | 'C', code}`. `code` is the thrown code, for example the writer's
`credential-revoke-failed`, or `decision-consumed`.
- It sends an inbox message:
> Fire of <agent>: job <job> revoked its grants (<list>) but stopped at step <B|C> (<code>). The instance
> is still firing. Run the fire again once <code> is cleared.
- The instance stays firing. A repeat fire finds an empty set and takes today's path for that state, with the
chief's own claim: B and C after a step B failure (T12), the delisted path (webby-0094 N1) after a step C
failure.
`grant.revoke.refused` with reason `internal` stays only for an unexpected throw inside step G.
### The delete (sage-2575)
`lifecycleDelete` (broker.mjs:3293) stays synchronous. After its running and starting checks, it reads the
grant set.
- **No grant.** An empty set: `instances.set delete`, one request, exactly as today (condition 4, T3c).
- **The first request.** For a non-empty set, it takes the lock and records `grants.revoking {job, agent,
verb: 'lifecycle.delete', grants}`. It starts the job and refuses `grants-revoking` at once. The launcher
closes the request with that code, well inside its 10 s.
The launcher can carry only the code, so the broker also sends the human an inbox message (condition 1):
> Delete of <agent> is waiting on its grants. Revoking started (job <job>) for: host:<id>, ..., gitea.
> Raise the delete again when the inbox says the revoke is done.
- **A delete raised while the job runs.** It refuses `grants-revoking` again and starts no second job
(condition 2, the 3e lock). No second message goes out either: the first one still applies.
- **The job ends.** The human gets one inbox message (condition 3).
- If it completed, the message names each grant revoked, with any `grantsLeft` (section 5), then "Raise the
delete again." It records `grants.revoked.done {job, agent, verb, grants, grantsLeft}`.
- If it refused, the message names the grant, the reason and how to clear it by hand, as section 4
describes. It records `grant.revoke.refused {job, ...}`.
- **The delete raised after a completed job** finds an empty set and goes as today.
- **The delete raised after a refused job** doesn't complete over a live grant. Its set is non-empty, since
the refused grant's record is still there. It refuses `grant-revoke-failed`, naming the earlier job's
reason in a message:
> Delete of <agent> refused: job <job> couldn't revoke <grant> (<reason>). Retrying now as job <new>. Raise
> the delete again when the inbox says it's done, or clear <grant> by hand first.
It starts a new job for what's left. The broker knows the last job refused because the agent's latest job
end event is a `grant.revoke.refused` with verb `lifecycle.delete`, and no `grants.revoked.done` came after
it. A delete after the human cleared the grant by hand doesn't find an empty set if a manual revoke left a
line (Q1). The leftover line makes the set non-empty, so that delete starts a job for the line removal
and refuses `grants-revoking` (d6, N6, and T3b's second case). If nothing is left, the set is empty and the
delete goes on.
The tradeoff (ruled by sage-2575): deleting a granted instance now always takes two requests. That keeps the
cli and the host channel out of row 281, and a delete of an instance with a grant is rare. Holding the
launcher's request open past its 10 s would need a host channel change and a strand path, and polling would
widen the row into the launcher for a one-time wait.
While a delete's step G runs, the instance is still listed and not firing, so it could be started. That's
harmless: its grants are being revoked, and the repeat delete refuses `instance-running` as today. No new
start check.
### Why not in `beforeWrite` or `revoke()`
Both are synchronous and run inside the organization writer, after its checks. A host `remove` is SSH and a
Gitea revoke is HTTP, so they can't run there. The business package also has no reach into the broker's grant
tables, and shouldn't get any.
### Why after step A
A fire is the decision to stop the agent. If step G came first, a host that can't be reached would keep the
fired agent running. After step A, a refused step G leaves the same state as a failed step B today: the
instance is "firing" (stopped, still listed) until a repeat. Row 194 already handles that state. The repeat
fire's delisted path (webby-0094 N1) also runs step G, since the set is read before step B on both paths.
## 3. Step G: what it revokes, and how
The set, for (business, agent), is read in one store transaction: step A's for a fire, the delete's checks
for a delete (section 2). It holds:
- each `host_grants` row of the agent;
- each host named by an open host grant decision for the agent;
- the Gitea grant if the agent has a `gitea_grants` row, a `gitea_users` row, or an open Gitea grant decision.
An open decision (d4, B1) is any grant decision raised for the agent with no outcome yet. That is: not denied,
not expired, no `grant.applied` or `grant.failed`, and no `grant.revoked` of the same (agent, host), or of the
agent's Gitea, recorded after its raise. A raised decision the human hasn't decided is open. d3 required
"approved", which left that one out.
An empty set means no step G and no new records. A fire of an instance with no grant is then exactly today's
fire, with no new events (brief tests, fourth bullet).
### 3a. A host grant: the host line first (D2)
hosts.revoke goes local first: the record drops, the key leaves memory and its file, and only then is
`remove <user> <tag>` sent. If the host can't be reached, the revoke closes with `hostLineLeft` and the
record is already gone. The brief says a fire may not drop a grant record while its key could still be on the
host. So the fire's host revoke reverses one step and keeps the rest the same:
1. Abort an executor in flight for (agent, host), with no record change (`hostKeys.abortClaim`, as the
`credential-unknown` branch of `#revokeHost` already does).
2. `remove <user> <tag>` through the installer, pinned to the host record's keys. `mosaic-keys-write` says
"a missing tag changes nothing", so this is safe to repeat and safe for a host named only by an open
decision. If the host record is gone, or the remove doesn't exit 0, step G refuses for this host
(section 4). Nothing local has changed for this grant: the record, the key file and the memory copy stay.
3. hosts.revoke's step 1 transaction, unchanged: the `host_grants` row goes and `grant.revoked {revoke, agent,
host, tag, hostLine: 'pending', granted, reason, by}` is recorded, plus `fire: true` (or `delete: true`)
so the human can tell it from a manual revoke.
4. hosts.revoke's step 2, unchanged: `hostKeys.revoke`, so the key leaves memory and its file goes.
5. `grant.revoke.closed {hostLine: 'removed'}` with no second remove. Step 2 already did it.
This is one shared method, `#revokeHostGrant(business, agent, host, reason, by, { lineFirst })`.
hosts.revoke calls it with `lineFirst: false` and its behavior and records don't change (brief item 4). The
fire and the delete call it with `lineFirst: true`.
The tradeoff: hosts.revoke goes local first so that "the part that matters can't wait on the network" (r6
section 6). For a fire, step A has already stopped the agent's runs, and a refused fire keeps the instance
"firing". So waiting on the host costs nothing in exposure, and the record never lies about the host.
### 3b. The Gitea grant (D3)
The Gitea revoke stays grants.revoke's steps with `fire: true`, which is what `credentials delete` already
runs (#436b section 6). Step 1 takes the token out of memory and unlinks the file. Step 2 is
`#giteaRevokeLocal(..., fire: true)`. Step 3 is `#giteaCloseRevoke`: every token deleted and
`prohibit_login: true`. The fire awaits step 3 instead of leaving it to `setImmediate`.
The Gitea side can't go remote first the way a host does. The record has to drop and `grant.revoked` has to
name the tokens before the DELETE calls, or a restart in between would find a live record whose tokens are
gone (section 5's reconcile). Two changes keep the fire closed:
- **Preflight.** Before step 1, `#giteaAdminChecked` runs. If the admin token can't be read or checked,
step G refuses with nothing changed.
- **Left means not done.** If `grant.revoke.closed` lists anything in `serviceLeft`, step G refuses. The
token ids stay in the evidence, which is exactly what `mosaic grants sweep gitea` retries, so nothing
forgets them. The record is gone, but no key of this stack's is still usable: the file and memory copy are
gone, and only the Gitea side is left. That is the case the brief's host rule guards against, and it is
kept visible here, not hidden.
**A Gitea executor in flight** (d3 T6c, and B3 seen from the executor's side). Step G's Gitea part goes in
this order:
1. Record `grant.revoked {granted: false}` for each open Gitea decision (3c).
2. Await this agent's tracked Gitea executors and remote undos, if any are running. Each one ends `revoked` at
its next `#giteaRevokedSince` check, or it committed `applied` before step 1.
3. Read the `gitea_grants` row again and run the steps above.
`#giteaCloseRevoke` with `fire: true` lists the user's tokens and deletes every one. The list comes after
step 2, so it includes any token an in-flight executor created. `#giteaRuns` is a single Set today. It
becomes keyed by (business, agent) so step G can await one agent's executors alone.
### 3c. Open decisions
A host named only by an open decision has no record. Step 3a still runs `remove` (a no-op) and records
`grant.revoked {granted: false}`, as hosts.revoke does for a revoke with no record (N4). The executor's
`#revokedSince` then ends that decision `revoked`. The Gitea side is the same through
`#giteaRevokedSince`.
This is the case where a decision is approved before the fire and executed after it (sage-2574). Both checks
look for a `grant.revoked` of the agent recorded after the decision was raised (broker.mjs:1156 and :1678 at
47a35bf43). A fire's `grant.revoked` is always later than the approval, so the executor ends the decision
whenever it runs. The fired agent keeps its roles entry, so `#hostDefinition` and `#giteaRole` still pass, and
this check is the only thing that stops the decision. T6 and T6b test it. A delete also removes the roles
entry, so the executor refuses there as well.
The same holds for a decision raised but not yet approved (B1). It's in the set, so it gets its
`grant.revoked {granted: false}`. If the human approves it later, its executor ends `revoked` (T6d). The
decision stays approvable in the inbox. Approving it does nothing but end it, which `grant.failed` reports as
it does for any revoked grant.
### 3d. A raise for a fired agent (d4, B1)
`#hostGrantBinding` and `#giteaGrantBinding` both run inside the raise transaction (broker.mjs:1539 and
:1548; the host raise's keyscan is awaited before that transaction). Both gain one check. The raise refuses
`instance-fired` if the agent is:
- firing (`#firing`);
- fired, by the writer's own rule (instance-settings.mjs, hire): a roles entry, not in `launch.instances`,
and not `launch.by`.
A rehire lists the name again, so a rehired agent can be granted again.
Because the check is in the transaction that records the decision, there's no gap. A raise that commits
before step A's transaction is in the set. One that commits after it refuses. A raise for an agent whose
delete's step G is running refuses `grant-revoke-running` (3e).
No executor recheck is added. A raise can't commit after `fire.stopped`, and every decision raised before it
is in the set and gets its `grant.revoked`.
### 3e. One step G per agent (d4, B3)
The broker keeps `#revoking`, a map from (business, agent) to the verb whose step G is running. The verb sets
it synchronously, in the same turn as the set read, before it answers. The job clears it in a `finally`.
While it's set:
| Caller | Answer |
|---|---|
| `instance.fire` | refuses `fire-in-progress` |
| `lifecycleDelete` | refuses `grants-revoking`, no second job |
| `hosts.revoke` / `grants.revoke` for the agent | refuse `grant-revoke-running` |
| grant raise for the agent | refuses `grant-revoke-running` |
| `grants.sweep` | skips this agent's entries and names it as `running` in its result |
Two fires started together (the chief retrying after a client timeout): the first takes the lock before it
answers, and the second refuses. The map is in memory. After a broker restart the job is gone and so is the
lock. The instance is still firing, or the delete still has a non-empty set, and the repeat starts a fresh
step G (section 5).
A manual revoke can't race step G, since it refuses while the lock is held. Step G's transactions still
check what they read: a `host_grants` row already gone at 3a step 3 records `grant.revoked {granted: false}`
and goes on, as hosts.revoke does today for a revoke with no record.
## 4. A refusal (D4)
Step G stops at the first grant that can't be revoked. Grants already revoked in this step G stay revoked and
recorded, and the rest stay as they were. The job doesn't run step B: the organization file is unchanged, and
the credentials are unrevoked, since `revoke()` never ran. A fire stays "firing". A delete's request was
already closed with `grants-revoking` (section 2).
The verb has already answered, so the refusal is a record, not a thrown code:
- One grant-evidence event, `grant.revoke.refused {job, agent, grant: 'host:<id>' | 'gitea', reason, verb:
'instance.fire' | 'lifecycle.delete', by}`.
- `reason` is one of `host-unknown`, `host-remove-failed`, `gitea-admin-unavailable` and `gitea-left:<ids>`.
- `grant-revoke-failed` is the code in the event and in the inbox message.
A message lands in the human's inbox, as `grant.failed` does today (broker.mjs:1139). It names the instance,
the grant and the reason, with the manual way out beside the retry (N2):
> Fire of <agent> stopped: its grant on <host> couldn't be revoked (<reason>). Run the fire again once <host>
> is reachable. If <host> won't come back, run `mosaic hosts revoke <agent> <host>` first: the grant record
> goes, the line is named as left, and the next fire completes and names that line (section 5).
The delete's message has the same shape, with "raise the delete again". For Gitea, the way out is
`mosaic grants sweep gitea` once Gitea is back.
A job that throws something unexpected inside step G records `grant.revoke.refused` with reason `internal`,
sends the same message, and releases the lock. There's no unhandled rejection. A failure at step B or C is
`fire.failed` (section 2).
**A restart mid-job (d6, N4).** The lock and the job are in memory, so a restart ends them with no end
record. A fire would stay "firing" until the chief happened to repeat it. A delete's request is already
closed, so no one would hear anything. Boot closes that gap:
- A job is a start record (`fire.revoking` or `grants.revoking`) whose `job` has no end record. The end
records are `fire.done`, `fire.failed`, `grants.revoked.done`, `grant.revoke.refused` and the new
`grants.job.interrupted`.
- When the broker starts, for each such job of each business, it records `grants.job.interrupted {job,
agent, verb}` and sends the human one inbox message:
> <Fire|Delete> of <agent>: job <job> was interrupted by a bus restart. Some grants may be revoked. Run the
> fire again, or raise the delete again. The repeat goes on from what's left.
- Recording the end record in the same transaction as the message means a second boot finds nothing to
send.
- Boot reads only events, with no network call, so it adds nothing to the start time beyond one query.
## 5. A repeat fire (D5)
A repeat runs the whole fire again: the checks, step A (nothing left to stop), the set read, then step G, B
and C in the job. A repeat while the job runs refuses `fire-in-progress` (3e). A repeat delete works the same
way through the human's new request.
- A host grant already revoked has no row and no open decision, so it isn't in the set. It's done.
- A host grant that refused still has its row, so it's retried from 3a step 1.
- Gitea (d4, N1): if the agent has entries in `#giteaLeft` or `#giteaOpenRevokes`, step G retries only those.
It calls `#giteaCloseRevoke` for each, as `#sweepGitea` does per entry. It doesn't run the business-wide
sweep, which would retry every agent's entries and settle every open rotation under the chief's name. Any
entry still left for this agent refuses `gitea-left`. An entry left by an earlier manual
`mosaic grants revoke` of the same agent blocks the fire the same way. That's intended: no grant outlives
its instance, whichever verb started its revoke. These entries count toward a non-empty set (section 2,
fire step 3), so they go to the job and never run inside the verb.
- A host line left by an earlier manual `hosts.revoke` doesn't block the fire (Q1 below). These are the
agent's `grant.revoke.closed` records with `hostLineLeft` and no later line removal for that tag. For each
one whose host record still exists, step G sends `remove <user> <tag>` once. On success it records
`grant.revoke.closed {revoke: <the old revoke id>, hostLine: 'removed', fire: true}`, so a later fire doesn't
find it again. On failure it records nothing new and the fire goes on. The host and the tag go in
`grantsLeft` (section 6). These leftover lines also make the set non-empty, so the retry runs in the job,
inside B2's answer (dewey-0823 accepted the single retry on that condition).
## Rulings
- **Q1 (sage-2573):** a leftover line doesn't block the fire. If the host is reachable, the fire tries the
removal once, because a fired agent's key line still on a host is the exact risk this row closes. If that
try fails, the fire doesn't refuse. It names the host and the leftover line in the result and in the event
the human sees. The human accepted that residue when their manual `hosts.revoke` left it, so it shouldn't
stop a fire forever on a dead host. Dewey may push back on the retry if it widens step G more than it's
worth.
- **Q2 (sage-2573):** a delete refused at step G closes its request through the launcher's normal refusal
path, and the human raises it again. A request left taken would strand, which is the shape DEFERRED already
records for the >64-stops done. Conditions: the refusal names the instance, the grant and the reason (the
`grant.revoke.refused` event, section 4). The repeat delete skips grants already revoked, as the repeat fire
does. T3b tests both.
- **Q2 extended (sage-2575):** every delete of a granted instance takes two requests. The first starts the
revoke job and refuses `grants-revoking`, and the human raises the delete again when the inbox says it's
done. This keeps the cli and the host channel out of the row, and such a delete is rare. Conditions, each
with a test:
1. The first refusal tells the human what happened and what's next: the named grants, and to raise the
delete again when the inbox says done. A bare code isn't enough (T3).
2. A delete raised while the job runs refuses `grants-revoking` again and starts no second job (T3, T9).
3. The job's end message names each grant revoked. If one failed, it names the grant, the reason and the
manual way out (`mosaic hosts revoke` or `mosaic grants revoke`). The next delete then refuses with that
reason and doesn't complete over a live grant (T3b).
4. A delete of an instance with no grant completes in one request, exactly as today (T3c).
5. Section 2 says the fire uses the same answer-at-once job, and the fire's result and event name the job
(T2).
## 6. The result and the event (D6)
- **A fire with a non-empty set** answers at once with `job`, `revoking` and `done: false`, and records
`fire.revoking {job, grants}` (section 2).
- **The job's outcome** is recorded, not returned. `fire.done` (step C) carries `job` and `grants: [{grant:
'host:<id>' | 'gitea', revoke, granted}]`, plus `grantsLeft` for any leftover from section 5's last bullet.
So the event the human sees names them.
- **A delete's job** records `grants.revoking {job, ...}` at the start and `grants.revoked.done {job, agent,
verb: 'lifecycle.delete', grants, grantsLeft}` at the end. It sends the inbox messages in section 2.
- **A fire with an empty set** carries none of these keys, so its shape is exactly today's.
## 7. What doesn't change (brief item 4)
- `hosts.revoke`, `grants.revoke` and `grants.sweep`: same callers and the same records. Their refusals stay
the same (`tester-dev-only` included) with one addition from section 3e (d6, N5): while a step G job holds
the agent's lock, `hosts.revoke` and `grants.revoke` for that agent refuse `grant-revoke-running`, and
`grants.sweep` skips the agent and names it as `running`. hosts.revoke's own order stays local first.
- No new verb, no Console route, no CLI change, no schema change. Every new record is an `events` row in the
existing grant-evidence shape.
- `revoke()` in runtime.mjs and the organization writer don't change.
- `server.mjs`, `process.mjs`, `cli/src/host.mjs` and `cli/src/launcher.mjs` don't change. Both verbs stay
synchronous. The launcher already closes a request with whatever code `lifecycleDelete` refuses.
- New codes: `fire-in-progress`, `grants-revoking`, `grant-revoke-running`, `instance-fired`, and
`grant-revoke-failed` in events and messages. New event types: `fire.revoking`, `grants.revoking`,
`grants.revoked.done`, `grant.revoke.refused`, `fire.failed` and `grants.job.interrupted`.
- The broker's start gains one events query for interrupted jobs (section 4, N4). `lifecycleDone` checks a refusal against `LIFECYCLE_CODE =
/^[a-z-]{1,64}$/` (broker.mjs:149), and `grants-revoking` matches it.
## 8. Tests (each in the owning package, whole files)
The bus tests use the fake installer and the fake Gitea from rows 235 and 236. They wait on the job with a new
trusted test API, `grantsIdle()`, which resolves once every step G job and Gitea executor has ended. It's the
same shape as `giteaIdle()`. Unless a test says otherwise, every fire or delete below is awaited that way.
- T1 (pins section 1 before the change): a fire of an instance with a host grant refuses
`credential-revoke-failed` today. A delete leaves the `host_grants` row. Then: what the next start does with
a record whose key file is gone.
- T2: a fire with a host grant on two hosts and a Gitea grant. The verb answers with a `job` id, `revoking`
with all three, and `done: false`, and `fire.revoking` carries the same job (sage-2575, condition 5). After
the job:
- all three are revoked, each with `grant.revoked` and `grant.revoke.closed` as the manual verbs record
them, plus `fire: true`;
- the line is gone on both fake hosts, every token is deleted, and login is prohibited at the fake Gitea;
- `fire.done` names all three;
- that agent's `host.run` and Gitea use both refuse.
- T3 (sage-2575, conditions 1-3): the same grants through the delete (`lifecycleTake` then
`lifecycleDelete`).
- The first delete refuses `grants-revoking` and starts the job. The human's inbox gets the start message
naming the job and all three grants, and saying to raise the delete again when it's done.
- A delete raised while the job is held refuses `grants-revoking`, starts no second job and sends no second
message.
- After the job, the end message names the job and each grant revoked.
- A third request completes as today.
- T3b (Q2, sage-2575 condition 3): a delete whose job refuses (host B unreachable).
- The end message and `grant.revoke.refused` name the instance, `host:B`, the reason and
`mosaic hosts revoke`.
- The file and the instance are unchanged.
- The next delete refuses `grant-revoke-failed`, not `grants-revoking`. Its message names the earlier job's
reason and the new job, and the instance is not deleted.
- With B back, that retry job revokes only B and Gitea, skipping host A, and the delete after it completes.
- A second case clears B by hand with `hosts.revoke` before the next delete. That delete starts a job only
for Gitea and the leftover line.
- T3c (sage-2575 condition 4): a delete of an instance with no grant completes in one request. Same result,
same events counted, no inbox message.
- T4: host B unreachable in a fire. The job records `grant.revoke.refused` naming the agent, `host:B` and
`host-remove-failed`, and the inbox message names `mosaic hosts revoke` (N2). Afterwards:
- the file is unchanged and the instance is still firing;
- host A is revoked and recorded, and host B's row and key file are still there;
- the Gitea grant is untouched, since it comes after B in the order;
- the credentials are unrevoked.
A repeat with B reachable completes and revokes only B and Gitea.
- T4b (Q1): an earlier manual `hosts.revoke` left a line on host C. A fire with C reachable removes it and
records the close. With C unreachable, the fire completes and names C and the tag in `grantsLeft` and in
`fire.done`.
- T5: Gitea's admin token can't be checked. The job refuses with the Gitea grant intact.
- T5b: a token DELETE fails. The job refuses `gitea-left`. A repeat with Gitea back retries only this agent's
entries and completes. Another agent's left entry is still left afterwards (N1).
- T6 (sage-2574): a host grant decision approved before the fire, executed after it. The fire records
`grant.revoked {granted: false}` for (agent, host). The executor runs after `fire.done` and ends the decision
`revoked`: no `host_grants` row, no key file, no `add` sent to the host.
- T6b (sage-2574): the same for a Gitea grant decision. The executor ends it `revoked` before any Gitea call:
no `gitea_grants` row, no token file, and the fake Gitea saw no token create.
- T6c: a host or Gitea executor already in flight when step G starts.
- The host side aborts its claim (3a step 1) and ends `revoked`.
- The Gitea side: step G awaits the executor (3b), and then the fake Gitea holds no token for the user. That
includes one the executor created mid-flight.
- T6d (B1): a host decision and a Gitea decision, raised before the fire and still undecided. The fire puts
both in the set. The human approves both after `fire.done`, and both executors end `revoked`, with nothing
applied.
- T6e (B1): a host raise and a Gitea raise refuse `instance-fired`:
- while the instance is firing;
- after `fire.done`.
After a rehire of the same name, a raise goes through. While a delete's job runs, a raise refuses
`grant-revoke-running`.
- T7: an instance with no grant fires exactly as today, synchronously. Same result keys, no `revoking`, and
the same events, counted.
- T8: hosts.revoke and grants.revoke keep their records, their order and their tester refusals.
- T9 (B3): with the job held on a remove:
- a second fire refuses `fire-in-progress`;
- a delete of the same agent refuses `grants-revoking` and starts no second job;
- `hosts.revoke` and `grants.revoke` for the agent refuse `grant-revoke-running`;
- `grants.sweep` names the agent as `running`.
After a release, one set of `grant.revoked` records exists per grant.
- T10 (B2): the fake installer holds a remove and the fake Gitea holds a DELETE, neither answering until
released.
- `instance.fire` and `lifecycleDelete` both answer before either is released. The test checks this with a
timer, not a wall-clock bound.
- Meanwhile the broker answers another verb from another session.
- After release, the jobs complete.
- A cli test drives the launcher's delete pass against a bus whose remove is held. The pass closes the
request `grants-revoking` and the host channel stays up.
- T11 (d6, C1): a fire whose job is held on a remove. While it's held, the chief's run ends and its claim is
released, and processing is halted. After the release, the job completes: `fire.done` is recorded with the
job, the consumed `action.allowed` event is there, a second use of the same authority refuses
`decision-consumed`, and the instance is out of the file. No `fire.failed` and no `grant.revoke.refused`.
- T12 (d6, C1): a fire with a grant whose step B refuses in the job. The test uses T1's setup, where
`revoke()` refuses `credential-revoke-failed`. After the job:
- `fire.failed` carries the job, step `B` and `credential-revoke-failed`, and the inbox message names the
job, the revoked grants, the step and the code;
- there's no `grant.revoke.refused` and no `fire.done`;
- the instance is still firing and still in the file.
With the cause cleared, a repeat fire finds an empty set, runs B and C synchronously and completes.
- T13 (d6, N4): a fire and a delete, each with its job held, then the broker is closed and a new one opened
on the same data root. The new broker records `grants.job.interrupted` for both jobs and sends two inbox
messages, one saying to fire again and one to raise the delete again. A third broker on the same root sends
nothing more. A job that ended before the restart gets no interrupted record.
Files (N3). Every file listed runs whole in the manifest round, touched or not:
- New: `packages/bus/tests/fire-revokes-grants.test.mjs` holds T2-T13.
- Changed:
- `grant-host.test.mjs` and `gitea-grant.test.mjs`: T1's pins, and the T8 regressions next to their
existing cases;
- `packages/cli/tests/lifecycle.test.mjs`: the T10 launcher pass;
- `fake-gitea-api.mjs` and `host-fixtures.mjs`, if T10's holds need a hook there.
- Run whole because they call `instance.fire` or `lifecycleDelete` at 47a35bf43:
- bus: `chief-of-staff`, `lifecycle-revoke`, `lifecycle-verbs`, `reload`;
- cli: `lifecycle`, `instances`;
- harness: `tools`.
- Also run: `grant-executor.test.mjs` (the executors' `#giteaRuns` change) and `gitea-grant-docker.test.mjs`
(brief gate).
- `server.mjs`, `process.mjs`, `host.mjs` and `launcher.mjs` are unchanged, so no test changes there for this
row.
## 9. Mutants
- M1: step G skips the host revokes. Killed by T2.
- M2: step G skips the Gitea revoke. Killed by T2.
- M3: a refused grant revoke is ignored and the job goes on to step B. Killed by T4.
- M4: `lifecycleDelete` skips step G. Killed by T3.
- M5: the fire's host revoke goes local first (`lineFirst: false`). Killed by T4, because B's row is gone.
- M6: a Gitea revoke with something in `serviceLeft` counts as done. Killed by T5b.
- M7: open decisions left out of the set. Killed by T6 and T6b.
- M8: step G skips the leftover-line retry. Killed by T4b.
- M9: a failed leftover-line retry refuses the fire. Killed by T4b.
- M10: the set takes only approved decisions (d3's rule). Killed by T6d.
- M11: the raise bindings skip the fired check. Killed by T6e.
- M12: no `#revoking` lock. Killed by T9.
- M13: the verb awaits the job before it answers. Killed by T10.
- M14: step G's Gitea part doesn't await the in-flight executor. Killed by T6c.
- M15: step G's Gitea retry runs the business-wide `#sweepGitea`. Killed by T5b.
- M16: the delete's first refusal sends no start message. Killed by T3.
- M17: a delete after a refused job refuses `grants-revoking` instead of `grant-revoke-failed`. Killed by T3b.
- M18: a delete of an instance with no grant goes through the job. Killed by T3c.
- M19: the job's step C uses `#consumeAuthority`, with the holder and processing checks. Killed by T11.
- M20: a step B or C failure in the job is recorded as `grant.revoke.refused internal`. Killed by T12.
- M21: boot skips the interrupted-job check. Killed by T13.
## 10. Order and rebase
Built from 47a35bf43 for this read. The code goes on whatever refactor is when the build starts. Rows 274, 256
and 194's follow-ups touch the broker and instance-settings, so expect a rebase and a whole rerun. The row lands
before the .53 Case 1 install hires an instance with a grant. It doesn't hold the dragon-lin re-pin.
## Build notes
Dewey approved d6 (dewey-0829, 10:11Z 10-22) with two build-time items. Both are test details, so there's no
new draft:
- N7, T12's setup. T1's setup makes `revoke()` refuse because the grant's `host.<id>` file is still in the
store. Step G's `hostKeys.revoke` removes that file before step B, so it can't be T12's cause. T12 uses a
step B refusal that survives step G, for example a stray file in the agent's store directory or a roles-entry
credential whose revoke fails. The test asserts it reaches `fire.failed` with step `B`.
- N8, `decision-consumed` in T11. A chief's fire is within-role: `#checkAuthority` returns `{ class }` with no
decision, and the lifecycle verbs take none (broker.mjs:3712). `#spent` never applies to a fire. T11 drops the
second-use `decision-consumed` assertion. Instead, a repeat fire after `fire.done` takes the delisted path and
answers done. Section 2's `decision-consumed` example for `fire.failed` can't occur, so the build uses only
codes that can.
Choices made in the code (b1, broker.mjs only so far). None changes an approved section; each fills in a
detail the design left open. Dewey, flag any you read differently.
- Record writers. `writeGrantEvent` takes only `grant.*` actions. So `fire.revoking`, `fire.done`, `fire.failed`
and `grants.job.interrupted` are not grant evidence. `fire.*` goes through `#event` under the chief's session
(the job keeps a copy, `sc`). `grants.revoking`, `grants.revoked.done` and `grants.job.interrupted` go under
the bus session `{ role: null, run: null }`, subject the agent. `grant.revoke.refused` is grant evidence, as
section 4 has it.
- A fired Gitea user. The `gitea_users` row survives a fire (#436b section 6), so on its own the row would
never let a fired agent's set empty. The user counts as pending until a later Gitea `grant.revoked` with
`fire: true` and a non-null `user` names the agent, and counts again after a new grant step (a rehire).
- Open Gitea decisions (3c) end with a `grant.revoked` that has `user: null`, `tokens: []`, `fire: false` and
`decisions: [ids]`, plus an immediate close with `serviceLeft: []`. `#giteaRevokedSince` then ends them. Since
`user` is null, the pair never counts as the user's fire.
- Leftover lines (section 5, Q1). A line is in the set as `line:<host>` when its tag is in `#hostLinesLeft`, its
host isn't one the agent holds a grant on, and no `fire.done` or `grants.revoked.done` for the agent since the
line was last named left lists it in `grantsLeft`. A line removed in step G gets `grant.revoke.closed` with
the old revoke id, `hostLine: 'removed'` and the flag. `#hostLinesLeft` now clears a tag on a later close
with `hostLine: 'removed'` and `fire` or `delete` set, so the line also leaves `hosts sweep`'s list. The
removed lines appear in `grants` as `{ grant: 'line:<host>', revoke, tag }`.
- 3a's wait for host executors. `#runHostGrant` marks itself in `#hostRuns` from its claim to its release. Step
G's host part runs `abortClaim`, waits for every mark on (agent, host), and sends the remove. If a new mark
appeared during the remove, it runs again, up to five rounds, and then refuses `host-remove-failed`. From the
last check to the step 1 commit there's no await.
- `hosts sweep gitea` (3e) skips the open revokes, settles and left entries of every agent a job holds, and
keeps those entries in `left`. Its answer has `running: [agents]` only when there's at least one. The retry
of one left entry is now `#giteaRetry`, which the sweep and step G share. The behavior is unchanged.
- Step G's Gitea retries record a sweep-shaped pair for the agent: `grant.sweep { agent, job }`, then the
closes and retries, then `grant.swept` whose `left` keeps every other agent's entries. The pair is written
first, so a serviceLeft from a close between the two can't drop out of `#giteaLeft`.
- `#giteaRuns` is now a Map from each promise to its `business/agent` key, or null for business-wide work.
Step G waits for its own agent's keys and the null ones. `#track` adds `.catch` to its `finally` chain, so a
rejected executor can't leave an unhandled rejection behind.
- A delete repeat after a refusal. `grant-revoke-failed` is returned only when the agent's last delete job end
was a `grant.revoke.refused` whose grant is still in the set. If that grant was cleared by hand and only a
leftover line remains, the repeat answers `grants-revoking`.
- The delete job's reason, in `grant.revoked`, is `lifecycle.delete <request>`.
- Step G's messages go to the human's inbox alone (no decision, so no raising role to thread to), through the
same insert and route as `#grantNotice`.
- `#recordAuthority` is `#consumeAuthority` less `#checkAuthority`'s holder and processing checks, for a
fire's step C (C1).
Choices made in the tests (b2). Each changes where a test sits or how it checks, not what it checks:
- T1 is in `lifecycle-revoke.test.mjs`, not `grant-host.test.mjs` and `gitea-grant.test.mjs`. T1 pins the
runtime's `revoke()` (step B's credential check) and the start sweep, and only `lifecycle-revoke` builds a
broker through `startBroker` with the real credential store. Its `setup` takes a new `seed` hook that writes
a host record and grant before the start.
- T8 is in `grant-executor.test.mjs` for `hosts.revoke`, where G12, G13, G26 and G27 already are, and in
`gitea-grant.test.mjs` for `grants.revoke`. G12 already pins the host verb's two records, their bodies and
their order, and G26 its role refusals, so T8 adds what no test had: `tester-dev-only` once `bus.tester` is
off, and that a verb revoke writes no `fire.*` or `grants.*` record.
- T2-T13 use a fake installer and a fake organization in `fire-revokes-grants.test.mjs`. The organization's
`instance.fire` delists and reloads the business, as the writer does, and can throw a set error, which is
how T12 gets its step B cause (N7): a `credential-revoke-failed` that survives step G.
- "A fired agent's `host.run` and Gitea use refuse" is checked through the state those uses read: no
`host_grants` row, no key file, a revoked credential, and no Gitea token for the user.
- T10's cli case runs the real runtime and launcher. The seeded grant is on a TEST-NET host
(192.0.2.10), so the job's remove fails or waits. The first request closes `grants-revoking` either way, and
the case waits on `grantsIdle()` before close.
- The mutants are `scratch/megavolt-r281/r281-mutants.sh`, one sed each on `broker.mjs`, run against
`fire-revokes-grants.test.mjs`. M13's verbs are synchronous, so its mutant makes the fire return the job's
promise, and the call waits for the job. That's what the verb awaiting its job would look like.
Rebase onto 46dedf925, after row 256 (sage-2597). It's on branch `megavolt/row281-b`, and
`megavolt/row281` stays where it is. `git range-diff 0165be266..bd0bb96ae origin/refactor..HEAD` gives `=` for
all nine commits, and no path changes on both sides. Row 256 threads the actor through `planDelete`,
`checkDelete` and `instances.check`, and adds `protectedRefusal`. That doesn't change what a delete calls in
this row:
- At the verb, `instances.set` op delete already ran `instances.check` with `s.human` before 256. A
creative instance's delete by anyone but the human now refuses there, before any request exists, so no
step G job starts for it.
- In the pass, step G runs in `lifecycleDelete` before `#organization('instances.set')`, and the actor
is still `r.by`. `planDelete` there can still refuse after step G has revoked. Before 256 a references or
last-listed refusal could do the same thing, so the order is unchanged. A delete the verb accepted only
meets a new refusal in the pass if the file changed between the two.
- `read(business, actor)` changes only the listing's `endProtected`. The broker and these tests don't read it.
The T10 cli case deletes `sec-1` (security-reviewer), which isn't END_PROTECTED.
Rebase onto 980cec88c, after row 277 (sage-2605). It's on branch `megavolt/row281-c`.
`git range-diff 46dedf925..7454be493 980cec88c..c174f37ac` gives `=` for all ten commits. Row 277's
`thread.claims` change to `broker.mjs` doesn't touch step G or any line the mutants change:
`scratch/megavolt-r281/anchors.sh` counts each M1-M21 sed anchor in the rebased `broker.mjs`, and all 28
counts are 1. The mutants script runs it after a green base and stops if a count isn't 1.
b3, after the first base run on c174f37ac (11:24:47Z) came back red. These tests had never run before: b2
was written under git and docs only. bus: 120 tests, 116 pass, 4 fail. cli lifecycle: 24 tests, 23 pass,
1 fail. Every older test in those files passed. No mutant ran. Each failure is a test fault, and b3 changes
no code:
- T4b, `fire-revokes-grants`: **test changed**. The test read `manual.revoke`, but the `hosts.revoke`
answer has never carried `revoke`: `#revokeHost` drops it, so the verb's answer keeps its pre-281 shape.
The fire's close names the manual revoke, as section 3a says. The test now takes that id from the
manual's `grant.revoked` event.
- T5b, `fire-revokes-grants`: **test changed**. The job refused `gitea-left:9,tokens`, and the test
expected `gitea-left:<digits>`. Section 4 sets the reason as `gitea-left:<ids>`, the ids left at Gitea.
For a fire, a close's `serviceLeft` is the N10 list from before 281: each token id whose DELETE failed,
then `tokens` or `login` for a fire step that failed (`#giteaCloseRevoke`). With the DELETE failing,
`#giteaFire`'s token list never clears, so `tokens` is a real entry left at Gitea. It belongs in the
reason. The test now expects `gitea-left:<id>,tokens`.
- T6b, `fire-revokes-grants`: **test changed**. After the fire, the executor made 4 Gitea calls where the
test expected none. They are step 0's admin checks C1 to C4: version, self, token scopes and the repo
check, all GET. Step 1's `#giteaRevokedSince` then ends the decision `revoked` before any write. Section
3c of d6 names that step 1 check as the only thing that stops the decision. T6b's line in section 8 says
"before any Gitea call", but the evidence it lists is no row, no token file and no token create. That
phrase was wrong, because step 0 always runs first. The approved text stays as it is, and this note is
the correction. The test now checks that every call after the fire is a GET and that no token create
happened. Moving the revoked check ahead of step 0 would change the executor for every revoke, not just
step G's, and the design doesn't ask for that.
- T8, `gitea-grant`: **test changed**. The human revoke's events between `grant.revoked` and
`grant.revoke.closed` include its 5 `grant.call`/`grant.result` pairs (`#giteaLog` logs both around every
Gitea call). T8 checks that a human revoke writes no job records, not that it makes no calls. It now
leaves the call log out before comparing.
- T10, cli `lifecycle`: **test changed** (sage-2615 asked about this one). The `credential-file` came from
`startBroker`, not from the delete and not from Gitea. The seeded host grant gives `sec-1` a `host.web1`
store reference. `Credentials` checks the store root (`<config dir>/secrets/agents`) with `privateDir` at
start, and the fixture had no store root. That check is row 199's, and it is right to fail closed. The
test now makes the store root at 0700 under `dirname(p.f.config)`, as the bus host fixtures do, never
under the real config. sage-2575's condition still holds. This business has no Gitea grant, so
`#grantSet` adds no `gitea` entry (`#giteaPending` and `#giteaUnfired` read only the store), and step G
never reaches `#stepGGitea`. A delete with no Gitea grant reads no Gitea credential file.
### b4a: the M1 hang's cause (mos-1273, sage-2636)
The first M1-M21 run on 06ee5ed6b hung on M1. All 22 tests ended in about 80 s (16 fail, 5 pass, 1
cancelled), but the file's process stayed alive until the harness killed it at 1762 s. This is a test fault,
and no code changed.
- Cause: T6c holds `web1:add`, and the fake `install` loops in `pause()` on `sleep(5)` while the key is
held. Only the fire's abort signal can end that loop, and M1 removes the host revoke that sends the
abort. In M1's log, T6c fails at its `until` on line 683, before it deletes the key on line 691. So the loop keeps a
timer alive forever. That reading comes from the log and the file. The rerun checks it: before M1-M21 it runs M1
once without `--test-force-exit`, and that run must exit by itself inside 180 s.
- Fix: setup's `t.after` clears `held` and `failing` before it closes the store, so any held loop ends
with its test. The base is unchanged because every passing test already releases its keys.
- Sage's second candidate, the Gitea `gate` in T6c and T10, isn't a live handle. The fake server's
`t.after` closes all its connections, and a parked promise with no timer doesn't keep node alive. Both
tests now also release the gate in `t.after` (`go()` only, not `off()`: a second `off()` would splice
index -1 and remove another rule).
- The runner's 180 s timeout with `--test-force-exit` (sage-2633) still caps any future hang.
### b4: T6b pins the four reads (dewey-0839, sage-2627)
- T6b: **test changed**, stricter. `slice(calls).every(GET)` also passed with zero calls. It now
deep-equals the exact list in order: `GET /api/v1/version`, `GET /api/v1/user`, `GET /api/v1/token`,
`GET /api/v1/user/repos?limit=1&page=1` (step 0's C1 to C4, `gitea-grant.mjs` adminChecks). It also fails
if step 1 stops ending the run and step 2's reads go out. The title says "before any Gitea write".
### T14 and M22: a declined decision on a removed host (sage-2653, dewey-0844, N1)
Test only, written before the slot. Not run yet.
- T14 (new, in `fire-revokes-grants.test.mjs`): coder's web1 decision is raised and left undecided. The human
then runs `hosts.remove web1`, which goes through because hosts.remove doesn't check open decisions (N1).
The fire starts a job whose set is `host:web1`. Step G refuses `host-unknown` with one
`grant.revoke.refused`, and one inbox message names the job, web1 and host-unknown. There's no fire.done.
The human resolves the decision `no`. No executor runs (`#approvedGrant` is null for a no), so the closed
check in `#openAgentDecisions` is the only thing that takes it out of the set. The repeat fire answers
with no job and records one fire.done with no job.
- M22 deletes that closed check (`broker.mjs:1684`). The declined decision stays open, so the repeat fire
starts a second job and refuses host-unknown again, and T14 fails on `repeat.job`. M10 mutates the same
line in another way (approved-only), and T6d kills it. M22 runs on the fire-revokes file like M1-M21, and
its anchor makes 29.
- N1's hint text (hosts.remove naming the open decision) is a source change. It stays out of this row and
goes to a DEFERRED line that Sage files at landing.
- If T14 is red on its first run, it goes to Sage and then Mos untouched, because 281 has had its one placed red
(mos-1272).
### Gate plan (sage-2649, dewey-0840, mos-1275)
Written before the slot. The slot runs on row281-e after the rebase onto 278's landing, and every step
stops at the first red.
1. The base: the touched files whole (fire-revokes-grants, lifecycle-revoke, grant-executor, gitea-grant
in bus; lifecycle in cli). Then the 29 anchors, the M1 exit check without `--test-force-exit`, and
M1-M22 under `timeout -k 10 180` with `--test-force-exit`.
2. dewey-0840's files whole: bus chief-of-staff, lifecycle-verbs, reload, grant-host and
gitea-grant-docker; cli instances; harness tools. If gitea-grant-docker skips, the report names the skip
and its reason.
3. bus, business and cli whole.
4. Every `scripts/test-*.sh`, each as `timeout -k 10 LIMIT bash scripts/test-X.sh`, and no script is
edited. LIMIT is Gander's row 278 limit (`agents/gander/work/queue-278/SHELL-LIMITS.json`, sage-2646): 3x
the longest successful run from Gander's row 196 receipts, plus 120 s headroom. I checked it against two
more records. One is my row 236 gate at bd5f11ded (10-22). The other is 278's run 3
(`run3/whole/results.json`). Neither has a longer run, so every limit is at least 3x the longest of all
three. A script with no limit stops the gate before it starts. Any timeout goes to Sage, then Mos,
unplaced.
| Script | Row 236 | 196 receipts | 278 run 3 | Longest | 3x | LIMIT |
|---|---|---|---|---|---|---|
| test-auth | 1 s | 1.9 s | 0.8 s | 1.9 s | 6 s | 126 s |
| test-conductor | 6 s | 8.1 s | 6.1 s | 8.1 s | 25 s | 145 s |
| test-config | 1 s | 3.2 s | 1.9 s | 3.2 s | 10 s | 130 s |
| test-discord | 8 s | 8.8 s | 7.4 s | 8.8 s | 27 s | 147 s |
| test-extension-package | 1 s | 3.0 s | 1.4 s | 3.0 s | 10 s | 130 s |
| test-foundation | 39 s | 39.7 s | 39.9 s | 39.9 s | 120 s | 240 s |
| test-land-work | 11 s | 12.6 s | 11.3 s | 12.6 s | 38 s | 158 s |
| test-queue | 20 s | 23.4 s | 21.2 s | 23.4 s | 71 s | 191 s |
| test-release | 2 s | 12.2 s | 2.7 s | 12.2 s | 37 s | 157 s |
| test-task | 14 s | 36.8 s | 13.8 s | 36.8 s | 111 s | 231 s |
## Runs
The manifest-round slot on row281-e 7979fb41e (base 385a7194c, row 278's landing), CLEAR sage-2661. Runner
`r281-slot.sh` (scratch), from `scratch/megavolt-r222`: 12:43:17Z to 12:59:25Z, 10-22, SLOT_EXIT 0. No red,
no timeout. Every step ran in a detached worktree at the candidate, with TMPDIR under
`/mnt/storage/scratch/tmp`. The gate worktree was clean at the end, and the trap removed both worktrees.
1. The base, each file whole, plain `node --test`: bus fire-revokes-grants, lifecycle-revoke,
grant-executor and gitea-grant 121/121, 0 skipped, 22 s; cli lifecycle 24/24, 0 skipped, 13 s. T14 passed
on its first run. All 29 anchors matched once. The M1 exit check exited by itself: rc 1, 17 fails (the 16
on 3f29375a8, plus T14), 63 s. M1-M22: 22 killed, 0 survived (Mutants).
2. dewey-0840's files whole: bus chief-of-staff, lifecycle-verbs, reload, grant-host and gitea-grant-docker
76/76, 0 skipped, 11 s (gitea-grant-docker ran, with no skip); cli instances 14/14, 0 skipped, 3 s; harness
tools 23/23, 0 skipped.
3. Whole packages: bus 819/819, 23 s; business 231/231, 3 s; cli 837/837, 591 s. Each has 0 skipped.
4. The shells, each under `timeout -k 10 LIMIT`, all rc 0:
| Script | Result | Time | LIMIT |
|---|---|---|---|
| test-auth | 15 passed, 0 failed | 1 s | 126 s |
| test-conductor | 17 passed, 0 failed | 6 s | 145 s |
| test-config | 31 passed, 0 failed | 1 s | 130 s |
| test-discord | 81 passed, 0 failed | 8 s | 147 s |
| test-extension-package | 18 passed, 0 failed | 1 s | 130 s |
| test-foundation | 44 passed, 0 failed | 36 s | 240 s |
| test-land-work | 86 passed, 0 failed | 10 s | 158 s |
| test-queue | node 156/156; 28 passed, 0 failed | 20 s | 191 s |
| test-release | 14 passed, 0 failed | 3 s | 157 s |
| test-task | 91 passed, 0 failed | 13 s | 231 s |
Skips, from a grep of every shell log for `^skip`, `# skip` and `SKIP` (dewey-0845):
- test-task, lines 87 and 93: `skip live provider cases (row 244: no provider call in the gate)`. Row 244
keeps provider calls out of the gate by design. The log has no Docker lines and no other skip.
- test-queue, line 34: `skip queue verify and render --check: this checkout (.../r281-gate-wt) is not the
queue's canonical root (/mnt/storage/src/mosaic-stack)`. The gate runs in a worktree, so the canonical-root
checks are skipped.
- No other shell log, and no node suite above, has a skip.
## Mutants
Run on b4a 3f29375a8 (base 6af85df4b), 12:16:18Z to 12:20:15Z, 10-22, slot sage-2643; runner
`r281-mutants.sh` (scratch). Base first, plain `node --test`, each file whole: bus fire-revokes-grants,
lifecycle-revoke, grant-executor and gitea-grant 120/120, 0 skipped; cli lifecycle 24/24, 0 skipped. The
worktree was clean after the base, and all 28 sed anchors matched once in `broker.mjs`.
The M1 exit check (mos-1273) applied M1 and ran fire-revokes-grants without `--test-force-exit`. It exited by
itself, rc 1, with 16 fails, in 63 s. The first run on 06ee5ed6b hung there until it was killed at 1762 s
(b4a above).
Each mutant ran fire-revokes-grants.test.mjs whole under `timeout -k 10 180 node --test --test-force-exit`
(sage-2633). Every one ran all 21 tests, with no load error and no timeout.
| Mutant | Change | Verdict | rc | Fails of 21 | Time | Section 9 names | Failing tests (from the log) |
|---|---|---|---|---|---|---|---|
| M1 | skip-host-revokes | KILLED | 1 | 16 | 63 s | T2 | T2 T3 T3b×2 T4 T4b×2 T6 T6c T6d T6e T9 T10 T11 T12 T13 |
| M2 | skip-gitea-revoke | KILLED | 1 | 12 | 12 s | T2 | T2 T3 T3b×2 T4 T5 T5b T6b T6c T6d T9 T10 |
| M3 | refusal-goes-on | KILLED | 1 | 3 | 3 s | T4 | T4 T5 T5b |
| M4 | delete-skips-step-g | KILLED | 1 | 6 | 3 s | T3 | T3 T3b×2 T6e T10 T13 |
| M5 | host-local-first | KILLED | 1 | 4 | 3 s | T4 | T2 T3b×2 T4 |
| M6 | service-left-done | KILLED | 1 | 1 | 3 s | T5b | T5b |
| M7 | set-no-open-decisions | KILLED | 1 | 4 | 3 s | T6, T6b | T6 T6b T6c T6d |
| M8 | skip-line-retry | KILLED | 1 | 3 | 4 s | T4b | T3b T4b×2 |
| M9 | line-retry-refuses | KILLED | 1 | 2 | 3 s | T4b | T3b T4b |
| M10 | approved-only | KILLED | 1 | 1 | 3 s | T6d | T6d |
| M11 | raise-no-fired-check | KILLED | 1 | 3 | 3 s | T6e | T4 T6e T12 |
| M12 | no-lock | KILLED | 1 | 3 | 3 s | T9 | T3 T6e T9 |
| M13 | verb-awaits-job | KILLED | 1 | 14 | 3 s | T10 | T2 T4 T4b×2 T5 T5b T6 T6b T6c T6d T10 T11 T12 T13 |
| M14 | no-executor-wait | KILLED | 1 | 1 | 3 s | T6c | T6c |
| M15 | business-wide-sweep | KILLED | 1 | 1 | 4 s | T5b | T5b |
| M16 | no-start-message | KILLED | 1 | 2 | 3 s | T3 | T3 T3b |
| M17 | repeat-grants-revoking | KILLED | 1 | 1 | 3 s | T3b | T3b |
| M18 | empty-set-job | KILLED | 1 | 4 | 4 s | T3c | T3 T3b×2 T3c |
| M19 | step-c-consume | KILLED | 1 | 1 | 4 s | T11 | T11 |
| M20 | bc-as-internal | KILLED | 1 | 2 | 3 s | T12 | T6e T12 |
| M21 | no-interrupted-check | KILLED | 1 | 1 | 4 s | T13 | T13 |
Every mutant fails the test section 9 names for it; none was killed only by a different test. "T3b×2" and "T4b×2" are the two tests that share each id. The failing-test ids are taken from each log's "failing tests" list (dewey-0841).
21 killed, 0 survived. b4 makes one T6b assertion stricter after this run, and fire-revokes-grants is
rerun whole on b4 (Runs).
### On row281-e 7979fb41e (sage-2661)
12:43:17Z to 12:47:12Z, 10-22, the first part of the manifest-round slot (Runs). Same runner and flags as
above, with M22 added (T14 and M22 in the build notes). Every mutant ran all 22 tests, with no load error and
no timeout.
| Mutant | Change | Verdict | rc | Fails of 22 | Time | Section 9 names | Failing tests (from the log) |
|---|---|---|---|---|---|---|---|
| M1 | skip-host-revokes | KILLED | 1 | 17 | 62 s | T2 | T2 T3 T3b×2 T4 T4b×2 T6 T6c T6d T6e T9 T10 T11 T12 T13 T14 |
| M2 | skip-gitea-revoke | KILLED | 1 | 12 | 13 s | T2 | T2 T3 T3b×2 T4 T5 T5b T6b T6c T6d T9 T10 |
| M3 | refusal-goes-on | KILLED | 1 | 4 | 3 s | T4 | T4 T5 T5b T14 |
| M4 | delete-skips-step-g | KILLED | 1 | 6 | 3 s | T3 | T3 T3b×2 T6e T10 T13 |
| M5 | host-local-first | KILLED | 1 | 4 | 3 s | T4 | T2 T3b×2 T4 |
| M6 | service-left-done | KILLED | 1 | 1 | 3 s | T5b | T5b |
| M7 | set-no-open-decisions | KILLED | 1 | 5 | 3 s | T6, T6b | T6 T6b T6c T6d T14 |
| M8 | skip-line-retry | KILLED | 1 | 3 | 3 s | T4b | T3b T4b×2 |
| M9 | line-retry-refuses | KILLED | 1 | 2 | 3 s | T4b | T3b T4b |
| M10 | approved-only | KILLED | 1 | 2 | 3 s | T6d | T6d T14 |
| M11 | raise-no-fired-check | KILLED | 1 | 3 | 3 s | T6e | T4 T6e T12 |
| M12 | no-lock | KILLED | 1 | 3 | 3 s | T9 | T3 T6e T9 |
| M13 | verb-awaits-job | KILLED | 1 | 15 | 3 s | T10 | T2 T4 T4b×2 T5 T5b T6 T6b T6c T6d T10 T11 T12 T13 T14 |
| M14 | no-executor-wait | KILLED | 1 | 1 | 3 s | T6c | T6c |
| M15 | business-wide-sweep | KILLED | 1 | 1 | 3 s | T5b | T5b |
| M16 | no-start-message | KILLED | 1 | 2 | 3 s | T3 | T3 T3b |
| M17 | repeat-grants-revoking | KILLED | 1 | 1 | 3 s | T3b | T3b |
| M18 | empty-set-job | KILLED | 1 | 4 | 3 s | T3c | T3 T3b×2 T3c |
| M19 | step-c-consume | KILLED | 1 | 1 | 3 s | T11 | T11 |
| M20 | bc-as-internal | KILLED | 1 | 2 | 3 s | T12 | T6e T12 |
| M21 | no-interrupted-check | KILLED | 1 | 1 | 3 s | T13 | T13 |
| M22 | no-closed-check | KILLED | 1 | 1 | 3 s | T14 | T14 |
22 killed, 0 survived. Every mutant fails the test it's named for, and M22 fails T14 alone. Compared with
3f29375a8, the only new failures are T14's (under M1, M3, M7, M10, M13 and M22), and every other failing set is
unchanged.
+6
View File
@@ -1519,3 +1519,9 @@ are never rewritten or removed; corrections are new entries.
2026-10-22 10:30:51 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 274 landing, row 281 d6 and 264a r5 plans | 274 landed 4c485c63b (ff, #1779 closed c30491, P19 414 c646); review request posted for Morgana (no token) c30488; 281 d6 approved by Dewey, code started; 264a r5 c4c75b6b4/5cb234285 run plan accepted; N7 DEFERRED (runs suite gates mosaic-task.mjs) committed
2026-10-22 10:59:30 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 256 #1761 creative delete landing | 256 landed f12441d4f (ff on 0165be266, 4 Honker commits); Webby APPROVE c30519 posted as jarvis for Webby (no token), rev 3185; request c30518; note 3186, done 3187; #1761 closed c30520; P19 416 c649; 277 rebases onto this landing next (sage-2594)
2026-10-22 11:21:00 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 277 #1782 owner thread claims landing | 277 landed 62f53b1e0 (ff on 46dedf925, 7 Morgana commits); Duckworth APPROVE c30539 posted as jarvis, rev 3192; request c30538; note 3193, done 3194; #1782 closed c30540; P19 506 c652; Honker 256 and Megavolt 236 BUILD.md notes picked; DEFERRED M8 resolve pin
2026-10-22T11:25Z | Dewey (Opus 5.5, 84fb346c) | Vikunja 502 rebase carry onto 980cec88c (Rocko 407c8d7be/04a86cf33) | APPROVE 9bf4683c6 carried: range-diff 1:= 2:=, patch-ids 37135ebe/0dbcda54; conditional on green touched-file and Python reruns on the new base (f5611c937 drift under stop-run); dewey-0834/0835
2026-10-22 11:29:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | Vikunja 502 on 980cec88c | clean picks407c8d7be/04a86cf33, patch-ids match; whole Node28/28,Python4/4,no skips/timeouts,sweepclear; rerun packet6af85df4b pushed explicitly to rocko/vikunja502-980c-20261022; Sage lands.
2026-10-22T13:06Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) manifest round 1 on megavolt/row281-e e479521d3 (manifest b73d4c81, 7 paths, on 385a7194c) | APPROVE c30614, rev 3207; N1 hosts.remove/open-decision hint and N2 fire-tool text non-blocking, DEFERRED via Sage
2026-10-22 11:32 UTC | Sage (Opus 5.5, 1ef1e4f8) | Vikunja 502 landing; 281 and 264a slot reds placed | V502 LANDED 6af85df4b (ff from 980cec88c, Dewey carry dewey-0836). Row 281 c174f37ac and 264a r7 a23afbf95 each went red on first-run test defects. 281 b3 is test-only (T6b ruling accepted by Dewey 0837). 264a r8 pins the 3l seq-0 rows. Megavolt reruns 281 on 6af85df4b.
2026-10-22 13:10 UTC | Sage (Opus 5.5, 1ef1e4f8) | Rows 278 and 281 landings; slot order 281 then 264a r10 then 253 | 278 LANDED 385a7194c (Webby c30588), 281 LANDED e479521d3 (Dewey c30614). DEFERRED: grant gap closed, N1/N2 open. 264a rebasing to r10 on e479521d3; 253 waits on 264a.
2026-10-22 14:22 UTC | Sage (Opus 5.5, 1ef1e4f8) | Row 264a landing; doc sweep | 264a LANDED 84f53540d (Duckworth c30640, #1769 closed c30646, P19 463 c669) after four slot reds ruled in mos-1276 to mos-1279. Doc sweep: V502, 278/281 and 264a BUILD-LOG lines, DEFERRED 281 N1/N2 and 264a N1/N2. 253 rebases onto the landing next.
+45 -11
View File
@@ -431,17 +431,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
comes near 64 runs. The follow-up row either raises the cap or splits
the done. Its test: a fire's done over the cap refuses, and the runs it
left alive still get the alive flag. (2026-10-22)
- **A fired or deleted instance keeps its grants.** (Dewey on the row 236
rebase look, dewey-0815; Mos mos-1271; Sage sage-2558.) Row 194's
`instance.fire` and row 256's delete revoke only the roles entry's
credential services through `revoke()` in `runtime.mjs`. The agent's
host grants (row 235) and its Gitea user and token (row 236) stay live
until a human runs `mosaic hosts revoke` or `mosaic grants revoke`. The
Console has no revoke route and doesn't say a grant is left. Row 281
(#1786) closes it: a fire or a delete revokes every host and Gitea grant
the instance holds, fail closed, with a test for each kind. It lands
before the .53 Case 1 install hires an instance with a grant.
(2026-10-22)
- **The runs suite doesn't gate a change to `scripts/mosaic-task.mjs`.**
(Dewey N7 on row 191, repeated on the Vikunja 502 review.)
@@ -460,6 +449,44 @@ at every gate. Started 2026-09-12 during the control board MVP.
changes `packages/cli/tests/tester.test.mjs`: add the resolve pin so M8
dies on an assertion. (2026-10-22)
- **Row 274's user reply check is scoped to the thread, not the session.**
(Row 264a r6, sage-2591.) In `packages/bus/src/broker.mjs`, the check that
a run answers a user only in reply to that user's message in its thread
joins `message_threads` by thread. That holds while a user's thread has
only its default session: a user can't call `session.create`, and
`#thread` refuses anyone but the owner. Binds row 272 (264e, #1777), or
any row that lets a user add sessions: make the check session-scoped and
test it with two sessions. (2026-10-22)
- **Row 281's refusal texts point the wrong way.** (Dewey N1 and N2 on
the row 281 manifest round, dewey-0842/0843 and dewey-0846; Sage
sage-2653.) N1: when a host decision is still open and the host is
removed, step G's fire or delete job refuses `host-unknown`, and the
inbox hint names a verb that can't help. The way out is to decline or
withdraw the decision (`packages/bus/src/broker.mjs`, the closed check
near :1684 and `decision.withdraw` near :5015). T14 now pins that path.
N2: the chief's fire tool text in `packages/harness/src/tools.mjs`
(near :186) doesn't say the fire's job can come back for an answer.
Neither changes behaviour. Binds the next row that changes the step G
hint text in `broker.mjs` or the fire tool text in `tools.mjs`: fix
both wording gaps and pin the hint in a test. (2026-10-22)
- **Row 264a's refusal check in sessions.test doesn't pin the actor.**
(Duckworth N1 on the row 264a r12 delta, duckworth-0138; Sage sage-2695.)
`refusedOnce` in `packages/bus/tests/sessions.test.mjs` checks that each
refused call adds one `action.refused` event with code `not-holder`, but
not that the event's `actor_run` is the refused run. Binds row 269 (264b,
#1774), or the next row that changes `sessions.test.mjs`: pin `actor_run`
in `refusedOnce`. (2026-10-22)
- **editor-provenance skips its rebuild check on esbuild 0.28.1.** (Duckworth
N2 on the row 264a manifest round, duckworth-0146.) The webui test
"rebuilding from the source gives the same bytes" needs esbuild 0.28.2,
the checkout has 0.28.1, and the gate logs a skip (webui.log:99). The file
is at base and was not in 264a's manifest. Binds the next gate that
touches the webui build: install the pinned esbuild in the gate checkout
so the check runs, or record why it can't. (2026-10-22)
## Queue
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
@@ -531,3 +558,10 @@ Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
`packages/cli/tests/gate-lane-auth.test.mjs` proves no gate step mounts
the host default. The real-model cases this moved off the gate are open
above.
- A fired or deleted instance kept its grants (2026-10-22, Dewey on the
row 236 rebase look, mos-1271): closed by row 281 (#1786), landing
e479521d3. A fire or a delete now runs a step G job that revokes every
host and Gitea grant the instance holds, fail closed, and tells the
human in the inbox when it can't. Megavolt built it; Dewey approved the
design (d6) and the manifest (c30614). The text gaps it left are open
above.
+4 -4
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -300,7 +300,7 @@ Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not e
| 261 | Email E05: Graph source module against a test-local registry (post-v1) | unassigned | #1766 | queued | 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E05 Graph source module against a test-local registry | — |
| 262 | Email E06: IMAP source module (post-v1) | unassigned | #1767 | queued | 261 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E06 IMAP source module | — |
| 263 | Email E07: address and rule evaluator (post-v1) | unassigned | #1768 | queued | 258 done, 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E07 address and rule evaluator | — |
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | in-progress | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat). |
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | done | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695). |
| 265 | Case 2 C2-09b rooms: room.open, room.send with @tags, room.read | rocko; reviewers dewey | #1770 | queued | 219 done | Dewey approves the manifest. The brief's tests run whole plus the bus suite and every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09b rooms: room.open, room.send with @tags, room.read | — |
| 266 | Case 2 C2-10c Console room view: merged timeline and @name completion | researcher; reviewers dewey | #1771 | queued | 225 done, 265 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every touched suite. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10c Console room view: merged timeline and @name completion | Haiku high subagent under decision 129. |
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | queued | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
@@ -314,10 +314,10 @@ Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not e
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | briefed | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | — |
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
| 277 | The owner sees a thread agent's claims: an owner or reader read lists held thread claims (10-27, mos-1262; no user thread live before it) | morgana; reviewers duckworth | #1782 | done | 205 done | Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the owner sees thread claims | LANDED 62f53b1e0 ff from 46dedf925 (7 Morgana commits, range patch-id 7f0533520bd2). Duckworth APPROVE manifest e281aa2c (10 paths), comment 30539. Gate 11:01-11:14Z: 7 touched files 0 fail, bus 793, cli 836, harness 151, business 231, seat 167, 10 scripts rc=0, M1-M9 killed; skips named, env only (test-queue root, test-task live x2). Overlap 13/257 clean. N3 per-session claims carry to 264a r7. |
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | briefed | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | — |
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | done | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes. |
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | done | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | LANDED ceadad993 (carry of cad927137 onto 343b6dd76: range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey APPROVE dewey-0809 c30409 + carry). Fix: stopRun loop is for(;;), deadline branch is the only time exit. Sage gate green: business 209/0/0, cli 794/0/0, discord processing+stop 9/0/0; Rocko stop-run 13/13, processing-control 62/62, M1 killed. Overlap sweep clean (13, 257). Next: 194 b16 rebases onto it (mos-1269). |
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | in-progress | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | — |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | done | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614. |
<!-- mosaic-queue:end -->
+698 -21
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 3194,
"revision": 3216,
"rows": [
{
"id": 1,
@@ -26839,7 +26839,7 @@
"closes": [
1769
],
"state": "in-progress",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -26854,16 +26854,70 @@
"reviewers": [
"duckworth"
],
"review": null,
"claim": {
"seat": "gizmoduck",
"op": "gizmoduck-264-claim-1"
"review": {
"rounds": [
{
"n": 1,
"op": "gizmoduck-264-review-r13b",
"by": "gizmoduck",
"at": "2026-10-22T14:11:07.284Z",
"issue": 1769,
"candidate": {
"kind": "manifest",
"digest": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e",
"text": "0f14a8e813a75abc8ab2a49d4574496e68a83ae3a37019c5bbfbbbc439fbe2ab agents/gizmoduck/work/queue-264/BUILD.md\nee456d3f7f5f36c499b1144f5c1a51bd3400662bd576c0a2c403a70cd49772c8 docs/plans/DEFERRED.md\n764b3d7865bfa3a0829f3c7d5553446fc61478cc8b46c0da5dd9463ea7ef1543 packages/bus/README.md\ncd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a packages/bus/schema.sql\na581d9e687ec2784751e293bd320885ba2ee75cbff38dd0f7f0816c3b6ccf1f1 packages/bus/src/broker.mjs\neb2822c73e527697399691ae520092277ddbd4d2a9a42a0731b3c81cc3569e58 packages/bus/src/migrations/3k-3l.sql\nca4a8eceef394dae290a5ad7bdb9f335ebbe53ba02aa3af71be7001ba2725a93 packages/bus/src/store.mjs\n4e2f46031bb2025bb7d674a7da5b663312ed827d346f354df0453d3595fa8781 packages/bus/tests/account-migration.test.mjs\necf7536dbb12765ef30897fbd0436f68d11233da7e79ff12d4cf80baf3b961fd packages/bus/tests/chief-of-staff.test.mjs\n7d8ddb1f603bda89488e521e27b68ada373efb19f23864da78613eb14baf6f6d packages/bus/tests/comment-migration.test.mjs\n45b3a93609179a2f856a9f2620acca2f0b07ab239d2d5957a792d6c6fadeb0af packages/bus/tests/end-launch.test.mjs\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/tests/fixtures/schema-3k.sql\n06c33d2101278f71b315327d7be1650d97f828b0dc2aafcc28c73a57d0fe0024 packages/bus/tests/gitea-migration.test.mjs\nb7a560894b9a6eb999b80dc9b2989ac4745b568f74d0b52eec3ab0459b46ad2b packages/bus/tests/grant-migration.test.mjs\n6e41496a5d55705f484e074e2264a84f5299e04211cd1a2f384f18c5b4f6f9c5 packages/bus/tests/message-filter.test.mjs\nb1921b88ba6ee0c28b4d9652ff529abba4b7e8b12423e99cf7996c3e207ef3f5 packages/bus/tests/name-migration.test.mjs\n52e7de4bdf04f7787f70079f78017fd034abd968370059f22d859ce5a58e27a8 packages/bus/tests/schema.test.mjs\nc09cdcca750d44ae1cebd3e8970708d39edd70682b99eeb77e19258d9fa8c068 packages/bus/tests/sessions.test.mjs\nc2f9002a8ba4474fa1bafc84d8ffd67756d450cf9a1346194a871cd0d541d524 packages/bus/tests/spec-migration.test.mjs\n4fd9c541735a8e654bee84ecab0172f9b1f21c668091fe12624ce812919327e4 packages/bus/tests/tester-writes.test.mjs\n0007f55235ef25e70bf89cfb9407e07c4780b54566163183d19b1f902fc0251e packages/bus/tests/thread-claims.test.mjs\n19e4200961fd81a23f2bf7089f81b2a57165a6a81c026ef879ab756587d705dc packages/bus/tests/thread-migration.test.mjs\n90d25f35389478cd3307b2383d93dc0a2519dd88eddc3c26bd37c39161083269 packages/bus/tests/threads.test.mjs\ncdd758deb9bcbb33eb1f4138673a11b9a5fd82fdd4fd88c14197d5240fe4b780 packages/bus/tests/usage-migration.test.mjs\n43f09ab4e7d3f8178a6cab6889ce2d85c83b8151a0f0dc26325d39bbe4236dc0 packages/cli/tests/stack-release.test.mjs\ncf6c1310f32d8495b5838620760646abcff7be0d38924511e14c53528d581802 packages/cli/tests/thread-cli.test.mjs\n4d4885bc624056b559e4c5cbdd7650c4fdeedc83695c8f1884a0bfa27b00a0cf packages/cli/tests/thread-launch.test.mjs\n3e794faab71337d13a531564183b5e0fd316c666bffbe834c8e5eedf9fad4a6a packages/webui/tests/chat.test.mjs\nd03dd152cde23c895bfbd07f1df8084925ae6638854d3a99a6aca82779ad4f12 scripts/stack-release.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "gizmoduck-264-review-r13b",
"by": "gizmoduck",
"at": "2026-10-22T14:11:07.284Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-264-review-r13b",
"by": "sage",
"at": "2026-10-22T14:12:46.925Z",
"state": "posted",
"comment": 30639,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30639,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "duckworth",
"op": "duckworth-0146-record",
"at": "2026-10-22T14:17:52.013Z",
"verdict": "approve",
"comment": 30640,
"candidate": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e"
}
]
}
]
},
"note": "Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat).",
"claim": null,
"note": "LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695).",
"blockedReason": null,
"createdAt": "2026-10-21T22:42:17.481Z",
"updatedAt": "2026-10-22T00:04:18.160Z",
"updatedBy": "gizmoduck"
"updatedAt": "2026-10-22T14:19:39.888Z",
"updatedBy": "sage"
},
{
"id": 265,
@@ -27642,7 +27696,7 @@
"closes": [
1783
],
"state": "briefed",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -27662,12 +27716,69 @@
"reviewers": [
"webby"
],
"review": null,
"review": {
"rounds": [
{
"n": 1,
"op": "gander-278-inreview-r1",
"by": "gander",
"at": "2026-10-22T12:34:26.185Z",
"issue": 1783,
"candidate": {
"kind": "manifest",
"digest": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3",
"text": "426007525d15ad0fa11ba02d6cc5d8436c7ff3fa38beb01e9a651c202169d6a5 packages/webui/README.md\n7ac85787f614aabce4b8eccef01fcdbf1018003e806c5de97d40f47c4041710a packages/webui/src/cli.mjs\nc0306a4db82c286826b157cceb036496666f58ce8073d14280e58c10377ed0d0 packages/webui/src/public/bus.js\naa25b50543b5bc632ac984e85b0d8b43b1c9af2d617bfc7c19351d07310f396d packages/webui/src/serve.mjs\nfd7ad903b682f8f6ceee2d75111b01aa97f50233cd875dd5553e3e83de33ce32 packages/webui/tests/shell.test.mjs\n3b2770db03a2fcce87512b600f386a51f822b251ccb992a46208ee484ee99f7e packages/webui/tests/thread-claims-browser.test.mjs\n48117fe02ef9b76ad12f15dfb0d25e77d187c088c0bff572f33e18418e566913 packages/webui/tests/thread-claims.test.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "gander-278-inreview-r1",
"by": "gander",
"at": "2026-10-22T12:34:26.185Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-278-review-r1b",
"by": "sage",
"at": "2026-10-22T12:37:23.447Z",
"state": "posted",
"comment": 30587,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30587,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "webby",
"op": "webby-278-verdict-r1",
"at": "2026-10-22T12:38:51.557Z",
"verdict": "approve",
"comment": 30588,
"candidate": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3"
}
]
}
]
},
"claim": null,
"note": null,
"note": "LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes.",
"blockedReason": null,
"createdAt": "2026-10-22T05:15:43.015Z",
"updatedAt": "2026-10-22T05:16:17.583Z",
"updatedAt": "2026-10-22T12:40:51.151Z",
"updatedBy": "sage"
},
{
@@ -27860,7 +27971,7 @@
"closes": [
1786
],
"state": "in-progress",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -27880,16 +27991,70 @@
"reviewers": [
"dewey"
],
"review": null,
"claim": {
"seat": "megavolt",
"op": "megavolt-281-claim"
"review": {
"rounds": [
{
"n": 1,
"op": "megavolt-281-inreview-r1",
"by": "megavolt",
"at": "2026-10-22T13:01:27.161Z",
"issue": 1786,
"candidate": {
"kind": "manifest",
"digest": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56",
"text": "7ce8139b6ffa3077d858f0cb059f6d2b8d5c09f6a8acf867611d84830be35868 agents/megavolt/work/queue-281/BUILD.md\n5fb103dccf550a0d0568771ca3ddec47196d7f5f24775501c5aecff7599343db packages/bus/src/broker.mjs\n6fe79a481bb35c6ab5bcda487bcc346fcb812e8ed96bd62f7198ebb09e6d292a packages/bus/tests/fire-revokes-grants.test.mjs\n5f9a8b73b6df8b83db990f6c9f15935e51dcc00ddf59d10eabe0f1dfd23d718c packages/bus/tests/gitea-grant.test.mjs\n88c5193c723f9362008995b46b697b8f455574b3fc2ddc7f31f549082f941efd packages/bus/tests/grant-executor.test.mjs\ndd3d99d48d28e2a001a013bafb486385c777056ea9d29e9c5ec9e38174aa02cd packages/bus/tests/lifecycle-revoke.test.mjs\n5ce79205c4ca6e5f6e77671968ce9d8b8ebfdbdde07b31676f2d05b284acb938 packages/cli/tests/lifecycle.test.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "megavolt-281-inreview-r1",
"by": "megavolt",
"at": "2026-10-22T13:01:27.161Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-281-review-r1b",
"by": "sage",
"at": "2026-10-22T13:02:59.024Z",
"state": "posted",
"comment": 30613,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30613,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "dewey",
"op": "dewey-281-verdict-r1",
"at": "2026-10-22T13:05:20.490Z",
"verdict": "approve",
"comment": 30614,
"candidate": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56"
}
]
}
]
},
"note": null,
"claim": null,
"note": "LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614.",
"blockedReason": null,
"createdAt": "2026-10-22T09:44:24.187Z",
"updatedAt": "2026-10-22T09:51:21.600Z",
"updatedBy": "megavolt"
"updatedAt": "2026-10-22T13:07:09.587Z",
"updatedBy": "sage"
}
],
"log": [
@@ -112108,6 +112273,518 @@
"receipt": "ok sage-277-done rev 3194 row 277 in-review→done round 1"
},
"viewSha": "47105a02fc98e4b65b8f9b39fbbcfea3a7a295516e992e95c26f6e5f2d052fdb"
},
{
"rev": 3195,
"op": "gander-278-start",
"verb": "move",
"args": {
"id": 278,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "gander",
"at": "2026-10-22T12:04:51.778Z",
"semantics": 2,
"result": {
"row": 278,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok gander-278-start rev 3195 row 278 briefed→in-progress"
},
"viewSha": "d3e0918fab9cd369618d3cce25053035c3593cc157ef02b3e67824577540a383"
},
{
"rev": 3196,
"op": "gander-278-inreview-r1",
"verb": "move",
"args": {
"id": 278,
"to": "in-review",
"reason": null,
"candidate": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-278/MANIFEST.sha256",
"evidence": null,
"issue": null
},
"by": "gander",
"at": "2026-10-22T12:34:26.185Z",
"semantics": 2,
"result": {
"row": 278,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1783,
"candidate": {
"kind": "manifest",
"digest": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3",
"text": "426007525d15ad0fa11ba02d6cc5d8436c7ff3fa38beb01e9a651c202169d6a5 packages/webui/README.md\n7ac85787f614aabce4b8eccef01fcdbf1018003e806c5de97d40f47c4041710a packages/webui/src/cli.mjs\nc0306a4db82c286826b157cceb036496666f58ce8073d14280e58c10377ed0d0 packages/webui/src/public/bus.js\naa25b50543b5bc632ac984e85b0d8b43b1c9af2d617bfc7c19351d07310f396d packages/webui/src/serve.mjs\nfd7ad903b682f8f6ceee2d75111b01aa97f50233cd875dd5553e3e83de33ce32 packages/webui/tests/shell.test.mjs\n3b2770db03a2fcce87512b600f386a51f822b251ccb992a46208ee484ee99f7e packages/webui/tests/thread-claims-browser.test.mjs\n48117fe02ef9b76ad12f15dfb0d25e77d187c088c0bff572f33e18418e566913 packages/webui/tests/thread-claims.test.mjs\n"
},
"receipt": "ok gander-278-inreview-r1 rev 3196 row 278 in-progress→in-review round 1 on #1783; request gander-278-inreview-r1 requesting"
},
"viewSha": "10e0b103a4099660e8e0f332214db3ffec46d7e31afce0be052efa11b29aca43"
},
{
"rev": 3197,
"op": "gander-278-inreview-r1.outcome",
"verb": "review-outcome",
"args": {
"id": 278,
"attempt": "gander-278-inreview-r1",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "gander",
"at": "2026-10-22T12:35:03.307Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"attempt": "gander-278-inreview-r1",
"state": "failed",
"receipt": "ok gander-278-inreview-r1.outcome rev 3197 row 278 round 1 request gander-278-inreview-r1 failed"
},
"viewSha": "807e3fdfac503cb45c13b8963b38c9131d36b5c9fdc6a071525ca0a115b2c39b"
},
{
"rev": 3198,
"op": "sage-278-review-r1b",
"verb": "review-request",
"args": {
"id": 278
},
"by": "sage",
"at": "2026-10-22T12:37:23.447Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"attempt": "sage-278-review-r1b",
"state": "requesting",
"receipt": "ok sage-278-review-r1b rev 3198 row 278 round 1 request sage-278-review-r1b requesting"
},
"viewSha": "6ab64156b87571dcc2e7d9fe0b7750eff8f576419ad147e6af460fd9ba9018b3"
},
{
"rev": 3199,
"op": "sage-278-review-r1b.outcome",
"verb": "review-outcome",
"args": {
"id": 278,
"attempt": "sage-278-review-r1b",
"outcome": "posted",
"status": 201,
"comment": 30587,
"detail": "created"
},
"by": "sage",
"at": "2026-10-22T12:38:00.443Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"attempt": "sage-278-review-r1b",
"state": "posted",
"receipt": "ok sage-278-review-r1b.outcome rev 3199 row 278 round 1 request sage-278-review-r1b posted comment 30587"
},
"viewSha": "459834d1aec5d3abd4e09095e189ef39c9696265d30c85a6ee69b1668bfe773d"
},
{
"rev": 3200,
"op": "webby-278-verdict-r1",
"verb": "review-record",
"args": {
"id": 278,
"verdict": "approve",
"comment": 30588,
"candidate": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3"
},
"by": "webby",
"at": "2026-10-22T12:38:51.557Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"verdict": "approve",
"receipt": "ok webby-278-verdict-r1 rev 3200 row 278 round 1 approve by webby (comment 30588)"
},
"viewSha": "7bc559a0a0ee4cd7c6fb80e79dfc96ab797460da2121ec82e784cecb57b72780"
},
{
"rev": 3201,
"op": "sage-278-landed-note",
"verb": "note",
"args": {
"id": 278,
"text": "LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes."
},
"by": "sage",
"at": "2026-10-22T12:40:15.700Z",
"semantics": 2,
"result": {
"row": 278,
"field": "note",
"receipt": "ok sage-278-landed-note rev 3201 row 278 note"
},
"viewSha": "0c929f230fb391defb269606b2e29c1b3d49bc53723762270d49c71eedd95afb"
},
{
"rev": 3202,
"op": "sage-278-done",
"verb": "move",
"args": {
"id": 278,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T12:40:51.151Z",
"semantics": 2,
"result": {
"row": 278,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-278-done rev 3202 row 278 in-review→done round 1"
},
"viewSha": "b9a7f299fbd07afe042bec4491791dd511487e47577e2cf816ef3b00506aca98"
},
{
"rev": 3203,
"op": "megavolt-281-inreview-r1",
"verb": "move",
"args": {
"id": 281,
"to": "in-review",
"reason": null,
"candidate": "/mnt/storage/scratch/megavolt-r281/manifest-r1.txt",
"evidence": null,
"issue": null
},
"by": "megavolt",
"at": "2026-10-22T13:01:27.161Z",
"semantics": 2,
"result": {
"row": 281,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1786,
"candidate": {
"kind": "manifest",
"digest": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56",
"text": "7ce8139b6ffa3077d858f0cb059f6d2b8d5c09f6a8acf867611d84830be35868 agents/megavolt/work/queue-281/BUILD.md\n5fb103dccf550a0d0568771ca3ddec47196d7f5f24775501c5aecff7599343db packages/bus/src/broker.mjs\n6fe79a481bb35c6ab5bcda487bcc346fcb812e8ed96bd62f7198ebb09e6d292a packages/bus/tests/fire-revokes-grants.test.mjs\n5f9a8b73b6df8b83db990f6c9f15935e51dcc00ddf59d10eabe0f1dfd23d718c packages/bus/tests/gitea-grant.test.mjs\n88c5193c723f9362008995b46b697b8f455574b3fc2ddc7f31f549082f941efd packages/bus/tests/grant-executor.test.mjs\ndd3d99d48d28e2a001a013bafb486385c777056ea9d29e9c5ec9e38174aa02cd packages/bus/tests/lifecycle-revoke.test.mjs\n5ce79205c4ca6e5f6e77671968ce9d8b8ebfdbdde07b31676f2d05b284acb938 packages/cli/tests/lifecycle.test.mjs\n"
},
"receipt": "ok megavolt-281-inreview-r1 rev 3203 row 281 in-progress→in-review round 1 on #1786; request megavolt-281-inreview-r1 requesting"
},
"viewSha": "64bd164fc938ff48e88bbe1f73b1b2092a44b947ef319f97956425d99324c86b"
},
{
"rev": 3204,
"op": "megavolt-281-inreview-r1.outcome",
"verb": "review-outcome",
"args": {
"id": 281,
"attempt": "megavolt-281-inreview-r1",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "megavolt",
"at": "2026-10-22T13:02:03.649Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"attempt": "megavolt-281-inreview-r1",
"state": "failed",
"receipt": "ok megavolt-281-inreview-r1.outcome rev 3204 row 281 round 1 request megavolt-281-inreview-r1 failed"
},
"viewSha": "dffd6ed44dbe0fce968c061f6be08378dde897ddaa251c9b8dd6d2c293fb2db4"
},
{
"rev": 3205,
"op": "sage-281-review-r1b",
"verb": "review-request",
"args": {
"id": 281
},
"by": "sage",
"at": "2026-10-22T13:02:59.024Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"attempt": "sage-281-review-r1b",
"state": "requesting",
"receipt": "ok sage-281-review-r1b rev 3205 row 281 round 1 request sage-281-review-r1b requesting"
},
"viewSha": "b862b16cf093400a938b3506bace6fae82155c9e651734447760c73f0c20ff8e"
},
{
"rev": 3206,
"op": "sage-281-review-r1b.outcome",
"verb": "review-outcome",
"args": {
"id": 281,
"attempt": "sage-281-review-r1b",
"outcome": "posted",
"status": 201,
"comment": 30613,
"detail": "created"
},
"by": "sage",
"at": "2026-10-22T13:03:38.863Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"attempt": "sage-281-review-r1b",
"state": "posted",
"receipt": "ok sage-281-review-r1b.outcome rev 3206 row 281 round 1 request sage-281-review-r1b posted comment 30613"
},
"viewSha": "d4b562903728382f89434caf9e4f41d7ddbaf8daa313f6c5d174bbbab3731ca7"
},
{
"rev": 3207,
"op": "dewey-281-verdict-r1",
"verb": "review-record",
"args": {
"id": 281,
"verdict": "approve",
"comment": 30614,
"candidate": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56"
},
"by": "dewey",
"at": "2026-10-22T13:05:20.490Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"verdict": "approve",
"receipt": "ok dewey-281-verdict-r1 rev 3207 row 281 round 1 approve by dewey (comment 30614)"
},
"viewSha": "ed7c23dfa5ee98a2502e4b6410b2527b6f357c775cb6b2afa7386f7feafb50fd"
},
{
"rev": 3208,
"op": "sage-281-landed-note",
"verb": "note",
"args": {
"id": 281,
"text": "LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614."
},
"by": "sage",
"at": "2026-10-22T13:06:32.779Z",
"semantics": 2,
"result": {
"row": 281,
"field": "note",
"receipt": "ok sage-281-landed-note rev 3208 row 281 note"
},
"viewSha": "e777e0997655c84a7f3d842126db35fcb0d3e5b02a57aa9358eebda281f49e76"
},
{
"rev": 3209,
"op": "sage-281-done",
"verb": "move",
"args": {
"id": 281,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T13:07:09.587Z",
"semantics": 2,
"result": {
"row": 281,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-281-done rev 3209 row 281 in-review→done round 1"
},
"viewSha": "9ac1eeb17b185f038531d58b8ba85ccca0cd9607902614d3b1bc2f5e8085c0c0"
},
{
"rev": 3210,
"op": "gizmoduck-264-review-r13b",
"verb": "move",
"args": {
"id": 264,
"to": "in-review",
"reason": null,
"candidate": "/mnt/storage/scratch/gizmo-264-out/manifest-264-r13b.txt",
"evidence": null,
"issue": null
},
"by": "gizmoduck",
"at": "2026-10-22T14:11:07.284Z",
"semantics": 2,
"result": {
"row": 264,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1769,
"candidate": {
"kind": "manifest",
"digest": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e",
"text": "0f14a8e813a75abc8ab2a49d4574496e68a83ae3a37019c5bbfbbbc439fbe2ab agents/gizmoduck/work/queue-264/BUILD.md\nee456d3f7f5f36c499b1144f5c1a51bd3400662bd576c0a2c403a70cd49772c8 docs/plans/DEFERRED.md\n764b3d7865bfa3a0829f3c7d5553446fc61478cc8b46c0da5dd9463ea7ef1543 packages/bus/README.md\ncd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a packages/bus/schema.sql\na581d9e687ec2784751e293bd320885ba2ee75cbff38dd0f7f0816c3b6ccf1f1 packages/bus/src/broker.mjs\neb2822c73e527697399691ae520092277ddbd4d2a9a42a0731b3c81cc3569e58 packages/bus/src/migrations/3k-3l.sql\nca4a8eceef394dae290a5ad7bdb9f335ebbe53ba02aa3af71be7001ba2725a93 packages/bus/src/store.mjs\n4e2f46031bb2025bb7d674a7da5b663312ed827d346f354df0453d3595fa8781 packages/bus/tests/account-migration.test.mjs\necf7536dbb12765ef30897fbd0436f68d11233da7e79ff12d4cf80baf3b961fd packages/bus/tests/chief-of-staff.test.mjs\n7d8ddb1f603bda89488e521e27b68ada373efb19f23864da78613eb14baf6f6d packages/bus/tests/comment-migration.test.mjs\n45b3a93609179a2f856a9f2620acca2f0b07ab239d2d5957a792d6c6fadeb0af packages/bus/tests/end-launch.test.mjs\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/tests/fixtures/schema-3k.sql\n06c33d2101278f71b315327d7be1650d97f828b0dc2aafcc28c73a57d0fe0024 packages/bus/tests/gitea-migration.test.mjs\nb7a560894b9a6eb999b80dc9b2989ac4745b568f74d0b52eec3ab0459b46ad2b packages/bus/tests/grant-migration.test.mjs\n6e41496a5d55705f484e074e2264a84f5299e04211cd1a2f384f18c5b4f6f9c5 packages/bus/tests/message-filter.test.mjs\nb1921b88ba6ee0c28b4d9652ff529abba4b7e8b12423e99cf7996c3e207ef3f5 packages/bus/tests/name-migration.test.mjs\n52e7de4bdf04f7787f70079f78017fd034abd968370059f22d859ce5a58e27a8 packages/bus/tests/schema.test.mjs\nc09cdcca750d44ae1cebd3e8970708d39edd70682b99eeb77e19258d9fa8c068 packages/bus/tests/sessions.test.mjs\nc2f9002a8ba4474fa1bafc84d8ffd67756d450cf9a1346194a871cd0d541d524 packages/bus/tests/spec-migration.test.mjs\n4fd9c541735a8e654bee84ecab0172f9b1f21c668091fe12624ce812919327e4 packages/bus/tests/tester-writes.test.mjs\n0007f55235ef25e70bf89cfb9407e07c4780b54566163183d19b1f902fc0251e packages/bus/tests/thread-claims.test.mjs\n19e4200961fd81a23f2bf7089f81b2a57165a6a81c026ef879ab756587d705dc packages/bus/tests/thread-migration.test.mjs\n90d25f35389478cd3307b2383d93dc0a2519dd88eddc3c26bd37c39161083269 packages/bus/tests/threads.test.mjs\ncdd758deb9bcbb33eb1f4138673a11b9a5fd82fdd4fd88c14197d5240fe4b780 packages/bus/tests/usage-migration.test.mjs\n43f09ab4e7d3f8178a6cab6889ce2d85c83b8151a0f0dc26325d39bbe4236dc0 packages/cli/tests/stack-release.test.mjs\ncf6c1310f32d8495b5838620760646abcff7be0d38924511e14c53528d581802 packages/cli/tests/thread-cli.test.mjs\n4d4885bc624056b559e4c5cbdd7650c4fdeedc83695c8f1884a0bfa27b00a0cf packages/cli/tests/thread-launch.test.mjs\n3e794faab71337d13a531564183b5e0fd316c666bffbe834c8e5eedf9fad4a6a packages/webui/tests/chat.test.mjs\nd03dd152cde23c895bfbd07f1df8084925ae6638854d3a99a6aca82779ad4f12 scripts/stack-release.sh\n"
},
"receipt": "ok gizmoduck-264-review-r13b rev 3210 row 264 in-progress→in-review round 1 on #1769; request gizmoduck-264-review-r13b requesting"
},
"viewSha": "7a3ea1661ded736fbe138c569e5ab120d96bd48c8348a394f2da89f669bb7fc9"
},
{
"rev": 3211,
"op": "gizmoduck-264-review-r13b.outcome",
"verb": "review-outcome",
"args": {
"id": 264,
"attempt": "gizmoduck-264-review-r13b",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "gizmoduck",
"at": "2026-10-22T14:11:43.637Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"attempt": "gizmoduck-264-review-r13b",
"state": "failed",
"receipt": "ok gizmoduck-264-review-r13b.outcome rev 3211 row 264 round 1 request gizmoduck-264-review-r13b failed"
},
"viewSha": "cadd960bd868b8c63ecb7a1128b9e2628434706849bdcac6f2b8a24c26bc6701"
},
{
"rev": 3212,
"op": "sage-264-review-r13b",
"verb": "review-request",
"args": {
"id": 264
},
"by": "sage",
"at": "2026-10-22T14:12:46.925Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"attempt": "sage-264-review-r13b",
"state": "requesting",
"receipt": "ok sage-264-review-r13b rev 3212 row 264 round 1 request sage-264-review-r13b requesting"
},
"viewSha": "fe49d268532fe1223929e2c660b3acc815daf109b5607f7909d30c9d53bf18fb"
},
{
"rev": 3213,
"op": "sage-264-review-r13b.outcome",
"verb": "review-outcome",
"args": {
"id": 264,
"attempt": "sage-264-review-r13b",
"outcome": "posted",
"status": 201,
"comment": 30639,
"detail": "created"
},
"by": "sage",
"at": "2026-10-22T14:13:24.411Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"attempt": "sage-264-review-r13b",
"state": "posted",
"receipt": "ok sage-264-review-r13b.outcome rev 3213 row 264 round 1 request sage-264-review-r13b posted comment 30639"
},
"viewSha": "a7fb599ce8e5c0320b4b9dbd9f812934b1098406169790370dc37bce946668c1"
},
{
"rev": 3214,
"op": "duckworth-0146-record",
"verb": "review-record",
"args": {
"id": 264,
"verdict": "approve",
"comment": 30640,
"candidate": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e"
},
"by": "duckworth",
"at": "2026-10-22T14:17:52.013Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"verdict": "approve",
"receipt": "ok duckworth-0146-record rev 3214 row 264 round 1 approve by duckworth (comment 30640)"
},
"viewSha": "765bffd5742a3e0d8fac6bc4971ddba7fea0a937ce63529fd1ff3a82baa66d35"
},
{
"rev": 3215,
"op": "sage-264-landed-note",
"verb": "note",
"args": {
"id": 264,
"text": "LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695)."
},
"by": "sage",
"at": "2026-10-22T14:19:03.485Z",
"semantics": 2,
"result": {
"row": 264,
"field": "note",
"receipt": "ok sage-264-landed-note rev 3215 row 264 note"
},
"viewSha": "830e39c04975c14ffe061b4a292ac05b088574a6932f47bd91ac43d982e92ef6"
},
{
"rev": 3216,
"op": "sage-264-done",
"verb": "move",
"args": {
"id": 264,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T14:19:39.888Z",
"semantics": 2,
"result": {
"row": 264,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-264-done rev 3216 row 264 in-review→done round 1"
},
"viewSha": "e81b02877f6780a2cc04aaa05922ddcfec132716d03de5613412d8c6f85a579a"
}
]
}
+76 -11
View File
@@ -240,10 +240,14 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
| `thread.open` | `{thread}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,after?,limit?}`; human sessions only. The thread's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the thread), at most `limit` (default 50, 1 to 200). Writes nothing. |
| `thread.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
### Projects and threads (row 121, #1615)
@@ -281,7 +285,31 @@ any run but the thread's current holder.
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
another user's thread.
another user's thread, unless it is shared with them (Thread shares, below).
### Thread shares (row 206, #1704)
Schema `3m` adds `thread_shares`, append-only: one row per `share` or `unshare`, and the latest
row per thread and target is the share's state. A thread's owner shares it read-only with a
user (`user:<id>`), the business human or everyone (`*`). The 3l→3m step only adds the table,
so every existing thread stays private to its owner.
A session may read a thread it doesn't own only when all four hold (row 193 design 5.4):
1. a share is in force to the caller's owner string or to `*`;
2. a user caller has a live grant, of any level, covering the thread's project and instance;
3. a thread owned by a user is still covered by that user's grant;
4. neither the caller nor the owner is the tester.
Only a human principal matches a share: the business human, a reader session as that human, or
a user. An agent session matches none, `*` included, even when it is bound to the thread.
A reader session reads as the business human. A sharee can't call `session.list` on the
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
access document the broker reads then, so a removed, expired or unreadable grant or an
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`). Tests: `tests/thread-shares.test.mjs`.
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
@@ -311,6 +339,42 @@ the threads they made. A revoked run can't claim its thread again (`run-revoked`
thread run can still be stopped another way: the launcher stops it once the thread is idle, the
human stops it with `mosaic stop <run>`, and the run's end releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
### Sessions (row 264a, #1769)
Schema `3l` puts sessions below a thread. `sessions` and `session_defaults` are new and
append-only. `thread_claims`, `message_threads` and `thread_opens` each gain a `session`
column, and `thread_claims` gains `generation`, the claim's number in its session counting
from 1. A release or revoke carries its claim's number.
Every thread has a default session, the latest `session_defaults` row (D3). `thread.create`
writes one with a random id. The 3k→3l step gives each existing thread a default session whose
id is the thread's own, named `default` with op `legacy-3l`, and files every old claim, message
and open under it. A thread verb, or a `bindLaunch` record, that names no `session` works the
default, so a client from before 3l keeps working. A `session` from another thread refuses
`session-not-found`, and a non-thread role that names one refuses `thread-not-allowed`.
The thread rules above hold per session. One run holds a session at a time, and a run holds at
most one session in its business, while sibling sessions of a thread may each have a holder.
A thread run receives and replies in its own session, and the chief of staff's reply returns
to the session that sent the work. `threadsState` and `threadIdle` keep one row per thread,
computed on its default session; 264b's launcher starts the others.
A thread run's claim fences by generation (T2). A run acts only while its session's latest claim
row has the generation of the run's own latest claim. After a release or revoke, once another run
claims the session, the old run's `decision.withdraw`, `message.receive` and `message.read`
refuse `not-holder` and write nothing.
`thread.create` writes the default session with op `default`, so a `session.create` retry naming
`default` with op `default` answers with it. A migrated thread's default has op `legacy-3l`, and
the same retry there refuses `session-exists`.
The triggers check that a session has its thread's business, instance and owner, that its
predecessor and any default, claim, message or open name a session of the same thread, and that
a claim's generation is the one its session expects. `delivery_same_session` replaces
`delivery_same_thread`. `sessions_id_not_thread` and `threads_id_not_session` keep a session id
from naming another thread and a thread id from naming a session. A 3k engine refuses a 3l
store with `schema-mismatch`.
### Agent state (row 59, #1548)
For a human or reader session, `agents` derives each held role's state from
@@ -349,8 +413,9 @@ Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
`thread.claims` takes no arguments and lists the held thread claims in the
business, oldest thread first, as `{thread, instance, holder_run, harness,
at}`. A claim is held while its thread's latest `thread_claims` row is a
claim, so a released or revoked thread drops out. Human and reader sessions
at}`. A claim is held while its session's latest `thread_claims` row is a
claim (row 264a), so a released or revoked session drops out and a thread
with two held sessions lists twice. Human and reader sessions
only: a role session refuses `human-required` and the tester
`tester-refused`. The result carries the thread id but never its name, which
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
@@ -727,10 +792,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3k, SHA-256
`aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0`.
It has 20 tables, 72 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3k`, WAL,
`schema.sql` is v3m, SHA-256
`d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9`.
It has 23 tables, 90 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3m`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
+89 -11
View File
@@ -248,11 +248,34 @@ CREATE TABLE threads (
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
@@ -261,55 +284,95 @@ CREATE TABLE thread_claims (
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
@@ -415,3 +478,18 @@ CREATE TABLE gitea_grants (
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
-- Row 206 (193b2, #1704, row 193 design 5.2): a thread owner's read-only shares. The latest row per
-- (thread, target) is the share's state. Append-only.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
-- Row 264a (#1769, T1 item 7): sessions below a thread. Each existing thread gets one legacy default
-- session whose id is the thread id, and its claims, messages and opens name that session (D9's
-- mapping). thread_claims, message_threads and thread_opens are rebuilt with a session column, and
-- thread_claims gains generation, the claim's number in its session. Rows are copied with their seq
-- values and old columns as they were, and the sequences carry over, as 3h-3i did. The guards and the
-- delivery trigger that read the old tables are dropped first, so the renames rewrite nothing.
DROP TRIGGER thread_one_holder;
DROP TRIGGER thread_one_per_run;
DROP TRIGGER thread_release_by_holder;
DROP TRIGGER thread_release_same_run;
DROP TRIGGER thread_revoke_needs_decision;
DROP TRIGGER thread_claims_business;
DROP TRIGGER message_threads_match;
DROP TRIGGER delivery_same_thread;
DROP TRIGGER thread_claims_no_update;
DROP TRIGGER thread_claims_no_delete;
DROP TRIGGER thread_claims_no_replace;
DROP TRIGGER message_threads_no_update;
DROP TRIGGER message_threads_no_delete;
DROP TRIGGER message_threads_no_replace;
DROP TRIGGER thread_opens_no_update;
DROP TRIGGER thread_opens_no_delete;
DROP TRIGGER thread_opens_no_replace;
DROP INDEX thread_claims_thread;
DROP INDEX message_threads_thread;
ALTER TABLE thread_claims RENAME TO thread_claims_3k;
ALTER TABLE message_threads RENAME TO message_threads_3k;
ALTER TABLE thread_opens RENAME TO thread_opens_3k;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
INSERT INTO sessions(at,business,thread,id,instance,owner,name,op,predecessor)
SELECT at,business,id,id,instance,owner,'default','legacy-3l',NULL FROM threads ORDER BY seq;
INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads ORDER BY seq;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
INSERT INTO thread_claims(seq,at,business,thread,session,generation,op,holder_run,harness,address,by,reason,decision)
SELECT c.seq,c.at,c.business,c.thread,c.thread,
(SELECT count(*) FROM thread_claims_3k p WHERE p.thread = c.thread AND p.op = 'claim' AND p.seq <= c.seq),
c.op,c.holder_run,c.harness,c.address,c.by,c.reason,c.decision FROM thread_claims_3k c ORDER BY c.seq;
INSERT INTO message_threads(message,thread,session) SELECT message,thread,thread FROM message_threads_3k ORDER BY rowid;
INSERT INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,thread,by FROM thread_opens_3k ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name IN ('thread_claims','thread_opens');
INSERT INTO sqlite_sequence(name,seq) SELECT 'thread_claims', seq FROM sqlite_sequence WHERE name='thread_claims_3k';
INSERT INTO sqlite_sequence(name,seq) SELECT 'thread_opens', seq FROM sqlite_sequence WHERE name='thread_opens_3k';
DROP TABLE thread_claims_3k;
DROP TABLE message_threads_3k;
DROP TABLE thread_opens_3k;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
+15
View File
@@ -0,0 +1,15 @@
-- Row 206 (193b2, #1704, row 193 design 5.2): thread shares. Only a new table, its index and its
-- append-only triggers; no existing row changes, so every thread stays private to its owner.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+16 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0';
export const SCHEMA_VERSION = '3k';
export const SCHEMA_SHA256 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
export const SCHEMA_VERSION = '3m';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -66,6 +66,17 @@ const migrations = [{
from: '3j',
to: '3k',
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
}, {
// Row 264a (#1769): sessions below a thread. Each thread gets a default session whose id is the
// thread id, and thread_claims, message_threads and thread_opens are rebuilt to name it.
from: '3k',
to: '3l',
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
}, {
// Row 206 (193b2, #1704): thread shares. Only a new table; no existing row changes.
from: '3l',
to: '3m',
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -77,8 +88,11 @@ const timedTables = [
'task_snapshots',
'projects',
'threads',
'sessions',
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
'instance_names',
'usage_records',
];
+22 -7
View File
@@ -15,6 +15,10 @@ const AT = '2026-10-18T00:00:00.000Z';
const SEQUENCE = 50;
const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url), 'utf8');
const here = (p) => new URL(p, import.meta.url);
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -48,6 +52,15 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). An after-migration compare
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
// new row still fails.
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'session_defaults', seq: 0 }, { name: 'sessions', seq: 0 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
@@ -71,6 +84,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -78,9 +93,9 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3m');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
assert.deepEqual({ ...before.rows.sqlite_sequence.find(r => r.name === 'usage_records') }, { name: 'usage_records', seq: SEQUENCE });
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
@@ -90,7 +105,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
// Row 236 (#1742) adds 3j→3k's; its test is gitea-migration.test.mjs.
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1',
'gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
assert.deepEqual(master(path).filter(r => !later.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)).map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
const store = new Store(root);
try {
@@ -128,8 +143,8 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.equal(version(path), '3m');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -140,14 +155,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3l';")
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3k ready/);
assert.match(migrated.stdout, /bus schema 3m ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+8 -1
View File
@@ -8,6 +8,12 @@ import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { Client } from '../src/client.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
const businesses = {
demo: {
@@ -149,7 +155,8 @@ test('broker process: launch ops authorize role.launch, record refusals and end
const side = new Store(data);
side.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', '2026-10-15T00:00:00.000Z', 'demo', 'work', null, 'jason');
side.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'th-1', '2026-10-15T00:00:00.000Z', 'demo', 'p-1', 'coder', 'jason', 'one');
side.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', '2026-10-15T00:00:01.000Z', 'th-1', 'jason');
for (const sql of DEFAULT_SESSION) side.run(sql, 'th-1');
side.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', '2026-10-15T00:00:01.000Z', 'th-1', 'th-1', 'jason');
side.close();
const ready = await ask(child, {
op: 'boot',
@@ -0,0 +1,954 @@
// Row 281 (#1786, design d6 section 8): a fire or a delete revokes the instance's host and Gitea grants
// (step G), as a job after the verb answers. T2-T13. The broker runs bare, as chief-of-staff.test.mjs
// runs it, with a fake organization writer, a fake installer in place of the host's remote steps, and
// the fake Gitea from fake-gitea-api.mjs on 127.0.0.1. Every token is a fake the fake made; no test
// reaches git.mosaicstack.dev or any provider, and none creates a real account or token (Q188). Host
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
// test needs outside the human's yes go in by SQL as fixtures.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Broker, GRANT_EVIDENCE, registrySha256 } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { adminSha256, definitionSha256 as giteaDefinitionSha256, giteaGrant, giteaKeys, identitySha256 } from '../src/gitea-grant.mjs';
import { definitionSha256 as hostDefinitionSha256, grantKeys, grantTag, hostGrant } from '../src/host-grant.mjs';
import { Store } from '../src/store.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { grantDecision, hostRecord } from './host-fixtures.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const REV = 'b'.repeat(64);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
const ENTRY = { address: '127.0.0.1', port: 2222, user: 'deploy', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl'] };
const HOSTS = ['web1', 'web2', 'web3'];
const GENERATION = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69';
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error('timed out');
await sleep(10);
}
}
const rejects = (fn, code) => assert.rejects(async () => fn(), (e) => (assert.equal(e.code, code), true));
const code = (fn) => {
try {
fn();
} catch (e) {
return e.code;
}
return null;
};
function makeBusiness(url) {
const gitea = (name, user) => ({ name, email: `${user}@example.test`, gitea: { url, user } });
return {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: chiefAuthority },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none, identity: gitea('Coder', 'demo-coder'), publish: ['app'] },
helper: { definition: 'coder', ...none, identity: gitea('Helper', 'demo-helper'), publish: ['app'] },
ops: { definition: 'ops', ...none },
},
definitions: {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }, { service: 'host' }] },
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
},
projects: { app: { remote: `${url}/acme/app` } },
launch: { by: 'chief', instances: ['pm', 'coder', 'helper', 'ops'] },
vars: { 'bus.tester': true },
};
}
async function setup(t) {
const dir = mkdtempSync(join(tmpdir(), 'g281-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
gitea.addUser('acme');
gitea.addRepo('acme', 'app');
const configDir = join(dir, 'cfg');
mkdirSync(configDir, { mode: 0o700 });
const storeRoot = join(configDir, 'secrets', 'agents');
const runRoot = join(configDir, 'secrets', 'run');
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data'));
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
t.after(() => {
// A test that fails while a host step is held must not leave pause() looping after it ends.
held.clear();
failing.clear();
credentials.close();
store.close();
});
const doc = makeBusiness(gitea.url);
const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: 5000 });
const meta = { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT };
grantApi.writeAdmin('demo', gitea.adminToken, meta);
const gKeys = giteaKeys({ credentials, storeRoot });
const hKeys = grantKeys({ credentials, storeRoot });
// The fake installer: each host's lines as a set of tags. `held` keeps a host's add or remove from
// answering until the test deletes the key; `failing` makes every request to a host fail.
const lines = Object.fromEntries(HOSTS.map((h) => [h, new Set()]));
const sent = [];
const held = new Set();
const failing = new Set();
const pause = async (key, signal) => {
while (held.has(key)) {
if (signal?.aborted) throw new Error('aborted');
await sleep(5);
}
if (signal?.aborted) throw new Error('aborted');
};
const installer = {
...hostGrant({ configDir, runRoot }),
async install(business, host, record, tag, publicLine, signal) {
sent.push(`add ${host} ${tag}`);
await pause(host + ':add', signal);
if (failing.has(host)) throw new Error('host-failed');
lines[host].add(tag);
},
async verify(host, record, key, signal) {
if (signal?.aborted) throw new Error('aborted');
if (failing.has(host)) throw new Error('host-failed');
},
async remove(business, host, record, tag) {
sent.push(`remove ${host} ${tag}`);
await pause(host + ':remove');
if (failing.has(host)) throw new Error('host-failed');
lines[host].delete(tag);
},
};
// The fake organization writer. A fire delists the name and a delete drops the role, each with a
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse.
const org = { log: [], fireError: null };
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
let broker;
const organization = (business, op, args) => {
org.log.push([op, args?.name ?? args?.instance ?? null]);
if (op === 'instance.fire') {
if (org.fireError) throw org.fireError;
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
broker.reloadBusiness('demo', doc);
return { revision: REV, transaction: 't-' + org.log.length };
}
if (op === 'instances.set') {
delete doc.roles[args.instance];
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.instance);
broker.reloadBusiness('demo', doc);
return { revision: REV, transaction: 't-' + org.log.length };
}
return { revision: REV };
};
const make = () =>
new Broker({
store,
businesses: { demo: structuredClone(doc) },
secretCheck: (value) => credentials.assertClean(value),
organization,
processing: { read: () => sw.state },
hostGrant: installer,
hostKeys: hKeys,
giteaGrant: grantApi,
giteaKeys: gKeys,
});
broker = make();
for (const host of HOSTS) hostRecord(store, { host, entry: ENTRY });
const caps = {};
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = broker.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const grantEvents = () =>
store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
const allowed = (action) =>
store
.all("SELECT actor_role, subject, body FROM events WHERE business='demo' AND kind='action.allowed' ORDER BY seq")
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
.filter((e) => e.body.action === action);
// The bus's step G messages to the human: no decision, so no grant notice.
const inbox = () =>
store.all("SELECT body FROM messages WHERE business='demo' AND from_role='_bus' AND to_role='human' AND decision IS NULL ORDER BY rowid").map((r) => r.body);
const raiseHost = (agent, host, commands = [['uptime']]) =>
call('chief', 'decision.raise', {
action: 'credential.mint', question: `Grant ${agent} ${host}?`, options, recommendation: 'yes', blocking: false,
grant: { method: 'host', agent, host, commands, rotateBy: dayFrom(30) },
});
const raiseGitea = (agent) =>
call('chief', 'decision.raise', {
action: 'credential.mint', question: `Grant ${agent} Gitea?`, options, recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent, target: { projects: ['app'] }, rotateBy: dayFrom(30) },
});
const approve = async (d) => {
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => ended(d.id));
await broker.grantsIdle();
return ended(d.id);
};
const approveHost = async (agent, host) => {
const end = await approve(await raiseHost(agent, host));
assert.equal(end.action, 'grant.applied', `${agent} on ${host}`);
return end;
};
const approveGitea = async (agent) => {
const end = await approve(await raiseGitea(agent));
assert.equal(end.action, 'grant.applied', `${agent} gitea`);
return end;
};
// A host decision approved and never run, with R4's body built here (T6).
const fixtureHost = (agent, host, commands = [['uptime']]) =>
grantDecision(store, {
grant: {
method: 'host', agent, host, commands, commandsSha256: sha256(JSON.stringify(commands)), rotateBy: dayFrom(30),
registrySha256: registrySha256({ ...ENTRY, sudo: true }), definitionSha256: hostDefinitionSha256(doc.definitions[doc.roles[agent].definition]),
},
});
// A Gitea decision approved and never run (T6b).
const fixtureGitea = (agent) =>
grantDecision(store, {
grant: {
method: 'gitea', agent, target: { projects: ['app'] }, rotateBy: dayFrom(30),
definitionSha256: giteaDefinitionSha256(doc.definitions[doc.roles[agent].definition]),
identitySha256: identitySha256(doc.roles[agent], doc.projects),
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
},
});
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'the work is done' });
// A delete as the launcher's pass runs it: the human's request, the take, then lifecycleDelete. A
// refused delete closes the request with its code, as the launcher does.
const remove = (instance) => {
const { request } = call('human', 'instances.set', { revision: REV, instance, op: 'delete' });
assert.equal(broker.lifecycleTake({ business: 'demo', request }).go, true);
try {
return { request, ...broker.lifecycleDelete({ business: 'demo', request }) };
} catch (e) {
if (typeof e.code !== 'string') throw e;
broker.lifecycleDone({ business: 'demo', request, refusal: e.code });
return { request, code: e.code };
}
};
const tag = (agent, host) => grantTag('demo', agent, host);
const keyFile = (agent, host) => join(storeRoot, 'demo', agent, 'host.' + host);
const tokenFile = (agent) => join(storeRoot, 'demo', agent, 'gitea');
const hostRow = (agent, host) => store.get("SELECT * FROM host_grants WHERE business='demo' AND agent=? AND host=?", agent, host);
const giteaRow = (agent) => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent=?", agent);
const state = (agent, service) => credentials.status().find((e) => e.instance === 'demo/' + agent && e.service === service)?.state;
const userTokens = (login) => gitea.tokens(login) ?? [];
const revoked = (agent, where = () => true) => grantEvents().filter((e) => e.action === 'grant.revoked' && e.agent === agent && where(e));
const closed = (revoke) => grantEvents().filter((e) => e.action === 'grant.revoke.closed' && e.revoke === revoke);
const noLeak = () => {
const text = [
...store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body),
...store.all("SELECT body FROM messages WHERE business='demo'").map((r) => r.body),
JSON.stringify(gitea.requests),
].join('\n');
assert.ok(gitea.issued.every((tok) => !text.includes(tok)), 'a token value reached the store or the request log');
};
return {
dir, gitea, store, credentials, doc, org, sw, lines, sent, held, failing, caps, call, make, grantEvents, ended, allowed, inbox,
raiseHost, raiseGitea, approve, approveHost, approveGitea, fixtureHost, fixtureGitea, fire, remove, tag, keyFile, tokenFile,
hostRow, giteaRow, state, userTokens, revoked, closed, noLeak,
get broker() {
return broker;
},
};
}
// Coder's three grants: web1, web2 and Gitea, each through the human's yes and its executor.
async function threeGrants(f) {
await f.approveHost('coder', 'web1');
await f.approveHost('coder', 'web2');
await f.approveGitea('coder');
for (const h of ['web1', 'web2']) {
assert.ok(f.lines[h].has(f.tag('coder', h)));
assert.ok(existsSync(f.keyFile('coder', h)));
}
assert.equal(f.userTokens('demo-coder').length, 1);
assert.ok(existsSync(f.tokenFile('coder')));
}
// Every grant of coder is gone: records, key and token files, host lines, Gitea tokens, login.
function allRevoked(f, hosts = ['web1', 'web2']) {
for (const h of hosts) {
assert.equal(f.hostRow('coder', h), undefined, h);
assert.equal(existsSync(f.keyFile('coder', h)), false, h);
assert.equal(f.lines[h].has(f.tag('coder', h)), false, h);
assert.notEqual(f.state('coder', 'host.' + h), 'valid', h);
}
assert.equal(f.giteaRow('coder'), undefined);
assert.equal(existsSync(f.tokenFile('coder')), false);
assert.notEqual(f.state('coder', 'gitea'), 'valid');
assert.deepEqual(f.userTokens('demo-coder'), []);
assert.equal(f.gitea.users.get('demo-coder').prohibit_login, true);
}
test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers with a job, then revokes all three', async (t) => {
const f = await setup(t);
await threeGrants(f);
const out = f.fire('coder');
assert.equal(out.done, false);
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1', 'host:web2', 'gitea']);
assert.ok(!('transaction' in out));
const [revoking, ...more] = f.allowed('fire.revoking');
assert.equal(more.length, 0);
assert.equal(revoking.body.job, out.job);
assert.deepEqual(revoking.body.grants, out.revoking);
assert.equal(revoking.actor_role, 'chief');
assert.equal(revoking.subject, 'coder');
await f.broker.grantsIdle();
// Each host as hosts.revoke records it, with fire: true, line first so the close says removed.
for (const h of ['web1', 'web2']) {
const [r, ...rest] = f.revoked('coder', (e) => e.host === h);
assert.equal(rest.length, 0, h);
assert.equal(r.fire, true);
assert.equal(r.granted, true);
assert.equal(r.hostLine, 'pending');
assert.equal(r.tag, f.tag('coder', h));
const [c] = f.closed(r.revoke);
assert.equal(c.hostLine, 'removed');
assert.equal(c.fire, true);
}
const [g, ...rest] = f.revoked('coder', (e) => e.method === 'gitea');
assert.equal(rest.length, 0);
assert.equal(g.user, 'demo-coder');
assert.equal(g.granted, true);
assert.deepEqual(f.closed(g.revoke).map((c) => c.serviceLeft), [[]]);
allRevoked(f);
const [done, ...again] = f.allowed('fire.done');
assert.equal(again.length, 0);
assert.equal(done.body.job, out.job);
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
assert.deepEqual(done.body.grantsLeft, []);
assert.match(done.body.transaction, /^t-/);
assert.equal(done.actor_role, 'chief');
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
assert.ok(!f.doc.launch.instances.includes('coder'));
// No step G message for a job that completed.
assert.deepEqual(f.inbox(), []);
f.noLeak();
});
test('row 281 T3 (sage-2575): a delete with grants takes two requests: the first starts the job, the next completes', async (t) => {
const f = await setup(t);
await threeGrants(f);
f.held.add('web1:remove');
const first = f.remove('coder');
assert.equal(first.code, 'grants-revoking');
const [start, ...more] = f.allowed('grants.revoking');
assert.equal(more.length, 0);
assert.match(start.body.job, UUID);
assert.deepEqual(start.body.grants, ['host:web1', 'host:web2', 'gitea']);
assert.equal(start.body.verb, 'lifecycle.delete');
assert.equal(start.body.request, first.request);
assert.equal(start.actor_role, null);
assert.equal(start.subject, 'coder');
const job = start.body.job;
const [message] = f.inbox();
assert.equal(f.inbox().length, 1);
for (const s of [job, 'host:web1', 'host:web2', 'gitea', 'Raise the delete again']) assert.ok(message.includes(s), s);
// Held: a second delete refuses grants-revoking, with no second job and no second message.
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
assert.equal(f.remove('coder').code, 'grants-revoking');
assert.equal(f.allowed('grants.revoking').length, 1);
assert.equal(f.inbox().length, 1);
f.held.delete('web1:remove');
await f.broker.grantsIdle();
for (const h of ['web1', 'web2']) assert.equal(f.revoked('coder', (e) => e.host === h && e.delete === true).length, 1, h);
allRevoked(f);
const [done] = f.allowed('grants.revoked.done');
assert.equal(done.body.job, job);
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
const end = f.inbox()[1];
for (const s of [job, 'host:web1', 'host:web2', 'gitea', 'Raise the delete again']) assert.ok(end.includes(s), s);
assert.equal(f.org.log.filter(([op]) => op === 'instances.set').length, 0);
// The third request completes as today.
const third = f.remove('coder');
assert.equal(third.code, undefined);
assert.match(third.transaction, /^t-/);
assert.equal(third.retry, false);
assert.deepEqual(f.org.log.filter(([op]) => op === 'instances.set'), [['instances.set', 'coder']]);
assert.equal(f.allowed('grants.revoking').length, 1);
f.noLeak();
});
test('row 281 T3b (Q2): a delete whose job refuses on host B; the retry refuses grant-revoke-failed and revokes only B and Gitea', async (t) => {
const f = await setup(t);
await threeGrants(f);
f.failing.add('web2');
assert.equal(f.remove('coder').code, 'grants-revoking');
await f.broker.grantsIdle();
const job = f.allowed('grants.revoking')[0].body.job;
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(more.length, 0);
assert.deepEqual(
{ job: refused.job, agent: refused.agent, grant: refused.grant, reason: refused.reason, code: refused.code, verb: refused.verb },
{ job, agent: 'coder', grant: 'host:web2', reason: 'host-remove-failed', code: 'grant-revoke-failed', verb: 'lifecycle.delete' },
);
const end = f.inbox()[1];
for (const s of [job, 'coder', 'web2', 'host-remove-failed', 'mosaic hosts revoke coder web2']) assert.ok(end.includes(s), s);
// Nothing deleted: the writer saw no delete and the role is still there. Host A is revoked, B isn't.
assert.equal(f.org.log.filter(([op]) => op === 'instances.set').length, 0);
assert.ok(f.doc.roles.coder);
assert.equal(f.hostRow('coder', 'web1'), undefined);
assert.ok(f.hostRow('coder', 'web2'));
assert.ok(existsSync(f.keyFile('coder', 'web2')));
assert.ok(f.giteaRow('coder'));
assert.equal(f.allowed('grants.revoked.done').length, 0);
// B back: the next delete refuses grant-revoke-failed, names the earlier reason and the new job.
f.failing.delete('web2');
assert.equal(f.remove('coder').code, 'grant-revoke-failed');
const retry = f.allowed('grants.revoking')[1].body;
assert.deepEqual(retry.grants, ['host:web2', 'gitea']);
const message = f.inbox()[2];
for (const s of [job, retry.job, 'host:web2', 'host-remove-failed']) assert.ok(message.includes(s), s);
assert.equal(f.org.log.filter(([op]) => op === 'instances.set').length, 0);
await f.broker.grantsIdle();
const [done] = f.allowed('grants.revoked.done');
assert.equal(done.body.job, retry.job);
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web2', 'gitea']);
// Host A was sent one remove only, by the first job.
assert.equal(f.sent.filter((x) => x === `remove web1 ${f.tag('coder', 'web1')}`).length, 1);
allRevoked(f);
assert.match(f.remove('coder').transaction, /^t-/);
});
test('row 281 T3b: B cleared by hand with hosts.revoke; the next delete starts a job for Gitea and the leftover line only', async (t) => {
const f = await setup(t);
await threeGrants(f);
f.failing.add('web2');
assert.equal(f.remove('coder').code, 'grants-revoking');
await f.broker.grantsIdle();
const manual = await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web2', reason: 'web2 is gone' });
assert.equal(manual.hostLineLeft, f.tag('coder', 'web2'));
assert.equal(f.hostRow('coder', 'web2'), undefined);
// The refused grant isn't in the set any more, so this is a new start, not a retry.
assert.equal(f.remove('coder').code, 'grants-revoking');
const second = f.allowed('grants.revoking')[1].body;
assert.deepEqual(second.grants, ['gitea', 'line:web2']);
await f.broker.grantsIdle();
const done = f.allowed('grants.revoked.done')[0].body;
assert.equal(done.job, second.job);
assert.deepEqual(done.grants.map((x) => x.grant), ['gitea']);
assert.deepEqual(done.grantsLeft, [{ host: 'web2', tag: f.tag('coder', 'web2') }]);
assert.ok(f.inbox().at(-1).includes(`${f.tag('coder', 'web2')} on web2`));
// The line was tried once and listed: it doesn't hold the delete again.
assert.match(f.remove('coder').transaction, /^t-/);
assert.equal(f.allowed('grants.revoking').length, 2);
});
test('row 281 T3c (sage-2575 condition 4): a delete of an instance with no grant completes in one request', async (t) => {
const f = await setup(t);
const before = f.store.get("SELECT count(*) AS n FROM events WHERE business='demo'").n;
const out = f.remove('helper');
assert.equal(out.code, undefined);
assert.match(out.transaction, /^t-/);
assert.equal(out.retry, false);
assert.deepEqual(f.org.log.filter(([op]) => op === 'instances.set'), [['instances.set', 'helper']]);
assert.equal(f.allowed('grants.revoking').length, 0);
assert.deepEqual(f.inbox(), []);
// The request, the take and the delete's own records: none from step G.
const after = f.store
.all("SELECT body FROM events WHERE business='demo' ORDER BY seq")
.slice(before)
.map((e) => JSON.parse(e.body).action ?? JSON.parse(e.body).operation ?? null);
assert.ok(after.every((a) => !String(a).startsWith('grant')), after.join());
await f.broker.grantsIdle();
assert.equal(f.allowed('grants.revoked.done').length, 0);
});
test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked, B and Gitea untouched; a repeat completes', async (t) => {
const f = await setup(t);
await threeGrants(f);
f.failing.add('web2');
const out = f.fire('coder');
await f.broker.grantsIdle();
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(more.length, 0);
assert.deepEqual(
{ job: refused.job, agent: refused.agent, grant: refused.grant, reason: refused.reason, verb: refused.verb },
{ job: out.job, agent: 'coder', grant: 'host:web2', reason: 'host-remove-failed', verb: 'instance.fire' },
);
const [message] = f.inbox();
for (const s of [out.job, 'web2', 'host-remove-failed', 'mosaic hosts revoke coder web2', 'Run the fire again']) assert.ok(message.includes(s), s);
// The file is unchanged and the instance still firing.
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), []);
assert.ok(f.doc.launch.instances.includes('coder'));
assert.equal(f.allowed('fire.done').length, 0);
assert.equal(f.allowed('fire.failed').length, 0);
await rejects(() => f.raiseHost('coder', 'web3'), 'instance-fired');
// A revoked; B's record, key and line there (line first: nothing local changed on B).
assert.equal(f.revoked('coder', (e) => e.host === 'web1').length, 1);
assert.equal(f.hostRow('coder', 'web1'), undefined);
assert.equal(f.revoked('coder', (e) => e.host === 'web2').length, 0);
assert.ok(f.hostRow('coder', 'web2'));
assert.ok(existsSync(f.keyFile('coder', 'web2')));
assert.equal(f.state('coder', 'host.web2'), 'valid');
// Gitea comes after B: untouched.
assert.equal(f.revoked('coder', (e) => e.method === 'gitea').length, 0);
assert.ok(f.giteaRow('coder'));
assert.equal(f.userTokens('demo-coder').length, 1);
assert.equal(f.state('coder', 'gitea'), 'valid');
// B back: the repeat revokes only B and Gitea.
f.failing.delete('web2');
const repeat = f.fire('coder');
assert.deepEqual(repeat.revoking, ['host:web2', 'gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
assert.equal(done.body.job, repeat.job);
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web2', 'gitea']);
allRevoked(f);
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
});
test('row 281 T4b (Q1): a line a manual hosts.revoke left on C is removed by the fire, with its close', async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
await f.approveHost('coder', 'web3');
f.failing.add('web3');
const manual = await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
const tag3 = f.tag('coder', 'web3');
assert.equal(manual.hostLineLeft, tag3);
f.failing.delete('web3');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.lines.web3.has(tag3), false);
const close = f.grantEvents().filter((e) => e.action === 'grant.revoke.closed' && e.host === 'web3' && e.fire === true);
assert.equal(close.length, 1);
assert.equal(close[0].hostLine, 'removed');
assert.equal(close[0].tag, tag3);
// hosts.revoke's answer has no revoke id; the manual's grant.revoked has it.
const [manualRevoked] = f.revoked('coder', (e) => e.host === 'web3' && e.fire !== true);
assert.equal(close[0].revoke, manualRevoked.revoke);
const [done] = f.allowed('fire.done');
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'line:web3']);
assert.deepEqual(done.body.grantsLeft, []);
});
test('row 281 T4b (Q1): with C unreachable, the fire completes and names the line in grantsLeft', async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
await f.approveHost('coder', 'web3');
f.failing.add('web3');
await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
const tag3 = f.tag('coder', 'web3');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
const [done] = f.allowed('fire.done');
assert.equal(done.body.job, out.job);
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1']);
assert.deepEqual(done.body.grantsLeft, [{ host: 'web3', tag: tag3 }]);
assert.ok(f.lines.web3.has(tag3));
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
});
test("row 281 T5: Gitea's admin token can't be checked: the job refuses with the Gitea grant intact", async (t) => {
const f = await setup(t);
await f.approveGitea('coder');
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/version', { status: 500, json: {} });
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
off();
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.deepEqual([refused.grant, refused.reason, refused.job], ['gitea', 'gitea-admin-unavailable', out.job]);
assert.ok(f.inbox()[0].includes('mosaic grants sweep gitea'));
assert.ok(f.giteaRow('coder'));
assert.ok(existsSync(f.tokenFile('coder')));
assert.equal(f.state('coder', 'gitea'), 'valid');
assert.equal(f.userTokens('demo-coder').length, 1);
assert.equal(f.gitea.users.get('demo-coder').prohibit_login, false);
assert.equal(f.revoked('coder').length, 0);
assert.equal(f.allowed('fire.done').length, 0);
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), []);
});
test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat retries only this agent's entries", async (t) => {
const f = await setup(t);
await f.approveGitea('helper');
await f.approveGitea('coder');
const tokenDelete = (login) => (e) => e.method === 'DELETE' && e.path.startsWith(`/api/v1/users/${login}/tokens/`);
// Another agent's left entry, from a manual revoke whose DELETE failed.
const offHelper = f.gitea.rule(tokenDelete('demo-helper'), { status: 500, json: {} });
const manual = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'helper', reason: 'test' });
assert.equal(manual.serviceLeft.length, 1);
const helperDeletes = () => f.gitea.requests.filter(tokenDelete('demo-helper')).length;
const before = helperDeletes();
const offCoder = f.gitea.rule(tokenDelete('demo-coder'), { status: 500, json: {} });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(refused.grant, 'gitea');
// The close's serviceLeft (N10): the token id, and "tokens" for the fire's list that didn't clear.
assert.match(refused.reason, /^gitea-left:\d+,tokens$/);
assert.equal(refused.job, out.job);
assert.equal(f.userTokens('demo-coder').length, 1);
assert.equal(f.allowed('fire.done').length, 0);
// Gitea back for coder only. The helper's DELETE still fails, so a business-wide sweep would show.
offCoder();
const repeat = f.fire('coder');
assert.deepEqual(repeat.revoking, ['gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
assert.equal(done.body.job, repeat.job);
assert.deepEqual(f.userTokens('demo-coder'), []);
const swept = f.grantEvents().filter((e) => e.action === 'grant.swept' && e.job === repeat.job);
assert.equal(swept.length, 1);
assert.equal(swept[0].agent, 'coder');
assert.deepEqual(swept[0].left.map((e) => e.agent), ['helper']);
// Neither job touched the helper's token.
assert.equal(helperDeletes(), before);
assert.equal(f.userTokens('demo-helper').length, 1);
offHelper();
});
test('row 281 T6 (sage-2574): a host decision approved before the fire and run after it ends revoked, with nothing applied', async (t) => {
const f = await setup(t);
const id = f.fixtureHost('coder', 'web1');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.host === 'web1');
assert.equal(r.granted, false);
assert.equal(r.fire, true);
assert.equal(f.allowed('fire.done').length, 1);
assert.equal(await f.broker.runHostGrant('demo', id), 'revoked');
assert.equal(f.ended(id).code, 'revoked');
assert.equal(f.hostRow('coder', 'web1'), undefined);
assert.equal(existsSync(f.keyFile('coder', 'web1')), false);
assert.ok(!f.sent.some((x) => x.startsWith('add ')));
});
test('row 281 T6b (sage-2574): a Gitea decision approved before the fire and run after it ends revoked before any Gitea write', async (t) => {
const f = await setup(t);
const id = f.fixtureGitea('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.method === 'gitea');
assert.deepEqual([r.user, r.granted, r.fire, r.decisions], [null, false, true, [id]]);
assert.equal(f.allowed('fire.done').length, 1);
const calls = f.gitea.requests.length;
await f.broker.runGiteaGrant('demo', id);
await f.broker.grantsIdle();
assert.equal(f.ended(id).code, 'revoked');
assert.equal(f.giteaRow('coder'), undefined);
assert.equal(existsSync(f.tokenFile('coder')), false);
// Only step 0's C1 to C4 reach Gitea, all reads; step 1's recheck ends it before any write.
assert.deepEqual(f.gitea.requests.slice(calls).map((e) => `${e.method} ${e.path}`), [
'GET /api/v1/version',
'GET /api/v1/user',
'GET /api/v1/token',
'GET /api/v1/user/repos?limit=1&page=1',
]);
assert.ok(!f.gitea.requests.some((e) => e.method === 'POST' && e.path.endsWith('/tokens')));
});
test('row 281 T6c: a host executor in flight aborts and ends revoked; a Gitea executor in flight is awaited and leaves no token', async (t) => {
const f = await setup(t);
// Host: held at its add.
f.held.add('web1:add');
const host = await f.raiseHost('coder', 'web1');
f.call('human', 'decision.resolve', { id: host.id, choice: 'yes' });
await until(() => f.sent.some((x) => x.startsWith('add web1 ')));
// Gitea: held at the token create.
let go;
const gate = new Promise((r) => (go = r));
t.after(() => go());
let reached = false;
const off = f.gitea.rule(
(e) => e.method === 'POST' && e.path === '/api/v1/users/demo-coder/tokens',
async () => {
reached = true;
await gate;
return { pass: true };
},
);
const gd = await f.raiseGitea('coder');
f.call('human', 'decision.resolve', { id: gd.id, choice: 'yes' });
await until(() => reached);
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await until(() => f.ended(host.id));
assert.equal(f.ended(host.id).code, 'revoked');
// The job waits on the Gitea executor.
await sleep(50);
assert.equal(f.allowed('fire.done').length, 0);
go();
await f.broker.grantsIdle();
off();
f.held.delete('web1:add');
assert.equal(f.hostRow('coder', 'web1'), undefined);
assert.equal(existsSync(f.keyFile('coder', 'web1')), false);
assert.equal(f.lines.web1.has(f.tag('coder', 'web1')), false);
assert.ok(f.ended(gd.id));
assert.deepEqual(f.userTokens('demo-coder'), []);
assert.equal(f.giteaRow('coder'), undefined);
assert.equal(f.allowed('fire.done').length, 1);
f.noLeak();
});
test('row 281 T6d (B1): undecided host and Gitea decisions are in the set, and approved after the fire both end revoked', async (t) => {
const f = await setup(t);
const host = await f.raiseHost('coder', 'web1');
const gd = await f.raiseGitea('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.done').length, 1);
for (const d of [host, gd]) {
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => f.ended(d.id));
}
await f.broker.grantsIdle();
assert.equal(f.ended(host.id).code, 'revoked');
assert.equal(f.ended(gd.id).code, 'revoked');
assert.ok(!f.sent.some((x) => x.startsWith('add ')));
assert.ok(!f.gitea.requests.some((e) => e.method === 'POST' && e.path.endsWith('/tokens')));
assert.equal(f.hostRow('coder', 'web1'), undefined);
assert.equal(f.giteaRow('coder'), undefined);
});
test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and after the fire, goes through after a rehire, and refuses grant-revoke-running during a delete job', async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
// Firing with no job: step B refused, so fire.stopped stands with no fire.done.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
f.fire('coder');
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.failed').length, 1);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// After fire.done: delisted.
f.org.fireError = null;
assert.match(f.fire('coder').transaction, /^t-/);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// A rehire lists the name again.
f.doc.launch.instances.push('coder');
f.broker.reloadBusiness('demo', f.doc);
assert.match((await f.raiseHost('coder', 'web2')).id, UUID);
// While a delete's job holds ops.
await f.approveHost('ops', 'web3');
f.held.add('web3:remove');
assert.equal(f.remove('ops').code, 'grants-revoking');
await rejects(() => f.raiseHost('ops', 'web1'), 'grant-revoke-running');
f.held.delete('web3:remove');
await f.broker.grantsIdle();
});
test('row 281 T7: an instance with no grant fires as today, synchronously, with the same events', async (t) => {
const f = await setup(t);
const out = f.fire('helper');
assert.deepEqual(Object.keys(out).sort(), ['cancelled', 'request', 'revision', 'runs', 'taken', 'transaction']);
assert.match(out.transaction, /^t-/);
// Recorded before the verb answered: no job.
assert.equal(f.allowed('fire.stopped').length, 1);
assert.equal(f.allowed('fire.done').length, 1);
assert.equal(f.allowed('fire.done')[0].body.job, undefined);
assert.equal(f.allowed('fire.revoking').length, 0);
assert.equal(f.allowed('role.fire').length, 1);
assert.deepEqual(f.org.log.map(([op]) => op), ['instance.fireCheck', 'instance.fire']);
await f.broker.grantsIdle();
assert.deepEqual(f.inbox(), []);
assert.equal(f.grantEvents().filter((e) => e.action?.startsWith('grant.revoke')).length, 0);
});
test('row 281 T9 (B3): one step G per agent; the manual verbs refuse while it runs, and sweep names it running', async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
await f.approveGitea('coder');
f.held.add('web1:remove');
f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
assert.equal(code(() => f.fire('coder')), 'fire-in-progress');
assert.equal(f.remove('coder').code, 'grants-revoking');
assert.equal(f.allowed('grants.revoking').length, 0);
assert.equal(f.allowed('fire.revoking').length, 1);
await rejects(() => f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web1', reason: 'r' }), 'grant-revoke-running');
await rejects(() => f.call('tester', 'hosts.revoke', { agent: 'coder', host: 'web1', reason: 'r' }), 'grant-revoke-running');
await rejects(() => f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'grant-revoke-running');
const sweep = await f.call('human', 'grants.sweep', { service: 'gitea' });
assert.deepEqual(sweep.running, ['coder']);
f.held.delete('web1:remove');
await f.broker.grantsIdle();
assert.equal(f.revoked('coder', (e) => e.host === 'web1').length, 1);
assert.equal(f.revoked('coder', (e) => e.method === 'gitea').length, 1);
assert.equal(f.allowed('fire.done').length, 1);
allRevoked(f, ['web1']);
});
test('row 281 T10 (B2): the fire and the delete answer while their jobs are held, and the broker answers other verbs', async (t) => {
const f = await setup(t);
await f.approveGitea('coder');
await f.approveHost('ops', 'web2');
let go;
const gate = new Promise((r) => (go = r));
t.after(() => go());
let reached = false;
const off = f.gitea.rule(
(e) => e.method === 'DELETE' && e.path.startsWith('/api/v1/users/demo-coder/tokens/'),
async () => {
reached = true;
await gate;
return { pass: true };
},
);
f.held.add('web2:remove');
// The verbs answer before the event loop turns, so before any part of a job has run.
let turned = false;
setImmediate(() => (turned = true));
const fired = f.fire('coder');
const deleted = f.remove('ops');
assert.equal(turned, false);
assert.equal(fired.done, false);
assert.equal(deleted.code, 'grants-revoking');
await until(() => reached && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
// Held: another session's verb answers.
assert.ok(f.call('pm', 'message.send', { to: 'chief', body: 'still here' }));
assert.equal(f.allowed('fire.done').length, 0);
assert.equal(f.allowed('grants.revoked.done').length, 0);
go();
f.held.delete('web2:remove');
await f.broker.grantsIdle();
off();
assert.equal(f.allowed('fire.done').length, 1);
assert.equal(f.allowed('grants.revoked.done').length, 1);
assert.deepEqual(f.userTokens('demo-coder'), []);
assert.equal(f.hostRow('ops', 'web2'), undefined);
});
test("row 281 T11 (C1): a fire's job completes after the chief's run ends and processing halts", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.held.add('web1:remove');
const out = f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
f.broker.endLaunch({ business: 'demo', run: 'chief-run', reason: 'test' });
f.sw.state = { version: 1, halted: true, generation: GENERATION };
f.held.delete('web1:remove');
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
assert.equal(done.body.job, out.job);
assert.match(done.body.transaction, /^t-/);
const [used, ...more] = f.allowed('role.fire');
assert.equal(more.length, 0);
assert.equal(used.actor_role, 'chief');
assert.equal(used.body.transaction, done.body.transaction);
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.allowed('fire.failed').length, 0);
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
});
test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the repeat runs B and C synchronously", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
// N7: the writer's own credential-revoke-failed, which step G's key revokes can't clear.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed, ...more] = f.allowed('fire.failed');
assert.equal(more.length, 0);
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'credential-revoke-failed']);
assert.equal(failed.actor_role, 'chief');
const [message] = f.inbox();
for (const s of [out.job, 'host:web1', 'step B', 'credential-revoke-failed']) assert.ok(message.includes(s), s);
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
assert.equal(f.allowed('fire.done').length, 0);
assert.ok(f.doc.launch.instances.includes('coder'));
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
// The grant was revoked before step B.
assert.equal(f.hostRow('coder', 'web1'), undefined);
f.org.fireError = null;
const repeat = f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
assert.ok(!f.doc.launch.instances.includes('coder'));
});
test('row 281 T13 (N4): a restart records each held job interrupted once, with one message each; an ended job gets none', async (t) => {
const f = await setup(t);
await f.approveHost('helper', 'web3');
await f.approveHost('coder', 'web1');
await f.approveHost('ops', 'web2');
// A delete job that ended before the restart.
assert.equal(f.remove('helper').code, 'grants-revoking');
await f.broker.grantsIdle();
assert.equal(f.allowed('grants.revoked.done').length, 1);
f.held.add('web1:remove');
f.held.add('web2:remove');
const fired = f.fire('coder');
assert.equal(f.remove('ops').code, 'grants-revoking');
const deleteJob = f.allowed('grants.revoking').at(-1).body.job;
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`) && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
const restartNotes = () => f.inbox().filter((m) => m.includes('interrupted by a bus restart'));
const second = f.make();
second.reconcileGrants();
const interrupted = f.allowed('grants.job.interrupted');
assert.deepEqual(
interrupted.map((e) => [e.body.job, e.body.agent, e.body.verb]).sort(),
[[fired.job, 'coder', 'instance.fire'], [deleteJob, 'ops', 'lifecycle.delete']].sort(),
);
assert.ok(interrupted.every((e) => e.actor_role === null));
const notes = restartNotes();
assert.equal(notes.length, 2);
assert.ok(notes.some((m) => m.includes(fired.job) && m.includes('Run the fire again')));
assert.ok(notes.some((m) => m.includes(deleteJob) && m.includes('Raise the delete again')));
// A third broker on the same root sends nothing more.
f.make().reconcileGrants();
assert.equal(f.allowed('grants.job.interrupted').length, 2);
assert.equal(restartNotes().length, 2);
f.held.clear();
await f.broker.grantsIdle();
});
test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision stops the fire on host-unknown; the human's no closes it, and the repeat fire has no job", async (t) => {
const f = await setup(t);
const d = await f.raiseHost('coder', 'web1');
// N1: hosts.remove doesn't check open decisions, so the decision outlives its host.
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
const out = f.fire('coder');
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(more.length, 0);
assert.deepEqual([refused.job, refused.grant, refused.reason, refused.verb], [out.job, 'host:web1', 'host-unknown', 'instance.fire']);
const [message, ...others] = f.inbox();
assert.equal(others.length, 0);
for (const s of [out.job, 'web1', 'host-unknown']) assert.ok(message.includes(s), s);
assert.equal(f.allowed('fire.done').length, 0);
assert.ok(f.doc.launch.instances.includes('coder'));
// The human's no: no executor runs, so the closed check alone takes it out of the set (M22).
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
await f.broker.grantsIdle();
assert.equal(f.ended(d.id), undefined);
const repeat = f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
assert.equal(f.allowed('fire.done')[0].body.job, undefined);
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 1);
});
+417
View File
@@ -0,0 +1,417 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+480
View File
@@ -0,0 +1,480 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+21
View File
@@ -1216,6 +1216,27 @@ test('X-G14: a revoke Gitea cannot reach stays open, and a restart closes it', a
await rejects(f.call('pm', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'revoke-refused');
});
// Row 281 (#1786, d6 section 8, T8): grants.revoke shares its Gitea steps with a fire's step G now. This
// pins the tester's dev-only refusal, the verb's records and their order, and that it starts no job.
test('row 281 T8: a tester grants.revoke refuses tester-dev-only once bus.tester is off; the human revoke keeps its records and starts no job', async (t) => {
const f = await setup(t);
await f.approve();
f.broker.reloadBusiness('demo', { ...f.business, vars: { 'bus.tester': 'yes' } });
const before = f.grantEvents().length;
await rejects(f.call('tester', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'tester-dev-only');
assert.equal(f.grantEvents().length, before);
assert.ok(f.record());
await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' });
const after = f.grantEvents().slice(before)
.filter((e) => e.action !== 'grant.call' && e.action !== 'grant.result')
.map((e) => [e.action, e.fire ?? false]);
assert.deepEqual(after, [['grant.revoked', false], ['grant.revoke.closed', false]]);
assert.deepEqual(f.userTokens(), []);
const jobs = f.store.all("SELECT body FROM events WHERE business='demo' AND kind='action.allowed'").map((e) => JSON.parse(e.body).action)
.filter((a) => /^(fire|grants)\./.test(a));
assert.deepEqual(jobs, []);
});
test('X-G15: look-alike commit steps outside the grant writer are ignored at start, by revoke and by status', async (t) => {
const f = await setup(t);
const first = await f.approve();
+23 -8
View File
@@ -32,14 +32,27 @@ function source3j(t) {
db.close(); chmodSync(path, 0o600);
return { root, path };
}
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). The compare after the step
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
// new row still fails.
function migrated3l(now) {
const left = now.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
assert.deepEqual(left, [{ name: 'sessions', seq: 0 }, { name: 'session_defaults', seq: 0 }]);
return { ...now, sqlite_sequence: now.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
@@ -61,6 +74,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -82,10 +97,10 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(rows(path), before);
assert.equal(version(path), '3m');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
const store = new Store(root);
try {
@@ -108,10 +123,10 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3m');
});
test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k', async t => {
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
const { root, path } = source3j(t);
new Store(root, { migrateSchema: true }).close();
const after = rows(path), was = master(path);
@@ -119,14 +134,14 @@ test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k',
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
@@ -780,6 +780,24 @@ test('G26: the human, the assistant and the tester revoke; the chief and a pm re
assert.deepEqual((await f.call('tester', 'hosts.sweep', { host: 'web1' })).removed, []);
});
// Row 281 (#1786, d6 section 8, T8): hosts.revoke shares its steps with a fire's step G now. G12 pins its
// records and their order; this pins the tester's dev-only refusal and that a verb revoke starts no job.
test('row 281 T8: a tester hosts.revoke refuses tester-dev-only once bus.tester is off, and a human revoke writes no job records', async (t) => {
const f = await setup(t);
await f.approve();
f.caps.tester = f.broker.bindTester({ business: 'demo' });
f.broker.reloadBusiness('demo', { ...f.business, vars: { 'bus.tester': 'yes' } });
const args = { agent: 'ops', host: 'web1', reason: 'r' };
await refusesWith(f.call('tester', 'hosts.revoke', args), 'tester-dev-only');
assert.equal(revokeBodies(f).length, 0);
assert.ok(f.record());
assert.equal((await f.call('human', 'hosts.revoke', args)).granted, true);
assert.deepEqual(revokeBodies(f).map((e) => [e.action, e.fire, e.delete]), [['grant.revoked', undefined, undefined], ['grant.revoke.closed', undefined, undefined]]);
const jobs = f.store.all("SELECT body FROM events WHERE business='demo' AND kind='action.allowed'").map((e) => JSON.parse(e.body).action)
.filter((a) => /^(fire|grants)\./.test(a));
assert.deepEqual(jobs, []);
});
test('G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then', async (t) => {
const f = await setup(t);
await f.approve();
+23 -8
View File
@@ -18,6 +18,10 @@ const here = (p) => new URL(p, import.meta.url);
const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
// Row 236 (#1742): 3j→3k's objects, after this step. Their test is gitea-migration.test.mjs.
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -37,11 +41,20 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). The compare after the step
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
// new row still fails.
function migrated3l(now) {
const left = now.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
assert.deepEqual(left, [{ name: 'sessions', seq: 0 }, { name: 'session_defaults', seq: 0 }]);
return { ...now, sqlite_sequence: now.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
@@ -64,6 +77,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -87,10 +102,10 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(rows(path), before);
assert.equal(version(path), '3m');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
const store = new Store(root);
try {
@@ -112,10 +127,10 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3m');
});
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
const { root, path } = source3i(t);
new Store(root, { migrateSchema: true }).close();
const after = rows(path), was = master(path);
@@ -123,14 +138,14 @@ test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j',
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+38 -1
View File
@@ -9,6 +9,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { busBusiness } from '../src/business.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
@@ -19,7 +21,7 @@ const store = (agent, service) => ({ store: true, [service === 'gitea' ? 'rotate
// coder: gitea in the store, vikunja a file reference. rev-2 and rev-3: both in the store, with a
// tracker bot. `drop` names instances whose credentials the boot copy leaves out, as a restart that
// dropped the entry would: the running bus then has no entry for a file on disk (sage-1592).
async function setup(t, { drop = [] } = {}) {
async function setup(t, { drop = [], seed = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-lifecycle-revoke-'));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
@@ -31,6 +33,12 @@ async function setup(t, { drop = [] } = {}) {
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
// Row 281 T1: records written before the start, as an earlier bus left them.
if (seed) {
const seeded = new Store(join(root, 'data'));
seed(seeded);
seeded.close();
}
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
@@ -181,3 +189,32 @@ test('T21 delete: a write between the raise and the take makes the delete refuse
assert.deepEqual(readdirSync(f.agentDir('rev-2')), ['vikunja']);
assert.deepEqual(f.status('rev-2'), { gitea: 'not-issued', vikunja: 'valid' });
});
// Row 281 (#1786, d6 section 1) T1: pins what section 1 read from the code, and what still holds after the
// change. Step B's revoke refuses while a grant's file is in the agent's store, which is why step G runs
// first. A fire with no grant record goes straight to step B, so a stray host.<id> file planted after the
// start (the start sweep would unlink it) still refuses there.
test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses step B credential-revoke-failed, file unchanged', async (t) => {
const f = await setup(t);
f.plant('coder', 'host.web1');
const before = f.bytes();
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
assert.equal(f.bytes(), before);
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
rmSync(join(f.agentDir('coder'), 'host.web1'));
assert.equal(f.fire('coder').delisted, true);
});
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
// start loads the reference with no token, so the bus comes up and the entry reads not-issued.
test('row 281 T1 start: a host grant record whose key file is gone starts and reads not-issued', async (t) => {
const entry = { address: '192.0.2.10', port: 22, user: 'deploy', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
const f = await setup(t, {
seed: (store) => {
hostRecord(store, { host: 'web1', entry });
hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['uptime']] });
},
});
assert.equal(existsSync(join(f.agentDir('coder'), 'host.web1')), false);
assert.equal(f.status('coder')['host.web1'], 'not-issued');
});
+8 -1
View File
@@ -15,6 +15,12 @@ import { fixture } from './spec-fixtures.mjs';
const AT = '2026-10-21T00:00:00.000Z';
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
// A thread owned by `owner`, on the business's assistant instance.
function threadOf(store, project, owner, name) {
@@ -23,6 +29,7 @@ function threadOf(store, project, owner, name) {
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
id, AT, 'demo', project, 'assistant', owner, name,
);
for (const sql of DEFAULT_SESSION) store.run(sql, id);
return id;
}
// A message with its thread row, its routed delivery and one event about it. The event names the
@@ -33,7 +40,7 @@ function put(store, { thread = null, from_role, from_run, to_role, in_reply_to =
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
id, AT, 'demo', from_role, from_run, to_role, 'INFO', in_reply_to, decision, null, body,
);
if (thread) store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', id, thread);
if (thread) store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', id, thread, thread);
store.run('INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)', id, AT, 'routed', null, null, null);
store.run(
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
+12 -2
View File
@@ -50,19 +50,29 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
// thread_claims and thread_opens stay in the compare: the step carries their seq.
const SEQ_3L = ['sessions', 'session_defaults'];
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'sessions', seq: 1 }, { name: 'session_defaults', seq: 1 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
}
test('3f copy migrates to 3g preserving every old column/row, with an empty instance_names and null to_name', t => {
const { root, path, copy } = source3f(t), before = snapshot(path), untouched = readFileSync(copy);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
} finally { store.close(); }
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(readFileSync(copy), untouched);
assert.deepEqual(snapshot(copy).rows, before.rows);
new Store(root).close();
+4 -2
View File
@@ -521,7 +521,9 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 124 adds usage_records and its three append-only triggers.
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
assert.equal(count('table') - 1, 20);
assert.equal(count('trigger'), 71);
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
assert.equal(count('table') - 1, 23);
assert.equal(count('trigger'), 89);
assert.equal(count('view'), 5);
});
+456
View File
@@ -0,0 +1,456 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
// Row 264a (#1769, T1 item 7): sessions below a thread, in the bus. T1's bus half, T2 and T7 of
// docs/plans/2026-10-21_t1-sessions.md. The launcher, seat and Console halves are 264b-264e's.
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const roles = {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
};
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
projects: { stack: { root: '/nonexistent' } },
},
};
const AT = '2026-10-21T00:00:00.000Z';
const now = () => new Date().toISOString();
const here = (p) => new URL(p, import.meta.url);
const ddl3k = readFileSync(here('./fixtures/schema-3k.sql'), 'utf8');
function broker(t, store) {
const b = new Broker({ store, businesses });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const caps = { human };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
return caps[name];
};
const claim = (name) => call(name, 'role.claim');
const sessionOf = (message) => store.get('SELECT session FROM message_threads WHERE message=?', message)?.session ?? null;
const claims = (session) => store.all('SELECT op, holder_run, generation FROM thread_claims WHERE session=? ORDER BY seq', session).map((r) => ({ ...r }));
return { b, store, caps, call, launch, claim, sessionOf, claims };
}
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const ctx = broker(t, store);
const project = ctx.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
const A = ctx.call('human', 'thread.create', { project, name: 'alpha' }).id;
for (const r of ['chief', 'pm']) {
ctx.launch(r, r);
ctx.claim(r);
}
const [first] = ctx.call('human', 'session.list', { thread: A });
return { ...ctx, root, project, A, D: first.id };
}
test('T1 REQ-ROLE-2: two sessions of one thread keep their own history, delivery and replies', (t) => {
const { call, launch, claim, sessionOf, store, project, A, D } = setup(t);
// thread.create made one default session, with a random id that isn't the thread's.
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.name, r.predecessor, r.default]), [['default', null, true]]);
assert.notEqual(D, A);
assert.match(D, /^[a-f0-9-]{36}$/);
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.id, r.name, r.default]), [
[D, 'default', true],
[S, 'second', false],
]);
// A run with no session named works the default (D3). Each session has its own holder.
launch('a1', 'assistant', { thread: A });
launch('a2', 'assistant', { thread: A, session: S });
assert.deepEqual(claim('a1'), { role: 'assistant', run: 'a1-run', thread: A });
assert.deepEqual(claim('a2'), { role: 'assistant', run: 'a2-run', thread: A });
const toDefault = call('human', 'thread.send', { thread: A, body: 'to the default' });
const toSecond = call('human', 'thread.send', { thread: A, session: S, body: 'to the second' });
assert.equal(sessionOf(toDefault.id), D);
assert.equal(sessionOf(toSecond.id), S);
assert.deepEqual(call('a1', 'message.receive').map((m) => m.body), ['to the default']);
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['to the second']);
assert.deepEqual(call('a1', 'message.receive'), []);
// A reply stays in its run's session, and each history reads only its own.
const reply = call('a2', 'message.send', { to: 'human', body: 'second answers', in_reply_to: toSecond.id });
assert.equal(sessionOf(reply.id), S);
assert.deepEqual(call('human', 'thread.messages', { thread: A }).map((m) => m.body), ['to the default']);
assert.deepEqual(call('human', 'thread.messages', { thread: A, session: S }).map((m) => m.body), ['to the second', 'second answers']);
// The chief's reply to the second session's work comes back to that session.
const ask = call('a2', 'message.send', { to: 'chief', body: 'need help', class: 'REQUEST' });
const back = call('chief', 'message.send', { to: 'assistant', body: 'here', in_reply_to: ask.id });
assert.equal(sessionOf(back.id), S);
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['here']);
assert.deepEqual(call('a1', 'message.receive'), []);
// An open names its session too.
call('human', 'thread.open', { thread: A, session: S });
assert.equal(store.get('SELECT session FROM thread_opens ORDER BY seq DESC LIMIT 1').session, S);
// A non-thread role names no session, and a session of another thread is refused.
assert.throws(() => launch('p2', 'pm', { session: S }), { code: 'thread-not-allowed' });
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
assert.throws(() => launch('a3', 'assistant', { thread: B, session: S }), { code: 'session-not-found' });
assert.throws(() => call('human', 'thread.send', { thread: B, session: S, body: 'x' }), { code: 'session-not-found' });
assert.throws(() => call('human', 'thread.messages', { thread: B, session: 'no-such-session' }), { code: 'session-not-found' });
});
test('T2 REQ-ROLE-2: one holder per session, generations count its claims, and a sibling session\'s message never reaches the other holder', (t) => {
const { b, call, launch, claim, claims, store, project, A, D } = setup(t);
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
launch('a1', 'assistant', { thread: A });
claim('a1');
launch('a1b', 'assistant', { thread: A });
assert.throws(() => claim('a1b'), { code: 'thread-already-held' });
// A sibling session is free to claim while the default is held.
launch('a2', 'assistant', { thread: A, session: S });
claim('a2');
// Release and claim again: the second claim is generation 2, and its release carries it.
call('a2', 'role.release');
launch('a3', 'assistant', { thread: A, session: S });
claim('a3');
b.endLaunch({ business: 'demo', run: 'a3-run', reason: 'idle' });
assert.deepEqual(claims(S), [
{ op: 'claim', holder_run: 'a2-run', generation: 1 },
{ op: 'release', holder_run: 'a2-run', generation: 1 },
{ op: 'claim', holder_run: 'a3-run', generation: 2 },
{ op: 'release', holder_run: 'a3-run', generation: 2 },
]);
// The human's stop revokes each held session, with that claim's generation.
launch('a4', 'assistant', { thread: A, session: S });
claim('a4');
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), {
stopped: [
{ thread: A, run: 'a1-run' },
{ thread: A, run: 'a4-run' },
],
});
assert.deepEqual(claims(D).at(-1), { op: 'revoke', holder_run: 'a1-run', generation: 1 });
assert.deepEqual(claims(S).at(-1), { op: 'revoke', holder_run: 'a4-run', generation: 3 });
// The triggers hold these for a write that skips the broker.
const raw = (thread, session, generation, run) =>
store.run(
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo',?,?,?,'claim',?,'pi',?)",
now(),
thread,
session,
generation,
run,
run,
);
assert.throws(() => raw(A, S, 5, 'x-run'), /a claim row names its session's generation/);
assert.throws(() => raw(A, S, 3, 'x-run'), /a claim row names its session's generation/);
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
const [{ id: BD }] = call('human', 'session.list', { thread: B });
assert.throws(() => raw(A, BD, 1, 'x-run'), /a claim's session is in its thread/);
raw(A, S, 4, 'x-run');
assert.throws(() => raw(B, BD, 1, 'x-run'), /run already holds a session/);
// A threaded message goes only to its own session's holder.
launch('a5', 'assistant', { thread: A });
claim('a5');
const m = call('human', 'thread.send', { thread: A, session: S, body: 'for x' });
assert.throws(
() => store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'a5-run'),
/a threaded message goes only to its session's holder/,
);
store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'x-run');
});
test('T2 sage-2257: a released or revoked run\'s retired generation refuses its approval, delivery and read, and writes nothing', (t) => {
const { call, launch, claim, claims, store, A, D } = setup(t);
const tables = store.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").map((r) => r.name);
const counts = () => Object.fromEntries(tables.map((n) => [n, store.get(`SELECT count(*) AS n FROM "${n}"`).n]));
// The run raises a decision, takes one message and leaves a second waiting.
const prepare = (name) => {
launch(name, 'assistant', { thread: A });
claim(name);
const d = call(name, 'decision.raise', {
action: 'spend',
target: 'demo',
question: 'spend?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'no',
blocking: false,
});
call('human', 'thread.send', { thread: A, body: `for ${name}` });
const [taken] = call(name, 'message.receive');
call('human', 'thread.send', { thread: A, body: `waiting after ${name}` });
return { d, taken };
};
// Each refusal leaves its one action.refused event (request()'s catch, #refused); nothing else is
// written: no approval, delivery, read or claim state (mos-1277).
const lastSeq = () => store.get('SELECT max(seq) AS s FROM events').s;
const refusedOnce = (fn, label) => {
const from = lastSeq();
assert.throws(fn, { code: 'not-holder' }, label);
const added = store.all('SELECT kind, body FROM events WHERE seq > ? ORDER BY seq', from);
assert.deepEqual(
added.map((e) => [e.kind, JSON.parse(e.body)]),
[['action.refused', { code: 'not-holder' }]],
label,
);
};
const refused = (name, { d, taken }) => {
const { events, ...before } = counts();
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval');
refusedOnce(() => call(name, 'message.receive'), 'delivery');
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read');
const { events: after, ...rest } = counts();
assert.deepEqual(rest, before);
assert.equal(after, events + 3);
};
// Released: generation 1 is retired once a2 claims generation 2.
const one = prepare('a1');
call('a1', 'role.release');
const two = prepare('a2');
refused('a1', one);
// Revoked by the human's stop: generation 2 is retired once a3 claims generation 3.
call('human', 'assistant.stop', { instance: 'assistant' });
prepare('a3');
refused('a2', two);
assert.deepEqual(claims(D).map((r) => [r.op, r.holder_run, r.generation]), [
['claim', 'a1-run', 1],
['release', 'a1-run', 1],
['claim', 'a2-run', 2],
['revoke', 'a2-run', 2],
['claim', 'a3-run', 3],
]);
});
test('session.create is idempotent on its op, refuses a reused op or a taken name, and reaches only the owner\'s threads', (t) => {
const { call, store, project, A, D } = setup(t);
const first = call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' });
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' }), first);
assert.throws(() => call('human', 'session.create', { thread: A, name: 'third', op: 'op-1' }), { code: 'session-op-conflict' });
assert.throws(() => call('human', 'session.create', { thread: A, name: 'second', op: 'op-2' }), { code: 'session-exists' });
assert.throws(() => call('human', 'session.create', { thread: A, name: 'default', op: 'op-3' }), { code: 'session-exists' });
// thread.create's op answers a retry that names the default.
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'default', op: 'default' }), { id: D, thread: A, name: 'default' });
for (const args of [{ thread: A, name: 'x\ny', op: 'op-4' }, { thread: A, name: 'x' }, { thread: A, name: 'x', op: 'op-5', extra: 1 }])
assert.throws(() => call('human', 'session.create', args), { code: 'invalid-request' });
assert.equal(store.get('SELECT count(*) n FROM sessions WHERE thread=?', A).n, 2);
// Another owner's thread, made by hand with the session the broker would write.
const other = 'thread-of-someone-else';
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', other, now(), 'demo', project, 'assistant', 'someone-else', 'theirs');
store.run("INSERT INTO sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-theirs',?,'demo',?,'assistant','someone-else','default','default')", now(), other);
store.run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,'s-theirs','someone-else')", now(), other);
for (const [verb, args] of [
['session.list', { thread: other }],
['session.create', { thread: other, name: 'mine', op: 'op-6' }],
['thread.send', { thread: other, session: 's-theirs', body: 'hi' }],
])
assert.throws(() => call('human', verb, args), { code: 'thread-not-owned' }, verb);
assert.throws(() => call('human', 'session.list', { thread: 'nope' }), { code: 'thread-not-found' });
// Agents don't call the session verbs.
assert.throws(() => call('pm', 'session.list', { thread: A }), { code: 'human-required' });
});
test('the 3l triggers keep sessions in their thread and session ids apart from thread ids', (t) => {
// A bare database from schema.sql, so only the triggers stand between a write and the tables.
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-sql-'));
const db = new DatabaseSync(join(root, 'bus.sqlite'));
t.after(() => {
db.close();
rmSync(root, { recursive: true, force: true });
});
db.exec(readFileSync(here('../schema.sql'), 'utf8'));
const run = (sql, ...args) => db.prepare(sql).run(...args);
const session = (id, thread, extra = {}) =>
run(
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
id,
now(),
extra.business ?? 'demo',
thread,
extra.instance ?? 'assistant',
extra.owner ?? 'jason',
extra.name ?? id,
extra.op ?? id,
extra.predecessor ?? null,
);
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
for (const id of ['th-1', 'th-2'])
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,'demo','p-1','assistant','jason',?)", id, now(), id);
session('s-1', 'th-1');
// The legacy shape: a session whose id is its own thread's.
session('th-1', 'th-1', { name: 'default', op: 'legacy-3l' });
// A session id that names another thread, and a thread id that names a session, are refused.
assert.throws(() => session('th-2', 'th-1'), /a session id names another thread/);
assert.throws(
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('s-1',?,'demo','p-1','assistant','jason','x')", now()),
/a thread id names a session/,
);
// A session has its thread's business, instance and owner, and its predecessor is in its thread.
for (const extra of [{ business: 'other' }, { instance: 'pm' }, { owner: 'someone-else' }])
assert.throws(() => session('s-x', 'th-1', extra), /a session has its thread's business, instance and owner/, JSON.stringify(extra));
assert.throws(() => session('s-x', 'th-2', { predecessor: 's-1' }), /a session's predecessor is in its thread/);
session('s-2', 'th-1', { predecessor: 's-1' });
// The names and op keys are unique in a thread.
assert.throws(() => session('s-3', 'th-1', { name: 's-1' }), /UNIQUE/);
assert.throws(() => session('s-3', 'th-1', { op: 's-1' }), /UNIQUE/);
// A default is a session of its thread.
assert.throws(() => run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /a default session is in its thread/);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
// A message's and an open's session is in their thread.
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
assert.throws(() => run("INSERT INTO message_threads(message,thread,session) VALUES('m-1','th-2','s-1')"), /a threaded message's session is in its thread/);
assert.throws(() => run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /an open's session is in its thread/);
});
// T7: a 3k store from the pinned 3k schema (8f51900b5's, the parent of this change's schema.sql)
// and fixture rows. No live store is read or copied.
function source3k(t) {
const root = mkdtempSync(join(tmpdir(), 'sessions-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3k).digest('hex'), 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0');
db.exec(ddl3k);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3k');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const id of ['t1', 't2'])
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', 'jason', id);
// Duckworth's fixture: claim, release, claim, revoke on t1, with t2's claim between, at seqs
// with gaps so a renumbered copy would show.
const claimRow = (seq, thread, op, holder, extra = {}) =>
run(
'INSERT INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by,reason) VALUES(?,?,?,?,?,?,?,?,?)',
seq, AT, 'demo', thread, op, holder, 'pi', extra.by ?? holder, extra.reason ?? null,
);
claimRow(10, 't1', 'claim', 'a-run');
claimRow(11, 't2', 'claim', 'b-run');
claimRow(12, 't1', 'release', 'a-run');
claimRow(20, 't1', 'claim', 'c-run');
claimRow(40, 't1', 'revoke', 'c-run', { by: 'jason', reason: 'stuck' });
for (const [id, thread] of [['m-1', 't1'], ['m-2', 't2'], ['m-3', 't1']]) {
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', id, AT, 'demo', 'human', 'human-cli', 'assistant', 'INFO', id);
run('INSERT INTO message_threads VALUES(?,?)', id, thread);
}
run('INSERT INTO thread_opens(seq,at,thread,by) VALUES(?,?,?,?)', 7, AT, 't1', 'jason');
db.close(); chmodSync(path, 0o600);
return { root, path };
}
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
} finally { db.close(); }
};
const rows = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens', 'meta']
.map((name) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all().map((r) => ({ ...r }))]));
} finally { db.close(); }
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
};
test('T7: a 3k store migrates to 3l, each thread\'s default session has the thread\'s id, and old rows keep their seqs', (t) => {
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
const store = new Store(root);
t.after(() => store.close());
assert.deepEqual(store.all('SELECT id,at,business,thread,instance,owner,name,op,predecessor FROM sessions ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
{ id, at: AT, business: 'demo', thread: id, instance: 'assistant', owner: 'jason', name: 'default', op: 'legacy-3l', predecessor: null })));
assert.deepEqual(store.all('SELECT at,thread,session,by FROM session_defaults ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
{ at: AT, thread: id, session: id, by: 'migration-3l' })));
// Generation counts claim rows only: claim, release, claim, revoke on t1 is 1, 1, 2, 2.
assert.deepEqual(store.all('SELECT seq,thread,session,op,generation FROM thread_claims ORDER BY seq').map((r) => ({ ...r })), [
{ seq: 10, thread: 't1', session: 't1', op: 'claim', generation: 1 },
{ seq: 11, thread: 't2', session: 't2', op: 'claim', generation: 1 },
{ seq: 12, thread: 't1', session: 't1', op: 'release', generation: 1 },
{ seq: 20, thread: 't1', session: 't1', op: 'claim', generation: 2 },
{ seq: 40, thread: 't1', session: 't1', op: 'revoke', generation: 2 },
]);
assert.deepEqual(store.all('SELECT message,thread,session FROM message_threads ORDER BY rowid').map((r) => ({ ...r })), [
{ message: 'm-1', thread: 't1', session: 't1' },
{ message: 'm-2', thread: 't2', session: 't2' },
{ message: 'm-3', thread: 't1', session: 't1' },
]);
assert.deepEqual({ ...store.get('SELECT seq,thread,session,by FROM thread_opens') }, { seq: 7, thread: 't1', session: 't1', by: 'jason' });
for (const [name, seq] of [['thread_claims', 40], ['thread_opens', 7]])
assert.equal(store.get('SELECT seq FROM sqlite_sequence WHERE name=?', name).seq, seq, name);
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
// A legacy client names no session and gets the default: t2's holder releases at generation 1,
// and t1's next claim is its third.
const { call, launch, claim, claims, sessionOf } = broker(t, store);
for (const r of ['chief', 'pm']) {
launch(r, r);
claim(r);
}
assert.throws(() => call('human', 'session.create', { thread: 't1', name: 'default', op: 'op-new' }), { code: 'session-exists' });
store.run("INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo','t2','t2',1,'release','b-run','pi','b-run')", now());
assert.equal(store.get("SELECT seq FROM thread_claims WHERE thread='t2' ORDER BY seq DESC LIMIT 1").seq, 41);
launch('d', 'assistant', { thread: 't1' });
claim('d');
assert.deepEqual(claims('t1').at(-1), { op: 'claim', holder_run: 'd-run', generation: 3 });
const sent = call('human', 'thread.send', { thread: 't1', body: 'after the step' });
assert.equal(sessionOf(sent.id), 't1');
assert.deepEqual(call('d', 'message.receive').map((m) => m.body), ['m-1', 'm-3', 'after the step']);
assert.deepEqual(call('human', 'thread.messages', { thread: 't1' }).map((m) => m.body), ['m-1', 'm-3', 'after the step']);
});
// The 3k engine is store.mjs at this change with the 3k schema and version and no 3k→3l step.
async function engine3k(root) {
const moduleRoot = join(root, 'engine-3k');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3k);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3k(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = rows(path);
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
const { root: old } = source3k(t);
new Store3k(old).close();
});
+12 -2
View File
@@ -48,17 +48,27 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
// thread_claims and thread_opens stay in the compare: the step carries their seq.
const SEQ_3L = ['sessions', 'session_defaults'];
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'sessions', seq: 1 }, { name: 'session_defaults', seq: 1 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
}
test('3e copy migrates through 3f to 3g preserving every old column/row and adding null spec citations', t => {
const { root, path, copy } = source3e(t), before = snapshot(path), untouched = readFileSync(copy);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
} finally { store.close(); }
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(readFileSync(copy), untouched);
assert.deepEqual(snapshot(copy).rows, before.rows);
new Store(root).close();
@@ -23,6 +23,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
'instance.launch', 'instance.end', 'instance.request',
// Row 264a (#1769): the sessions of the tester's own threads.
'session.list', 'session.create',
]);
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
+24 -3
View File
@@ -13,6 +13,11 @@ import { Broker } from '../src/broker.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
const ROW = ['thread', 'instance', 'holder_run', 'harness', 'at'];
// Row 264a (#1769): a hand-made thread gets the default session the 3k-3l migration gives an old one.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-claims-'));
@@ -45,10 +50,12 @@ function setup(t) {
const thread = (name) => {
const id = randomUUID();
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', project, 'assistant', 'jason', name);
for (const sql of DEFAULT_SESSION) store.run(sql, id);
return id;
};
const claim = (H, run) => {
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H });
// A run on the thread's default session, or on `session` when one is named.
const claim = (H, run, session) => {
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H, ...(session ? { session } : {}) });
call(cap, 'role.claim');
return cap;
};
@@ -108,8 +115,22 @@ test('T5: the result carries neither the thread name a user wrote nor a message
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
m, AT, 'demo', 'assistant', 'assistant-run', 'human', 'INFO', null, null, null, 'alice secret message body',
);
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', m, H);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', m, H, H);
const text = JSON.stringify(call('human', 'thread.claims'));
assert.ok(text.includes(H), 'the thread row is there');
assert.doesNotMatch(text, /secret/);
});
// Row 264a (#1769, sage-2591, duckworth-0124 C4): a claim is held per session, so one thread with two
// held sessions lists both runs, and releasing one leaves the other. A per-thread latest row would show
// only the last claim.
test('T6: two held sessions of one thread list as two rows, each its session\'s latest claim', (t) => {
const { call, thread, claim } = setup(t);
const H = thread('plan');
const S = call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' }).id;
const first = claim(H, 'run-default');
claim(H, 'run-second', S);
assert.deepEqual(call('human', 'thread.claims').map((r) => [r.thread, r.holder_run]), [[H, 'run-default'], [H, 'run-second']]);
call(first, 'role.release');
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-second'], 'the default released, the second held');
});
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3k ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
});
+439
View File
@@ -0,0 +1,439 @@
// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const here = (p) => new URL(p, import.meta.url);
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
// As user-principal.test.mjs: the access document is a function each test edits between requests.
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => {
if (access.fail) throw new Error('access file refused');
return access.doc;
},
});
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const p1 = call('human', 'project.create', { name: 'shared' }).id;
const p2 = call('human', 'project.create', { name: 'private' }).id;
// alice and bob hold collaborator grants on p1, carol on p2 only.
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
access.doc.grants = base();
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
}
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
const { call, code, A, p1, rows, read } = setup(t);
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
const [listed] = call('bob', 'thread.list', { shared: true });
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
// The plain list keeps its shape and holds only bob's own threads.
assert.deepEqual(call('bob', 'thread.list'), []);
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
for (const [verb, args] of [
['thread.send', { body: 'bob was here' }],
['thread.open', {}],
['thread.share', { to: '*' }],
['thread.share', { to: 'jason' }],
['thread.unshare', { to: 'user:bob' }],
['thread.shares', {}],
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
assert.equal(rows(), 1);
assert.deepEqual(read('alice', A), ['alice: hello']);
// The share is bob's alone: the owner and carol still can't read.
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
});
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
const { call, code, A, H, rows, read } = setup(t);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(read('human', A), ['alice: hello']);
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
assert.equal(rows(), 2);
});
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
const { access, call, code, base, A, p2, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
// A grant on another instance is the same.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
access.doc.grants = base();
assert.deepEqual(read('bob', A), ['alice: hello']);
// Removed, expired or an unreadable file: the gate refuses bob's next request.
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('bob', A), ['alice: hello'], what);
}
});
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
const { access, call, code, base, A, sent, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
assert.deepEqual(read('human', A), ['alice: hello']);
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('human', A), ['alice: hello'], what);
}
// A viewer grant still covers: rule 3 asks for any level.
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('human', A), ['alice: hello']);
});
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
const { store, call, code, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
{ op: 'share', target: 'user:bob', by: 'user:alice' },
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
{ op: 'share', target: 'user:bob', by: 'user:alice' },
]);
// An unshare of `*` leaves bob's own share in force.
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.unshare', { thread: A, to: '*' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(rows(), 5);
});
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
const { access, call, code, base, A, p1, rows } = setup(t);
// carol's grant covers p2 only; dave has none; erin's is expired.
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
for (const to of ['user:carol', 'user:dave', 'user:erin'])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
assert.equal(rows(), 0);
// A viewer grant is enough to be shared with, though at this base it can't bind a session.
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
// An unshare checks no coverage: an uncovered target's share can still be ended.
access.doc.grants = base();
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
assert.equal(rows(), 2);
});
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
const { call, code, A, H, rows } = setup(t);
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
for (const to of ['jason', 'user:x y'])
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
assert.equal(rows(), 0);
// The owner shares its own threads only; a user's thread refuses the business human too.
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
assert.equal(rows(), 0);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
});
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
const { access, call, code, base, A, p2, rows } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
assert.equal(rows(), 1);
});
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
const { store, call, code, A, H, rows } = setup(t);
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
const was = refusals();
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
for (const thread of [T, H, A]) {
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
}
// Each refusal is recorded, as every refused request is.
assert.equal(refusals() - was, 18);
assert.equal(rows(), 0);
// A share with `*` of the owner's thread doesn't reach the tester's reads.
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
const { store, call, code, A, sent } = setup(t);
// The run's reply to alice, as R3's `to: 'user'` path writes it.
const reply = randomUUID();
store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, A);
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
call('alice', 'thread.share', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
}
// The tester never reads another's thread (rule 4).
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
});
test('a share covers every session of the thread', (t) => {
const { store, call, code, H, read } = setup(t);
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
call('human', 'thread.share', { thread: H, to: 'user:alice' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
});
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
const { call, code, A, H } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
// The owners still list their own.
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
});
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
const { b, caps, call, code, A, H, sent } = setup(t);
// The assistant run serving alice's thread, and a chief run with no thread.
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
for (const who of ['agent', 'chief']) call(who, 'role.claim');
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.share', { thread: A, to: 'jason' });
// The human principals now read it; the agents and the tester see what they saw before.
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
for (const who of ['agent', 'chief']) {
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
}
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
const AT = '2026-10-22T00:00:00.000Z';
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
function source3l(t) {
const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
db.exec(ddl3l);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
}
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
db.close();
chmodSync(path, 0o600);
return { root, path };
}
const open = (path, fn) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return fn(db); } finally { db.close(); }
};
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
// Every table's rows but meta's, whose version and digest the step changes.
const tables = (path) => open(path, (db) => Object.fromEntries(
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
));
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
const { root, path } = source3l(t);
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
assert.deepEqual(after, before);
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
// No existing thread is shared: each stays private to its owner.
const store = new Store(root);
t.after(() => store.close());
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
for (const [sql, args] of [
["UPDATE thread_shares SET target='*'", []],
['DELETE FROM thread_shares', []],
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
});
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
async function engine3l(root) {
const moduleRoot = join(root, 'engine-3l');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3l(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = tables(path);
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
const { root: old } = source3l(t);
new Store3l(old).close();
});
+44 -18
View File
@@ -7,6 +7,12 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, THREAD_PAGE } from '../src/broker.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
// Row 121 (#1615): projects and threads in the bus. The assistant's messaging lists are the
// shipped roles/assistant.json's, so the chain below runs under the lists the stack runs.
@@ -81,32 +87,36 @@ test('REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the
const { b, store, call, launch, claim, A, B } = twoThreads(t);
launch('a2', 'assistant', { thread: A });
assert.throws(() => claim('a2'), { code: 'thread-already-held' });
// The trigger refuses a second claim that skips the broker.
// The trigger refuses a second claim that skips the broker. Row 264a: the claim is of the
// thread's default session, with the generation the trigger expects.
const sessionOf = (thread) => store.get('SELECT session FROM session_defaults WHERE thread=?', thread).session;
const second = () =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
now(),
'demo',
A,
sessionOf(A),
'a2-run',
'pi',
'a2-run',
);
assert.throws(second, /thread already held/);
// Second case: one run holds one thread. a-run already holds A.
assert.throws(second, /session already held/);
// Second case: one run holds one session. a-run already holds A's.
call('b', 'role.release');
assert.throws(
() =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
now(),
'demo',
B,
sessionOf(B),
'a-run',
'pi',
'a-run',
),
/run already holds a thread/,
/run already holds a session/,
);
// A released thread can be claimed again by a new run; the claim is the thread's, not the role's.
launch('b2', 'assistant', { thread: B });
@@ -128,12 +138,20 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
});
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));
const run = (sql, ...args) => db.prepare(sql).run(...args);
// Row 264a (#1769): a claim row names its session and the generation thread_claim_generation
// expects, so each refusal below comes from the trigger it names.
const generation = (op) =>
op === 'claim'
? 1 + db.prepare("SELECT count(*) n FROM thread_claims WHERE session='s-1' AND op='claim'").get().n
: (db.prepare("SELECT generation FROM thread_claims WHERE session='s-1' ORDER BY seq DESC LIMIT 1").get()?.generation ?? null);
const claim = (op, run_, extra = {}) =>
run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?)',
'INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
now(),
extra.business ?? 'demo',
extra.thread ?? 'th-1',
's-1',
generation(op),
op,
run_,
'pi',
@@ -143,9 +161,11 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
);
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-1',?,'demo','p-1','assistant','jason','one')", now());
run("INSERT INTO thread_opens(at,thread,by) VALUES(?,'th-1','jason')", now());
run("INSERT INTO sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-1',?,'demo','th-1','assistant','jason','default','default')", now());
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
run("INSERT INTO message_threads(message,thread) VALUES('m-1','th-1')");
run("INSERT INTO message_threads(message,thread,session) VALUES('m-1','th-1','s-1')");
// A thread is in its project's business.
assert.throws(
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-x',?,'other','p-1','assistant','jason','x')", now()),
@@ -154,20 +174,22 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
// A claim names the thread's business.
assert.throws(() => claim('claim', 'a-run', { business: 'other' }), /a thread claim names the thread's business/);
// Nothing releases or revokes a thread nobody holds.
assert.throws(() => claim('release', 'a-run'), /thread is not held/);
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /thread is not held/);
assert.throws(() => claim('release', 'a-run'), /session is not held/);
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /session is not held/);
claim('claim', 'a-run');
// Only the holder releases; a revoke needs a decision or a reason.
assert.throws(() => claim('release', 'b-run'), /only the holder releases; others revoke/);
assert.throws(() => claim('revoke', 'a-run'), /revoke needs a resolved decision or a reason/);
claim('revoke', 'a-run', { reason: 'stuck' });
// Every 3e table is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
// Every 3e table, and row 264a's two, is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
const rows = {
projects: ["id='p-1'", 'name', "INSERT OR REPLACE INTO projects(seq,id,at,business,name,created_by) SELECT seq,id,at,business,name,created_by FROM projects WHERE id='p-1'"],
threads: ["id='th-1'", 'name', "INSERT OR REPLACE INTO threads(seq,id,at,business,project,instance,owner,name) SELECT seq,id,at,business,project,instance,owner,name FROM threads WHERE id='th-1'"],
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by) SELECT seq,at,business,thread,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread) VALUES('m-1','th-1')"],
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,by) SELECT seq,at,thread,by FROM thread_opens WHERE seq=1'],
sessions: ["id='s-1'", 'name', "INSERT OR REPLACE INTO sessions(seq,id,at,business,thread,instance,owner,name,op) SELECT seq,id,at,business,thread,instance,owner,name,op FROM sessions WHERE id='s-1'"],
session_defaults: ['seq=1', 'by', 'INSERT OR REPLACE INTO session_defaults(seq,at,thread,session,by) SELECT seq,at,thread,session,by FROM session_defaults WHERE seq=1'],
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,session,generation,op,holder_run,harness,by) SELECT seq,at,business,thread,session,generation,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread,session) VALUES('m-1','th-1','s-1')"],
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,session,by FROM thread_opens WHERE seq=1'],
};
for (const [table, [where, col, replace]] of Object.entries(rows)) {
const only = new RegExp(`${table} is append-only`);
@@ -225,7 +247,7 @@ test('REQ-MSG-1: a message to or from the assistant names its thread, or is refu
// The store refuses a thread on a message the instance is not party to.
const pmOnly = call('pm', 'message.send', { to: 'coder', body: 'unrelated' });
assert.throws(
() => store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', pmOnly.id, A),
() => store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,(SELECT session FROM session_defaults WHERE thread=?))', pmOnly.id, A, A),
/to or from the thread's instance/,
);
void b;
@@ -277,8 +299,10 @@ test('REQ-MSG-1: a reply can\'t carry one thread instance\'s thread to another t
const helper = { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } };
const { store, call, launch, claim, count } = setup(t, demo({ roles: { ...roles, helper } }));
const project = call('human', 'project.create', { name: 'two instances' }).id;
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']])
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']]) {
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, now(), 'demo', project, instance, 'jason', id);
for (const sql of DEFAULT_SESSION) store.run(sql, id);
}
launch('chief', 'chief');
claim('chief');
launch('a', 'assistant', { thread: 'th-a' });
@@ -314,7 +338,7 @@ test('REQ-THREAD-1 REQ-USER-2: one thread\'s messages never reach another thread
'pi',
null,
),
/only to its thread's holder/,
/only to its session's holder/,
op,
);
// B's holder can't read A's message either.
@@ -437,6 +461,7 @@ test('Q169 REQ-USER-2: assistant.stop ends the human\'s own assistant thread run
'someone-else',
'theirs',
);
for (const sql of DEFAULT_SESSION) store.run(sql, other);
launch('o', 'assistant', { thread: other });
claim('o');
const last = (thread) => ({
@@ -486,6 +511,7 @@ test('REQ-USER-2: the human verbs refuse another owner\'s thread', (t) => {
'someone-else',
'theirs',
);
for (const sql of DEFAULT_SESSION) store.run(sql, other);
for (const [verb, args] of [
['thread.open', { thread: other }],
['thread.send', { thread: other, body: 'hi' }],
+16 -4
View File
@@ -46,17 +46,29 @@ function snapshot(path, columns) {
columns ??= Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
.map(({ name }) => [name, db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name)]));
const rows = Object.fromEntries(Object.entries(columns).map(([table, keys]) => [table,
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY rowid`).all()
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY ${table === 'sqlite_sequence' ? 'name' : 'rowid'}`).all()
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
return { columns, rows };
} finally { db.close(); }
}
test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old column, row and sequence; new usage table is empty', t => {
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
// thread_claims and thread_opens stay in the compare: the step carries their seq.
// The step writes the thread_claims and thread_opens rows again, after instance_names', so the
// snapshot reads sqlite_sequence by name, as account-migration.test.mjs does (row 126).
const SEQ_3L = ['sessions', 'session_defaults'];
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'session_defaults', seq: 1 }, { name: 'sessions', seq: 1 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
}
test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving every old column, row and sequence; new usage table is empty', t => {
const { root, path, copy } = source3g(t), before = snapshot(path), untouched = readFileSync(copy);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
@@ -64,7 +76,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
} finally { store.close(); }
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(readFileSync(copy), untouched);
assert.deepEqual(snapshot(copy).rows, before.rows);
new Store(root).close();
+3 -1
View File
@@ -139,7 +139,9 @@ test('R3: every verb off the collaborator list refuses a user access-refused bef
]);
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
// rather than leaving the set below with the verb it added (M3x, M6x).
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
// Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs).
'thread.share', 'thread.unshare', 'thread.shares']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others) {
+28 -1
View File
@@ -13,11 +13,13 @@ import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { hostGrantRecord, hostRecord } from "../../bus/tests/host-fixtures.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { processIdentity, registerRuntime, sweepRuntimes, unregisterRuntime, updateRuntime } from "../../processing-control/src/runtimes.mjs";
@@ -428,6 +430,31 @@ test("row 194 V5: a delete with no workspace commits and closes with the transac
assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false);
});
// Row 281 (#1786, d6 section 2, T10 B2): a delete of an instance with a grant answers at once. The pass
// closes the first request grants-revoking while step G runs as a job, and the host channel stays up:
// the next op answers. The grant is a seeded record on a TEST-NET host, so the job's remove can only
// fail or wait; either way the request has already closed.
test("row 281 T10: the delete pass closes a granted instance's first request grants-revoking and the host channel stays up", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
// A host grant gives the agent a host.web1 store reference, so Credentials checks the store root at start.
mkdirSync(join(dirname(p.f.config), "secrets", "agents"), { recursive: true, mode: 0o700 });
const seeded = new Store(p.f.dataRoot);
const entry = { address: "192.0.2.10", port: 22, user: "deploy", hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests"] };
hostRecord(seeded, { business: "acme", host: "web1", entry });
hostGrantRecord(seeded, { business: "acme", agent: "sec-1", host: "web1", commands: [["uptime"]] });
seeded.close();
const s = await start(t, p);
const del = s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request;
const r = await s.carry(del);
assert.equal(r.done.refusal, "grants-revoking");
assert.equal(r.done.transaction ?? null, null);
// The channel answers the next op and the next pass.
assert.ok(s.call("instances").revision);
await s.launcher.tick();
assert.ok(Object.hasOwn(JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8")).roles, "sec-1"));
await s.runtime.broker.grantsIdle();
});
// Every regular file under root, by path, as a sha256. Sockets and other special files are left out.
function snapshot(root) {
const out = {};
+1 -1
View File
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','thread_opens','instance_names','usage_records'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
+8
View File
@@ -17,6 +17,12 @@ import { run } from "../src/cli.mjs";
import { CliError } from "../src/errors.mjs";
import { busExit } from "../src/transport.mjs";
import { humanEnv, io } from "./helpers.mjs";
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), "utf8"));
const roles = {
@@ -185,6 +191,7 @@ test("REQ-CLI-1 a thread is its id, an id prefix of 8 characters or more, or <pr
const at = new Date().toISOString();
for (const n of ["1", "2"]) {
s.store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", `abcdef01-0000-4000-8000-00000000000${n}`, at, "demo", s.project, "assistant", "jason", `twin${n}`);
for (const sql of DEFAULT_SESSION) s.store.run(sql, `abcdef01-0000-4000-8000-00000000000${n}`);
}
const resolve = async (ref) => (await s.cli(["thread", "open", ref]));
const opened = (r) => r.out.trim();
@@ -322,6 +329,7 @@ test("REQ-USER-2 a thread of another user is refused for talk, open, read and fo
const theirs = randomUUID();
s.store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-theirs", at, "demo", "theirs", "stack", "someone-else");
s.store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", theirs, at, "demo", "p-theirs", "assistant", "someone-else", "private");
for (const sql of DEFAULT_SESSION) s.store.run(sql, theirs);
// A message waiting for the user: message.receive would write a delivery row for it.
s.call("chief", "message.send", { to: "human", body: "waiting", class: "INFO" });
const before = s.writes();
+8 -1
View File
@@ -27,6 +27,12 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
@@ -356,7 +362,8 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thre
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
for (const [id, instance] of [["../evil", "assistant"], ["coder-thread", "coder"]]) {
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", id, at(), "acme", "p-hand", instance, "jason", id);
store.run("INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)", at(), id, "jason");
for (const sql of DEFAULT_SESSION) store.run(sql, id);
store.run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)", at(), id, id, "jason");
}
store.close();
+9 -1
View File
@@ -5,6 +5,12 @@ import { createChat } from '../src/chat.mjs';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
const P = '10000000-0000-4000-8000-000000000001';
const T = '20000000-0000-4000-8000-000000000002';
@@ -220,15 +226,17 @@ test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated histor
const other = '40000000-0000-4000-8000-000000000004';
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
other, new Date().toISOString(), 'demo', project, 'assistant', 'someone-else', 'PRIVATE-NAME');
for (const sql of DEFAULT_SESSION) store.run(sql, other);
store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
M, new Date().toISOString(), 'demo', 'human', 'human-cli', 'assistant', 'INFO', 'PRIVATE-BODY');
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', M, other);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', M, other, other);
const foreignProject = '50000000-0000-4000-8000-000000000005';
const foreignThread = '60000000-0000-4000-8000-000000000006';
store.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)',
foreignProject, new Date().toISOString(), 'foreign', 'FOREIGN-PROJECT', null, 'jason');
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
foreignThread, new Date().toISOString(), 'foreign', foreignProject, 'assistant', 'jason', 'FOREIGN-THREAD');
for (const sql of DEFAULT_SESSION) store.run(sql, foreignThread);
assert.equal((await get('/api/chat/threads')).data.rows.length, 2);
const before = ['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n);
for (const id of [other, T, foreignThread]) for (const suffix of ['messages', 'open']) {
+1 -1
View File
@@ -417,7 +417,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads thread_opens instance_names usage_records"
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records"
STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");