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 oneaeb5ebf2plus row 49 r1. Filbert approved round 2 in 28578. Landing gate at3a4afdc03: 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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": []
|
||||
|
||||
Reference in New Issue
Block a user