// FED R3 (#414, federation design 3.5, 3.6 and the R3 row of section 7): the broker's user principal. // A user binds only with a live grant (row 275: a viewer grant binds too; viewer.test.mjs has the // viewer cases), reaches the exact collaborator verb list, sees only what its grants cover, and // talks to the granted instance in its own threads. The access // document comes in as a function, as the runtime passes R1's loadAccess; each test changes it. import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Store } from '../src/store.mjs'; import { Broker, ACTIONS, USER_VERBS, TESTER_VERBS, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, CREDENTIAL_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, TESTER_ORGANIZATION_VERBS, GRANTS_VERBS, LIFECYCLE_HUMAN_VERBS, } from '../src/broker.mjs'; const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over, }); // `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does. function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); // The document a test edits between requests; `reads` counts the loader calls. const access = { doc: { accessVersion: 1, revision: 1, users: [], grants }, reads: 0, fail: false }; const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, ...threadRoles, }, definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } }, }, }, ...(processing ? { processing } : {}), access: () => { access.reads++; if (access.fail) throw new Error('access file refused'); return access.doc; }, }); const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), tester: b.bindTester({ business: 'demo' }), }; const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); const code = (who, verb, args = {}) => { try { call(who, verb, args); } catch (e) { return e.code; } return null; }; const user = (name, as = name) => (caps[as] = b.bindUser({ business: 'demo', user: name })); const launch = (name, thread) => { caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread }); return call(name, 'role.claim'); }; const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n; return { b, store, access, caps, call, code, user, launch, inputs }; } // Two projects, alice granted only `p1`, and alice's thread A in it. function granted(t, options) { const ctx = setup(t, options); const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id; const p2 = ctx.call('human', 'project.create', { name: 'private' }).id; ctx.access.doc.grants = [grant({ projects: [p1] })]; ctx.user('alice'); const A = ctx.call('alice', 'thread.create', { project: p1, name: 'alice work' }).id; return { ...ctx, p1, p2, A }; } test('R3: bindUser binds a granted user and refuses one with no live grant', (t) => { const { b, access, user } = setup(t); assert.ok(user('alice')); // Row 275 (193b1b): a viewer grant binds; it used to refuse here as the case 'viewer level'. access.doc.grants = [grant({ level: 'viewer' })]; assert.ok(user('alice'), 'viewer level'); for (const [what, grants] of [ ['no grant', []], ['another user', [grant({ user: 'bob' })]], ['another business', [grant({ business: 'other' })]], ['expired', [grant({ expires: '2000-01-01T00:00:00Z' })]], ]) { access.doc.grants = grants; assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, what); } access.doc.grants = [grant()]; access.fail = true; assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, 'unreadable file'); access.fail = false; access.doc = null; assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, 'no file'); assert.throws(() => b.bindUser({ business: 'demo', user: 'alice', extra: 1 }), { code: 'invalid-request' }); }); test('R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say', (t) => { const { b, access } = setup(t); for (const name of ['jason', 'human', 'tester', 'user']) { access.doc.grants = [grant({ user: name })]; assert.throws(() => b.bindUser({ business: 'demo', user: name }), { code: 'access-refused' }, name); } access.doc.grants = [grant({ user: 'Alice!' })]; assert.throws(() => b.bindUser({ business: 'demo', user: 'Alice!' }), { code: 'access-refused' }); }); test('R3: each collaborator verb is allowed to a granted user', (t) => { const { call, store, p1, A } = granted(t); assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1]); assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]); assert.deepEqual(call('alice', 'thread.open', { thread: A }), { opened: A }); const sent = call('alice', 'thread.send', { thread: A, body: 'hello' }); assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent.id]); assert.ok(Array.isArray(call('alice', 'names'))); assert.equal(store.get('SELECT owner FROM threads WHERE id=?', A).owner, 'user:alice'); assert.equal(store.get('SELECT by FROM thread_opens WHERE thread=?', A).by, 'user:alice'); }); test('R3: every verb off the collaborator list refuses a user access-refused before its own checks', (t) => { const { b, caps } = granted(t); const others = new Set([ ...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...LIBRARY_VERBS, ...CREDENTIAL_VERBS, ...HOSTS_VERBS, // R3 r6 (sage-2576): the grants, lifecycle, web and host lists, each refused at the user gate too. ...GRANTS_VERBS, ...LIFECYCLE_HUMAN_VERBS, ...WEB_VERBS, ...HOST_VERBS, 'decision.raise', 'decision.resolve', 'decision.seen', 'role.claim', 'role.release', 'role.end', 'record.write', 'spec.read', 'usage.begin', 'usage.end', 'limits.set', 'organization.set', 'organization.revision', 'review.request', 'review.verdict', 'staff.request', 'state', 'input', 'launch', 'no.such.verb', ]); // R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here // rather than leaving the set below with the verb it added (M3x, M6x). assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names', // Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs). 'thread.share', 'thread.unshare', 'thread.shares']); for (const v of USER_VERBS) others.delete(v); assert.ok(others.size > 40); for (const verb of others) { // Bad arguments on purpose: the refusal comes before any argument check. assert.throws(() => b.request(caps.alice, { verb, args: { junk: true } }), { code: 'access-refused' }, verb); } }); test('R3: task, web and host verbs refuse a user access-refused', async (t) => { const { b, caps } = granted(t); for (const verb of TASK_VERBS) await assert.rejects(b.requestTask(caps.alice, { verb, args: {} }, async () => ({})), { code: 'access-refused' }, verb); for (const verb of WEB_VERBS) await assert.rejects(b.requestWeb(caps.alice, { verb, args: {} }, null), { code: 'access-refused' }, verb); for (const verb of HOST_VERBS) await assert.rejects(b.requestHost(caps.alice, { verb, args: {} }, null), { code: 'access-refused' }, verb); }); test('R3 leak: project.list shows granted projects only', (t) => { const { call, access, p1, p2 } = granted(t); assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1]); assert.deepEqual(call('human', 'project.list').map((p) => p.id), [p1, p2]); access.doc.grants = [grant()]; assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1, p2]); }); test('R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist', (t) => { const { call, code, access, p1, p2 } = granted(t); assert.equal(code('alice', 'thread.create', { project: p2, name: 'x' }), 'access-refused'); assert.equal(code('alice', 'thread.create', { project: '00000000-0000-4000-8000-000000000000', name: 'x' }), 'access-refused'); access.doc.grants = [grant({ projects: [p1] }), grant({ id: 'g-2', projects: [p2], instances: ['chief'] })]; assert.equal(code('alice', 'thread.create', { project: p2, name: 'x' }), 'access-refused', 'another instance'); assert.ok(call('alice', 'thread.create', { project: p1, name: 'second' }).id); }); test('R3 leak: thread.list shows the user its own threads that are still granted, never others', (t) => { const { call, code, access, user, p1, p2, A } = granted(t); const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id; const T = call('tester', 'thread.create', { project: p1, name: 'probe' }).id; access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })]; user('bob'); const B = call('bob', 'thread.create', { project: p2, name: 'bob work' }).id; assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]); assert.deepEqual(call('bob', 'thread.list').map((x) => x.id), [B]); // Neither the owner's nor the tester's lists carry a user's thread. assert.deepEqual(call('human', 'thread.list').map((x) => x.id), [H]); assert.deepEqual(call('tester', 'thread.list').map((x) => x.id), [T]); for (const [verb, args] of [['thread.open', {}], ['thread.send', { body: 'hi' }], ['thread.messages', {}]]) { for (const other of [B, H, T]) assert.equal(code('alice', verb, { thread: other, ...args }), 'thread-not-owned', `${verb} ${other}`); assert.equal(code('human', verb, { thread: A, ...args }), 'thread-not-owned', `human ${verb}`); assert.equal(code('tester', verb, { thread: A, ...args }), 'thread-not-owned', `tester ${verb}`); } // A thread whose project is no longer granted drops off the list and refuses the thread verbs. access.doc.grants = [grant({ projects: [p2] })]; assert.deepEqual(call('alice', 'thread.list'), []); for (const [verb, args] of [['thread.open', {}], ['thread.send', { body: 'hi' }], ['thread.messages', {}]]) assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb); }); test('R3 leak: names shows granted instances only', (t) => { const { call, access } = granted(t); call('human', 'name.set', { instance: 'assistant', name: 'ada' }); call('human', 'name.set', { instance: 'chief', name: 'cleo' }); assert.deepEqual(call('alice', 'names').map((n) => n.instance), ['assistant']); assert.deepEqual(call('human', 'names').map((n) => n.instance), ['assistant', 'chief']); access.doc.grants = [grant({ instances: ['*'] })]; assert.deepEqual(call('alice', 'names').map((n) => n.instance), ['assistant', 'chief']); }); test('R3: thread.send is from user: and writes no human.input', (t) => { const { store, call, inputs, A } = granted(t); const before = inputs(); const sent = call('alice', 'thread.send', { thread: A, body: 'please draft it' }); assert.equal(sent.request, null); assert.equal(inputs(), before); const m = store.get('SELECT * FROM messages WHERE id=?', sent.id); assert.equal(m.from_role, 'user'); assert.equal(m.from_run, 'user:alice'); assert.equal(m.to_role, 'assistant'); assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', sent.id).thread, A); }); test('R3: a reply-chain message.send into another user thread is refused', (t) => { const { call, code, access, user, p1 } = granted(t); access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })]; user('bob'); const B = call('bob', 'thread.create', { project: p1, name: 'bob work' }).id; const asked = call('bob', 'thread.send', { thread: B, body: 'bob asks' }).id; assert.equal(code('alice', 'message.send', { to: 'assistant', body: 'me too', in_reply_to: asked }), 'access-refused'); assert.equal(call('bob', 'thread.messages', { thread: B }).length, 1); }); test('R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice', (t) => { const { store, call, launch, A } = granted(t); const asked = call('alice', 'thread.send', { thread: A, body: 'question' }).id; launch('a', A); const before = store.get('SELECT count(*) n FROM messages').n; const reply = call('a', 'message.send', { to: 'user', body: 'answer', class: 'RESULT', in_reply_to: asked }); assert.equal('waiting' in reply, false); assert.equal(reply.request, null); assert.equal(store.get('SELECT count(*) n FROM messages').n, before + 1, 'no notice message'); const r = store.get('SELECT * FROM messages WHERE id=?', reply.id); assert.equal(r.to_role, 'user'); assert.equal(r.from_run, 'a-run'); assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [asked, reply.id]); // The owner's and the tester's receive get nothing of it. assert.deepEqual(call('human', 'message.receive'), []); assert.deepEqual(call('tester', 'message.receive'), []); }); test('R3: a reply to: user without a matching in_reply_to is refused user-reply-required', (t) => { const { store, call, code, access, user, launch, p1, A } = granted(t); access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })]; user('bob'); const B = call('bob', 'thread.create', { project: p1, name: 'bob work' }).id; const fromBob = call('bob', 'thread.send', { thread: B, body: 'bob asks' }).id; const fromAlice = call('alice', 'thread.send', { thread: A, body: 'alice asks' }).id; const A2 = call('alice', 'thread.create', { project: p1, name: 'alice other' }).id; const elsewhere = call('alice', 'thread.send', { thread: A2, body: 'alice asks elsewhere' }).id; const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id; const fromOwner = call('human', 'thread.send', { thread: H, body: 'owner asks' }).id; launch('a', A); const send = (in_reply_to) => code('a', 'message.send', { to: 'user', body: 'x', ...(in_reply_to ? { in_reply_to } : {}) }); assert.equal(send(null), 'user-reply-required', 'no in_reply_to'); assert.equal(send(fromBob), 'user-reply-required', 'another user message in another thread'); assert.equal(send(elsewhere), 'user-reply-required', 'the same user message outside the thread'); assert.equal(send(fromOwner), 'user-reply-required', 'the owner message'); assert.equal(send('00000000-0000-4000-8000-000000000000'), 'user-reply-required', 'an unknown id'); assert.equal(send(fromAlice), null, 'its own user message in its own thread'); assert.equal(store.get("SELECT count(*) n FROM messages WHERE to_role='user'").n, 1); }); test('R3: a non-thread run, the human and the tester cannot send to: user', (t) => { const { b, call, code, A } = granted(t); const asked = call('alice', 'thread.send', { thread: A, body: 'question' }).id; const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); b.request(chief, { verb: 'role.claim' }); assert.throws(() => b.request(chief, { verb: 'message.send', args: { to: 'user', body: 'x', in_reply_to: asked } }), { code: 'user-reply-required' }); assert.equal(code('human', 'message.send', { to: 'user', body: 'x' }), 'unknown-role'); assert.equal(code('tester', 'message.send', { to: 'user', body: 'x' }), 'unknown-role'); }); test('R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9', (t) => { const { store, call, code, launch, A } = granted(t); call('alice', 'thread.send', { thread: A, body: 'ship it' }); launch('a', A); assert.equal(code('a', 'message.send', { to: 'human', body: 'psst' }), 'user-thread-human'); const before = store.get('SELECT count(*) n FROM messages').n; const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id; assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', d).route_to, 'human'); assert.ok(call('human', 'inbox').some((x) => x.id === d), 'in the owner inbox'); assert.equal(store.get('SELECT count(*) n FROM messages').n, before, 'no message to any role'); assert.equal(code('alice', 'inbox'), 'access-refused', 'the user has no inbox'); assert.equal(code('a', 'message.send', { to: 'human', body: 'about that raise' }), 'user-thread-human'); }); test('R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request', (t) => { const { call, code, access, store, A } = granted(t); const reads = access.reads; call('alice', 'thread.list'); call('alice', 'project.list'); assert.equal(access.reads, reads + 2); const kept = access.doc.grants; access.doc.grants = []; assert.equal(code('alice', 'thread.list'), 'access-revoked', 'removed'); access.doc.grants = kept.map((g) => ({ ...g, expires: '2000-01-01T00:00:00Z' })); assert.equal(code('alice', 'project.list'), 'access-revoked', 'expired'); // Row 275 (193b1b, design 4.3): a grant lowered to viewer still reads the user's own threads. access.doc.grants = kept.map((g) => ({ ...g, level: 'viewer' })); assert.equal(code('alice', 'thread.list'), null, 'downgraded'); // mos-1311 condition 1: the downgraded viewer still can't send or open, and nothing is stored. const stored = () => ['messages', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n); const before = stored(); assert.equal(code('alice', 'thread.send', { thread: A, body: 'still mine?' }), 'access-refused', 'downgraded send'); assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'downgraded open'); assert.deepEqual(stored(), before, 'downgraded: nothing stored'); access.fail = true; access.doc.grants = kept; assert.equal(code('alice', 'thread.list'), 'access-revoked', 'file refused'); access.fail = false; assert.equal(code('alice', 'thread.list'), null, 'restored'); }); test('R3: a user refusal is recorded as action.refused by user:', (t) => { const { store, code, access, A } = granted(t); assert.equal(code('alice', 'inbox'), 'access-refused'); access.doc.grants = []; assert.equal(code('alice', 'thread.messages', { thread: A }), 'access-revoked'); const rows = store.all("SELECT actor_role, actor_run, body FROM events WHERE kind='action.refused' AND actor_run='user:alice' ORDER BY seq"); assert.deepEqual(rows.map((r) => [r.actor_role, JSON.parse(r.body).code]), [['user', 'access-refused'], ['user', 'access-revoked']]); }); test('R3: a business with a role keyed user refuses invalid-business', (t) => { const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-boot-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, user: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, }, }, }; assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' }); delete businesses.demo.roles.user; assert.ok(new Broker({ store, businesses }), 'the same business without it loads'); }); test('R3: name.set refuses the name user', (t) => { const { code } = setup(t); assert.equal(code('human', 'name.set', { instance: 'assistant', name: 'user' }), 'name-is-instance'); }); // FED R3 r4 (sage-2486): halt is the kill switch, so it refuses a granted user's write as it refuses // the human's. The read still answers, and nothing is stored. test('R3 r4: while halted, a granted user\'s thread.send refuses processing-halted and thread.messages still answers', (t) => { const generation = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69'; const sw = { state: { version: 1, halted: false, generation } }; const { call, code, store, A } = granted(t, { processing: { read: () => sw.state } }); const sent = call('alice', 'thread.send', { thread: A, body: 'before' }); sw.state = { version: 1, halted: true, generation }; const stored = store.get('SELECT count(*) n FROM messages').n; assert.equal(code('alice', 'thread.send', { thread: A, body: 'during' }), 'processing-halted'); assert.equal(store.get('SELECT count(*) n FROM messages').n, stored); assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent.id]); }); // FED R3 r4 (sage-2557): row 194's hire takes an instance name, and `user` is refused as human and // tester are, so a hire can't write a role the load check refuses. test('R3 r4: the chief\'s hire refuses an instance named user with the code it gives human and tester', (t) => { const { b, code, caps } = setup(t); caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' }); b.request(caps.chief, { verb: 'role.claim' }); const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' }); for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'decision-required', name); // A name that isn't reserved passes the name check and refuses later, for want of role files. assert.notEqual(hire('helper'), 'invalid-request'); }); // FED R3 r4 (sage-2557, sage-2501): `access` is checked with the other function arguments, before // the broker touches the store, so a null store never gets read. test('R3 r4: a non-function access throws before the store is read', () => { assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/); }); test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => { const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } }); const project = ctx.call('human', 'project.create', { name: 'shared' }).id; const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id; ctx.access.doc.grants = [grant({ projects: [project] })]; ctx.user('alice'); assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused'); for (const id of [privateProject, '00000000-0000-4000-8000-000000000000']) assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused'); assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0); const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' }); assert.equal(allowed.instance, 'assistant'); ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })]; const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' }); assert.equal(bean.instance, 'bean'); assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused'); }); test('264e deferred B6: a reply cannot cite the same user in a sibling session', t => { const { b, store, caps, call, A } = granted(t); // Session writes remain off USER_VERBS (Q7). Seed the future multi-session case directly, // without granting a collaborator new authority just to exercise the reply fence. const sibling = 'f0000000-0000-4000-8000-000000000001'; store.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,?,instance,owner,'sibling','264e-fixture' FROM threads WHERE id=?", sibling, A); const original = call('alice', 'thread.send', { thread: A, body: 'original asks' }).id; const second = call('alice', 'thread.send', { thread: A, session: sibling, body: 'sibling asks' }).id; caps.worker = b.bindLaunch({ business:'demo', role:'assistant', run:'session-worker', harness:'pi', thread:A, session:sibling }); call('worker', 'role.claim'); const before = store.get('SELECT count(*) n FROM messages').n; assert.throws(() => call('worker', 'message.send', { to:'user', in_reply_to:original, body:'wrong context' }), { code:'user-reply-required' }); assert.equal(store.get('SELECT count(*) n FROM messages').n, before); const reply = call('worker', 'message.send', { to:'user', in_reply_to:second, body:'right context' }); assert.equal(store.get('SELECT session FROM message_threads WHERE message=?', reply.id).session, sibling); });