bus: the chief of staff ends runs and changes the organization file, schema 3c to 3d (row 123, #1617)

The chief of staff role can revoke a run with a reason (role.revoke) and
write the organization file (organization.set), each under a broker
grant. organization.set checks the grant before the writer runs and
consumes it only after the writer returns, so a refused change leaves no
action.allowed. migrations/3c-3d.sql takes the store from 3c to 3d;
migrate-schema.mjs chains it after 3b to 3c. roles/chief-of-staff.json
and the business vocabulary carry the new actions. The harness tools for
them are row 132, which must land before 3d goes live.

Candidate 255a960582f1018b8d400e8d193e8e95713a078930277652d59ef4153197d9f6,
Researcher packet agents/researcher/work/queue-123/r2 (b959160f9), built
on eaeb5ebf2 plus row 49 r1. Filbert approved round 2 in 28578. Landing
gate at 3a4afdc03: 24 suites green, 0 failed. 14 package suites passed
1611 tests (bus 191, business 92, harness 84, tasks 97, webui 112). The
10 script suites passed (land-work 84, queue 156).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 12:49:33 -05:00
co-authored by Claude Opus 5.5
parent 3a4afdc03a
commit 89fd2d6fee
13 changed files with 721 additions and 34 deletions
+6 -2
View File
@@ -138,7 +138,8 @@ 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 `<definition>.<instance>`, 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 +476,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.
+2 -2
View File
@@ -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;
+42 -10
View File
@@ -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,26 @@ 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;
// This branch runs outside the store transaction below, so the grant is checked before the writer
// and consumed only once the writer has committed: a refused change logs action.refused alone,
// as a refused human limits.set does.
if (role) {
this.#checkAuthority(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);
@@ -731,6 +749,7 @@ export class Broker {
}
throw new BusError("outcome-unknown");
}
if (role) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
return result;
}
@@ -804,13 +823,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 +1397,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 +1410,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
+5
View File
@@ -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;
+6 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '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',
+401
View File
@@ -0,0 +1,401 @@
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 writer = { error: null };
const b = new Broker({
store,
businesses: demo(),
organization: (...args) => {
organization.push(args);
if (writer.error) throw writer.error;
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);
const refused = () =>
store.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq").map((e) => ({ ...e, body: JSON.parse(e.body) }));
return { b, store, caps, call, lastClaim, allowed, refused, organization, writer };
}
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, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set', (t) => {
const { call, allowed, refused, organization, writer } = setup(t);
// The grant is consumed only after the writer commits, so a refusal leaves no allowed event behind.
const codes = [];
for (const [error, code] of [
[Object.assign(new Error('stale'), { code: 'revision-conflict' }), 'revision-conflict'],
[Object.assign(new Error('bad'), { name: 'BusinessError' }), 'invalid-request'],
[new Error('disk'), 'outcome-unknown'],
]) {
writer.error = error;
assert.throws(() => call('chief', 'organization.set', PATCH), new RegExp(code));
assert.throws(() => call('human', 'limits.set', PATCH), new RegExp(code));
codes.push(code, code);
}
// A refusal before the writer (a key outside the write path) leaves the same trace.
assert.throws(() => call('chief', 'organization.set', { ...PATCH, human: 'mallory' }), /invalid-request/);
assert.equal(organization.length, 6);
assert.equal(allowed('organization.change').length, 0);
assert.deepEqual(refused().map((e) => [e.actor_role, e.body.code]), [
...codes.map((code, i) => [i % 2 ? null : 'chief', code]), // a human has no role
['chief', 'invalid-request'],
]);
// A change that commits leaves exactly one allowed event, and no further refusal.
writer.error = null;
call('chief', 'organization.set', PATCH);
assert.equal(allowed('organization.change').length, 1);
assert.equal(refused().length, 7);
});
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/);
});
@@ -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);
+229
View File
@@ -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;
+5 -2
View File
@@ -123,8 +123,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.
+5
View File
@@ -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",
@@ -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");
});
+3 -2
View File
@@ -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.
+1 -1
View File
@@ -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": []