feat(bus): decision-backed approvals are single-use (row 43, S2b, rocko)

authorize, message.send and role.revoke consume a cited decision once,
inside the write transaction (lead decision 64). A second use refuses
with decision-consumed; a class mismatch refuses with decision-mismatch.

Candidate agents/rocko/work/slice1-s2b-r2, build.patch 56d572fe,
manifest a89d64ca. Darkwing approved round 2 on #1525 (comment 26769).
Integration gate in a worktree on bba75b4a: every package green on
Node 26; bus 55/55 on Node 24; every scripts/test-*.sh green. Node 24
failures in conversation, ledger, queue, seat and webui are identical
on the unpatched base (container /tmp is overlayfs, no jsonschema).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-05 17:52:41 -05:00
co-authored by Claude Opus 5.5
parent bba75b4a4f
commit 4afab55254
3 changed files with 291 additions and 16 deletions
+22 -3
View File
@@ -122,9 +122,22 @@ Authorization requires the resolved option identified by `approvalChoice`
Inbox projections expose `{action,target,approvalChoice}` as `authorization`: Inbox projections expose `{action,target,approvalChoice}` as `authorization`:
**S4 must display this context when offering resolution**, not infer approval **S4 must display this context when offering resolution**, not infer approval
from recommendation or option position. Any other option is a refusal, even if from recommendation or option position. Any other option is a refusal, even if
the decision is resolved. Approval is context-bound, not an exactly-once service the decision is resolved. Every decision-backed authorization is single-use,
operation receipt. S3/S6 must implement their own external-operation identity and including cross-role approval (lead decision 64). `authorize`, `message.send`
uncertain-outcome handling. Raising/superseding and resolving are transactional. and `role.revoke` share one authority-and-consumption helper. Each caller keeps
the consumption check, `action.allowed` event and any broker-owned effect in one
transaction.
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.
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
must implement their own external-operation identity and uncertain-outcome
handling. Raising/superseding and resolving are transactional.
## Human and reader paths ## Human and reader paths
@@ -140,6 +153,12 @@ check: command and launch-registry checks still apply. All other read failures
refuse, and the CLI itself must have a readable nonce environment. refuse, and the CLI itself must have a readable nonce environment.
Reading `/proc` is Linux-specific. Reparenting and malicious same-UID PID/nonce Reading `/proc` is Linux-specific. Reparenting and malicious same-UID PID/nonce
impersonation remain within the explicit cooperative trust limit. impersonation remain within the explicit cooperative trust limit.
Lead decision 62 accepts this limit for slice 1: the proof refuses a direct
agent invocation, but deliberate detachment and environment clearing (as in
Darkwing's `setsid -f env -i` probe) can obtain human authority. S6 must close
this gap for managed agents with their own PID namespace or user and no human
socket mounted. That isolation is not supplied by this S2 package. T3 seats
must not invoke the human CLI or work around its proof.
Human resolutions and launch toggles append `human.input`. Agent capabilities Human resolutions and launch toggles append `human.input`. Agent capabilities
and read-only WebUI capabilities cannot reach these operations. Reader and read-only WebUI capabilities cannot reach these operations. Reader
+27 -13
View File
@@ -262,7 +262,7 @@ export class Broker {
) )
fail('launch-revoked'); fail('launch-revoked');
const cls = this.#classification(s, action); const cls = this.#classification(s, action);
if (cls === 'within-role') return { class: cls }; if (cls === 'within-role' && !decision) return { class: cls };
if (!decision) fail('decision-required'); if (!decision) fail('decision-required');
const d = this.#decision(s, decision), const d = this.#decision(s, decision),
r = this.#closed(d.id); r = this.#closed(d.id);
@@ -273,6 +273,7 @@ export class Broker {
); );
const context = evidence ? JSON.parse(evidence.body) : null; const context = evidence ? JSON.parse(evidence.body) : null;
if ( if (
d.class !== cls ||
d.action !== action || d.action !== action ||
d.raised_by_role !== s.role || d.raised_by_role !== s.role ||
d.raised_by_run !== s.run || d.raised_by_run !== s.run ||
@@ -285,19 +286,31 @@ export class Broker {
fail('decision-mismatch'); fail('decision-mismatch');
return { class: cls, decision: d.id }; return { class: cls, decision: d.id };
} }
// Caller owns the transaction: authority, consumption and effect commit together.
#consumeAuthority(s, action, context = {}) {
const result = this.#checkAuthority(s, action, context);
// decision.raise records context, not permission consumed by an executor.
if (
result.decision &&
this.#store.get(
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1",
s.business,
result.decision,
)
)
fail('decision-consumed');
this.#event(
s,
'action.allowed',
{ action, ...result, target: context.target ?? null },
context.target ?? null,
);
return result;
}
// Trusted S3 handler calls inside its own operation; not a generic socket action executor. // Trusted S3 handler calls inside its own operation; not a generic socket action executor.
authorize(cap, action, context = {}) { authorize(cap, action, context = {}) {
const s = this.#session(cap); const s = this.#session(cap);
return this.#store.transaction(() => { return this.#store.transaction(() => this.#consumeAuthority(s, action, context));
const result = this.#checkAuthority(s, action, context);
this.#event(
s,
'action.allowed',
{ action, ...result, target: context.target ?? null },
context.target ?? null,
);
return result;
});
} }
// Trusted adapters only. No agent socket route reaches this method. // Trusted adapters only. No agent socket route reaches this method.
recordEvent(cap, { kind, body, subject = null }) { recordEvent(cap, { kind, body, subject = null }) {
@@ -385,7 +398,7 @@ export class Broker {
keys(a, ['role', 'decision'], ['role', 'decision']); keys(a, ['role', 'decision'], ['role', 'decision']);
identifier(a.role); identifier(a.role);
identifier(a.decision); identifier(a.decision);
this.#checkAuthority(s, 'role.revoke', { decision: a.decision, target: a.role }); this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
const h = this.#holder(s.business, a.role); const h = this.#holder(s.business, a.role);
if (h?.op !== 'claim') fail('not-held'); if (h?.op !== 'claim') fail('not-held');
this.#claimEnd(s, h, 'revoke', a.decision); this.#claimEnd(s, h, 'revoke', a.decision);
@@ -539,7 +552,8 @@ export class Broker {
} }
case 'message.send': { case 'message.send': {
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']); keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
if (!s.human) this.#checkAuthority(s, 'message.send', { decision: a.decision ?? null, target: a.to }); if (!s.human)
this.#consumeAuthority(s, 'message.send', { decision: a.decision ?? null, target: a.to });
else this.#human(s); else this.#human(s);
if (a.to !== 'human' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); if (a.to !== 'human' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role');
string(a.body, 32768); string(a.body, 32768);
+242
View File
@@ -0,0 +1,242 @@
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 { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'cto', delivery: 'pm' },
roles: {
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
cto: { authority: { withinRole: [], crossRole: [] } },
pm: { authority: { withinRole: [], crossRole: [] } },
},
},
};
function fixture(t, gatedMessages = false) {
const root = mkdtempSync(join(tmpdir(), 'bus-once-'));
let store, b, cap, human;
const policy = structuredClone(businesses);
if (gatedMessages) policy.demo.roles.coder.authority.withinRole = [];
const call = (c, verb, args = {}) => b.request(c, { verb, args });
const bind = (run) => b.bindLaunch({ business: 'demo', role: 'coder', run, harness: 'pi', address: run });
function open() {
store = new Store(root);
b = new Broker({ store, businesses: policy });
cap = bind('run1');
human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
}
open();
call(cap, 'role.claim');
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
function approve(action = 'deploy', domain = 'delivery', target = 'release1') {
const d = call(cap, 'decision.raise', {
action,
domain,
target,
question: 'Allow?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'no',
blocking: false,
});
if (d.route_to === 'human') call(human, 'decision.resolve', { id: d.id, choice: 'yes' });
else {
const c = b.bindLaunch({ business: 'demo', role: 'cto', run: 'cto1', harness: 'pi', address: 'cto1' });
call(c, 'role.claim');
call(c, 'decision.resolve', { id: d.id, choice: 'yes' });
}
return d;
}
return {
get b() {
return b;
},
get store() {
return store;
},
get cap() {
return cap;
},
call,
bind,
approve,
use(d, c = cap) {
return b.authorize(c, d.action, { decision: d.id, target: d.authorization.target });
},
setPolicy(withinRole, crossRole) {
policy.demo.roles.coder.authority = { withinRole, crossRole };
},
narrowToCross(action) {
policy.demo.roles.coder.authority.crossRole.push(action);
},
reopen() {
store.close();
open();
},
count(d) {
return store.get(
"SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise'",
d.id,
).n;
},
};
}
test('gated approval authorizes once, survives store reopen, and fresh approval works', (t) => {
const f = fixture(t),
d = f.approve();
assert.equal(f.use(d).class, 'gated');
assert.equal(f.count(d), 1);
assert.throws(() => f.use(d), /decision-consumed/);
assert.equal(f.count(d), 1);
f.reopen();
assert.throws(() => f.use(d), /decision-consumed/);
const fresh = f.approve();
f.use(fresh);
assert.equal(f.count(fresh), 1);
});
test('another run cannot consume an approval; a failed check leaves it usable', (t) => {
const f = fixture(t),
d = f.approve(),
other = f.bind('run2');
f.call(f.cap, 'role.release');
f.call(other, 'role.claim');
assert.throws(() => f.use(d, other), /decision-mismatch/);
assert.equal(f.count(d), 0);
f.call(other, 'role.release');
f.call(f.cap, 'role.claim');
assert.throws(
() => f.b.authorize(f.cap, 'deploy', { decision: d.id, target: 'other' }),
/decision-mismatch/,
);
f.use(d);
assert.equal(f.count(d), 1);
});
test('two scheduled callers have exactly one grant and one consumed refusal', async (t) => {
const f = fixture(t),
d = f.approve();
const results = await Promise.allSettled([
Promise.resolve().then(() => f.use(d)),
Promise.resolve().then(() => f.use(d)),
]);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(results.find((r) => r.status === 'rejected').reason.code, 'decision-consumed');
assert.equal(f.count(d), 1);
});
test('failed commit rolls consumption back; cross-role consumes and within-role stays reusable', (t) => {
const f = fixture(t),
d = f.approve(),
transaction = f.store.transaction.bind(f.store);
f.store.transaction = (fn) =>
transaction(() => {
fn();
throw Error('fixture rollback');
});
assert.throws(() => f.use(d), /fixture rollback/);
f.store.transaction = transaction;
assert.equal(f.count(d), 0);
f.use(d);
const cross = f.approve('task.scope.change', 'technical');
f.use(cross);
assert.throws(() => f.use(cross), /decision-consumed/);
f.reopen();
assert.throws(() => f.use(cross), /decision-consumed/);
assert.equal(f.count(cross), 1);
for (let n = 0; n < 2; n++) assert.equal(f.b.authorize(f.cap, 'message.send').class, 'within-role');
const within = f.call(f.cap, 'decision.raise', {
action: 'message.send',
question: 'Send?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'yes',
choice: 'yes',
blocking: false,
});
assert.equal(within.route_to, 'coder');
f.use(within);
assert.throws(() => f.use(within), /decision-consumed/);
assert.equal(f.count(within), 1);
});
for (const [from, to] of [
['gated', 'cross-role'],
['cross-role', 'gated'],
['gated', 'within-role'],
['cross-role', 'within-role'],
['within-role', 'gated'],
['within-role', 'cross-role'],
]) {
test(`class drift ${from} to ${to} refuses before consumption`, (t) => {
const f = fixture(t),
action = 'task.priority.change';
f.setPolicy(from === 'within-role' ? [action] : [], from === 'cross-role' ? [action] : []);
f.reopen();
let d;
if (from === 'within-role')
d = f.call(f.cap, 'decision.raise', {
action,
target: 'release1',
question: 'Allow?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'yes',
choice: 'yes',
blocking: false,
});
else d = f.approve(action, 'technical');
f.setPolicy(to === 'within-role' ? [action] : [], to === 'cross-role' ? [action] : []);
f.reopen();
assert.throws(() => f.use(d), /decision-mismatch/);
assert.equal(f.count(d), 0);
});
}
test('message.send consumes approval and prevents a later send or authorize', (t) => {
const f = fixture(t, true),
d = f.approve('message.send', 'delivery', 'pm');
// Invalid effect must roll the consumption insert back with the message.
assert.throws(() => f.call(f.cap, 'message.send', { to: 'pm', body: '', decision: d.id }), /invalid/);
assert.equal(f.count(d), 0);
f.call(f.cap, 'message.send', { to: 'pm', body: 'once', decision: d.id });
assert.equal(f.count(d), 1);
assert.throws(
() => f.call(f.cap, 'message.send', { to: 'pm', body: 'twice', decision: d.id }),
/decision-consumed/,
);
assert.throws(() => f.use(d), /decision-consumed/);
assert.equal(f.store.get('SELECT count(*) AS n FROM messages').n, 1);
const fresh = f.approve('message.send', 'delivery', 'pm');
f.use(fresh);
assert.throws(
() => f.call(f.cap, 'message.send', { to: 'pm', body: 'after authorize', decision: fresh.id }),
/decision-consumed/,
);
});
test('role.revoke consumes approval and prevents a later revoke or authorize', (t) => {
const f = fixture(t),
pm = f.b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm1', harness: 'pi', address: 'pm1' });
f.call(pm, 'role.claim');
const d = f.approve('role.revoke', 'delivery', 'pm');
f.call(f.cap, 'role.revoke', { role: 'pm', decision: d.id });
assert.equal(f.count(d), 1);
assert.throws(() => f.call(f.cap, 'role.revoke', { role: 'pm', decision: d.id }), /decision-consumed/);
assert.throws(() => f.use(d), /decision-consumed/);
assert.equal(f.store.get("SELECT count(*) AS n FROM role_claims WHERE op='revoke'").n, 1);
const next = f.b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm2', harness: 'pi', address: 'pm2' });
f.call(next, 'role.claim');
const fresh = f.approve('role.revoke', 'delivery', 'pm');
f.use(fresh);
assert.throws(() => f.call(f.cap, 'role.revoke', { role: 'pm', decision: fresh.id }), /decision-consumed/);
});