diff --git a/packages/bus/README.md b/packages/bus/README.md index 2973590c5..015b8a7fc 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -138,7 +138,9 @@ an operator inspect content and delivery evidence after an uncertain result. |---|---| | `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. | | `role.release` | No args; only its current holder releases. | -| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. | +| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run and the assistant's are `role-end-refused`; any other role sending `reason` gets `decision-required`. | +| `organization.set` | `{revision,limits?,models?,instances?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `.`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits` and `limits.set` stay human-only. | + | `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and a gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. | | `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. | @@ -475,7 +477,10 @@ fixtures. It takes the same exclusive writer lock, checks the pinned source schema and metadata, WAL, timestamps and integrity, then runs named migration steps in order. Each step atomically adds its DDL, changes only schema version and digest metadata, restores the metadata update guard, and verifies its target -digest. The 3b→3c step also records an immutable `comment_cursor_epoch`. Prior +digest. The 3b→3c step also records an immutable `comment_cursor_epoch`. The 3c→3d +step (row 123) replaces the `role_revoke_needs_decision` trigger: a revoke needs a resolved +decision or a reason. Storage accepts a revoke with a reason and no decision, so only the +broker decides who may revoke that way (`role.end`, in the `role.revoke` row above). Prior steps stay pinned under `src/migrations/`; later versions append a step. A completed step remains valid if a later step refuses; rerunning resumes from that version. Already-current migration is a validated no-op. Missing stores refuse. diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index e68424252..337ae80b9 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -124,8 +124,8 @@ 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 - BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END; + 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; diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 7c281d635..ddff2cc54 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -20,6 +20,8 @@ export const ACTIONS = Object.freeze([ 'message.external', 'role.launch', 'role.revoke', + 'role.end', + 'organization.change', 'credential.mint', 'spend', 'deploy', @@ -93,6 +95,10 @@ function identifier(x) { const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x); // A git commit id, SHA-1 or SHA-256. const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x); +// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept. +const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances']; +// Row 123 (#1617): role.end never reaches the assistant (the user's seat) or the caller's own run. +const END_PROTECTED = ['assistant']; const VERDICTS = ['approve', 'changes']; // Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an // action.allowed for git.push.working that names its commit, written only by recordPush. @@ -715,14 +721,23 @@ export class Broker { const args = request.args ?? {}; if (!object(args)) fail('invalid-request'); this.#secretCheck(args); - if (["limits", "limits.set"].includes(request.verb)) { - if (!s.human) fail("human-required"); + if (["limits", "limits.set", "organization.set"].includes(request.verb)) { + // Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose + // file lists organization.change. It takes the same keys, runs the same write path and + // logs the same receipt; only the actor differs: the role definition and instance. + const role = request.verb === "organization.set"; + if (!role && !s.human) fail("human-required"); if (!this.#organization) fail("not-configured"); if (request.verb === "limits") keys(args, []); - else keys(args, ["revision", "limits", "models", "instances"], ["revision"]); + else keys(args, ORGANIZATION_KEYS, ["revision"]); + let actor = s.human; + if (role) { + this.#store.transaction(() => this.#consumeAuthority(s, "organization.change")); + actor = this.#business(s.business).roles[s.role].definition + "." + s.role; + } let result; try { - result = this.#organization(s.business, request.verb, args, s.human); + result = this.#organization(s.business, role ? "limits.set" : request.verb, args, actor); } catch (e) { if (e.code === "revision-conflict") fail("revision-conflict"); if (e.code?.startsWith("organization-")) fail(e.code); @@ -804,13 +819,24 @@ export class Broker { return { released: true }; } case 'role.revoke': { - keys(a, ['role', 'decision'], ['role', 'decision']); + keys(a, ['role', 'decision', 'reason'], ['role']); identifier(a.role); - identifier(a.decision); - this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role }); + // Row 123 (#1617, REQ-ROLE-2, REQ-COS-1): a role whose file lists role.end ends a run with a + // reason and no decision; the reason rides in the action.allowed body. Every other caller + // still cites a resolved decision for this exact target and holder incarnation. + if ('reason' in a) { + if ('decision' in a) fail('invalid-request'); + string(a.reason, 500); + this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason }); + const b = this.#business(s.business); + if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused'); + } else { + identifier(a.decision); + this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role }); + } const h = this.#holder(s.business, a.role); if (h?.op !== 'claim') fail('not-held'); - this.#claimEnd(s, h, 'revoke', a.decision); + this.#claimEnd(s, h, 'revoke', a.decision ?? null, a.reason ?? null); return { revoked: true }; } case 'decision.raise': { @@ -1367,9 +1393,10 @@ export class Broker { ).map((m) => ({ id: m.id, from: m.from, run: m.run, at: m.at })); return { ...c, state, working, inputNeeded, stranded }; } - #claimEnd(s, h, op, decision) { + // Row 123 (#1617): a role.end revoke carries its reason in role_claims.reason and no decision. + #claimEnd(s, h, op, decision, reason = null) { this.#store.run( - 'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)', + 'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)', this.#now(), s.business, h.role, @@ -1379,6 +1406,7 @@ export class Broker { h.address, s.run ?? s.human, decision, + reason, ); } // A message's originating human.input: a human instruction's own, or for the diff --git a/packages/bus/src/migrations/3c-3d.sql b/packages/bus/src/migrations/3c-3d.sql new file mode 100644 index 000000000..af600bae5 --- /dev/null +++ b/packages/bus/src/migrations/3c-3d.sql @@ -0,0 +1,5 @@ +-- Row 123 (#1617): a revoke needs a resolved decision or a reason (the chief of staff's role.end). +DROP TRIGGER role_revoke_needs_decision; +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; diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index 0c4d14f2d..99e1f1ac0 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 = '7587ac1df813e5148fdb73dc4733ec8064e6a142e4647f0da71be009a73eebdb'; -export const SCHEMA_VERSION = '3c'; +export const SCHEMA_SHA256 = 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5'; +export const SCHEMA_VERSION = '3d'; // 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'; @@ -25,6 +25,10 @@ const migrations = [{ initialize: (store) => store.run( 'INSERT INTO meta(key,value) VALUES (?,?)', 'comment_cursor_epoch', new Date().toISOString(), ), +}, { + from: '3c', + to: '3d', + sql: readFileSync(new URL('./migrations/3c-3d.sql', import.meta.url), 'utf8'), }]; const timedTables = [ 'events', diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs new file mode 100644 index 000000000..645b41dec --- /dev/null +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -0,0 +1,368 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { Store, schemaDigest } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; +import { startBroker } from '../src/runtime.mjs'; +import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs'; + +// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization +// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the +// organization file), REQ-LIMIT-1 (limits are settings), REQ-COS-1 (the chief of staff runs the +// organization). The grant is read from the shipped roles/chief-of-staff.json, so the test pins the +// file the stack runs, not a copy of it. +const shipped = (name) => + JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); +const chiefAuthority = shipped('chief-of-staff').authority; +const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; + +const roles = { + chief: { definition: 'chief-of-staff', authority: chiefAuthority }, + assistant: { definition: 'assistant', ...none }, + pm: { definition: 'pm', ...none }, + coder: { definition: 'coder', ...none }, +}; +const demo = () => ({ + demo: { + id: 'demo', + human: 'jason', + arbiters: { technical: 'pm', delivery: 'chief' }, + roles, + definitions: {}, + projects: { stack: { root: '/nonexistent' } }, + vars: { 'bus.tester': true }, + }, +}); + +const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } }; + +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'bus-chief-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const organization = []; + const b = new Broker({ + store, + businesses: demo(), + organization: (...args) => { + organization.push(args); + return { revision: 'b'.repeat(64) }; + }, + }); + const caps = {}; + 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.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + caps.reader = b.bindReader({ business: 'demo' }); + caps.tester = b.bindTester({ business: 'demo' }); + const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); + const lastClaim = (role) => ({ + ...store.get('SELECT op, decision, reason, by FROM role_claims WHERE role=? ORDER BY rowid DESC LIMIT 1', role), + }); + const allowed = (action) => + store + .all("SELECT actor_role, subject, body FROM events WHERE kind='action.allowed' ORDER BY seq") + .map((e) => ({ ...e, body: JSON.parse(e.body) })) + .filter((e) => e.body.action === action); + return { b, store, caps, call, lastClaim, allowed, organization }; +} + +test('REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged', (t) => { + const { call, lastClaim, allowed } = setup(t); + assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', reason: 'stuck on the same test for an hour' }), { + revoked: true, + }); + assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'stuck on the same test for an hour', by: 'chief-run' }); + const [event, ...rest] = allowed('role.end'); + assert.equal(rest.length, 0); + assert.equal(event.actor_role, 'chief'); + assert.equal(event.subject, 'coder'); + assert.equal(event.body.reason, 'stuck on the same test for an hour'); + assert.equal(event.body.target, 'coder'); + assert.equal(event.body.class, 'within-role'); + assert.ok(!('decision' in event.body)); + // The coder's run is over: it no longer holds the role. + assert.throws(() => call('coder', 'message.send', { to: 'pm', body: 'hi' }), /not-holder/); +}); + +test('REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke', (t) => { + const { call, lastClaim, allowed } = setup(t); + for (const who of ['pm', 'coder', 'assistant']) { + assert.throws(() => call(who, 'role.revoke', { role: 'coder', reason: 'no decision' }), /decision-required/, who); + assert.throws(() => call(who, 'role.revoke', { role: 'coder' }), /invalid-request/, who); + } + assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' }); + assert.equal(allowed('role.end').length, 0); + // The human cannot use the reason path either: it has no role to hold role.end. + assert.throws(() => call('human', 'role.revoke', { role: 'coder', reason: 'no' }), /agent-required/); + assert.throws(() => call('reader', 'role.revoke', { role: 'coder', reason: 'no' }), /read-only/); + assert.throws(() => call('tester', 'role.revoke', { role: 'coder', reason: 'no' }), /tester-refused/); +}); + +test('REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither', (t) => { + // Storage does not decide who may revoke by reason; the broker does (tests above). It only + // refuses a revoke that carries nothing. + const { store } = setup(t); + const insert = (decision, reason) => + store.run( + "INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES('2026-10-15T00:00:00.000Z','demo','pm','revoke','pm-run','pi',NULL,'chief-run',?,?)", + decision, + reason, + ); + assert.throws(() => insert(null, null), /revoke needs a resolved decision or a reason/); + insert(null, 'a reason'); +}); + +test('REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path', (t) => { + const { call, lastClaim } = setup(t); + assert.throws(() => call('chief', 'role.revoke', { role: 'coder', decision: 'missing' }), /decision-not-found/); + assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' }); +}); + +test('REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason', (t) => { + const { call, lastClaim, allowed } = setup(t); + const d = call('pm', 'decision.raise', { + action: 'role.revoke', + target: 'coder', + question: 'Stop the coder?', + options: [ + { key: 'yes', text: 'Yes' }, + { key: 'no', text: 'No' }, + ], + recommendation: 'yes', + blocking: false, + }); + call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); + assert.deepEqual(call('pm', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true }); + assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'pm-run' }); + assert.equal(allowed('role.revoke').filter((e) => e.body.class === 'gated' && e.body.decision === d.id && e.body.target === 'coder').length, 1); + assert.equal(allowed('role.end').length, 0); +}); + +test('REQ-ROLE-2: a reason is required, bounded, and never sent with a decision', (t) => { + const { call, lastClaim } = setup(t); + for (const args of [ + { role: 'coder', reason: '' }, + { role: 'coder', reason: 'x'.repeat(501) }, + { role: 'coder', reason: 7 }, + { role: 'coder', reason: 'why', decision: 'some-decision' }, + { role: 'coder', reason: 'why', extra: true }, + ]) + assert.throws(() => call('chief', 'role.revoke', args), /invalid-request/, JSON.stringify(args).slice(0, 60)); + assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' }); + // The bound is inclusive. + assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', reason: 'x'.repeat(500) }), { revoked: true }); +}); + +test('REQ-COS-1: the chief of staff cannot end its own run or the assistant\'s through role.end', (t) => { + const { call, lastClaim, allowed } = setup(t); + for (const role of ['chief', 'assistant']) + assert.throws(() => call('chief', 'role.revoke', { role, reason: 'no' }), /role-end-refused/, role); + assert.deepEqual(lastClaim('chief'), { op: 'claim', decision: null, reason: null, by: 'chief-run' }); + assert.deepEqual(lastClaim('assistant'), { op: 'claim', decision: null, reason: null, by: 'assistant-run' }); + // The refusal rolls its action.allowed back with the transaction. + assert.equal(allowed('role.end').length, 0); + // A role that is not held is refused as before, and logs nothing. + call('chief', 'role.revoke', { role: 'pm', reason: 'first' }); + assert.throws(() => call('chief', 'role.revoke', { role: 'pm', reason: 'again' }), /not-held/); + assert.equal(allowed('role.end').length, 1); +}); + +test('REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor', (t) => { + const { call, organization } = setup(t); + const done = call('chief', 'organization.set', PATCH); + assert.deepEqual(done, { revision: 'b'.repeat(64) }); + // Same callback verb as a human's limits.set; only the actor differs. + assert.deepEqual(organization, [['demo', 'limits.set', PATCH, 'chief-of-staff.chief']]); + call('human', 'limits.set', PATCH); + assert.deepEqual(organization.at(-1), ['demo', 'limits.set', PATCH, 'jason']); +}); + +test('REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change', (t) => { + const { call, organization, allowed } = setup(t); + for (const who of ['pm', 'coder', 'assistant']) + assert.throws(() => call(who, 'organization.set', PATCH), /decision-required/, who); + assert.throws(() => call('human', 'organization.set', PATCH), /agent-required/); + assert.throws(() => call('reader', 'organization.set', PATCH), /read-only/); + assert.throws(() => call('tester', 'organization.set', PATCH), /tester-refused/); + assert.equal(organization.length, 0); + assert.equal(allowed('organization.change').length, 0); + // limits.set and limits stay with the human. + for (const verb of ['limits', 'limits.set']) + assert.throws(() => call('chief', verb, verb === 'limits' ? {} : PATCH), /human-required/, verb); + assert.equal(organization.length, 0); +}); + +test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human\'s keys', (t) => { + const { call, organization, allowed } = setup(t); + call('chief', 'organization.set', PATCH); + const [event] = allowed('organization.change'); + assert.equal(event.actor_role, 'chief'); + assert.equal(event.body.class, 'within-role'); + // Fields outside the write path are refused for the chief of staff exactly as for a human, and + // nothing reaches the writer. + const outside = [ + { launch: { by: 'coder' } }, + { human: 'mallory' }, + { arbiters: { technical: 'coder', delivery: 'coder' } }, + { roles: {} }, + { actor: 'jason' }, + { 'launch.by': 'coder' }, + ]; + const before = organization.length; + for (const extra of outside) + for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']]) + assert.throws( + () => call(who, verb, { ...PATCH, ...extra }), + /invalid-request/, + `${who} ${Object.keys(extra)[0]}`, + ); + assert.throws(() => call('chief', 'organization.set', { limits: { total: 1 } }), /invalid-request/); + assert.equal(organization.length, before); +}); + +test('REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human\'s; an invalid one is refused', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-chief-runtime-')); + const rolesDir = rolesCopy(root); + const file = join(root, 'businesses/demo.json'); + writeJson(file, businessDoc(root, 'demo')); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + const bus = await startBroker({ + dataRoot: join(root, 'data'), + businesses: demo(), + organization: { dir: root, rolesDir, system: systemFor(root) }, + }); + t.after(async () => { + await bus.close(); + rmSync(root, { recursive: true, force: true }); + }); + const { broker } = bus; + const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); + broker.request(chief, { verb: 'role.claim' }); + const receipts = () => + readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); + const document = () => JSON.parse(readFileSync(file, 'utf8')); + + // A human's change, for comparison. + const first = broker.request(human, { verb: 'limits' }); + const byHuman = broker.request(human, { verb: 'limits.set', args: { revision: first.revision, limits: { total: 5 } } }); + assert.equal(document().launch.limits.total, 5); + + // The chief of staff's change: validated, written, logged, with the same shape of receipt. + const byChief = broker.request(chief, { + verb: 'organization.set', + args: { revision: byHuman.revision, limits: { total: 9 }, models: document().launch.models }, + }); + assert.equal(byChief.limits.total, 9); + assert.equal(document().launch.limits.total, 9); + const [h, c] = receipts().filter((r) => r.state === 'committed'); + assert.equal(h.actor, 'jason'); + assert.equal(c.actor, 'chief-of-staff.chief'); + assert.deepEqual(Object.keys(c).sort(), Object.keys(h).sort()); + assert.equal(c.after, byChief.revision); + assert.equal(c.business, 'demo'); + assert.ok(receipts().some((r) => r.state === 'prepared' && r.transaction === c.transaction)); + + // An invalid change is refused, leaves the file and the receipts alone, and names no state change. + const bytes = readFileSync(file, 'utf8'); + const count = receipts().length; + for (const args of [ + { revision: byChief.revision, limits: { total: -1 } }, + { revision: byChief.revision, limits: { total: 'many' } }, + { revision: byChief.revision, limits: { surprise: 1 } }, + { revision: byChief.revision, instances: { chief: 'no-such-model' } }, + { revision: byChief.revision, instances: { nobody: 'claude-opus-5-5' } }, + { revision: byChief.revision, models: {} }, + ]) + assert.throws( + () => broker.request(chief, { verb: 'organization.set', args }), + /invalid-request|unknown-model/, + JSON.stringify(args), + ); + assert.throws( + () => broker.request(chief, { verb: 'organization.set', args: { revision: byHuman.revision, limits: { total: 1 } } }), + /revision-conflict/, + ); + assert.equal(readFileSync(file, 'utf8'), bytes); + assert.equal(receipts().length, count); +}); + +// REQ-ROLE-2, REQ-COS-1: the 3c→3d step. The 3c database comes out of row 49's real 3b→3c step, run by a +// copy of the engine pinned at 3c (its schema.sql is tests/fixtures/schema-3c.sql), not hand-built DDL. +const here = (path) => new URL(path, import.meta.url); +const REVOKE_NEITHER = + "INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES('2026-10-15T00:00:00.000Z','demo','coder','revoke','coder-run','pi',NULL,'chief-run',NULL,NULL)"; +async function database3c(t) { + const root = mkdtempSync(join(tmpdir(), 'bus-chief-migrate-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const ddl3b = readFileSync(here('./fixtures/schema-3b.sql'), 'utf8'); + mkdirSync(join(root, 'bus'), { mode: 0o700 }); + const path = join(root, 'bus/bus.sqlite'); + const db = new DatabaseSync(path); + db.exec(ddl3b); + db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3b'); + db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db)); + db.prepare('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)') + .run('preserved', '2026-10-15T00:00:00.000Z', 'demo', 'action.allowed', '{"fixture":true}'); + db.prepare( + "INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by) VALUES('2026-10-15T00:00:00.000Z','demo','coder','claim','coder-run','pi',NULL,'coder-run')", + ).run(); + db.close(); + chmodSync(path, 0o600); + // The engine pinned at 3c: current store.mjs with the 3c→3d step removed and the 3c schema. + const ddl3c = readFileSync(here('./fixtures/schema-3c.sql'), 'utf8'); + const moduleRoot = join(root, 'engine-3c'); + mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true }); + 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 = '3d';", "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'); + writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned); + writeFileSync(join(moduleRoot, 'schema.sql'), ddl3c); + for (const name of ['3b.sql', '3b-3c.sql']) + copyFileSync(here('../src/migrations/' + name), join(moduleRoot, 'src/migrations', name)); + const { Store: Store3c } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs'))); + new Store3c(root, { migrateSchema: true }).close(); + return { root, path }; +} + +test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused', async (t) => { + const { root, path } = await database3c(t); + // The fixture really is 3c: the old trigger refuses a revoke that carries only a reason. + const old = new DatabaseSync(path); + assert.equal(old.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3c'); + assert.throws(() => old.exec(REVOKE_NEITHER.replace('NULL,NULL)', "NULL,'a reason')")), /revoke needs a resolved decision$/); + old.close(); + // A 3c database does not open as 3d until the explicit step runs. + 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, '3d'); + 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 }); + const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); + b.request(chief, { verb: 'role.claim' }); + assert.deepEqual(b.request(chief, { verb: 'role.revoke', args: { role: 'coder', reason: 'migrated and ended' } }), { + revoked: true, + }); + assert.deepEqual( + { ...store.get("SELECT op, decision, reason FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1") }, + { op: 'revoke', decision: null, reason: 'migrated and ended' }, + ); + assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/); +}); diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs index 8d730e1eb..d2ac6928a 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 3c; normal startup never migrates', t => { +test('explicit 3b copy migration preserves evidence and opens as 3d; 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, '3c'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d'); 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 3c to a fixture-only future version in order', async t => { +test('named migration registry chains 3b through 3d 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 3c 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 = '3c';", "export const SCHEMA_VERSION = '3d';") - .replace('}];\nconst timedTables', `}, { from: '3c', to: '3d', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); + .replace("export const SCHEMA_VERSION = '3d';", "export const SCHEMA_VERSION = '3e';") + .replace('}];\nconst timedTables', `}, { from: '3d', to: '3e', 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']) + for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.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, '3d'); + assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e'); 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 3c ready/); + assert.match(migrated.stdout, /bus schema 3d 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-3c.sql b/packages/bus/tests/fixtures/schema-3c.sql new file mode 100644 index 000000000..e68424252 --- /dev/null +++ b/packages/bus/tests/fixtures/schema-3c.sql @@ -0,0 +1,229 @@ +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) +) 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 + BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); 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; diff --git a/packages/business/README.md b/packages/business/README.md index 9a6baad3c..3cc21756d 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -116,8 +116,11 @@ The `chief-of-staff` role (`roles/chief-of-staff.json`, contract `roles/chief-of-staff.md`, REQ-COS-1) runs the organization: it hires and terminates role instances, owns the structure, takes work from the assistant and never talks to the user. It holds `message.send` and -`role.launch` within role (decision 139), with no credentials and the same -read-only tools. It is the only shipped role that holds `role.launch`, so +`role.launch` within role (decision 139), plus `role.end` and +`organization.change` (row 123): it ends a run with a reason and no decision +(`role.revoke` stays gated for every role), and it changes the organization +file through the broker's `organization.set`, limited to the keys the human's +`limits.set` takes. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so it is the only role `launch.by` can name. The PM holds no `role.launch` (REQ-LAUNCH-1); it asks the chief of staff for a session. diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 592a476aa..c24993250 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -26,6 +26,11 @@ export const ACTIONS = Object.freeze([ "message.external", "role.launch", "role.revoke", + // Row 123 (#1617, REQ-COS-1): the chief of staff ends a run with a reason and no decision, + // and changes the organization file through the shared write path. Neither is gated-only, + // so the grant lives in a role file; role.revoke itself stays gated for every role. + "role.end", + "organization.change", "credential.mint", "spend", "deploy", diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs index b993cf5c6..c92c4cc6e 100644 --- a/packages/business/tests/chief-of-staff.test.mjs +++ b/packages/business/tests/chief-of-staff.test.mjs @@ -11,12 +11,12 @@ import { join } from "node:path"; import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs"; import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs"; -// Decision 104's list plus role.launch (decision 139). A role file that -// differs from it fails. +// Decision 104's list plus role.launch (decision 139) plus role.end and +// organization.change (row 123, REQ-COS-1). 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"]), crossRole: Object.freeze([]) }), + authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change"]), crossRole: Object.freeze([]) }), credentials: Object.freeze([]), }); @@ -83,11 +83,14 @@ 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 and message.send; every other action is gated", () => { +test("the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated", () => { const record = resolved(loadFixture()); + const held = ["message.send", "role.launch", "role.end", "organization.change"]; for (const action of ACTIONS) { - assert.equal(classify(record, action), action === "message.send" || action === "role.launch" ? "within" : "gated", action); + assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action); } + // role.revoke itself stays gated for the chief of staff: role.end is the grant, not a loophole. + assert.equal(classify(record, "role.revoke"), "gated"); assert.equal(classify(record, "task.create"), "gated"); }); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index 6ab36b534..582fd6385 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -29,7 +29,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => { } }); -// Row 119 (#1613): the chief of staff holds launch and message.send, and nothing else. +// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change (no typed tools). test("the chief of staff gets launch and send_message, and no task verbs", (t) => { const chief = resolvedFor(t, "chief"); const tools = typedTools({ resolved: chief, tracker: true }); @@ -38,7 +38,8 @@ test("the chief of staff gets launch and send_message, and no task verbs", (t) = assert.equal(launch.verb, "launch"); assert.equal(launch.action, "role.launch"); assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]); - assert.deepEqual(untypedActions(chief, tools), []); + // Row 123 (#1617, REQ-COS-1): the chief also holds role.end and organization.change on the bus; no typed tool yet. + assert.deepEqual(untypedActions(chief, tools), ["role.end", "organization.change"]); }); // Row 119 (#1613): the assistant holds message.send alone. diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json index aafbd2a6e..8613710a4 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"], + "withinRole": ["message.send", "role.launch", "role.end", "organization.change"], "crossRole": [] }, "credentials": []