diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 223a1be90..71d1f5d5a 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -4967,3 +4967,12 @@ Not done: row 152 (#1646) is in review. - Follow-ups in Vikunja P19, filed with agents/sage/work/vikunja-setup/file-r148-followups.mjs: 318 (Save stays off after a paint throw outside the render), 319 (the dead NUL check at bus.js:671 and :698), 320 (tracks Gitea #1650, the 1 MiB + 64 KiB buffer before the human proof). Correction to mos-0636's labels: #1650 is the buffering issue, not the NUL check. - Earlier the same hour: pushed 862af3079..a4779d2a5 (Dewey's and Filbert's SESSIONS lines and queue revs 1588-1623) with no gate, under Mos's 18:57Z docs rule; no test reads the live queue files. - Not live: the stack runs 52f35e7a0. The editor reaches the Console only through a re-pin, which needs Mos's go. Row 141 r1 is with Filbert, who holds the slot from 22:24Z for single test files. + +2026-10-16 22:36:51 UTC | Sage | row 141 landed as de352dfaf (messages addressed by agent name, #1635); schema 3f to 3g; two follow-ups filed + +- Before: refactor at 7e40fb83b, row 141 in review on round 1 (Darkwing, build.patch bb036c6d, manifest d474b330, 26 paths, base 18aee8fa1), approved by Filbert (28841). After: de352dfaf on origin/refactor, row 141 done (queue rev 1626, committed in 28eeef8ea with Filbert's rev 1625), #1635 closed with comment 28844. +- Drift: row 148 (d4ea7e5a6) changed packages/bus/README.md and packages/bus/src/broker.mjs. Sage built a rebase-only round 2 in agents/sage/work/queue-141-r2/: the r1 patch applied unchanged on 7e40fb83b, build.patch 72b890d9, manifest cae29087. The interdiff has only index and @@ lines: README +11, broker +5 for the first hunk and +41 for the other three. Filbert confirmed it and the per-file offsets in 28842. +- Landing gate on 7e40fb83b plus the candidate in /mnt/storage/scratch/sage-r141: 25 steps all rc 0 (15 packages, 10 script suites), 22:30:48Z to 22:35:15Z, network sage-r141_default removed, worktree removed. The commit was rebased onto Filbert's 2a85b41da, which adds one docs/SESSIONS.md line that no test reads; the manifest checks OK after the rebase. Push d49da64a9..de352dfaf: fast-forward, no binaries, secrets grep 0 matches; it carried Filbert's two SESSIONS commits 7e40fb83b and 2a85b41da. +- The row gate's "migration tested on a 3f copy" is name-migration.test.mjs on the tests/fixtures/schema-3f.sql store, never the live store. +- Carries: row 155's brief (docs/plans/2026-10-16_q169-assistant-config.md) now says the chief's untypedActions and bundle untyped list are ["instance.name"] and that row 155 brings both back to [] (tools.test.mjs:43, bundle.test.mjs:94). Vikunja P19 321 (TOOLS.md lacks mosaic name and names; acceptance 314 step 10 wording, Filbert's doc-only follow-up) and 322 (refuse or report a new key or definition equal to a current name), filed with agents/sage/work/vikunja-setup/file-r141-followups.mjs. +- Not live: the stack runs 52f35e7a0 at schema 3f. Per mos-0638, rows 148 and 141 go live in one re-pin window, with the 3f to 3g migration and the chief-manifest check expecting untyped == ["instance.name"]. Sage writes that plan next; the window needs Mos's go. diff --git a/agents/sage/work/queue-141-r2/README.md b/agents/sage/work/queue-141-r2/README.md new file mode 100644 index 000000000..d21425ce8 --- /dev/null +++ b/agents/sage/work/queue-141-r2/README.md @@ -0,0 +1,27 @@ +# Row 141 round 2: rebase only (Sage) + +Darkwing's round 1 (agents/darkwing/work/queue-141/, build.patch bb036c6d, +manifest d474b330, base 18aee8fa1) was approved by Filbert (28841). Row 148 +(d4ea7e5a6) then changed two of its 26 paths: packages/bus/README.md and +packages/bus/src/broker.mjs. + +Sage applied the round 1 patch unchanged with `git apply --index` onto +7e40fb83b with no conflicts. Both paths move by offset only: + +- packages/bus/README.md: index line moved; its one hunk moved from 752/754 + to 763/765, an 11-line offset from row 148's README addition. +- packages/bus/src/broker.mjs: index line moved; its four hunks moved by 5 + (539/543 to 544/548) and by 41 for the other three (1536, 1583, 1917 to + 1577, 1624, 1958). Row 148's broker lines above each hunk account for + the shift. + +No other path's index or @@ line changed. + +- build.patch: 72b890d9, the staged diff on 7e40fb83b +- candidate-manifest.sha256: cae29087, the same 26 paths. Only the two paths + above change hash. +- interdiff.txt: `diff` of the r1 and r2 build.patch. With `index ` and `@@ ` + lines removed, both patches are byte-identical (checked with cmp). + +The reviewer checks that interdiff.txt touches only index and @@ lines. Sage's +full landing gate runs on 7e40fb83b plus this patch. diff --git a/agents/sage/work/queue-141-r2/build.patch b/agents/sage/work/queue-141-r2/build.patch new file mode 100644 index 000000000..00ee11671 --- /dev/null +++ b/agents/sage/work/queue-141-r2/build.patch @@ -0,0 +1,1408 @@ +diff --git a/packages/bus/README.md b/packages/bus/README.md +index 0e43da238..cf299049f 100644 +--- a/packages/bus/README.md ++++ b/packages/bus/README.md +@@ -187,9 +187,11 @@ an operator inspect content and delivery evidence after an uncertain result. + | `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). | + | `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. | + | `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. A human withdraw sends the raising role a DECISION message; the raising run's own withdraw sends none. | +-| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`, plus `waiting` when no run holds the target (Launch notices, below). Human instruction's `request` is its `human.input` event ID. | ++| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`, plus `waiting` when no run holds the target (Launch notices, below). Human instruction's `request` is its `human.input` event ID. `to` is a role instance, its name (row 141, #1635, REQ-MSG-2), `human` or `tester`. A name resolves to its instance before any check; the message stores the instance in `to_role` and the name in `to_name`, null for a send by instance. An instance key, `human`, `tester` and a role definition the business knows win over a name equal to them, so an instance added later with a key some instance holds as a name still gets its messages, and a send to a later definition's name refuses `unknown-role`. A name the business doesn't hold refuses `unknown-role`. | + | `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. A human decision close's message carries the close's `human.input` answer event as its `request`; a role close's has `null`. | + | `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. | ++| `name.set` | `{instance,name}`; action `instance.name`, routed under REQ-MSG-2 (row 141, #1635). The outside-agent human CLI may; an agent only when its role file grants `instance.name` within-role, which the chief of staff's does, and the write logs `action.allowed` with the instance as target. Any other class refuses `not-authorized`, so no decision sets a name. `name` matches the business ID pattern. It refuses `name-is-instance` when it equals an instance key, `human` or `tester`, `name-is-role` when it equals a role definition the loaded business knows (one an instance uses or one it defines), and `name-taken` when another instance of the business holds it now. The `instance_names` trigger backs up the last. Naming an instance again replaces its name. Returns `{instance,name}`. | ++| `names` | No args; human and agent sessions. Each named instance's current `{instance,name,at}`, by instance. | + | `staff.request` | `{task_ref,roles,note?,decision?}`; action `message.send` (row 140, #1634, REQ-COS-1). `roles` is 1 to 10 distinct `{role,count}` entries, `count` an integer 1 to 20; any other shape is `invalid-request`. Only an instance of the `pm` definition sends it (`staff-request-refused`), and it goes to the business's launcher, which must be an instance of `chief-of-staff` (`no-chief-of-staff`); the definitions' `messaging` lists apply as for `message.send`. Sends that instance a STAFF-REQUEST message naming the task and the roles with counts, and writes an `action.allowed` event with `operation: staff.request`, `staff_for` and `roles`. `message.send` and `thread.send` refuse the class `invalid-class`, so no other path writes one. It grants nothing: the chief of staff launches through `role.launch` within the organization's limits and replies to the message with the instances it launched or the refusal. Returns `{message,to}`. | + | `review.request` | `{task_ref,commit,to,note?,decision?}`; action `review.request` (row 93, #1587). `commit` must be the task's latest pushed commit: an older one refuses `stale-commit`, any other `unknown-commit`. `to` must be another role holding `review.verdict` (`unknown-role`, `self-review`, `not-a-reviewer`). Writes a `review.requested` event `{task_ref,project,ref,commit,to,message,held}` and sends `to` a REVIEW-REQUEST message naming it. `held` is the run id holding the `to` role when the request is routed (row 105, #1599), or `null` when no run holds it: the message then waits for a claim, and the requester should tell whoever launches roles. Returns `{request,message,commit,ref,held}`. | + | `review.verdict` | `{task_ref,request,verdict,body,decision?}`; action `review.verdict`. `verdict` is `approve` or `changes`. `request` must be a `review.requested` event on the task addressed to the caller's role (`request-not-found`), with no verdict yet (`verdict-exists`). Writes a `review.verdict` event `{task_ref,request,commit,verdict,message}` and sends the requesting role a REVIEW-RESULT in reply to the request's message. Returns `{verdict,message,commit}`. | +@@ -641,10 +643,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services. + + ## Schema and verification + +-`schema.sql` is v3f, SHA-256 +-`472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2`. +-It has 14 tables, 64 triggers and five views. Source digest and a trusted full +-sqlite_master digest are checked at open, alongside schema version `3f`, WAL, ++`schema.sql` is v3g, SHA-256 ++`a79b3dbe6f4dfba10871c01ad8513ea290071eb8d8af2b87be6db52dbcf23428`. ++It has 15 tables, 68 triggers and five views. Source digest and a trusted full ++sqlite_master digest are checked at open, alongside schema version `3g`, 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. +@@ -763,3 +765,9 @@ with nulls on existing rows. It changes no event or resolution and creates no + spec/confirmation tables. The 3e fixture is pinned from 26a0b0fab's tree; no live + store is used. Mos runs this step only in an approved stopped-bus/re-pin window, + as described in the candidate packet's MIGRATION.md. ++ ++The 3f→3g step (row 141, #1635, REQ-MSG-2) adds the append-only `instance_names` table, ++its triggers, and a nullable `to_name` column on messages with nulls on existing rows. It ++changes no other row. The 3f fixture, `tests/fixtures/schema-3f.sql`, is pinned from ++4ebef2284's tree; no live store is used. The step runs only in its own stopped-bus/re-pin ++window on Mos's go. +diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql +index 6603cdad8..6e306431f 100644 +--- a/packages/bus/schema.sql ++++ b/packages/bus/schema.sql +@@ -79,7 +79,7 @@ CREATE TABLE messages ( + decision TEXT REFERENCES decisions(id), + corrects TEXT REFERENCES messages(id), + body TEXT NOT NULL +-) STRICT; ++, to_name TEXT) STRICT; + CREATE TABLE deliveries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + message TEXT NOT NULL REFERENCES messages(id), +@@ -319,3 +319,19 @@ CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN + 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; +diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs +index a4ccee699..a72f95885 100644 +--- a/packages/bus/src/broker.mjs ++++ b/packages/bus/src/broker.mjs +@@ -1,6 +1,7 @@ + import { randomUUID, randomBytes } from 'node:crypto'; + import { loadRequirements } from '../../tasks/src/requirements.mjs'; + import { MERGE, LandingRefusal, readLanding } from './landing.mjs'; ++import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; + + // Kept closed while S1 lands; its vocabulary is the integration authority. + export const ACTIONS = Object.freeze([ +@@ -24,6 +25,8 @@ export const ACTIONS = Object.freeze([ + 'role.revoke', + 'role.end', + 'organization.change', ++ // Row 141 (#1635): routed under PRD 1.5 REQ-MSG-2. ++ 'instance.name', + 'credential.mint', + 'spend', + 'deploy', +@@ -55,6 +58,7 @@ const ROUTED = new Set([ + 'role.revoke', + 'role.end', + 'organization.change', ++ 'instance.name', + 'policy.change', + 'prd.approve', + 'decision.resolve.technical', +@@ -544,6 +548,22 @@ export class Broker { + #sender(s) { + return s.human ? 'human' : s.tester ? 'tester' : s.role; + } ++ // Row 141 (#1635, REQ-MSG-2): the role names a name may not equal, which are the definitions the ++ // loaded business knows (sage-0590). The broker doesn't read roles/. ++ #definitions(b) { ++ return new Set([...Object.keys(b.definitions ?? {}), ...Object.values(b.roles).map((r) => r.definition)]); ++ } ++ // Row 141 (#1635, REQ-MSG-2): the instance that currently holds `name` in the business, or null. ++ // An instance's latest instance_names row is its current name. ++ #named(business, name) { ++ return ( ++ this.#store.get( ++ 'SELECT instance FROM instance_names n WHERE business=? AND name=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance)', ++ business, ++ name, ++ )?.instance ?? null ++ ); ++ } + #decision(s, id) { + const d = this.#store.get('SELECT * FROM decisions WHERE id=? AND business=?', id, s.business); + if (!d) fail('decision-not-found'); +@@ -1577,30 +1597,39 @@ export class Broker { + } + case 'message.send': { + keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']); ++ // Row 141 (#1635, REQ-MSG-2): `to` is an instance key, an instance's name, "human" or ++ // "tester". A name resolves to its instance first, so every check below reads the instance ++ // key, and the message records both. name.set refuses a name equal to a key or a role, but an ++ // instance or definition added later can take a word some instance holds as its name. The key ++ // or the role wins: a send to a definition no instance is keyed by refuses unknown-role. ++ const named = typeof a.to === 'string' && !Object.hasOwn(b.roles, a.to) && a.to !== 'human' && a.to !== 'tester' ++ && !this.#definitions(b).has(a.to) ++ ? this.#named(s.business, a.to) : null; ++ const to = named ?? a.to; + if (s.tester) { + // A tester writes to a role only, says so in its first words, and cites no decision. + // It consumes no authority and writes no human.input. + keys(a, ['to', 'body', 'class', 'in_reply_to'], ['to', 'body']); +- if (!Object.hasOwn(b.roles, a.to)) fail('unknown-role'); ++ if (!Object.hasOwn(b.roles, to)) fail('unknown-role'); + if (typeof a.body !== 'string' || !a.body.startsWith(TESTER_PREFIX)) fail('tester-prefix'); + } else if (!s.human) { + const withinRole = this.#classification(s, 'message.send') === 'within-role'; + this.#consumeAuthority(s, 'message.send', { + decision: withinRole ? null : (a.decision ?? null), +- target: a.to, ++ target: to, + }); + // A role answers a tester only in reply to a tester's message. + if ( +- a.to === 'tester' && ++ to === 'tester' && + !(id(a.in_reply_to) && this.#store.get("SELECT 1 FROM messages WHERE id=? AND business=? AND from_role='tester'", a.in_reply_to, s.business)) + ) + fail('tester-reply-required'); + } else { + this.#human(s); +- if (a.to === 'tester') fail('unknown-role'); ++ if (to === 'tester') fail('unknown-role'); + } +- if (a.to !== 'human' && a.to !== 'tester' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); +- this.#checkMessaging(s.business, this.#sender(s), a.to); ++ if (to !== 'human' && to !== 'tester' && !Object.hasOwn(b.roles, to)) fail('unknown-role'); ++ this.#checkMessaging(s.business, this.#sender(s), to); + string(a.body, 32768); + if ( + a.class !== undefined && +@@ -1624,27 +1653,28 @@ export class Broker { + } + } + if (a.decision) this.#decision(s, a.decision); +- const thread = this.#threadFor(s, a.to, a.in_reply_to); ++ const thread = this.#threadFor(s, to, a.in_reply_to); + const mid = randomUUID(); + this.#store.run( +- 'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)', ++ 'INSERT INTO messages(id,at,business,from_role,from_run,to_role,to_name,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', + mid, + this.#now(), + s.business, + s.human ? 'human' : s.role, + s.human ? 'human-cli' : s.tester ? 'tester-cli' : s.run, +- a.to, ++ to, ++ named ? a.to : null, + a.class ?? 'INFO', + a.in_reply_to ?? null, + a.decision ?? null, + a.corrects ?? null, + a.body, + ); +- this.#route(s.business, mid, a.to, thread); ++ this.#route(s.business, mid, to, thread); + const request = s.human + ? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) + : null; +- const waiting = this.#waiting(s, mid, a.to, thread); ++ const waiting = this.#waiting(s, mid, to, thread); + return waiting ? { id: mid, request, waiting } : { id: mid, request }; + } + case 'message.receive': { +@@ -1958,6 +1988,43 @@ export class Broker { + // --read`, row 150); since row 142 `mosaic talk` prints only replies to its own message. + // An id naming no message in the business refuses message-not-found, as message.send does + // for in_reply_to; trail would return []. ++ // Row 141 (#1635, REQ-MSG-2): the human names an instance through the CLI; an agent may only ++ // with `instance.name` granted within-role, which the chief of staff holds. Any other class ++ // refuses here, so neither a cross-role grant nor a decision can set a name. A name is unique ++ // in the business (the store's trigger backs this up) and equals no instance key, no role ++ // definition, "human" or "tester", so `to` never means two things. ++ case 'name.set': { ++ keys(a, ['instance', 'name'], ['instance', 'name']); ++ if (!s.human && this.#classification(s, 'instance.name', a.instance) !== 'within-role') { ++ this.#agent(s); ++ fail('not-authorized'); ++ } ++ if (s.human) this.#human(s); ++ if (!Object.hasOwn(b.roles, identifier(a.instance))) fail('unknown-role'); ++ if (typeof a.name !== 'string' || !ID_PATTERN.test(a.name)) fail('invalid-request'); ++ if (Object.hasOwn(b.roles, a.name) || a.name === 'human' || a.name === 'tester') fail('name-is-instance'); ++ if (this.#definitions(b).has(a.name)) fail('name-is-role'); ++ const holder = this.#named(s.business, a.name); ++ if (holder && holder !== a.instance) fail('name-taken'); ++ if (!s.human) this.#consumeAuthority(s, 'instance.name', { target: a.instance }); ++ this.#store.run( ++ 'INSERT INTO instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)', ++ this.#now(), ++ s.business, ++ a.instance, ++ a.name, ++ s.human || s.run, ++ ); ++ return { instance: a.instance, name: a.name }; ++ } ++ case 'names': { ++ keys(a, []); ++ if (!s.human) this.#agent(s); ++ return this.#store.all( ++ 'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance', ++ s.business, ++ ); ++ } + // Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a + // project from the business file; the rename to repos is a P19 follow-up. + case 'project.create': { +diff --git a/packages/bus/src/migrations/3f-3g.sql b/packages/bus/src/migrations/3f-3g.sql +new file mode 100644 +index 000000000..b40fda2b6 +--- /dev/null ++++ b/packages/bus/src/migrations/3f-3g.sql +@@ -0,0 +1,20 @@ ++-- Row 141 (#1635, REQ-MSG-2): a role instance's name. Append-only; an instance's latest row is its ++-- current name. The broker checks the name against the business file's instance keys and roles. ++-- messages.to_name records the name a send used; to_role stays the instance key. ++ALTER TABLE messages ADD COLUMN to_name TEXT; ++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; +diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs +index 92bd59b50..8f29dd37c 100644 +--- a/packages/bus/src/store.mjs ++++ b/packages/bus/src/store.mjs +@@ -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 = '472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2'; +-export const SCHEMA_VERSION = '3f'; ++export const SCHEMA_SHA256 = 'a79b3dbe6f4dfba10871c01ad8513ea290071eb8d8af2b87be6db52dbcf23428'; ++export const SCHEMA_VERSION = '3g'; + // 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'; +@@ -42,6 +42,11 @@ const migrations = [{ + from: '3e', + to: '3f', + sql: readFileSync(new URL('./migrations/3e-3f.sql', import.meta.url), 'utf8'), ++}, { ++ // Row 141 (#1635): instance names, and the name a message was sent to. Messages keep a null to_name. ++ from: '3f', ++ to: '3g', ++ sql: readFileSync(new URL('./migrations/3f-3g.sql', import.meta.url), 'utf8'), + }]; + const timedTables = [ + 'events', +@@ -55,6 +60,7 @@ const timedTables = [ + 'threads', + 'thread_claims', + 'thread_opens', ++ 'instance_names', + ]; + const hex = (value) => createHash('sha256').update(value).digest('hex'); + if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch'); +diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs +index 6de389662..46505ef73 100644 +--- a/packages/bus/tests/chief-of-staff.test.mjs ++++ b/packages/bus/tests/chief-of-staff.test.mjs +@@ -369,7 +369,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 = '3f';", "export const SCHEMA_VERSION = '3c';") ++ .replace("export const SCHEMA_VERSION = '3g';", "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'); +@@ -393,7 +393,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, '3f'); ++ assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g'); + 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 }); +diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs +index 0aa4b0292..d7675e297 100644 +--- a/packages/bus/tests/comment-migration.test.mjs ++++ b/packages/bus/tests/comment-migration.test.mjs +@@ -28,12 +28,12 @@ function legacy(t) { + return { root, source, path }; + } + +-test('explicit 3b copy migration preserves evidence and opens as 3f; normal startup never migrates', t => { ++test('explicit 3b copy migration preserves evidence and opens as 3g; normal startup never migrates', t => { + const { root, source } = legacy(t); + 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, '3f'); ++ assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g'); + 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; +@@ -62,7 +62,7 @@ test('migration refuses active writers and drifted legacy schemas, with no parti + assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/); + }); + +-test('named migration registry chains 3b through 3f to a fixture-only future version in order', async t => { ++test('named migration registry chains 3b through 3g to a fixture-only future version in order', async t => { + const { root } = legacy(t); + const { writeFileSync } = await import('node:fs'); + const { pathToFileURL } = await import('node:url'); +@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3f 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 = '3f';", "export const SCHEMA_VERSION = '3g';") +- .replace('}];\nconst timedTables', `}, { from: '3f', to: '3g', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); ++ .replace("export const SCHEMA_VERSION = '3g';", "export const SCHEMA_VERSION = '3h';") ++ .replace('}];\nconst timedTables', `}, { from: '3g', to: '3h', 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']) ++ for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.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, '3g'); ++ assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3h'); + 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 3f ready/); ++ assert.match(migrated.stdout, /bus schema 3g ready/); + new Store(root).close(); + const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); + assert.equal(usage.status, 2); +diff --git a/packages/bus/tests/fixtures/schema-3f.sql b/packages/bus/tests/fixtures/schema-3f.sql +new file mode 100644 +index 000000000..6603cdad8 +--- /dev/null ++++ b/packages/bus/tests/fixtures/schema-3f.sql +@@ -0,0 +1,321 @@ ++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 ++) 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; +diff --git a/packages/bus/tests/name-migration.test.mjs b/packages/bus/tests/name-migration.test.mjs +new file mode 100644 +index 000000000..3c3e361f9 +--- /dev/null ++++ b/packages/bus/tests/name-migration.test.mjs +@@ -0,0 +1,69 @@ ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { createHash } from 'node:crypto'; ++import { DatabaseSync } from 'node:sqlite'; ++import { Store, schemaDigest } from '../src/store.mjs'; ++// Row 141 (#1635, REQ-MSG-2): the 3f→3g step on a copy of a 3f store built here from the pinned 3f ++// schema (4ebef2284's schema.sql) and fixture rows. No live store is read or copied. ++const AT = '2026-10-16T00:00:00.000Z'; ++function source3f(t) { ++ const root = mkdtempSync(join(tmpdir(), 'name-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); ++ const ddl = readFileSync(new URL('./fixtures/schema-3f.sql', import.meta.url), 'utf8'); ++ assert.equal(createHash('sha256').update(ddl).digest('hex'), '472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2'); ++ db.exec(ddl); ++ const run = (sql, ...args) => db.prepare(sql).run(...args); ++ run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3f'); ++ run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db)); ++ run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT); ++ run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'event', AT, 'demo', 'action.allowed', '{}'); ++ run('INSERT INTO role_claims(at,business,role,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'coder', 'claim', 'run', 'pi', 'run'); ++ run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', ++ 'decision', AT, 'demo', 'coder', 'run', 'gated', 'deploy', 'human', '?', '[{"key":"yes","text":"Yes"},{"key":"no","text":"No"}]', 'yes', 0, 'docs/spec.md', 'c'.repeat(40)); ++ run('INSERT INTO decision_events(decision,at,op,by,choice) VALUES(?,?,?,?,?)', 'decision', AT, 'resolved', 'jason', 'yes'); ++ run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'message', AT, 'demo', 'human', 'human', 'assistant', 'INFO', 'kept'); ++ run('INSERT INTO deliveries(message,at,op) VALUES(?,?,?)', 'message', AT, 'routed'); ++ run('INSERT INTO task_snapshots(at,business,task_ref,updated,digest,fields,source,role,run) VALUES(?,?,?,?,?,?,?,?,?)', AT, 'demo', 'vikunja:1/1', AT, 'a'.repeat(64), '{"bucket":1,"done":false}', 'self', 'coder', 'run'); ++ run('INSERT INTO task_comment_cursors(business,task_id,last_comment) VALUES(?,?,?)', 'demo', 1, 8); ++ run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'project', AT, 'demo', 'Work', 'stack', 'jason'); ++ run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'thread', AT, 'demo', 'project', 'assistant', 'jason', 'One'); ++ run('INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'thread', 'claim', 'assistant-run', 'pi', 'assistant-run'); ++ run('INSERT INTO message_threads VALUES(?,?)', 'message', 'thread'); ++ run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', AT, 'thread', 'jason'); ++ db.close(); chmodSync(path, 0o600); ++ const copy = join(root, 'untouched-3f.sqlite'); copyFileSync(path, copy); ++ return { root, path, copy }; ++} ++function snapshot(path, columns) { ++ const db = new DatabaseSync(path, { readOnly: true }); ++ try { ++ 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() ++ .filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))])); ++ return { columns, rows }; ++ } finally { db.close(); } ++} ++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, '3g'); ++ 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(readFileSync(copy), untouched); ++ assert.deepEqual(snapshot(copy).rows, before.rows); ++ new Store(root).close(); ++}); +diff --git a/packages/bus/tests/names.test.mjs b/packages/bus/tests/names.test.mjs +new file mode 100644 +index 000000000..fe80499da +--- /dev/null ++++ b/packages/bus/tests/names.test.mjs +@@ -0,0 +1,180 @@ ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { fileURLToPath } from 'node:url'; ++import { Store } from '../src/store.mjs'; ++import { Broker } from '../src/broker.mjs'; ++ ++// Row 141 (#1635, REQ-MSG-2): messages are addressed by an instance's name. The human sets names ++// through the CLI; the chief of staff sets them because its role file grants `instance.name` ++// within-role. The grant is read from the shipped roles/chief-of-staff.json, so removing it there ++// fails the chief's test. Instance keys differ from definitions here, so each refusal is its own. ++const shipped = (name) => ++ JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); ++const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; ++ ++const roles = { ++ chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority }, ++ lead: { definition: 'pm', ...none }, ++ dev: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: ['instance.name'] } }, ++}; ++const demo = () => ({ ++ demo: { ++ id: 'demo', ++ human: 'jason', ++ arbiters: { technical: 'lead', delivery: 'chief' }, ++ roles, ++ definitions: { reviewer: {} }, ++ vars: { 'bus.tester': true }, ++ }, ++}); ++ ++function setup(t) { ++ const root = mkdtempSync(join(tmpdir(), 'bus-names-')); ++ const store = new Store(root); ++ t.after(() => { ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const b = new Broker({ store, businesses: demo() }); ++ const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) }; ++ for (const role of Object.keys(roles)) { ++ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' }); ++ b.request(caps[role], { verb: 'role.claim' }); ++ } ++ caps.reader = b.bindReader({ business: 'demo' }); ++ caps.tester = b.bindTester({ business: 'demo' }); ++ const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); ++ return { b, store, caps, call }; ++} ++ ++test('REQ-MSG-2: the chief of staff names an instance under its within-role grant, and the write is logged', (t) => { ++ const { store, call } = setup(t); ++ assert.ok(shipped('chief-of-staff').authority.withinRole.includes('instance.name'), 'the shipped chief file grants instance.name'); ++ assert.deepEqual(call('chief', 'name.set', { instance: 'dev', name: 'rex' }), { instance: 'dev', name: 'rex' }); ++ const [event, ...rest] = store ++ .all("SELECT actor_role, subject, body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='instance.name'") ++ .map((e) => ({ ...e, body: JSON.parse(e.body) })); ++ assert.equal(rest.length, 0); ++ assert.equal(event.actor_role, 'chief'); ++ assert.equal(event.subject, 'dev'); ++ assert.equal(event.body.class, 'within-role'); ++ assert.equal(event.body.target, 'dev'); ++ assert.deepEqual({ ...store.get('SELECT business, instance, name, by FROM instance_names') }, { ++ business: 'demo', ++ instance: 'dev', ++ name: 'rex', ++ by: 'chief-run', ++ }); ++ assert.deepEqual( ++ call('chief', 'names').map(({ instance, name }) => ({ instance, name })), ++ [{ instance: 'dev', name: 'rex' }], ++ ); ++}); ++ ++test('REQ-MSG-2: the human names an instance through the CLI', (t) => { ++ const { store, call } = setup(t); ++ assert.deepEqual(call('human', 'name.set', { instance: 'lead', name: 'patty' }), { instance: 'lead', name: 'patty' }); ++ assert.equal(store.get('SELECT by FROM instance_names').by, 'jason'); ++ assert.deepEqual( ++ call('human', 'names').map(({ instance, name }) => ({ instance, name })), ++ [{ instance: 'lead', name: 'patty' }], ++ ); ++}); ++ ++test('REQ-MSG-2: a role without the grant, or holding it cross-role, is refused not-authorized', (t) => { ++ const { store, call } = setup(t); ++ // lead holds no instance.name at all; dev holds it cross-role. Neither may set a name: the refusal ++ // comes before #checkAuthority, so dev gets not-authorized, not decision-required, and name.set ++ // takes no decision argument at all. ++ assert.throws(() => call('lead', 'name.set', { instance: 'dev', name: 'rex' }), /not-authorized/); ++ assert.throws(() => call('dev', 'name.set', { instance: 'dev', name: 'rex' }), /not-authorized/); ++ assert.throws( ++ () => call('dev', 'name.set', { instance: 'dev', name: 'rex', decision: '00000000-0000-4000-8000-000000000000' }), ++ /invalid-request/, ++ ); ++ assert.throws(() => call('reader', 'name.set', { instance: 'dev', name: 'rex' }), /read-only/); ++ assert.throws(() => call('tester', 'name.set', { instance: 'dev', name: 'rex' }), /tester-refused/); ++ assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0); ++}); ++ ++test('REQ-MSG-2: a name is unique in the business and is never an instance key or a role name', (t) => { ++ const { store, call } = setup(t); ++ call('human', 'name.set', { instance: 'dev', name: 'rex' }); ++ assert.throws(() => call('human', 'name.set', { instance: 'lead', name: 'rex' }), /name-taken/); ++ assert.throws(() => call('chief', 'name.set', { instance: 'lead', name: 'rex' }), /name-taken/); ++ for (const key of ['chief', 'lead', 'human', 'tester']) ++ assert.throws(() => call('human', 'name.set', { instance: 'dev', name: key }), /name-is-instance/, key); ++ // A definition an instance uses, and one the business only defines. ++ for (const definition of ['coder', 'chief-of-staff', 'reviewer']) ++ assert.throws(() => call('human', 'name.set', { instance: 'dev', name: definition }), /name-is-role/, definition); ++ for (const bad of ['', 'Rex', '_bus', 'a b', 'x'.repeat(65), 7]) ++ assert.throws(() => call('human', 'name.set', { instance: 'dev', name: bad }), /invalid-request/, String(bad)); ++ assert.throws(() => call('human', 'name.set', { instance: 'nobody', name: 'zed' }), /unknown-role/); ++ // Renaming frees the old name for another instance. ++ call('human', 'name.set', { instance: 'dev', name: 'rexy' }); ++ call('human', 'name.set', { instance: 'lead', name: 'rex' }); ++ assert.deepEqual( ++ call('human', 'names').map(({ instance, name }) => ({ instance, name })), ++ [{ instance: 'dev', name: 'rexy' }, { instance: 'lead', name: 'rex' }], ++ ); ++ // The store refuses a duplicate on its own, and the table is append-only. ++ assert.throws( ++ () => store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','chief','rex','t')"), ++ /unique in its business/, ++ ); ++ assert.throws(() => store.run("UPDATE instance_names SET name='zed'"), /append-only/); ++ assert.throws(() => store.run('DELETE FROM instance_names'), /append-only/); ++}); ++ ++test('REQ-MSG-2: a send by name reaches the instance and records both; a send by instance key is unchanged', (t) => { ++ const { call } = setup(t); ++ call('human', 'name.set', { instance: 'dev', name: 'rex' }); ++ const byName = call('lead', 'message.send', { to: 'rex', body: 'by name' }); ++ const byKey = call('lead', 'message.send', { to: 'dev', body: 'by key' }); ++ const fromHuman = call('human', 'message.send', { to: 'rex', body: 'from the human' }); ++ const got = call('dev', 'message.receive'); ++ assert.deepEqual( ++ got.map(({ id, to_role, to_name, body }) => ({ id, to_role, to_name, body })), ++ [ ++ { id: byName.id, to_role: 'dev', to_name: 'rex', body: 'by name' }, ++ { id: byKey.id, to_role: 'dev', to_name: null, body: 'by key' }, ++ { id: fromHuman.id, to_role: 'dev', to_name: 'rex', body: 'from the human' }, ++ ], ++ ); ++ // The tester may send by name too; a name no instance holds is an unknown role. ++ call('tester', 'message.send', { to: 'rex', body: '[mos test] by name' }); ++ assert.equal(call('dev', 'message.receive')[0].to_role, 'dev'); ++ assert.throws(() => call('lead', 'message.send', { to: 'nobody', body: 'x' }), /unknown-role/); ++ // After a rename, the old name no longer reaches anyone. ++ call('human', 'name.set', { instance: 'dev', name: 'rexy' }); ++ assert.throws(() => call('lead', 'message.send', { to: 'rex', body: 'x' }), /unknown-role/); ++ assert.equal(call('lead', 'message.send', { to: 'rexy', body: 'renamed' }).id.length, 36); ++}); ++ ++test('REQ-MSG-2: an instance key beats a name that equals it, as when a later instance takes the key', (t) => { ++ const { store, call } = setup(t); ++ // name.set refuses this name; the store's trigger doesn't know the business, so a row stands in for ++ // a name set before an instance with that key joined. ++ store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','dev','lead','jason')"); ++ call('chief', 'message.send', { to: 'lead', body: 'to the key' }); ++ const [m] = call('lead', 'message.receive'); ++ assert.equal(m.body, 'to the key'); ++ assert.equal(m.to_role, 'lead'); ++ assert.equal(m.to_name, null); ++ assert.deepEqual(call('dev', 'message.receive'), []); ++}); ++ ++test('REQ-MSG-2: a role name beats a name that equals it, as when a later definition takes the word (sage-0590)', (t) => { ++ const { store, call } = setup(t); ++ // As above, rows stand in for names set before the business knew these definitions: one an ++ // instance uses (coder) and one the business only defines (reviewer). Neither is an instance key. ++ store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','dev','reviewer','jason')"); ++ store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','lead','coder','jason')"); ++ for (const word of ['reviewer', 'coder']) ++ assert.throws(() => call('chief', 'message.send', { to: word, body: 'to the role' }), /unknown-role/, word); ++ assert.deepEqual(call('dev', 'message.receive'), []); ++ assert.deepEqual(call('lead', 'message.receive'), []); ++}); +diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs +index 1320a90c3..230b2a8fc 100644 +--- a/packages/bus/tests/project-runtime.test.mjs ++++ b/packages/bus/tests/project-runtime.test.mjs +@@ -16,9 +16,13 @@ test('Broker keeps the existing sandbox client import closure: no new imports or + "import { loadRequirements } from '../../tasks/src/requirements.mjs';", + // Row 137 (#1631): land-work's checks, in RUNNER_FILES beside the broker. They import nothing. + "import { MERGE, LandingRefusal, readLanding } from './landing.mjs';", ++ // Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in ++ // RUNNER_FILES and imports nothing. ++ "import { ID_PATTERN } from '../../business/src/vocabulary.mjs';", + ]); + assert.doesNotMatch(source, /\bimport\s*\(/); + assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/); ++ assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/); + }); + + test('without a trusted project adapter all three async paths refuse project-not-configured', async t => { +diff --git a/packages/bus/tests/schema.test.mjs b/packages/bus/tests/schema.test.mjs +index ef940a297..a975d8fbf 100644 +--- a/packages/bus/tests/schema.test.mjs ++++ b/packages/bus/tests/schema.test.mjs +@@ -517,7 +517,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => { + const count = (type) => db.prepare('SELECT count(*) n FROM sqlite_master WHERE type = ?').get(type).n; + for (const label of refused) assert.ok(tested.has(label), label); + // Row 121 (#1615) adds 5 tables and 24 triggers for projects and threads; threads.test.mjs tests them. +- assert.equal(count('table') - 1, 14); +- assert.equal(count('trigger'), 63); ++ // Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them. ++ assert.equal(count('table') - 1, 15); ++ assert.equal(count('trigger'), 67); + assert.equal(count('view'), 5); + }); +diff --git a/packages/bus/tests/spec-migration.test.mjs b/packages/bus/tests/spec-migration.test.mjs +index bf6ec31c0..6034c4598 100644 +--- a/packages/bus/tests/spec-migration.test.mjs ++++ b/packages/bus/tests/spec-migration.test.mjs +@@ -48,12 +48,12 @@ function snapshot(path, columns) { + return { columns, rows }; + } finally { db.close(); } + } +-test('3e copy migrates to 3f preserving every old column/row and adding null spec citations', t => { ++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, '3f'); ++ assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g'); + 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'), []); +diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs +index 5a3785580..c0c80db67 100644 +--- a/packages/bus/tests/thread-migration.test.mjs ++++ b/packages/bus/tests/thread-migration.test.mjs +@@ -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 = '3f';", "export const SCHEMA_VERSION = '3d';") ++ .replace("export const SCHEMA_VERSION = '3g';", "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); +@@ -86,6 +86,12 @@ const rows = (path) => { + db.close(); + } + }; ++// Row 141: 3f→3g adds messages.to_name, which reads null on a message from before names. Rows from ++// node:sqlite have a null prototype, so the expected rows keep one. ++const named = ({ messages, deliveries }) => ({ ++ messages: messages.map((m) => Object.assign(Object.create(null), m, { to_name: null })), ++ deliveries, ++}); + + test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and leaves messages from before threads as written', async (t) => { + const { root, path, copy } = await database3d(t); +@@ -95,13 +101,13 @@ 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, '3f'); ++ assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g'); + 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. + assert.deepEqual(store.migrationReport, REPORT); + store.close(); +- assert.deepEqual(rows(path), before); ++ assert.deepEqual(rows(path), named(before)); + assert.deepEqual(rows(copy), before); + // Opening again needs no step and reports nothing. + const again = new Store(root, { migrateSchema: true }); +@@ -135,7 +141,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l + ]); + assert.deepEqual(b.request(run, { verb: 'message.receive', args: {} }), []); + current.close(); +- assert.deepEqual(rows(path), before); ++ assert.deepEqual(rows(path), named(before)); + }); + + test('REQ-THREAD-1: a 3d bus with a changed schema is refused and keeps its version and tables', async (t) => { +@@ -155,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 3f ready', ''].join('\n')); ++ assert.equal(migrated.stdout, [...REPORT, 'bus schema 3g ready', ''].join('\n')); + }); +diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs +index 05d09d626..325da91ff 100644 +--- a/packages/business/src/vocabulary.mjs ++++ b/packages/business/src/vocabulary.mjs +@@ -32,6 +32,9 @@ export const ACTIONS = Object.freeze([ + // so the grant lives in a role file. No shipped role is granted role.revoke, so it stays gated. + "role.end", + "organization.change", ++ // Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of ++ // staff set names once its role file grants that within-role. ++ "instance.name", + "credential.mint", + "spend", + "deploy", +@@ -50,7 +53,8 @@ export const ACTIONS = Object.freeze([ + // to ACTIONS is a hard wait until a PRD version says it isn't, so a new + // action joins this list only by the reviewed commit that names it routed. + // git.merge.protected, policy.change, prd.approve and role.revoke left the +-// hard waits in 1.5 and route by REQ-DEC-2. ++// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed ++// under REQ-MSG-2 (1.5). + export const ROUTED = Object.freeze([ + "task.create", + "task.assign", +@@ -70,6 +74,7 @@ export const ROUTED = Object.freeze([ + "role.revoke", + "role.end", + "organization.change", ++ "instance.name", + "policy.change", + "prd.approve", + "decision.resolve.technical", +diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs +index 1d85010b1..51a49123b 100644 +--- a/packages/business/tests/chief-of-staff.test.mjs ++++ b/packages/business/tests/chief-of-staff.test.mjs +@@ -13,11 +13,12 @@ import { THREAD_IDLE_SECONDS } from "../src/business.mjs"; + import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs"; + + // Decision 104's list plus role.launch (decision 139) plus role.end and +-// organization.change (row 123, REQ-COS-1) and record.write (row 138). A role file that differs from it fails. ++// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name ++// (row 141, REQ-MSG-2, sage-0589). A role file that differs from it fails. + const APPROVED = Object.freeze({ + tools: ["read", "grep", "find", "ls"], + network: "api-only", +- authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write"]), crossRole: Object.freeze([]) }), ++ authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]), crossRole: Object.freeze([]) }), + credentials: Object.freeze([]), + }); + +@@ -84,9 +85,9 @@ test("the resolved record matches the approved list, so a widened file fails", ( + assert.deepEqual(record.credentials, {}); + }); + +-test("the chief of staff holds role.launch, message.send, role.end, organization.change and record.write; every other action is gated", () => { ++test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write and instance.name; every other action is gated", () => { + const record = resolved(loadFixture()); +- const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write"]; ++ const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]; + for (const action of ACTIONS) { + assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action); + } +diff --git a/packages/business/tests/hard-waits.test.mjs b/packages/business/tests/hard-waits.test.mjs +index 57132425d..06d8ff4c0 100644 +--- a/packages/business/tests/hard-waits.test.mjs ++++ b/packages/business/tests/hard-waits.test.mjs +@@ -30,6 +30,12 @@ test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the rou + for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action); + }); + ++test("REQ-WAIT-3: instance.name is routed under REQ-MSG-2 (1.5), not a hard wait (row 141, sage-0589)", () => { ++ assert.ok(ACTIONS.includes("instance.name")); ++ assert.ok(ROUTED.includes("instance.name")); ++ assert.ok(!GATED_ONLY.includes("instance.name")); ++}); ++ + test("REQ-WAIT-2: a role listing a hard wait at either class is refused; the actions that left can be granted", () => { + for (const action of WAITS) { + assert.throws(() => role({ withinRole: [action], crossRole: [] }), /always gated/, action); +diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs +index a81c2b2b9..48917bb68 100644 +--- a/packages/business/tests/role.test.mjs ++++ b/packages/business/tests/role.test.mjs +@@ -67,6 +67,14 @@ test("shipped authority follows the note's table", () => { + assert.deepEqual(auth("reviewer").crossRole, []); + assert.ok(auth("chief-of-staff").withinRole.includes("role.launch")); + assert.ok(!auth("pm").withinRole.includes("role.launch")); ++ // Row 141 (REQ-MSG-2): the chief alone of the shipped roles sets instance names. ++ assert.ok(auth("chief-of-staff").withinRole.includes("instance.name")); ++ const others = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && !["conductor-policy.json", "chief-of-staff.json"].includes(f)); ++ assert.ok(others.length >= 10, String(others.length)); ++ for (const name of others.map((f) => f.slice(0, -5))) { ++ assert.ok(!auth(name).withinRole.includes("instance.name"), name); ++ assert.ok(!auth(name).crossRole.includes("instance.name"), name); ++ } + assert.ok(auth("cto").withinRole.includes("decision.resolve.technical")); + assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical")); + }); +diff --git a/packages/cli/README.md b/packages/cli/README.md +index ae67d6deb..323d38a1b 100644 +--- a/packages/cli/README.md ++++ b/packages/cli/README.md +@@ -32,6 +32,8 @@ mosaic agents [--business ] [--json] + mosaic trail [--business ] [--json] + mosaic talk [--wait | --follow] [--business ] + mosaic follow [--business ] ++mosaic name [--business ] ++mosaic names [--business ] [--json] + mosaic stop + mosaic launches off|on [--business ] + mosaic launches list [--json] +@@ -102,6 +104,13 @@ mosaic launches list [--json] + and `--follow` together refuse. These commands consume the same human + inbox; run one receiving CLI at a time. Console only reads history, so a + reply to a Console send stays unread in `inbox` until it is marked read. ++- `name` gives a role instance a name (row 141, #1635, REQ-MSG-2) and prints ++ `named `. `talk` and every `message.send` then accept the ++ name where they took the instance. A name is unique in the business and ++ can't be an instance key, a role definition, `human` or `tester`; the bus ++ refuses with `name-taken`, `name-is-instance` or `name-is-role`. Naming an ++ instance again replaces its name and frees the old one. `names` lists each ++ named instance and its current name. + - `launches off` and `on` are the broker's `launch.revoke` and + `launch.restore`. While off, every `role.launch` refuses with + `launch-revoked`; running sessions keep running. Stop them with `stop`. +diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs +index 99a070ac0..0c1c81448 100644 +--- a/packages/cli/src/cli.mjs ++++ b/packages/cli/src/cli.mjs +@@ -40,6 +40,8 @@ export const USAGE = `usage: + mosaic trail [--business ] [--json] [--tester] + mosaic talk [--wait | --follow] [--business ] [--tester] + mosaic follow [--business ] [--tester] ++ mosaic name [--business ] ++ mosaic names [--business ] [--json] + mosaic stop + mosaic launches off|on [--business ] + mosaic launches list [--json] +@@ -269,6 +271,21 @@ async function human(verb, rest, io, deps) { + return print(await call("agents"), formatAgents); + } + ++// Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an ++// instance; `mosaic names` lists the current ones. Both are the human's. ++async function names(verb, rest, io, deps) { ++ const parsed = parse(rest, { flags: verb === "names" ? ["--json"] : [], values: ["--business"] }); ++ if (parsed.positional.length !== (verb === "name" ? 2 : 0)) throw usage(verb === "name" ? "name takes an instance and a name" : "names takes no arguments"); ++ const call = callFor(parsed, io, deps); ++ if (verb === "name") { ++ const [instance, name] = parsed.positional; ++ await call("name.set", { instance, name }); ++ return io.stdout.write(`named ${instance} ${name}\n`); ++ } ++ const rows = await call("names"); ++ io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(rows, null, 2)}\n` : rows.map((r) => `${r.instance} ${r.name}\n`).join("")); ++} ++ + const TALK_WAIT_S = 900; + const TESTER_PREFIX = "[mos test]"; + const TALK_POLL_MS = 2000; +@@ -487,6 +504,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, + if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps)); + if (verb === "talk") return talk(rest, io, deps); + if (verb === "follow") return talk(rest, io, deps, true); ++ if (verb === "name" || verb === "names") return names(verb, rest, io, deps); + if (verb === "stop") return stop(rest, io, deps); + if (verb === "launches") return launches(rest, io, deps); + if (verb === "bus") return bus(rest, io, deps); +diff --git a/packages/cli/tests/names.test.mjs b/packages/cli/tests/names.test.mjs +new file mode 100644 +index 000000000..b2f749ec5 +--- /dev/null ++++ b/packages/cli/tests/names.test.mjs +@@ -0,0 +1,59 @@ ++// Row 141 (#1635, REQ-MSG-2): `mosaic name` and `mosaic names`, through the in-process broker from ++// helpers.mjs. The broker's own checks are in packages/bus/tests/names.test.mjs. ++ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { main, USAGE } from "../src/cli.mjs"; ++import { CliError } from "../src/errors.mjs"; ++import { broker, io, tmp } from "./helpers.mjs"; ++ ++function setup(t) { ++ const bus = broker(t); ++ const dataRoot = tmp(t); ++ const run = async (argv, x = io()) => { ++ try { ++ await main(argv, x, { system: { dataRoot }, transport: bus.transport }); ++ return { code: 0, io: x }; ++ } catch (e) { ++ if (!(e instanceof CliError)) throw e; ++ return { code: e.exitCode, error: e.message, io: x }; ++ } ++ }; ++ return { ...bus, run }; ++} ++ ++test("the human names an instance, lists it, and talk's message.send reaches it by that name", async (t) => { ++ const s = setup(t); ++ const named = await s.run(["name", "coder", "rex", "--business", "demo"]); ++ assert.equal(named.code, 0, named.error); ++ assert.equal(named.io.out.text, "named coder rex\n"); ++ assert.deepEqual(s.calls.at(-1), { business: "demo", verb: "name.set", args: { instance: "coder", name: "rex" } }); ++ ++ const listed = await s.run(["names", "--business", "demo"]); ++ assert.equal(listed.code, 0, listed.error); ++ assert.equal(listed.io.out.text, "coder rex\n"); ++ const json = await s.run(["names", "--json", "--business", "demo"]); ++ assert.deepEqual( ++ JSON.parse(json.io.out.text).map(({ instance, name }) => ({ instance, name })), ++ [{ instance: "coder", name: "rex" }], ++ ); ++ ++ const sent = await s.run(["talk", "rex", "hello", "--wait", "0", "--business", "demo"]); ++ assert.equal(sent.code, 0, sent.error); ++ assert.match(sent.io.out.text, /^sent [0-9a-f]{8} to rex\n/); ++ const [m] = s.b.request(s.coder, { verb: "message.receive" }); ++ assert.equal(m.to_role, "coder"); ++ assert.equal(m.to_name, "rex"); ++}); ++ ++test("name and names refuse the wrong number of arguments with the usage", async (t) => { ++ const s = setup(t); ++ for (const argv of [["name", "coder"], ["name", "coder", "rex", "extra"], ["names", "coder"]]) { ++ const r = await s.run([...argv, "--business", "demo"]); ++ assert.equal(r.code, 4, argv.join(" ")); ++ assert.ok(r.error.endsWith(USAGE), r.error); ++ } ++ assert.equal(s.calls.length, 0); ++ assert.match(USAGE, /^ {2}mosaic name \[--business \]$/m); ++ assert.match(USAGE, /^ {2}mosaic names \[--business \] \[--json\]$/m); ++}); +diff --git a/packages/harness/README.md b/packages/harness/README.md +index 30220f783..020f7db56 100644 +--- a/packages/harness/README.md ++++ b/packages/harness/README.md +@@ -95,7 +95,10 @@ and `count`, 1 to 20) and an optional `note`. A pm has no `launch` tool, and + the chief of staff's `launch` offer is still its launch block's instances. + + The chief of staff's two grants from row 123 have tools (row 132, #1628), +-so its manifest lists both in `typed` and nothing in `untyped`: ++so its manifest lists both in `typed`. Its `instance.name` grant (row 141, ++#1635) has no tool until row 155 (#1649), so the manifest lists it in ++`untyped` and the prompt names it; the human sets names with `mosaic name` ++until then. The two row 123 tools: + + - `end_run` (role.end holders) sends `role.revoke` with `role` and a + `reason` of 1 to 500 characters, never a `decision`. The broker refuses +diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs +index 92eb1f054..495b1acfd 100644 +--- a/packages/harness/src/tools.mjs ++++ b/packages/harness/src/tools.mjs +@@ -93,7 +93,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project + const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters }); + if (held.has("message.send")) { + add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else.", obj({ +- to: str("Role instance, or \"human\""), ++ to: str("Role instance, its name, or \"human\""), + body: str("Message text"), + class: str("Message class", { enum: MESSAGE_CLASSES }), + in_reply_to: str("Id of the message this answers"), +diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs +index 1c27a3180..31a9d22a5 100644 +--- a/packages/harness/tests/bundle.test.mjs ++++ b/packages/harness/tests/bundle.test.mjs +@@ -82,16 +82,18 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest' + assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []); + }); + +-// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed, nothing untyped. +-test("a chief-of-staff bundle lists end_run and change_organization as typed and nothing untyped, for either harness", (t) => { ++// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed. ++// Row 141 (#1635, REQ-MSG-2) grants instance.name before row 155 (#1649) gives it a tool, so it is ++// untyped until then, and the prompt tells the chief so. ++test("a chief-of-staff bundle lists end_run and change_organization as typed and only instance.name untyped, for either harness", (t) => { + for (const harness of ["pi", "claude-code"]) { + const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" })); + const b = build(t, r); + const prefix = harness === "pi" ? "" : "mcp__mosaic__"; + assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "change_organization", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness); +- assert.deepEqual(b.manifest.untyped, [], harness); ++ assert.deepEqual(b.manifest.untyped, ["instance.name"], harness); + assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, change_organization, read_spec, write_record, raise_decision, /); +- assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/); ++ assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet: instance\.name\. /); + } + }); + +diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs +index 893d245b9..fc2882ce7 100644 +--- a/packages/harness/tests/tools.test.mjs ++++ b/packages/harness/tests/tools.test.mjs +@@ -38,8 +38,9 @@ test("the chief of staff gets launch, send_message, end_run and change_organizat + assert.equal(launch.verb, "launch"); + assert.equal(launch.action, "role.launch"); + assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]); +- // Row 132 (#1628): role.end and organization.change have their tools, so nothing the chief holds is untyped. +- assert.deepEqual(untypedActions(chief, tools), []); ++ // Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) grants ++ // instance.name, whose tool is row 155's (#1649), so it is the one untyped action until then. ++ assert.deepEqual(untypedActions(chief, tools), ["instance.name"]); + }); + + // Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the +diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json +index 100c018bd..60fb6cf9a 100644 +--- a/roles/chief-of-staff.json ++++ b/roles/chief-of-staff.json +@@ -6,7 +6,7 @@ + "tools": ["read", "grep", "find", "ls"], + "network": "api-only", + "authority": { +- "withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write"], ++ "withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"], + "crossRole": [] + }, + "credentials": [] diff --git a/agents/sage/work/queue-141-r2/candidate-manifest.sha256 b/agents/sage/work/queue-141-r2/candidate-manifest.sha256 new file mode 100644 index 000000000..78da07663 --- /dev/null +++ b/agents/sage/work/queue-141-r2/candidate-manifest.sha256 @@ -0,0 +1,26 @@ +4e6a7f2c37d7bde7c3c7cafeaeb2d12ebf7437c4ab7952c5acce7d6a594dbbf0 packages/business/src/vocabulary.mjs +202ded4cf70e65a88d0da231ac868aa36f5e8c634e8b5d27572ac93a25838e03 packages/business/tests/chief-of-staff.test.mjs +8db9e978f63cf4340065d39a6868d12e6b1014111d2818448ffcf3a3e0f4c237 packages/business/tests/hard-waits.test.mjs +52a98179fc56bb9fd4753c91503872802338b2b7f103b14551a7b894f75b76ff packages/business/tests/role.test.mjs +9dc9d0461c306a4d61bc6b9a045bc8fd07f0b56ce783f32789c17e961e116c6c packages/bus/README.md +a79b3dbe6f4dfba10871c01ad8513ea290071eb8d8af2b87be6db52dbcf23428 packages/bus/schema.sql +13774485342b58c95fbd495ed96974924fbf2cfd8b0bf3526e5a69789e90a929 packages/bus/src/broker.mjs +82f54b264919e34282bc214e34e9849f701cd3723b9b2f0a0d107c16f939dc05 packages/bus/src/migrations/3f-3g.sql +d5a2e64760155fbe0680b8dbe5e93af6de7b13e115b96bc08ec1e2c1bef2dc08 packages/bus/src/store.mjs +867ac287eb2eb04741b84b8ed72424302d926f28c0141d4eabcb3b05257368de packages/bus/tests/chief-of-staff.test.mjs +aa4f2420aeceacc677f4fc3b09256562380ab20617176d3458002ec3935763cb packages/bus/tests/comment-migration.test.mjs +472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2 packages/bus/tests/fixtures/schema-3f.sql +092a9f51a93bbc227c212e16d5c052dece0df99586f5af6f7019a640cac489ee packages/bus/tests/name-migration.test.mjs +bb67a7a3aebbcdf63f0dd847283f437ad9a31dc3771b046d78f89f251796360c packages/bus/tests/names.test.mjs +8da64ea9ef252368b6ec342ba0a5c7921c1eccf0d278f7d650eb56812796cfbb packages/bus/tests/project-runtime.test.mjs +0ab303ae5f424407742e0f66f57a3a8b7f8b2ade7f7b627c77698148aaebc273 packages/bus/tests/schema.test.mjs +0dfadf443d94e8d334a1337a112c7868b738ca02d26e44264dd316ab62da6e3f packages/bus/tests/spec-migration.test.mjs +9633ac1878ac696029027b643196585cb1723ae7bb46b40f7866cdcdf0f8bdec packages/bus/tests/thread-migration.test.mjs +1653cd668ceebdb3fa0fe8f50bda60854cef8e630f1b7fd7207568b7546ff1c9 packages/cli/README.md +fd0dde5d4c97578a8f266c223108c8e2cff0a94b30cd5995c21a8f477e61dbce packages/cli/src/cli.mjs +8f66da03db1db6f25e076525cf15d0e6acf1dd546b07b45a653d32430935c178 packages/cli/tests/names.test.mjs +71d6dd7a060b8e258520f957578bc3a00975e8d907c15cf51ff78938378ddcf3 packages/harness/README.md +e75325c2d443875bddf5237cf790377267d14971ab63274c4a4bd8c36cba6c1f packages/harness/src/tools.mjs +f9b984c4176a0d9c843ae951e00e23f628e2b8d95adb3a2af0715ed81889b796 packages/harness/tests/bundle.test.mjs +ecd511e74c9378d589445db2e7ae11da0fc7b4e1b204353c3222254d65df055c packages/harness/tests/tools.test.mjs +d227fb5f0a2d241a0091b57f6f396379d7be5a8329e89532e654c90a8f3acd53 roles/chief-of-staff.json diff --git a/agents/sage/work/queue-141-r2/interdiff.txt b/agents/sage/work/queue-141-r2/interdiff.txt new file mode 100644 index 000000000..184970fa5 --- /dev/null +++ b/agents/sage/work/queue-141-r2/interdiff.txt @@ -0,0 +1,28 @@ +2c2 +< index 1726b61b5..e6bbc3a8c 100644 +--- +> index 0e43da238..cf299049f 100644 +33c33 +< @@ -752,3 +754,9 @@ with nulls on existing rows. It changes no event or resolution and creates no +--- +> @@ -763,3 +765,9 @@ with nulls on existing rows. It changes no event or resolution and creates no +77c77 +< index e89d27fe0..f7ca4458c 100644 +--- +> index a4ccee699..a72f95885 100644 +105c105 +< @@ -539,6 +543,22 @@ export class Broker { +--- +> @@ -544,6 +548,22 @@ export class Broker { +128c128 +< @@ -1536,30 +1556,39 @@ export class Broker { +--- +> @@ -1577,30 +1597,39 @@ export class Broker { +174c174 +< @@ -1583,27 +1612,28 @@ export class Broker { +--- +> @@ -1624,27 +1653,28 @@ export class Broker { +208c208 +< @@ -1917,6 +1947,43 @@ export class Broker { +--- +> @@ -1958,6 +1988,43 @@ export class Broker { diff --git a/agents/sage/work/vikunja-setup/2026-10-16_r141-followups.json b/agents/sage/work/vikunja-setup/2026-10-16_r141-followups.json new file mode 100644 index 000000000..123af9a06 --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-16_r141-followups.json @@ -0,0 +1,27 @@ +{ + "at": "2026-10-16T22:36:04.375Z", + "project": 19, + "created": [ + { + "title": "docs: name row 141's mosaic name and mosaic names in TOOLS.md and fix acceptance 314 step 10", + "id": 321, + "index": 161, + "inverse": "mark done: POST /api/v1/tasks/321 done true (pm); delete is Jason's or ops-01's", + "readBack": 200, + "project_back": 19, + "due_back": "0001-01-01T00:00:00Z", + "done": false + }, + { + "title": "business: refuse or report a new instance key or definition equal to a current instance name", + "id": 322, + "index": 162, + "inverse": "mark done: POST /api/v1/tasks/322 done true (pm); delete is Jason's or ops-01's", + "readBack": 200, + "project_back": 19, + "due_back": "0001-01-01T00:00:00Z", + "done": false + } + ], + "problems": [] +} diff --git a/agents/sage/work/vikunja-setup/file-r141-followups.mjs b/agents/sage/work/vikunja-setup/file-r141-followups.mjs new file mode 100644 index 000000000..ddc24614e --- /dev/null +++ b/agents/sage/work/vikunja-setup/file-r141-followups.mjs @@ -0,0 +1,67 @@ +// Sage, 2026-10-16: decision 110. Files row 141's two landing follow-ups (#1635) as tasks in project 19 +// with Sage's own key (bot user 13). Refuses if a title already exists. The sync +// key reads each back. No labels, no assignees, no bucket moves. Rollback is marking the task done; +// deletion is Jason's or ops-01's. Prints no secret. +// Usage: node file-r141-followups.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2); +if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only"); +if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing"); +const PROJECT = 19; +const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13)."; +const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); +const p = (...xs) => xs.map((x) => `

${esc(x)}

`).join("") + `

${esc(SIG)}

`; +const TASKS = [ + { title: "docs: name row 141's mosaic name and mosaic names in TOOLS.md and fix acceptance 314 step 10", + description: p("Row 141 round 1 (#1635), doc-only follow-up from Filbert (comment 28841), decision 137. Row 141 adds the CLI verbs mosaic name INSTANCE NAME and mosaic names [--json] (packages/cli/src/cli.mjs), and docs/acceptance/314.md step 10 points at TOOLS.md for them.", + "Three fixes. 1) docs/TOOLS.md has no entry for mosaic name or mosaic names; add them. 2) Step 10's command reads talk ; the name comes from mosaic names, so say that. 3) Step 10's pass line says the trail records both the name and the instance; name the read that shows to_role and to_name instead.", + "Doc-only. Before editing, grep packages/*/tests and scripts/test-*.sh for both files; if a test reads either one, it goes through the gate.") }, + { title: "business: refuse or report a new instance key or definition equal to a current instance name", + description: p("Row 141 (#1635) follow-up from Darkwing's BUILD.md, ruled bounded in sage-0590. Row 141 adds instance names; the resolver tries the instance key, then the role, then the name. Whoever adds instances or definitions to a business can create a key or definition equal to a current name. Today the key or role wins and the name stops resolving, which fails safe but silently.", + "Make the writer that adds an instance or a definition refuse that collision, or report the name it shadows, with a test. Row 155 excludes adding instances, so it does not carry this.") }, +]; +const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim(); +async function api(method, path, body, auth) { + const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" }); + const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {} + return { status: r.status, json }; +} +const list = (j) => j?.items ?? (Array.isArray(j) ? j : []); +async function existing() { + const out = []; + for (let page = 1; page < 20; page++) { + const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync); + if (r.status !== 200) throw new Error(`list tasks: ${r.status}`); + out.push(...list(r.json)); + if (list(r.json).length < 50) return out; + } + throw new Error("more than 19 pages"); +} +const rec = { at: new Date().toISOString(), project: PROJECT, created: [], problems: [] }; +const save = () => { + const out = JSON.stringify(rec, null, 1) + "\n"; + if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written"); + writeFileSync(OUT, out); +}; +const before = await existing(); +const clash = before.filter((t) => TASKS.some((x) => x.title === t.title)); +if (clash.length) throw new Error(`exists: ${clash.map((t) => `#${t.id} ${t.title}`).join("; ")}; refusing`); +writeFileSync(OUT, "", { flag: "wx" }); + +for (const t of TASKS) { + rec.created.push({ title: t.title }); + save(); + const body = { title: t.title, description: t.description, ...(t.due ? { due_date: t.due } : {}) }; + const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, body, key); + if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) { + rec.problems.push(`create ${t.title}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); + break; + } + Object.assign(rec.created.at(-1), { id: r.json.id, index: r.json.index, inverse: `mark done: POST /api/v1/tasks/${r.json.id} done true (pm); delete is Jason's or ops-01's` }); + const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync); + Object.assign(rec.created.at(-1), { readBack: back.status, project_back: back.json?.project_id, due_back: back.json?.due_date, done: back.json?.done }); + if (back.status !== 200 || back.json?.title !== t.title || back.json?.project_id !== PROJECT || back.json?.done) rec.problems.push(`${t.title} read-back differs`); +} +save(); +process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); +if (rec.problems.length) process.exit(2); diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 2ccd9a40a..3a8814202 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1093,3 +1093,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-16T22:25:47Z | Sage (Opus 5.5, 1ef1e4f8) | row 148 landing, #1642 | d4ea7e5a6 pushed after Mos's decision 123 go (mos-0636) and a green 25-step landing gate; row done rev 1624, #1642 closed 28838, Vikunja 318-320 filed; a4779d2a5 docs push; not live until a re-pin on Mos's go 2026-10-16T22:28:19Z | Filbert (Opus 5.5, 048f3c63) | row 141 round 1 review (#1635), manifest d474b330 rebuilt on 18aee8fa1; 15 test files singly on Sage's slot | approve, comment 28841, rev 1625; 114/114 pass 22:26:51Z-22:26:58Z; widened vocabulary.mjs pin holds (sandbox.test.mjs walk plus bare-import assertion); doc-only follow-up on acceptance 314 step 10 and TOOLS.md; row 148 drift on 2 paths noted for the landing 2026-10-16T22:31:42Z | Filbert (Opus 5.5, 048f3c63) | row 141 round 2 rebase check (#1635), manifest cae29087 on 7e40fb83b (sage-0640), read-only, no slot | confirmed, comment 28842; interdiff reproduced and index/@@ only, stripped patches cmp-identical, offsets +11 README and +5/+41 broker match row 148's hunks, rebuild manifest OK +2026-10-16T22:36:51Z | Sage (Opus 5.5, 1ef1e4f8) | row 141 landing, #1635 | rebase-only r2 packet (Filbert 28842), green 25-step gate, de352dfaf pushed; row done rev 1626 (28eeef8ea), #1635 closed 28844, Vikunja 321-322 filed, row 155 brief carry line; not live until the shared 148+141 window on Mos's go diff --git a/docs/plans/2026-10-16_q169-assistant-config.md b/docs/plans/2026-10-16_q169-assistant-config.md index cdc1299ed..b2aeba036 100644 --- a/docs/plans/2026-10-16_q169-assistant-config.md +++ b/docs/plans/2026-10-16_q169-assistant-config.md @@ -37,6 +37,14 @@ What exists on base 9aa90193d: instance-names table, a human-only CLI writer and `to_name`. It has no Console route and no chief of staff tool. Row 139 says whichever of 139 and 141 lands second gives the chief of staff the grant to set names. +- Row 141 landed second and carries that grant: `instance.name`, routed, + within-role in `roles/chief-of-staff.json:9`. The chief has no harness + tool for it, so `untypedActions(chief)` and the chief bundle's + `manifest.untyped` are `["instance.name"]` + (`packages/harness/tests/tools.test.mjs:43`, + `packages/harness/tests/bundle.test.mjs:94`). This row gives the chief + a tool for `instance.name`, brings both back to `[]` and re-pins those + two assertions (sage-0590). - Ending the assistant's run. `end_run` (`tools.mjs:157-162`) is refused at `broker.mjs:1131`. Once row 139 lets the chief of staff past that check, the end still can't work: `#holder` reads `role_claims`