A within-role message.send stores its decision as a citation: the broker
checks it exists in the business, and doesn't class-match it, consume it
or put it in action.allowed (lead decision 65). Sends that aren't
within-role keep the S2b authority rules. The README wording on
within-role sends is fixed.
Candidate agents/rocko/work/slice1-s2c-r1, build.patch 2c4f8d9f,
manifest aa249ad9. Darkwing approved round 1 on #1526 (comment 26778).
Integration gate in a worktree on ac4a6499: every package green on
Node 26; bus 58/58 on Node 24; every scripts/test-*.sh green. Node 24
failures in conversation, ledger, queue, seat and webui match the base.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
109 lines
4.4 KiB
JavaScript
109 lines
4.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
function fixture(t, cross = false) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-citation-')),
|
|
store = new Store(root);
|
|
const role = (withinRole = [], crossRole = []) => ({ authority: { withinRole, crossRole } });
|
|
const business = (id) => ({
|
|
id,
|
|
human: 'jason',
|
|
arbiters: { technical: 'cto', delivery: 'pm' },
|
|
roles: {
|
|
pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []),
|
|
cto: role(),
|
|
coder: role(),
|
|
},
|
|
});
|
|
const b = new Broker({ store, businesses: { demo: business('demo'), other: business('other') } });
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
|
|
const agent = (role, business = 'demo') => {
|
|
const cap = b.bindLaunch({ business, role, run: business + role, harness: 'pi', address: role });
|
|
call(cap, 'role.claim');
|
|
return cap;
|
|
};
|
|
const pm = agent('pm'),
|
|
human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const raise = (cap = pm, action = 'role.launch', target = 'coder') =>
|
|
call(cap, 'decision.raise', {
|
|
action,
|
|
target,
|
|
domain: 'technical',
|
|
question: 'Launch?',
|
|
options: [
|
|
{ key: 'yes', text: 'Yes' },
|
|
{ key: 'no', text: 'No' },
|
|
],
|
|
recommendation: 'yes',
|
|
blocking: true,
|
|
task_ref: 'vikunja:3/44',
|
|
});
|
|
const send = (id, body = 'Blocking: launch?') =>
|
|
call(pm, 'message.send', { to: 'human', class: 'DECISION', decision: id, body });
|
|
return { b, store, call, agent, pm, human, raise, send };
|
|
}
|
|
test('within-role sends cite an open gated launch decision without spending it or naming it in grants', (t) => {
|
|
const f = fixture(t),
|
|
d = f.raise();
|
|
assert.equal(d.class, 'gated');
|
|
const first = f.send(d.id),
|
|
second = f.send(d.id);
|
|
for (const id of [first.id, second.id])
|
|
assert.equal(f.store.get('SELECT decision FROM messages WHERE id=?', id).decision, d.id);
|
|
const events = f.store.all(
|
|
"SELECT subject,body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='message.send'",
|
|
);
|
|
assert.equal(events.length, 2);
|
|
for (const e of events) {
|
|
assert.equal(e.subject, 'human');
|
|
assert.equal(Object.hasOwn(JSON.parse(e.body), 'decision'), false);
|
|
}
|
|
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
assert.equal(f.b.authorize(f.pm, 'role.launch', { decision: d.id, target: 'coder' }).decision, d.id);
|
|
assert.throws(
|
|
() => f.b.authorize(f.pm, 'role.launch', { decision: d.id, target: 'coder' }),
|
|
/decision-consumed/,
|
|
);
|
|
// A citation is still valid after consumption: it grants no authority.
|
|
f.send(d.id, 'The launch was authorized.');
|
|
});
|
|
test('missing and foreign-business citations refuse and roll back message and grant', (t) => {
|
|
const f = fixture(t),
|
|
foreign = f.raise(f.agent('pm', 'other'));
|
|
for (const id of ['missing-decision', foreign.id]) assert.throws(() => f.send(id), /decision-not-found/);
|
|
assert.equal(f.store.get('SELECT count(*) AS n FROM messages').n, 0);
|
|
assert.equal(
|
|
f.store.get(
|
|
"SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='message.send'",
|
|
).n,
|
|
0,
|
|
);
|
|
});
|
|
test('cross-role sends still need a matching resolved decision and consume it once', (t) => {
|
|
const f = fixture(t, true),
|
|
unrelated = f.raise();
|
|
assert.throws(() => f.send(undefined), /decision-required/);
|
|
assert.throws(() => f.send(unrelated.id), /decision-mismatch/);
|
|
const d = f.raise(f.pm, 'message.send', 'human');
|
|
assert.throws(() => f.send(d.id), /decision-not-approved/);
|
|
f.call(f.agent('cto'), 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
f.send(d.id);
|
|
assert.throws(() => f.send(d.id), /decision-consumed/);
|
|
const e = f.store.get(
|
|
"SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='message.send' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",
|
|
);
|
|
assert.equal(JSON.parse(e.body).decision, d.id);
|
|
assert.throws(
|
|
() => f.b.authorize(f.pm, 'message.send', { decision: d.id, target: 'human' }),
|
|
/decision-consumed/,
|
|
);
|
|
});
|