Files
stack/packages/bus/tests/user-principal.test.mjs

433 lines
25 KiB
JavaScript

// 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:<id> 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:<id>', (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);
});