feat(bus): a within-role message cites a decision without consuming it (row 44, S2c, rocko)
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]>
This commit is contained in:
+11
-2
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user