commit 077f3a0fc8248562f7b7e610027775649f86ccb9 Author: Jason Woltje Date: Thu Oct 15 17:25:23 2026 -0500 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 --- packages/bus/tests/csuite-messaging.test.mjs | 74 +++++++++++++++++++++++++++ packages/business/tests/csuite.test.mjs | 3 ++ roles/chief-executive-officer.json | 6 ++- roles/chief-executive-officer.md | 1 + roles/chief-financial-officer.json | 6 ++- roles/chief-financial-officer.md | 1 + roles/chief-information-officer.json | 6 ++- roles/chief-information-officer.md | 1 + roles/chief-information-security-officer.json | 6 ++- roles/chief-information-security-officer.md | 1 + roles/chief-marketing-officer.json | 6 ++- roles/chief-marketing-officer.md | 1 + 12 files changed, 107 insertions(+), 5 deletions(-) diff --git a/packages/bus/tests/csuite-messaging.test.mjs b/packages/bus/tests/csuite-messaging.test.mjs new file mode 100644 index 000000000..c40a75b8b --- /dev/null +++ b/packages/bus/tests/csuite-messaging.test.mjs @@ -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 }); + }); +} diff --git a/packages/business/tests/csuite.test.mjs b/packages/business/tests/csuite.test.mjs index aad662481..cc8f434c2 100644 --- a/packages/business/tests/csuite.test.mjs +++ b/packages/business/tests/csuite.test.mjs @@ -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 diff --git a/roles/chief-executive-officer.json b/roles/chief-executive-officer.json index d601ce95f..95d6679e0 100644 --- a/roles/chief-executive-officer.json +++ b/roles/chief-executive-officer.json @@ -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"] + } } diff --git a/roles/chief-executive-officer.md b/roles/chief-executive-officer.md index 5b470df03..3cbec50bd 100644 --- a/roles/chief-executive-officer.md +++ b/roles/chief-executive-officer.md @@ -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. diff --git a/roles/chief-financial-officer.json b/roles/chief-financial-officer.json index 801427a4a..0384fac2c 100644 --- a/roles/chief-financial-officer.json +++ b/roles/chief-financial-officer.json @@ -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"] + } } diff --git a/roles/chief-financial-officer.md b/roles/chief-financial-officer.md index 17d56f2da..6fb381142 100644 --- a/roles/chief-financial-officer.md +++ b/roles/chief-financial-officer.md @@ -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. diff --git a/roles/chief-information-officer.json b/roles/chief-information-officer.json index 1daf8fa2b..4a415d7c7 100644 --- a/roles/chief-information-officer.json +++ b/roles/chief-information-officer.json @@ -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"] + } } diff --git a/roles/chief-information-officer.md b/roles/chief-information-officer.md index b04a719fc..89ce6b687 100644 --- a/roles/chief-information-officer.md +++ b/roles/chief-information-officer.md @@ -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. diff --git a/roles/chief-information-security-officer.json b/roles/chief-information-security-officer.json index 008990000..2db30f8fd 100644 --- a/roles/chief-information-security-officer.json +++ b/roles/chief-information-security-officer.json @@ -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"] + } } diff --git a/roles/chief-information-security-officer.md b/roles/chief-information-security-officer.md index 61af0eee7..239d2798d 100644 --- a/roles/chief-information-security-officer.md +++ b/roles/chief-information-security-officer.md @@ -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. diff --git a/roles/chief-marketing-officer.json b/roles/chief-marketing-officer.json index 91300eb2a..76312f93d 100644 --- a/roles/chief-marketing-officer.json +++ b/roles/chief-marketing-officer.json @@ -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"] + } } diff --git a/roles/chief-marketing-officer.md b/roles/chief-marketing-officer.md index 57056cc68..422210653 100644 --- a/roles/chief-marketing-officer.md +++ b/roles/chief-marketing-officer.md @@ -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.