roles: C-suite messaging limited to human, chief-of-staff, pm and cto (vikunja:19/306, row 145)

Jason-approved merge, landed unchanged: git diff bbf61d6dd..3c80fea74
from work/vikunja-19-306. Business reviewer verdict 8ee5be8b approve on that
commit; Jason resolved merge decision 0eebf83f via the CLI. Applied cleanly
on the parent landing; no conflict. Filbert reviews the rebased diff.

Integration with row 121, found in Filbert's round 1 (#1639, comment 28623):
packages/bus/tests/csuite-messaging.test.mjs setup() binds the assistant to a
thread, since row 121 made assistant a thread definition and bindLaunch refuses
an assistant run without one. The human creates a project and thread first.
No assertion or code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 17:50:59 -05:00
co-authored by Claude Opus 5.5
parent 5a5509839a
commit 92938ab43a
12 changed files with 107 additions and 5 deletions
@@ -0,0 +1,74 @@
// vikunja:19/306 (REQ-ROLE-4, REQ-ASSIST-2): the five C-suite roles talk to the user, the chief of
// staff, the pm and the cto, and to no one else, so an advisory role can't send work to the coder or
// reviewer around the pm. The lists are read from the shipped role files, so the test pins the files
// the stack runs. A message outside the lists is refused and logged, as the assistant's are.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const CSUITE = ['chief-executive-officer', 'chief-financial-officer', 'chief-information-officer', 'chief-information-security-officer', 'chief-marketing-officer'];
const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
const OTHERS = { chief: 'chief-of-staff', pm: 'pm', cto: 'cto', coder: 'coder', reviewer: 'reviewer', assistant: 'assistant' };
const roles = Object.fromEntries([...CSUITE.map((c) => [c, { definition: c, ...within }]), ...Object.entries(OTHERS).map(([i, d]) => [i, { definition: d, ...within }])]);
const definitions = Object.fromEntries([
...CSUITE.map((c) => [c, { messaging: shipped(c).messaging }]),
...Object.values(OTHERS).map((d) => [d, d === 'assistant' ? { messaging: shipped('assistant').messaging } : {}]),
]);
const RECIPIENT = 'message-recipient-refused';
const SENDER = 'message-sender-refused';
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-csuite-msg-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, definitions, projects: { stack: { root: '/nonexistent' } } } } });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
// Row 121: the assistant is a thread definition, so its run binds to a thread.
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
for (const role of Object.keys(roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
const send = (from, to) => b.request(caps[from], { verb: 'message.send', args: { to, body: 'hello' } });
const messages = () => store.get('SELECT count(*) n FROM messages').n;
const refusals = () => store.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq").map((e) => [e.actor_role, JSON.parse(e.body).code]);
return { send, messages, refusals };
}
test('each C-suite role file carries the same two lists: human, chief-of-staff, pm, cto', () => {
for (const c of CSUITE) assert.deepEqual(shipped(c).messaging, { sendTo: ['human', 'chief-of-staff', 'pm', 'cto'], receiveFrom: ['human', 'chief-of-staff', 'pm', 'cto'] }, c);
});
for (const c of CSUITE) {
test(`${c}: messages to and from the user, chief of staff, pm and cto pass`, (t) => {
const { send, messages } = setup(t);
for (const to of ['human', 'chief', 'pm', 'cto']) send(c, to);
for (const from of ['human', 'chief', 'pm', 'cto']) send(from, c);
assert.equal(messages(), 8);
});
test(`${c}: a message to or from the coder, reviewer or assistant is refused and logged, and nothing is stored`, (t) => {
const { send, messages, refusals } = setup(t);
const out = ['coder', 'reviewer', 'assistant'];
for (const to of out) assert.throws(() => send(c, to), { code: RECIPIENT }, `to ${to}`);
for (const from of out) assert.throws(() => send(from, c), from === 'assistant' ? { code: RECIPIENT } : { code: SENDER }, `from ${from}`);
assert.equal(messages(), 0);
assert.deepEqual(refusals(), [
...out.map(() => [c, RECIPIENT]),
['coder', SENDER], ['reviewer', SENDER], ['assistant', RECIPIENT],
]);
});
test(`${c}: another C-suite role isn't on the lists either`, (t) => {
const { send } = setup(t);
const other = CSUITE.find((x) => x !== c);
assert.throws(() => send(c, other), { code: RECIPIENT });
});
}
+3
View File
@@ -15,6 +15,9 @@ const APPROVED = Object.freeze({
network: "api-only",
authority: { withinRole: ["message.send"], crossRole: [] },
credentials: [],
// Least privilege (vikunja:19/306): an advisory role talks to the user, the chief of staff, the pm
// and the cto, so it can't send work to the coder or reviewer around the pm.
messaging: { sendTo: ["human", "chief-of-staff", "pm", "cto"], receiveFrom: ["human", "chief-of-staff", "pm", "cto"] },
});
// Jason's business file has the same shape: the five instances by definition name, no credentials
+5 -1
View File
@@ -9,5 +9,9 @@
"withinRole": ["message.send"],
"crossRole": []
},
"credentials": []
"credentials": [],
"messaging": {
"sendTo": ["human", "chief-of-staff", "pm", "cto"],
"receiveFrom": ["human", "chief-of-staff", "pm", "cto"]
}
}
+1
View File
@@ -37,4 +37,5 @@ instance as target, naming the run and saying why.
started. It is the truth for launch state and task status. A message
or a note of yours that disagrees with it is out of date.
- Send messages through the bus, addressed to a role, not a person.
- You may send messages to the user (`human`) and to the `chief-of-staff`, `pm` and `cto` roles, and receive messages from them, and from no other role. Work for the coder or reviewer goes through the pm's tasks. A message outside that list is refused and logged.
- Report a blocker as soon as you find it, with what you tried.
+5 -1
View File
@@ -9,5 +9,9 @@
"withinRole": ["message.send"],
"crossRole": []
},
"credentials": []
"credentials": [],
"messaging": {
"sendTo": ["human", "chief-of-staff", "pm", "cto"],
"receiveFrom": ["human", "chief-of-staff", "pm", "cto"]
}
}
+1
View File
@@ -37,4 +37,5 @@ instance as target, naming the run and saying why.
started. It is the truth for launch state and task status. A message
or a note of yours that disagrees with it is out of date.
- Send messages through the bus, addressed to a role, not a person.
- You may send messages to the user (`human`) and to the `chief-of-staff`, `pm` and `cto` roles, and receive messages from them, and from no other role. Work for the coder or reviewer goes through the pm's tasks. A message outside that list is refused and logged.
- Report a blocker as soon as you find it, with what you tried.
+5 -1
View File
@@ -9,5 +9,9 @@
"withinRole": ["message.send"],
"crossRole": []
},
"credentials": []
"credentials": [],
"messaging": {
"sendTo": ["human", "chief-of-staff", "pm", "cto"],
"receiveFrom": ["human", "chief-of-staff", "pm", "cto"]
}
}
+1
View File
@@ -37,4 +37,5 @@ instance as target, naming the run and saying why.
started. It is the truth for launch state and task status. A message
or a note of yours that disagrees with it is out of date.
- Send messages through the bus, addressed to a role, not a person.
- You may send messages to the user (`human`) and to the `chief-of-staff`, `pm` and `cto` roles, and receive messages from them, and from no other role. Work for the coder or reviewer goes through the pm's tasks. A message outside that list is refused and logged.
- Report a blocker as soon as you find it, with what you tried.
@@ -9,5 +9,9 @@
"withinRole": ["message.send"],
"crossRole": []
},
"credentials": []
"credentials": [],
"messaging": {
"sendTo": ["human", "chief-of-staff", "pm", "cto"],
"receiveFrom": ["human", "chief-of-staff", "pm", "cto"]
}
}
@@ -37,4 +37,5 @@ instance as target, naming the run and saying why.
started. It is the truth for launch state and task status. A message
or a note of yours that disagrees with it is out of date.
- Send messages through the bus, addressed to a role, not a person.
- You may send messages to the user (`human`) and to the `chief-of-staff`, `pm` and `cto` roles, and receive messages from them, and from no other role. Work for the coder or reviewer goes through the pm's tasks. A message outside that list is refused and logged.
- Report a blocker as soon as you find it, with what you tried.
+5 -1
View File
@@ -9,5 +9,9 @@
"withinRole": ["message.send"],
"crossRole": []
},
"credentials": []
"credentials": [],
"messaging": {
"sendTo": ["human", "chief-of-staff", "pm", "cto"],
"receiveFrom": ["human", "chief-of-staff", "pm", "cto"]
}
}
+1
View File
@@ -37,4 +37,5 @@ instance as target, naming the run and saying why.
started. It is the truth for launch state and task status. A message
or a note of yours that disagrees with it is out of date.
- Send messages through the bus, addressed to a role, not a person.
- You may send messages to the user (`human`) and to the `chief-of-staff`, `pm` and `cto` roles, and receive messages from them, and from no other role. Work for the coder or reviewer goes through the pm's tasks. A message outside that list is refused and logged.
- Report a blocker as soon as you find it, with what you tried.