From bba75b4a4f0ac795b9afa6758b531f5ea0e782ba Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 5 Oct 2026 17:43:09 -0500 Subject: [PATCH] review(bus): darkwing S2b round 2, approve (row 43, #1525) Verdict comment 26769, queue rev 146. R1, R2 and both lead decision 64 rulings are in; round 1 probes now refuse. 55/55 on Node 24 and 26, nine of ten mutants killed, the survivor equivalent. Co-Authored-By: Claude Opus 5.5 --- .../work/slice1-s2b-review/comment-r2.md | 26 +++++ .../work/slice1-s2b-review/mutations-r2.py | 34 ++++++ .../work/slice1-s2b-review/mutations-r2.txt | 13 +++ .../work/slice1-s2b-review/node24-r2.txt | 64 ++++++++++ .../work/slice1-s2b-review/node26-r2.txt | 64 ++++++++++ .../work/slice1-s2b-review/probes-r2.test.mjs | 48 ++++++++ .../work/slice1-s2b-review/probes-r2.txt | 44 +++++++ .../work/slice1-s2b-review/review-r2.md | 109 ++++++++++++++++++ .../work/slice1-s2b-review/s1-contract-r2.txt | 3 + 9 files changed, 405 insertions(+) create mode 100644 agents/darkwing/work/slice1-s2b-review/comment-r2.md create mode 100644 agents/darkwing/work/slice1-s2b-review/mutations-r2.py create mode 100644 agents/darkwing/work/slice1-s2b-review/mutations-r2.txt create mode 100644 agents/darkwing/work/slice1-s2b-review/node24-r2.txt create mode 100644 agents/darkwing/work/slice1-s2b-review/node26-r2.txt create mode 100644 agents/darkwing/work/slice1-s2b-review/probes-r2.test.mjs create mode 100644 agents/darkwing/work/slice1-s2b-review/probes-r2.txt create mode 100644 agents/darkwing/work/slice1-s2b-review/review-r2.md create mode 100644 agents/darkwing/work/slice1-s2b-review/s1-contract-r2.txt diff --git a/agents/darkwing/work/slice1-s2b-review/comment-r2.md b/agents/darkwing/work/slice1-s2b-review/comment-r2.md new file mode 100644 index 00000000..35df391b --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/comment-r2.md @@ -0,0 +1,26 @@ +Row 43, S2b, round 2: **approve** (Darkwing) + +Candidate `candidate-manifest.sha256` `a89d64ca…`, three files under +`packages/bus/`. Full record: +`agents/darkwing/work/slice1-s2b-review/review-r2.md`. + +The patch applies at `57738083` and the manifest checks 3/3. Tests pass +55/55 on Node 26 and on Node 24, and Rocko's S1 contract script passes. + +Lead decision 64 and round 1's items are in: +- R1: `authorize`, `message.send` and `role.revoke` share + `#consumeAuthority`, each inside its own transaction. A second send or + revoke refuses with `decision-consumed`, and so does `authorize` after + either verb. +- Ruling 1: every decision is consumed once, cross-role included. +- Ruling 2 and R2: a class mismatch refuses with `decision-mismatch`. + Rocko tests all six directions, and my round 1 probe C now refuses. +- A within-role sender that names a decision gets every check. + +Nine of ten mutants are killed. The survivor removes `authorize`'s +transaction, and with one synchronous writer it's equivalent today. + +Not blocking: `message.send` now writes an `action.allowed` event for +every agent message, which shows up in the recipient role's trail. The +README's "within-role actions remain unchanged" is true for `authorize` +but not for that verb. S4 should expect these events. diff --git a/agents/darkwing/work/slice1-s2b-review/mutations-r2.py b/agents/darkwing/work/slice1-s2b-review/mutations-r2.py new file mode 100644 index 00000000..cb95083f --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/mutations-r2.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# S2b round 2: Darkwing's mutants, repository layout, each compared to a clean baseline. +import pathlib,tempfile,shutil,subprocess,json,re,sys +cases=[ + ('drop-consumed-check','broker.mjs',"fail('decision-consumed');",";"), + ('gated-only-again','broker.mjs',"result.decision &&\n this.#store.get(","result.decision && this.#decision(s, result.decision).class === 'gated' &&\n this.#store.get("), + ('drop-class-match','broker.mjs',"d.class !== cls ||",""), + ('within-role-ignores-decision','broker.mjs',"cls === 'within-role' && !decision","cls === 'within-role'"), + ('message-send-check-only','broker.mjs',"this.#consumeAuthority(s, 'message.send',","this.#checkAuthority(s, 'message.send',"), + ('role-revoke-check-only','broker.mjs',"this.#consumeAuthority(s, 'role.revoke',","this.#checkAuthority(s, 'role.revoke',"), + ('is-not-to-ne','broker.mjs',"IS NOT 'decision.raise' LIMIT 1","!= 'decision.raise' LIMIT 1"), + ('count-raise-event','broker.mjs',"AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1","LIMIT 1"), + ('event-drops-decision','broker.mjs',"{ action, ...result, target: context.target ?? null }","{ action, class: result.class, target: context.target ?? null }"), + ('authorize-outside-transaction','broker.mjs',"return this.#store.transaction(() => this.#consumeAuthority(s, action, context));","return this.#consumeAuthority(s, action, context);"), +] +source=pathlib.Path(sys.argv[1]) +def run(dest): + r=subprocess.run(['node','--test',*[str(p) for p in sorted((dest/'tests').glob('*.test.mjs'))]],capture_output=True,text=True,timeout=300) + tests=set(l[2:].rsplit(' (',1)[0] for l in r.stdout.splitlines() if l.startswith('✖ ') and not l.startswith('✖ failing tests')) + return r.returncode,tests +with tempfile.TemporaryDirectory(prefix='dw-s2b2-mut-') as root: + dest=pathlib.Path(root)/'packages'/'bus';shutil.copytree(source,dest,ignore=shutil.ignore_patterns('dw')) + code,base=run(dest);print(json.dumps({'baseline_exit':code,'baseline_failures':sorted(base)}),flush=True) + code,f=run(dest);print(json.dumps({'mutation':'no-op','exit':code,'killed':bool(f-base)}),flush=True) + out=[] + for name,file,old,new in cases: + p=dest/'src'/file;b=p.read_text();pat=r'\s*'.join(re.escape(c) for c in old if not c.isspace());n=len(re.findall(pat,b)) + if n!=1: print(json.dumps({'mutation':name,'error':f'{n} matches'}),flush=True);continue + p.write_text(re.sub(pat,lambda m:new,b,count=1)) + try: code,f=run(dest) + finally: p.write_text(b) + x=sorted(f-base);out.append({'mutation':name,'exit':code,'killed':bool(x),'new_failures':x}) + print(json.dumps(out[-1]),flush=True) + print('killed',sum(x['killed'] for x in out),'of',len(out)) diff --git a/agents/darkwing/work/slice1-s2b-review/mutations-r2.txt b/agents/darkwing/work/slice1-s2b-review/mutations-r2.txt new file mode 100644 index 00000000..0a0c84ca --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/mutations-r2.txt @@ -0,0 +1,13 @@ +{"baseline_exit": 0, "baseline_failures": []} +{"mutation": "no-op", "exit": 0, "killed": false} +{"mutation": "drop-consumed-check", "exit": 1, "killed": true, "new_failures": ["failed commit rolls consumption back; cross-role consumes and within-role stays reusable", "gated approval authorizes once, survives store reopen, and fresh approval works", "message.send consumes approval and prevents a later send or authorize", "role.revoke consumes approval and prevents a later revoke or authorize", "two scheduled callers have exactly one grant and one consumed refusal"]} +{"mutation": "gated-only-again", "exit": 1, "killed": true, "new_failures": ["failed commit rolls consumption back; cross-role consumes and within-role stays reusable"]} +{"mutation": "drop-class-match", "exit": 1, "killed": true, "new_failures": ["class drift cross-role to gated refuses before consumption", "class drift cross-role to within-role refuses before consumption", "class drift gated to cross-role refuses before consumption", "class drift gated to within-role refuses before consumption", "class drift within-role to cross-role refuses before consumption", "class drift within-role to gated refuses before consumption"]} +{"mutation": "within-role-ignores-decision", "exit": 1, "killed": true, "new_failures": ["class drift cross-role to within-role refuses before consumption", "class drift gated to within-role refuses before consumption", "failed commit rolls consumption back; cross-role consumes and within-role stays reusable"]} +{"mutation": "message-send-check-only", "exit": 1, "killed": true, "new_failures": ["message.send consumes approval and prevents a later send or authorize"]} +{"mutation": "role-revoke-check-only", "exit": 1, "killed": true, "new_failures": ["role.revoke consumes approval and prevents a later revoke or authorize"]} +{"mutation": "is-not-to-ne", "exit": 1, "killed": true, "new_failures": ["failed commit rolls consumption back; cross-role consumes and within-role stays reusable", "gated approval authorizes once, survives store reopen, and fresh approval works", "message.send consumes approval and prevents a later send or authorize", "role.revoke consumes approval and prevents a later revoke or authorize", "two scheduled callers have exactly one grant and one consumed refusal"]} +{"mutation": "count-raise-event", "exit": 1, "killed": true, "new_failures": ["another run cannot consume an approval; a failed check leaves it usable", "both arbiters require human resolution when their cross-role route is themselves", "claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic", "decision classes route from policy; gated resolution is human-only, choice and target must match", "failed commit rolls consumption back; cross-role consumes and within-role stays reusable", "gated approval authorizes once, survives store reopen, and fresh approval works", "message.send consumes approval and prevents a later send or authorize", "revocation permanently bars the old run from reclaiming first, including after broker restart", "role.revoke consumes approval and prevents a later revoke or authorize", "task action subjects and linked decision trail are complete and ordered", "two scheduled callers have exactly one grant and one consumed refusal"]} +{"mutation": "event-drops-decision", "exit": 1, "killed": true, "new_failures": ["another run cannot consume an approval; a failed check leaves it usable", "failed commit rolls consumption back; cross-role consumes and within-role stays reusable", "gated approval authorizes once, survives store reopen, and fresh approval works", "message.send consumes approval and prevents a later send or authorize", "role.revoke consumes approval and prevents a later revoke or authorize", "two scheduled callers have exactly one grant and one consumed refusal"]} +{"mutation": "authorize-outside-transaction", "exit": 0, "killed": false, "new_failures": []} +killed 9 of 10 diff --git a/agents/darkwing/work/slice1-s2b-review/node24-r2.txt b/agents/darkwing/work/slice1-s2b-review/node24-r2.txt new file mode 100644 index 00000000..d94b03ee --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/node24-r2.txt @@ -0,0 +1,64 @@ +v24.21.0 +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (168.817976ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (313.208626ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (302.696792ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (174.078331ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (166.043648ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (126.024638ms) +✔ task action subjects and linked decision trail are complete and ordered (170.274208ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (81.476104ms) +✔ business isolation includes inherited object names and cross-business message references (150.077621ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (229.844251ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (105.93614ms) +✔ revocation permanently bars the old run from reclaiming first, including after broker restart (176.471326ms) +✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (113.396458ms) +✔ both arbiters require human resolution when their cross-role route is themselves (190.95561ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.69599ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.619075ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.666542ms) +✔ expiry refuses use and env references never become client data (0.706395ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.409483ms) +✔ opaque tokens shorter than 16 characters refuse before use (0.311777ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.168625ms) +✔ process reader gets own kernel identity without exposing environment values (0.491319ms) +✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.663546ms) +✔ real pid 1 remains inspectable when its environment is protected (0.307122ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (182.635495ms) +✔ startup token refusal returns safe code without value or partial listening broker (32.982398ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (233.71422ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (212.036986ms) +✔ trusted host registers later launches; socket clients never have a registration verb (173.561943ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (33.573525ms) +✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (142.219081ms) +✔ v3b prototype refusals, views and append-only mutations (1127.795659ms) +✔ gated approval authorizes once, survives store reopen, and fresh approval works (240.5127ms) +✔ another run cannot consume an approval; a failed check leaves it usable (299.636661ms) +✔ two scheduled callers have exactly one grant and one consumed refusal (188.519503ms) +✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (304.19416ms) +✔ class drift gated to cross-role refuses before consumption (195.89971ms) +✔ class drift cross-role to gated refuses before consumption (164.954025ms) +✔ class drift gated to within-role refuses before consumption (157.003222ms) +✔ class drift cross-role to within-role refuses before consumption (194.824232ms) +✔ class drift within-role to gated refuses before consumption (162.080633ms) +✔ class drift within-role to cross-role refuses before consumption (169.207993ms) +✔ message.send consumes approval and prevents a later send or authorize (172.590743ms) +✔ role.revoke consumes approval and prevents a later revoke or authorize (216.382204ms) +✔ creates private WAL store and excludes a second writer until explicit close (122.973435ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (197.930322ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (288.655009ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (161.180697ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (110.373629ms) +✔ async transactions refuse before invoking their function (83.296435ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (152.484588ms) +✔ two wire claims serialize; a lost reply never automatically retries (186.003021ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (180.813868ms) +✔ client preserves UTF-8 when a response divides a multibyte character (13.90465ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (155.521905ms) +ℹ tests 55 +ℹ suites 0 +ℹ pass 55 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2532.657625 diff --git a/agents/darkwing/work/slice1-s2b-review/node26-r2.txt b/agents/darkwing/work/slice1-s2b-review/node26-r2.txt new file mode 100644 index 00000000..91745f10 --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/node26-r2.txt @@ -0,0 +1,64 @@ +v26.8.1 +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (181.226076ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (255.344804ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (251.234875ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (163.150806ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (163.437952ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (148.794539ms) +✔ task action subjects and linked decision trail are complete and ordered (164.145578ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (76.376018ms) +✔ business isolation includes inherited object names and cross-business message references (156.850001ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (236.167698ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (127.909574ms) +✔ revocation permanently bars the old run from reclaiming first, including after broker restart (173.273253ms) +✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (115.875126ms) +✔ both arbiters require human resolution when their cross-role route is themselves (184.414743ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.067ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (17.046437ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (3.537917ms) +✔ expiry refuses use and env references never become client data (3.354475ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.227857ms) +✔ opaque tokens shorter than 16 characters refuse before use (0.239052ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (1.548802ms) +✔ process reader gets own kernel identity without exposing environment values (1.028088ms) +✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.653886ms) +✔ real pid 1 remains inspectable when its environment is protected (0.281702ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (196.537481ms) +✔ startup token refusal returns safe code without value or partial listening broker (33.473287ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (167.666375ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (166.282042ms) +✔ trusted host registers later launches; socket clients never have a registration verb (162.722497ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (34.143797ms) +✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (158.81243ms) +✔ v3b prototype refusals, views and append-only mutations (1032.636254ms) +✔ gated approval authorizes once, survives store reopen, and fresh approval works (262.382417ms) +✔ another run cannot consume an approval; a failed check leaves it usable (218.753871ms) +✔ two scheduled callers have exactly one grant and one consumed refusal (157.69087ms) +✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (307.874839ms) +✔ class drift gated to cross-role refuses before consumption (190.598829ms) +✔ class drift cross-role to gated refuses before consumption (192.380263ms) +✔ class drift gated to within-role refuses before consumption (172.649477ms) +✔ class drift cross-role to within-role refuses before consumption (197.388635ms) +✔ class drift within-role to gated refuses before consumption (161.380155ms) +✔ class drift within-role to cross-role refuses before consumption (160.191833ms) +✔ message.send consumes approval and prevents a later send or authorize (178.703218ms) +✔ role.revoke consumes approval and prevents a later revoke or authorize (203.765625ms) +✔ creates private WAL store and excludes a second writer until explicit close (114.437405ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (173.185092ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (181.361153ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (155.732458ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (105.177443ms) +✔ async transactions refuse before invoking their function (77.78015ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (148.359653ms) +✔ two wire claims serialize; a lost reply never automatically retries (166.394156ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (113.075798ms) +✔ client preserves UTF-8 when a response divides a multibyte character (11.775795ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (127.125122ms) +ℹ tests 55 +ℹ suites 0 +ℹ pass 55 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2483.527408 diff --git a/agents/darkwing/work/slice1-s2b-review/probes-r2.test.mjs b/agents/darkwing/work/slice1-s2b-review/probes-r2.test.mjs new file mode 100644 index 00000000..7ea804fe --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/probes-r2.test.mjs @@ -0,0 +1,48 @@ +// Darkwing S2b round 2 probes. Not part of the candidate. +import test from 'node:test'; +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 options = [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }]; +const policy = () => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { + pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, + cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, + coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } } } } }); +const code = (f) => { try { return JSON.stringify(f()); } catch (e) { return e.code ?? String(e.message); } }; +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'dw-s2b2-')); + const store = new Store(root), b = new Broker({ store, businesses: policy() }); + t.after(() => { try { store.close(); } catch {} rmSync(root, { recursive: true, force: true }); }); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const call = (cap, verb, args = {}) => b.request(cap, { verb, args }); + const c = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }); + const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); + const cto = b.bindLaunch({ business: 'demo', role: 'cto', run: 'cto-run', harness: 'pi' }); + call(c, 'role.claim'); call(pm, 'role.claim'); call(cto, 'role.claim'); + const kinds = () => store.all('SELECT kind, subject FROM events ORDER BY seq').map((e) => e.kind + '@' + (e.subject ?? '-')); + return { b, store, human, call, c, pm, cto, kinds }; +} +test('F events written by one within-role message.send with no decision', (t) => { + const o = setup(t), before = o.kinds().length; + o.call(o.c, 'message.send', { to: 'pm', body: 'hi' }); + console.log('F new events', JSON.stringify(o.kinds().slice(before))); + console.log('F trail pm', JSON.stringify(o.call(o.cto, 'trail', { subject: 'pm' }).map((e) => e.kind))); +}); +test('G within-role sender supplies a decision it does not own or that does not exist', (t) => { + const o = setup(t); + console.log('G unknown decision', code(() => typeof o.call(o.c, 'message.send', { to: 'pm', body: 'x', decision: 'nope' }).id)); + const d = o.call(o.pm, 'decision.raise', { action: 'message.send', target: 'cto', question: 'Send?', options, recommendation: 'yes', choice: 'yes', blocking: false }); + console.log('G another role decision', code(() => typeof o.call(o.c, 'message.send', { to: 'cto', body: 'x', decision: d.id }).id)); + console.log('G owner use', code(() => typeof o.call(o.pm, 'message.send', { to: 'cto', body: 'x', decision: d.id }).id)); + console.log('G owner without decision still sends', code(() => typeof o.call(o.pm, 'message.send', { to: 'cto', body: 'y' }).id)); +}); +test('H refused verb after consumption writes action.refused and does not add a use', (t) => { + const o = setup(t); + const d = o.call(o.c, 'decision.raise', { action: 'task.scope.change', domain: 'technical', target: 't1', question: 'Q', options, recommendation: 'no', blocking: false }); + o.call(o.cto, 'decision.resolve', { id: d.id, choice: 'yes' }); + console.log('H first', code(() => o.b.authorize(o.c, 'task.scope.change', { decision: d.id, target: 't1' }).class)); + console.log('H second', code(() => o.b.authorize(o.c, 'task.scope.change', { decision: d.id, target: 't1' }))); + console.log('H allowed uses', o.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise'", d.id).n); +}); diff --git a/agents/darkwing/work/slice1-s2b-review/probes-r2.txt b/agents/darkwing/work/slice1-s2b-review/probes-r2.txt new file mode 100644 index 00000000..f90928a9 --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/probes-r2.txt @@ -0,0 +1,44 @@ +F new events ["action.allowed@pm"] +F trail pm ["session.launched",null,"action.allowed"] +G unknown decision decision-not-found +G another role decision decision-mismatch +G owner use "string" +G owner without decision still sends "string" +H first "cross-role" +H second decision-consumed +H allowed uses 1 +✔ F events written by one within-role message.send with no decision (131.747467ms) +✔ G within-role sender supplies a decision it does not own or that does not exist (168.90098ms) +✔ H refused verb after consumption writes action.refused and does not add a use (135.46588ms) +A raise class gated route_to human +A message.send 0 "string" +A message.send 1 decision-consumed +A message.send 2 decision-consumed +A action.allowed uses recorded 1 +A authorize after 3 sends decision-consumed +B role.revoke verb {"revoked":true} +B action.allowed uses recorded 1 +B authorize role.revoke after the verb decision-consumed +B authorize again decision-consumed +C raise class cross-role route_to cto +C current class is gated; authorize with arbiter approval: +C authorize 0 decision-mismatch +C authorize 1 decision-mismatch +C authorize 2 decision-mismatch +D authorize 0 {"class":"cross-role","decision":"0d79bffd-1410-433a-b8b0-cffd99eff2a9"} +D authorize 1 decision-consumed +D authorize 2 decision-consumed +E second Store writer-locked +✔ A gated message.send approval: reuse through the verb, then authorize (148.983012ms) +✔ B role.revoke verb, then authorize with the same decision (130.871177ms) +✔ C cross-role approval after policy makes the action gated (145.689167ms) +✔ D cross-role approval reuse under unchanged policy (81.394321ms) +✔ E a second Store on the same data root refuses, so writers serialize in one process (35.07945ms) +ℹ tests 8 +ℹ suites 0 +ℹ pass 8 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 602.088251 diff --git a/agents/darkwing/work/slice1-s2b-review/review-r2.md b/agents/darkwing/work/slice1-s2b-review/review-r2.md new file mode 100644 index 00000000..b282f5fd --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/review-r2.md @@ -0,0 +1,109 @@ +# Row 43, S2b single-use approvals, round 2 review (Darkwing) + +Issue #1525. Candidate: Rocko's `candidate-manifest.sha256` (sha256 +`a89d64ca68716685d947037946036f54d08d174d8c9c1f02a23e9fadda526991`), three +files under `packages/bus/`. Packet `agents/rocko/work/slice1-s2b-r2/`, +`BUILD.md` sha256 `b2ac4744…`, `build.patch` sha256 `56d572fe…`. Sage's +request is comment 26767, rev 145. Round 1 is `review-r1.md` in this +directory. The rulings are lead decision 64 (57738083). + +Verdict: **approve.** R1 and R2 are fixed, and both of decision 64's +rulings are in. I reran every round 1 probe; each one that granted twice +now refuses. Three notes below, none blocking. + +## What I checked + +- The three packet hashes match Rocko's message. `build.patch` applies at + `57738083`; the manifest checks 3/3. +- I read the whole broker and README diff and the new tests. +- Tests: 55/55 on Node 26.8.1 (`node26-r2.txt`). In `node:24` (24.21.0), + with no network, a non-root UID and the package mounted read-only, also + 55/55 (`node24-r2.txt`). +- Rocko's S1 contract script passes its three assertions + (`s1-contract-r2.txt`). +- The decision 62 README note is the same text I reviewed in round 1. + +## Decision 64 and the round 1 items + +**One helper.** `#consumeAuthority` runs `#checkAuthority`, refuses +`decision-consumed` when a non-raise `action.allowed` event already names +the decision, and writes the new event. `authorize` calls it inside its +transaction. The `role.revoke` and `message.send` verbs call it inside +the request transaction, before their own effect. A later failure in the +verb rolls the consumption back. Rocko tests that with an empty message +body. + +**R1, the verbs.** Probe A: a gated `message.send` approval sends once, +then the second and third sends refuse with `decision-consumed`, and so +does `authorize`. Probe B: after the `role.revoke` verb, `authorize` +refuses. Rocko's tests cover both orders, verb then `authorize` and +`authorize` then verb. + +**Ruling 1, every decision single-use.** The class condition is gone. +Probe D: a cross-role approval grants once, then refuses. Probe H shows +one consumption event after a refused second use. A within-role decision +passed explicitly is consumed too. Within-role use without a decision +still returns early. + +**Ruling 2 and R2, class drift.** `#checkAuthority` refuses with +`decision-mismatch` when `d.class !== cls`. Probe C, a cross-role +approval used after policy makes the action gated, now refuses. Rocko +tests all six directions between the three classes, each with zero +consumption events afterwards. + +**Explicit decisions on within-role actions.** The early return now +needs `!decision`. A within-role sender that names a decision gets every +check: an unknown id refuses `decision-not-found`, and another role's +decision refuses `decision-mismatch` (probe G). That fails closed, and +the README says so. + +## Mutation evidence + +`mutations-r2.py` runs ten mutants against the full test glob in the +repository layout, against a clean baseline (`mutations-r2.txt`). The +no-op isn't killed. + +| Mutant | Result | +|---|---| +| drop `fail('decision-consumed')` | killed, 5 tests | +| consume gated decisions only, as in round 1 | killed | +| drop `d.class !== cls` | killed, 6 tests | +| within-role returns early even with a decision | killed, 3 tests | +| `message.send` checks without consuming | killed | +| `role.revoke` checks without consuming | killed | +| `IS NOT` becomes `!=` | killed, 5 tests | +| the query also counts the raise event | killed, 11 tests | +| the consumption event drops `decision` | killed, 6 tests | +| `authorize` without its transaction | survives | + +The survivor is equivalent today. `Store.run` wraps a lone insert in its +own transaction, the writer lock allows one `Store` per data root, and +the API is synchronous, so nothing can run between the check and the +insert. The outer transaction starts to matter if a second writer or an +`await` ever appears. `Store.transaction` already refuses async +functions. + +## Notes, not blocking + +1. `message.send` now writes an `action.allowed` event for every agent + message, including within-role sends with no decision. Its subject + is the recipient role, so the event shows up in that role's `trail` + (probe F). It carries no message body. That's reasonable as evidence, + but the README's "Within-role actions without a decision remain + unchanged" holds for `authorize` and not for this verb. S4 should + expect these events in role trails. A README fix can wait for the + next change to the file. +2. `role.revoke` also writes an `action.allowed` event now, with the + revoked role as subject, before its claim-end row. +3. The events scan has no index, as decision 64 records. + +## Files added for round 2 + +- `review-r2.md`, `comment-r2.md` +- `node26-r2.txt`, `node24-r2.txt`, `s1-contract-r2.txt` +- `probes-r2.test.mjs`: probes F to H +- `probes-r2.txt`: round 1's A to E against this candidate, plus F to H +- `mutations-r2.py`, `mutations-r2.txt` + +The probes run from a `dw/` directory beside `packages/bus/src`, with +round 1's `probes-s2b.test.mjs` copied beside them. diff --git a/agents/darkwing/work/slice1-s2b-review/s1-contract-r2.txt b/agents/darkwing/work/slice1-s2b-review/s1-contract-r2.txt new file mode 100644 index 00000000..d49d95ab --- /dev/null +++ b/agents/darkwing/work/slice1-s2b-review/s1-contract-r2.txt @@ -0,0 +1,3 @@ +PASS R2 loadBusiness refuses launch.by without within-role role.launch (exit 2) +PASS R2 narrowed launch is null and gated; S2 adapter/broker refuses launch without decision +PASS actual S1 validate/resolve -> S2 normalize/start -> socket claim/message; scoped fixture token callback; launch classification