998 lines
57 KiB
Diff
998 lines
57 KiB
Diff
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
|
index 2973590c5..3bfd35a55 100644
|
|
--- a/packages/bus/README.md
|
|
+++ b/packages/bus/README.md
|
|
@@ -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.
|
|
diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql
|
|
index e68424252..337ae80b9 100644
|
|
--- a/packages/bus/schema.sql
|
|
+++ b/packages/bus/schema.sql
|
|
@@ -124,8 +124,8 @@ CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
|
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
|
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
|
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
|
- WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
|
|
- BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
|
|
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
|
+ BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
|
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
|
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
|
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
|
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index 7c281d635..ecb18f8b8 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -20,6 +20,8 @@ export const ACTIONS = Object.freeze([
|
|
'message.external',
|
|
'role.launch',
|
|
'role.revoke',
|
|
+ 'role.end',
|
|
+ 'organization.change',
|
|
'credential.mint',
|
|
'spend',
|
|
'deploy',
|
|
@@ -93,6 +95,10 @@ function identifier(x) {
|
|
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
|
|
// A git commit id, SHA-1 or SHA-256.
|
|
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
|
|
+// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
|
|
+const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances'];
|
|
+// Row 123 (#1617): role.end never reaches the assistant (the user's seat) or the caller's own run.
|
|
+const END_PROTECTED = ['assistant'];
|
|
const VERDICTS = ['approve', 'changes'];
|
|
// Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an
|
|
// action.allowed for git.push.working that names its commit, written only by recordPush.
|
|
@@ -715,14 +721,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
|
|
diff --git a/packages/bus/src/migrations/3c-3d.sql b/packages/bus/src/migrations/3c-3d.sql
|
|
new file mode 100644
|
|
index 000000000..af600bae5
|
|
--- /dev/null
|
|
+++ b/packages/bus/src/migrations/3c-3d.sql
|
|
@@ -0,0 +1,5 @@
|
|
+-- Row 123 (#1617): a revoke needs a resolved decision or a reason (the chief of staff's role.end).
|
|
+DROP TRIGGER role_revoke_needs_decision;
|
|
+CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
|
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
|
+ BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
|
diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs
|
|
index 0c4d14f2d..99e1f1ac0 100644
|
|
--- a/packages/bus/src/store.mjs
|
|
+++ b/packages/bus/src/store.mjs
|
|
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
|
-export const SCHEMA_SHA256 = '7587ac1df813e5148fdb73dc4733ec8064e6a142e4647f0da71be009a73eebdb';
|
|
-export const SCHEMA_VERSION = '3c';
|
|
+export const SCHEMA_SHA256 = 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5';
|
|
+export const SCHEMA_VERSION = '3d';
|
|
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
|
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
|
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
|
@@ -25,6 +25,10 @@ const migrations = [{
|
|
initialize: (store) => store.run(
|
|
'INSERT INTO meta(key,value) VALUES (?,?)', 'comment_cursor_epoch', new Date().toISOString(),
|
|
),
|
|
+}, {
|
|
+ from: '3c',
|
|
+ to: '3d',
|
|
+ sql: readFileSync(new URL('./migrations/3c-3d.sql', import.meta.url), 'utf8'),
|
|
}];
|
|
const timedTables = [
|
|
'events',
|
|
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
|
|
new file mode 100644
|
|
index 000000000..a224c7546
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/chief-of-staff.test.mjs
|
|
@@ -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/);
|
|
+});
|
|
diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs
|
|
index 8d730e1eb..d2ac6928a 100644
|
|
--- a/packages/bus/tests/comment-migration.test.mjs
|
|
+++ b/packages/bus/tests/comment-migration.test.mjs
|
|
@@ -28,12 +28,12 @@ function legacy(t) {
|
|
return { root, source, path };
|
|
}
|
|
|
|
-test('explicit 3b copy migration preserves evidence and opens as 3c; normal startup never migrates', t => {
|
|
+test('explicit 3b copy migration preserves evidence and opens as 3d; normal startup never migrates', t => {
|
|
const { root, source } = legacy(t);
|
|
const original = readFileSync(source);
|
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
|
const store = new Store(root, { migrateSchema: true });
|
|
- assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3c');
|
|
+ assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
|
|
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
|
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
|
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
|
@@ -62,7 +62,7 @@ test('migration refuses active writers and drifted legacy schemas, with no parti
|
|
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
|
|
});
|
|
|
|
-test('named migration registry chains 3b through 3c to a fixture-only future version in order', async t => {
|
|
+test('named migration registry chains 3b through 3d to a fixture-only future version in order', async t => {
|
|
const { root } = legacy(t);
|
|
const { writeFileSync } = await import('node:fs');
|
|
const { pathToFileURL } = await import('node:url');
|
|
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3c to a fixture-only future ver
|
|
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
|
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
|
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
|
- .replace("export const SCHEMA_VERSION = '3c';", "export const SCHEMA_VERSION = '3d';")
|
|
- .replace('}];\nconst timedTables', `}, { from: '3c', to: '3d', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
|
+ .replace("export const SCHEMA_VERSION = '3d';", "export const SCHEMA_VERSION = '3e';")
|
|
+ .replace('}];\nconst timedTables', `}, { from: '3d', to: '3e', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
|
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
|
- for (const name of ['3b.sql', '3b-3c.sql'])
|
|
+ for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql'])
|
|
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
|
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
|
const future = new FutureStore(root, { migrateSchema: true });
|
|
- assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
|
|
+ assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
|
|
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
|
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
|
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
|
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
|
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
|
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
|
assert.equal(migrated.status, 0, migrated.stderr);
|
|
- assert.match(migrated.stdout, /bus schema 3c ready/);
|
|
+ assert.match(migrated.stdout, /bus schema 3d ready/);
|
|
new Store(root).close();
|
|
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
|
assert.equal(usage.status, 2);
|
|
diff --git a/packages/bus/tests/fixtures/schema-3c.sql b/packages/bus/tests/fixtures/schema-3c.sql
|
|
new file mode 100644
|
|
index 000000000..e68424252
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/fixtures/schema-3c.sql
|
|
@@ -0,0 +1,229 @@
|
|
+PRAGMA journal_mode = WAL;
|
|
+PRAGMA foreign_keys = ON;
|
|
+CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
|
+CREATE TABLE events (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ id TEXT NOT NULL UNIQUE,
|
|
+ at TEXT NOT NULL,
|
|
+ business TEXT NOT NULL,
|
|
+ kind TEXT NOT NULL CHECK (kind IN (
|
|
+ 'session.launched',
|
|
+ 'session.ended',
|
|
+ 'action.allowed',
|
|
+ 'action.refused',
|
|
+ 'task.created',
|
|
+ 'task.assigned',
|
|
+ 'task.state',
|
|
+ 'task.closed',
|
|
+ 'task.changed.external',
|
|
+ 'task.conflict',
|
|
+ 'task.missing',
|
|
+ 'review.requested',
|
|
+ 'review.verdict',
|
|
+ 'human.input',
|
|
+ 'config.refused',
|
|
+ 'credential.expiring',
|
|
+ 'credential.expired',
|
|
+ 'credential.changed',
|
|
+ 'launch.revoked',
|
|
+ 'launch.restored',
|
|
+ 'digest.sent')),
|
|
+ actor_role TEXT, actor_run TEXT,
|
|
+ subject TEXT,
|
|
+ corrects TEXT REFERENCES events(id),
|
|
+ body TEXT NOT NULL CHECK (json_valid(body))
|
|
+) STRICT;
|
|
+CREATE TABLE role_claims (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ at TEXT NOT NULL,
|
|
+ business TEXT NOT NULL, role TEXT NOT NULL,
|
|
+ op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
|
+ holder_run TEXT NOT NULL,
|
|
+ harness TEXT NOT NULL, address TEXT,
|
|
+ by TEXT NOT NULL, reason TEXT,
|
|
+ decision TEXT
|
|
+) STRICT;
|
|
+CREATE TABLE decisions (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ id TEXT NOT NULL UNIQUE,
|
|
+ at TEXT NOT NULL,
|
|
+ business TEXT NOT NULL, project TEXT,
|
|
+ raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
|
+ class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
|
+ action TEXT NOT NULL,
|
|
+ route_to TEXT NOT NULL,
|
|
+ question TEXT NOT NULL,
|
|
+ options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
|
+ recommendation TEXT NOT NULL,
|
|
+ task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
|
+ blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
|
+ supersedes TEXT REFERENCES decisions(id)
|
|
+) STRICT;
|
|
+CREATE TABLE decision_events (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ decision TEXT NOT NULL REFERENCES decisions(id),
|
|
+ at TEXT NOT NULL,
|
|
+ op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
|
+ by TEXT NOT NULL,
|
|
+ choice TEXT, note TEXT, via TEXT
|
|
+) STRICT;
|
|
+CREATE TABLE messages (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ id TEXT NOT NULL UNIQUE,
|
|
+ at TEXT NOT NULL,
|
|
+ business TEXT NOT NULL,
|
|
+ from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
|
+ to_role TEXT NOT NULL,
|
|
+ class TEXT NOT NULL,
|
|
+ in_reply_to TEXT REFERENCES messages(id),
|
|
+ decision TEXT REFERENCES decisions(id),
|
|
+ corrects TEXT REFERENCES messages(id),
|
|
+ body TEXT NOT NULL
|
|
+) STRICT;
|
|
+CREATE TABLE deliveries (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ message TEXT NOT NULL REFERENCES messages(id),
|
|
+ at TEXT NOT NULL,
|
|
+ op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
|
+ holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
|
+) STRICT;
|
|
+CREATE TABLE task_snapshots (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ at TEXT NOT NULL,
|
|
+ business TEXT NOT NULL,
|
|
+ task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
|
+ updated TEXT NOT NULL,
|
|
+ etag TEXT,
|
|
+ digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
|
+ fields TEXT NOT NULL CHECK (json_valid(fields)),
|
|
+ source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
|
+ via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
|
+ read_at TEXT,
|
|
+ role TEXT, run TEXT,
|
|
+ CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
|
+ CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
|
+ CHECK (json_type(fields, '$.bucket') IS 'integer'
|
|
+ OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
|
+) STRICT;
|
|
+CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
|
+CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
|
+ WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
|
+ SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
|
+ BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
|
+CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
|
+ WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
|
+ SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
|
+ BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
|
+CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
|
+ WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
|
+ BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
|
+CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
|
+ WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
|
+ BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
|
+CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
|
+ WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
|
+ BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
|
+CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
|
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
|
|
+ BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
|
|
+CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
|
+CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
|
+CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
|
+CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
|
+CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
|
+CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
|
+CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
|
+CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
|
+CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
|
+CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
|
+CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
|
+CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
|
+CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
|
+CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
|
+CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
|
+CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
|
+CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
|
+CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
|
+CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
|
+CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
|
+CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
|
+CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
|
+CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
|
+CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
|
+CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
|
+ WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
|
+ BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
|
+CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
|
+ WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
|
+ BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
|
+CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
|
+ WHEN NEW.kind GLOB 'credential.*' AND (
|
|
+ json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
|
+ OR json_extract(NEW.body, '$.instance') IS NULL)
|
|
+ BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
|
+CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
|
+ WHEN NEW.kind = 'task.missing' AND (
|
|
+ json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
|
+ OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
|
+ BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
|
+CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
|
+ WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
|
+ BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
|
+CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
|
+ WHEN NEW.kind = 'task.created' AND (
|
|
+ json_type(NEW.body, '$.request') IS NOT 'text'
|
|
+ OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
|
+ AND kind = 'human.input' AND business = NEW.business)
|
|
+ OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
|
+ OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
|
+ OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
|
+ BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
|
+CREATE VIEW launch_state AS
|
|
+ SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
|
+ FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
|
+ AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
|
+CREATE VIEW task_external_changes AS
|
|
+ SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
|
+ FROM task_snapshots p
|
|
+ LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
|
+ WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
|
+ WHERE p.source = 'poll'
|
|
+ AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
|
+ AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
|
+CREATE VIEW task_current AS
|
|
+ SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
|
+ FROM task_snapshots s
|
|
+ WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
|
+ WHERE c.business = s.business AND c.task_ref = s.task_ref
|
|
+ AND (c.source = 'self'
|
|
+ OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
|
+ WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
|
+ ORDER BY ls.seq DESC LIMIT 1), '')));
|
|
+CREATE VIEW tasks_open AS
|
|
+ SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
|
+ FROM task_current s
|
|
+ WHERE json_type(s.fields, '$.gone') IS NULL
|
|
+ AND json_extract(s.fields, '$.done') = 0;
|
|
+CREATE VIEW urgent_inbox AS
|
|
+ SELECT d.id, d.business, d.task_ref, d.question, d.at
|
|
+ FROM decisions d
|
|
+ WHERE d.class = 'gated' AND d.blocking = 1
|
|
+ AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
|
+CREATE TABLE task_comment_cursors (
|
|
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
+ business TEXT NOT NULL,
|
|
+ task_id INTEGER NOT NULL CHECK (task_id > 0),
|
|
+ last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
|
+) STRICT;
|
|
+CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
|
+CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
|
+ BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
|
+CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
|
+ BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
|
+CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
|
+ WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
|
+ BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
|
+CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
|
+ WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
|
+ WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
|
+ BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
|
diff --git a/packages/business/README.md b/packages/business/README.md
|
|
index d2967cb33..1d3802b87 100644
|
|
--- a/packages/business/README.md
|
|
+++ b/packages/business/README.md
|
|
@@ -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.
|
|
|
|
diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs
|
|
index 592a476aa..c24993250 100644
|
|
--- a/packages/business/src/vocabulary.mjs
|
|
+++ b/packages/business/src/vocabulary.mjs
|
|
@@ -26,6 +26,11 @@ export const ACTIONS = Object.freeze([
|
|
"message.external",
|
|
"role.launch",
|
|
"role.revoke",
|
|
+ // Row 123 (#1617, REQ-COS-1): the chief of staff ends a run with a reason and no decision,
|
|
+ // and changes the organization file through the shared write path. Neither is gated-only,
|
|
+ // so the grant lives in a role file; role.revoke itself stays gated for every role.
|
|
+ "role.end",
|
|
+ "organization.change",
|
|
"credential.mint",
|
|
"spend",
|
|
"deploy",
|
|
diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs
|
|
index b993cf5c6..c92c4cc6e 100644
|
|
--- a/packages/business/tests/chief-of-staff.test.mjs
|
|
+++ b/packages/business/tests/chief-of-staff.test.mjs
|
|
@@ -11,12 +11,12 @@ import { join } from "node:path";
|
|
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
|
|
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
|
|
|
-// Decision 104's list plus role.launch (decision 139). A role file that
|
|
-// differs from it fails.
|
|
+// Decision 104's list plus role.launch (decision 139) plus role.end and
|
|
+// organization.change (row 123, REQ-COS-1). A role file that differs from it fails.
|
|
const APPROVED = Object.freeze({
|
|
tools: ["read", "grep", "find", "ls"],
|
|
network: "api-only",
|
|
- authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch"]), crossRole: Object.freeze([]) }),
|
|
+ authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change"]), crossRole: Object.freeze([]) }),
|
|
credentials: Object.freeze([]),
|
|
});
|
|
|
|
@@ -83,11 +83,14 @@ test("the resolved record matches the approved list, so a widened file fails", (
|
|
assert.deepEqual(record.credentials, {});
|
|
});
|
|
|
|
-test("the chief of staff holds role.launch and message.send; every other action is gated", () => {
|
|
+test("the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated", () => {
|
|
const record = resolved(loadFixture());
|
|
+ const held = ["message.send", "role.launch", "role.end", "organization.change"];
|
|
for (const action of ACTIONS) {
|
|
- assert.equal(classify(record, action), action === "message.send" || action === "role.launch" ? "within" : "gated", action);
|
|
+ assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
|
|
}
|
|
+ // role.revoke itself stays gated for the chief of staff: role.end is the grant, not a loophole.
|
|
+ assert.equal(classify(record, "role.revoke"), "gated");
|
|
assert.equal(classify(record, "task.create"), "gated");
|
|
});
|
|
|
|
diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs
|
|
index 6ab36b534..582fd6385 100644
|
|
--- a/packages/harness/tests/tools.test.mjs
|
|
+++ b/packages/harness/tests/tools.test.mjs
|
|
@@ -29,7 +29,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
|
|
}
|
|
});
|
|
|
|
-// Row 119 (#1613): the chief of staff holds launch and message.send, and nothing else.
|
|
+// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change (no typed tools).
|
|
test("the chief of staff gets launch and send_message, and no task verbs", (t) => {
|
|
const chief = resolvedFor(t, "chief");
|
|
const tools = typedTools({ resolved: chief, tracker: true });
|
|
@@ -38,7 +38,8 @@ test("the chief of staff gets launch and send_message, and no task verbs", (t) =
|
|
assert.equal(launch.verb, "launch");
|
|
assert.equal(launch.action, "role.launch");
|
|
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
|
|
- assert.deepEqual(untypedActions(chief, tools), []);
|
|
+ // Row 123 (#1617, REQ-COS-1): the chief also holds role.end and organization.change on the bus; no typed tool yet.
|
|
+ assert.deepEqual(untypedActions(chief, tools), ["role.end", "organization.change"]);
|
|
});
|
|
|
|
// Row 119 (#1613): the assistant holds message.send alone.
|
|
diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json
|
|
index aafbd2a6e..8613710a4 100644
|
|
--- a/roles/chief-of-staff.json
|
|
+++ b/roles/chief-of-staff.json
|
|
@@ -6,7 +6,7 @@
|
|
"tools": ["read", "grep", "find", "ls"],
|
|
"network": "api-only",
|
|
"authority": {
|
|
- "withinRole": ["message.send", "role.launch"],
|
|
+ "withinRole": ["message.send", "role.launch", "role.end", "organization.change"],
|
|
"crossRole": []
|
|
},
|
|
"credentials": []
|