diff --git a/packages/bus/README.md b/packages/bus/README.md index bd5f65a7..20d3f9a0 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -131,8 +131,17 @@ A later use through any of those paths, including after restart, refuses with `decision-consumed`. The `decision.raise` context event does not consume approval. A failed transaction rolls consumption back. A mismatch between the decision's recorded class and current policy refuses with `decision-mismatch` before use, -in either direction. Within-role actions without a decision remain unchanged; -explicitly supplying a decision subjects it to the same checks. +in either direction. Within-role `authorize` calls without a decision retain +their existing behavior; explicitly supplying a decision to `authorize` subjects +it to the same checks. + +Every agent `message.send` writes `action.allowed`, including within-role sends +without a decision; its subject is the recipient role. On a within-role send, +`decision` is a citation: the broker checks that it exists in the same business +and stores it in `messages.decision`, but omits it from `action.allowed`. An open, +unresolved or already-consumed decision can be cited repeatedly, without class +matching or consumption. If sending is not within-role, `decision` supplies +authority and the full matching, resolution and single-use checks still apply. A consumed approval is not an exactly-once external service operation receipt; an uncertain external outcome must not be retried with that approval. S3/S6 diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 43ce89e8..e9e558f2 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -552,9 +552,13 @@ export class Broker { } case 'message.send': { keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']); - if (!s.human) - this.#consumeAuthority(s, 'message.send', { decision: a.decision ?? null, target: a.to }); - else this.#human(s); + if (!s.human) { + const withinRole = this.#classification(s, 'message.send') === 'within-role'; + this.#consumeAuthority(s, 'message.send', { + decision: withinRole ? null : (a.decision ?? null), + target: a.to, + }); + } else this.#human(s); if (a.to !== 'human' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); string(a.body, 32768); if ( diff --git a/packages/bus/tests/message-citation.test.mjs b/packages/bus/tests/message-citation.test.mjs new file mode 100644 index 00000000..7994cc20 --- /dev/null +++ b/packages/bus/tests/message-citation.test.mjs @@ -0,0 +1,108 @@ +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/, + ); +});