// Row 133 (#1627, REQ-ASSIST-2): whoever closes a decision messages the raising role, so // decision.raise refuses a route that can't message the raiser before it stores anything. The route // is the sender and the raiser the target, under row 120's messaging rules. A within-role decision, // routed to the raiser's own role, is exempt. 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'; // The assistant's lists come from the shipped role file, as in messaging.test.mjs. const assistantLists = JSON.parse( readFileSync(fileURLToPath(new URL('../../../roles/assistant.json', import.meta.url)), 'utf8'), ).messaging; const options = [ { key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }, ]; const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } }; const roles = { pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } }, chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, // Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter. coder: { definition: 'coder', ...pusher }, // Hears from neither arbiter nor the human, but may send to the human: a reversed check passes it. warden: { definition: 'warden', ...pusher }, // Hears from the human and both arbiters but sends only to pm: a reversed check refuses it. scribe: { definition: 'scribe', ...pusher }, }; const definitions = { pm: {}, 'chief-of-staff': {}, assistant: { messaging: assistantLists }, coder: { messaging: { sendTo: ['human', 'pm', 'chief-of-staff'], receiveFrom: ['human', 'pm'] } }, warden: { messaging: { sendTo: ['human', 'pm', 'chief-of-staff'], receiveFrom: ['coder'] } }, scribe: { messaging: { sendTo: ['pm'], receiveFrom: ['human', 'pm', 'chief-of-staff'] } }, }; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' }, roles, definitions, projects: { stack: { root: '/nonexistent' } }, }, }; const ROUTE = 'decision-route-refused'; function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-decision-route-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const b = new Broker({ store, businesses }); const caps = {}; caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); // Row 121 (#1615): the assistant runs for one 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 call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); const raise = (who, action, extra = {}) => call(who, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra }); const count = (sql) => store.get(sql).n; const stored = () => ({ decisions: count('SELECT count(*) n FROM decisions'), decisionEvents: count('SELECT count(*) n FROM decision_events'), messages: count('SELECT count(*) n FROM messages'), allowed: count("SELECT count(*) n FROM events WHERE kind='action.allowed'"), }); 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 { store, call, raise, stored, refusals }; } test('a cross-role raise whose arbiter the raiser does not hear from is refused and stores nothing', (t) => { const { raise, stored, refusals } = setup(t); const before = stored(); // Delivery goes to chief (chief-of-staff), which the coder's receiveFrom doesn't list. assert.throws(() => raise('coder', 'git.push.working', { domain: 'delivery' }), { code: ROUTE }); assert.throws(() => raise('warden', 'git.push.working', { domain: 'technical' }), { code: ROUTE }); assert.deepEqual(refusals(), [['coder', ROUTE], ['warden', ROUTE]]); assert.deepEqual(stored(), before, 'no decision, decision event, message or allowed event'); }); test('a gated raise by a role that does not hear from the human is refused and stores nothing', (t) => { const { raise, stored, refusals } = setup(t); const before = stored(); // deploy is gated, so the route is the human; warden's receiveFrom lacks human. assert.throws(() => raise('warden', 'deploy', { target: 'release-1' }), { code: ROUTE }); // A cross-role action outside the raiser's crossRole is gated too. assert.throws(() => raise('warden', 'task.create'), { code: ROUTE }); assert.deepEqual(refusals(), [['warden', ROUTE], ['warden', ROUTE]]); assert.deepEqual(stored(), before); }); test('a route the raiser hears from is allowed, checked from the route to the raiser', (t) => { const { raise, call, store } = setup(t); const technical = raise('coder', 'git.push.working', { domain: 'technical' }); const gated = raise('coder', 'deploy', { target: 'release-1' }); assert.deepEqual([technical.route_to, gated.route_to], ['pm', 'human']); // scribe sends only to pm, but every route here may message scribe. const routes = [ raise('scribe', 'git.push.working', { domain: 'delivery' }), raise('scribe', 'git.push.working', { domain: 'technical' }), raise('scribe', 'deploy', { target: 'release-1' }), ].map((d) => d.route_to); assert.deepEqual(routes, ['chief', 'pm', 'human']); // The close message the check stands for reaches the raiser. call('pm', 'decision.resolve', { id: technical.id, choice: 'yes' }); const close = store.get("SELECT from_role, to_role FROM messages WHERE class='DECISION' AND decision=?", technical.id); assert.deepEqual({ ...close }, { from_role: 'pm', to_role: 'coder' }); }); test('a refused supersede withdraws nothing', (t) => { const { raise, store } = setup(t); const old = raise('coder', 'git.push.working', { domain: 'technical' }); assert.throws( () => raise('coder', 'git.push.working', { domain: 'delivery', supersedes: old.id }), { code: ROUTE }, ); assert.equal(store.get('SELECT count(*) n FROM decision_events WHERE decision=?', old.id).n, 0); assert.equal(store.get('SELECT count(*) n FROM decisions').n, 1); }); test("the assistant's within-role and gated raises still work", (t) => { const { raise, call, store, refusals } = setup(t); // message.send is within role: routed to the assistant itself, which its own lists don't name. const within = raise('assistant', 'message.send', { choice: 'yes' }); assert.equal(within.route_to, 'assistant'); assert.equal( store.get("SELECT op FROM decision_events WHERE decision=?", within.id).op, 'resolved', ); // Anything else is gated: routed to the human, whom the assistant hears from. const gated = raise('assistant', 'task.create'); assert.equal(gated.route_to, 'human'); call('human', 'decision.resolve', { id: gated.id, choice: 'no' }); const close = store.get("SELECT from_role, to_role FROM messages WHERE class='DECISION' AND decision=?", gated.id); assert.deepEqual({ ...close }, { from_role: 'human', to_role: 'assistant' }); assert.deepEqual(refusals(), []); });