From 59f708ca21b2a281ad5605f23e59e5bb7f8ab754 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 21 Oct 2026 19:43:21 -0500 Subject: [PATCH] morgana: row 205a build (sage-2288): trail and replies hide other users' thread messages from owner, reader and tester; hidden answers as unknown; #readable owner-only; design d2 rulings --- agents/morgana/work/queue-205/BUILD.md | 63 +++++++ agents/morgana/work/queue-205/DESIGN.md | 33 +++- packages/bus/src/broker.mjs | 37 +++- packages/bus/tests/message-filter.test.mjs | 189 +++++++++++++++++++++ 4 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 agents/morgana/work/queue-205/BUILD.md create mode 100644 packages/bus/tests/message-filter.test.mjs diff --git a/agents/morgana/work/queue-205/BUILD.md b/agents/morgana/work/queue-205/BUILD.md new file mode 100644 index 000000000..814f9cc15 --- /dev/null +++ b/agents/morgana/work/queue-205/BUILD.md @@ -0,0 +1,63 @@ +# Row 205a (193b1a, #1703): the trail and replies message filter + +Design: DESIGN.md d2 in this directory, with rulings in section 7 (sage-2288). Base: refactor 02da6a2ae. No schema +change. Nothing has run yet: every test run waits for Sage's CLEAR. + +## Changes + +`packages/bus/src/broker.mjs`: +- `#hiddenMessage(s, id)`: the message's thread (`message_threads` joined to `threads`) is owned by a `user:` + other than `#owner(s)`, and `#readable(s, t)` is false. A message with no thread, or in the business human's + or the tester's thread, is never hidden. A reader session owns nothing, so every user thread is hidden from it. + The tester reads through its reader view, and `#owner` still gives `tester`. +- `#readable(s, t)` returns false. Rule 1 (a share in force) can't hold before 193b2's schema 3j, so nobody + reads another's thread. 193b2 adds the share rule and rules 2-4 here. +- `#trail`: a hidden subject returns `[]` before anything is read, exactly as an unknown id does (ruling 3). + After the rows are collected, it drops hidden `messages` rows, `deliveries` rows of a hidden message, and + `events` rows whose subject is a hidden message. Decisions, decision events, task snapshots, run events and + claims stay. +- `replies`: a hidden id fails `message-not-found`, as an unknown id does. Hidden replies are left out in SQL + (`NOT EXISTS` a thread whose owner starts with `user:` and isn't the caller), so the `REPLIES` limit counts + only visible rows. + - The SQL clause doesn't call `#readable`. That is exact while `#readable` is false. 193b2 must add the share + exception to the clause and to `#readable` together. + +`packages/bus/tests/message-filter.test.mjs` (new). It writes a `user:alice` thread and its messages +straight to the store, as R3's `thread.send` and the run's `to: 'user'` reply will write them, because nothing +makes a user session before R3. +- `trail` on a hidden id equals `trail` on an unknown id, byte for byte, for the owner, a reader and the tester. +- `replies` on a hidden id equals `replies` on an unknown id (code and message), for all three. +- `trail` on a user-thread run's decision shows the decision and the owner's message, and drops the user + thread message, its delivery and its event. +- `replies` with `REPLIES` hidden replies ahead of a visible one returns the visible one. +- Messages in the business human's and the tester's threads stay visible on both verbs (row 177 control). +- The governance pin, one assertion per item in the user sentence: inbox entry, claim, task, project file and + the closed decision in `decisions {list: 'recent'}` show to the owner. `trail` and `replies` hide the user + message, and the decision's trail carries no user body. + +## Mutants (each must fail its named test in message-filter.test.mjs) + +| Mutant | Edit | Named test | +|---|---|---| +| M1 | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user messages | +| M11 | the trail drop loop iterates nothing | trail on a decision keeps the decision and drops the user thread messages, their deliveries and their events | +| M12 | the replies `NOT EXISTS` clause removed | replies leaves out other users thread messages and still fills a page | +| M13 | trail fails `message-not-found` for a hidden subject | trail on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester | +| M14 | replies returns `[]` for a hidden id | replies on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester | + +## Gate + +The kit is /mnt/storage/scratch/morgana-r205 (`slot205.sh`, `mutate.py`, `busy.sh`), run as +`bash busy.sh && CAND= bash slot205.sh` on Sage's CLEAR. It runs these files whole: +- message-filter; +- the bus files that call trail or replies: replies, tester, tester-threads, tester-writes, broker, decisions, + decision-close, end-launch and task-read. + +Then it runs the bus suite, then M1 and M11-M14, each on its named test and restored after each. It uses umask +022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g205XXXX`. It refuses unless HEAD is the candidate +and the tree is clean, and it stops after two unexpected failures (mos-0823). + +Sage's gate adds every `scripts/test-*.sh`. The webui tests that read trail or replies run there. + +Static check: `node --check` on broker.mjs and the new test, run from `/`. Both parse. All five mutant anchors +match exactly once. diff --git a/agents/morgana/work/queue-205/DESIGN.md b/agents/morgana/work/queue-205/DESIGN.md index 18ba7690b..63a8eaa99 100644 --- a/agents/morgana/work/queue-205/DESIGN.md +++ b/agents/morgana/work/queue-205/DESIGN.md @@ -3,7 +3,7 @@ Owner Morgana, reviewer Duckworth, gate owner Sage (sage-2280). Brief: `docs/plans/2026-10-20_human-users-build.md`, "Brief: 193b1, viewer verbs, board refusals and the message filter" (blob a73e1bba0, rev 3088). Design: row 193 DESIGN.md r5 (e2d161e2e, Duckworth approved), sections 4.3, 5.4, 5.5, 5.7, 8 and 9. Base: refactor -02da6a2ae. Draft 1, not built. +02da6a2ae. Draft 2: Sage's rulings (sage-2288) are in section 7. Sections 1-6 are d1 as sent (morgana-0063). ## 1. The dependency gap @@ -140,3 +140,34 @@ files, any live access.json or `~/.mosaic`. 2. Mutant 1 as "`#readable` returns true" in 205a, and mutant 3 moved to 193b2? 3. A hidden `trail` subject returns `[]`, the same as an unknown id, in place of 5.7's `message-not-found` (3.2)? `replies` already fails `message-not-found` on an unknown id, so there the code stays. + +## 7. Rulings (sage-2288, re morgana-0063) + +1. **Split approved.** 205a is built now: the 5.7 `trail` and `replies` filter over owner, reader and tester + sessions, `#readable` owner-only, the owner-side governance pin test, and mutants 11 and 12. 205b comes + after R3's candidate and R4: viewer verbs, `covers` level checks, board reads refused to users, the + user-side pin, and mutants 6 and 10. 205a's base is origin/refactor 02da6a2ae or later. R3 builds on + 205a's candidate and extends the filter to user sessions, so the filter isn't written twice. +2. **Mutant 1** in 205a is "`#readable` returns true", killed by the pin test. **Mutant 3** moves to 193b2 + (row 206). Sage changes the brief to match. +3. **Hidden equals unknown.** A hidden `trail` subject returns `[]`, exactly as an unknown id does. `replies` + keeps `message-not-found` for both. The point of 5.7 is that a caller can't tell hidden from unknown. + So 205a adds: + - one test per verb comparing the hidden response with the unknown-id response byte for byte + (`JSON.stringify` of the result, or of the error's code and message), for each of the three sessions; + - a mutant M13 that makes the hidden path distinguishable: `trail` fails `message-not-found` for a + hidden subject. The `trail` equality test must kill it. M14 does the same for `replies`: a hidden id + returns `[]` instead of failing. The `replies` equality test must kill it. + +### 7.1 205a mutant table (replaces 3.5) + +| Mutant | Edit | Named test | +|---|---|---| +| M1 (design 1) | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user's messages | +| M11 (design 11) | the `trail` filter of hidden rows removed | trail on the decision hides the user's message, its delivery and its events | +| M12 (design 12) | the `replies` filter removed | replies drops other users' thread messages and still fills a page | +| M13 | `trail` fails `message-not-found` for a hidden subject | trail on a hidden id answers exactly as on an unknown id | +| M14 | `replies` returns `[]` for a hidden id | replies on a hidden id answers exactly as on an unknown id | + +Queue: Sage re-pins 205 to the split brief and adds 205b and the R3 row at the next gap. I claim after +that. No run before Sage's CLEAR. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 0f521ee92..4ab2330a8 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -549,6 +549,22 @@ export class Broker { #owner(s) { return s.tester ? 'tester' : s.human; } + // Row 205 (193b1a, #1703, row 193 design 5.7): a message in a thread a user other than the caller + // owns is hidden from trail and replies unless #readable holds. The business human's and the + // tester's threads are never hidden, so row 177 is unchanged. A reader owns nothing. + #hiddenMessage(s, id) { + const t = this.#store.get( + 'SELECT t.* FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=? AND t.business=?', + id, + s.business, + ); + return Boolean(t) && t.owner.startsWith('user:') && t.owner !== this.#owner(s) && !this.#readable(s, t); + } + // Row 193 design 5.4. Rule 1 needs a share in force, and there is no thread_shares table before + // 193b2 (schema 3j), so nobody reads another's thread yet. 193b2 adds the share rule and rules 2-4. + #readable(s, t) { + return false; + } #agent(s) { if (s.tester) fail('tester-refused'); if (s.reader) fail('read-only'); @@ -3663,10 +3679,15 @@ export class Broker { identifier(a.id); if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business)) fail('message-not-found'); + // Row 205 (design 5.7): a hidden id fails as an unknown one does. Hidden replies are left out + // in the query, so a page of them doesn't come back short; #readable is false until 193b2, + // which adds the share exception to this clause too. + if (this.#hiddenMessage(s, a.id)) fail('message-not-found'); return this.#store.all( - `SELECT * FROM messages WHERE business=? AND in_reply_to=? ORDER BY seq LIMIT ${REPLIES}`, + `SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ?) ORDER BY seq LIMIT ${REPLIES}`, s.business, a.id, + this.#owner(s) || null, ); } default: @@ -3915,6 +3936,8 @@ export class Broker { for (const m of rows) this.#delivery(m.id, 'routed', h); } #trail(s, subject) { + // Row 205 (design 5.7, sage-2288): a hidden subject answers exactly as an unknown id does. + if (this.#hiddenMessage(s, subject)) return []; const rows = new Map(); const add = (table, list) => { for (const row of list) { @@ -3996,6 +4019,18 @@ export class Broker { this.#store.all('SELECT * FROM role_claims WHERE business=? AND holder_run=?', s.business, run), ); } + // Row 205 (design 5.7): drop hidden messages, their deliveries and the events about them. + // Decision rows and their events stay: the run's governance is the owner's to see. + const hidden = new Set( + [...rows.values()].filter((r) => r.table === 'messages' && this.#hiddenMessage(s, r.id)).map((r) => r.id), + ); + for (const [key, r] of rows) + if ( + (r.table === 'messages' && hidden.has(r.id)) || + (r.table === 'deliveries' && hidden.has(r.message)) || + (r.table === 'events' && hidden.has(r.subject)) + ) + rows.delete(key); return [...rows.values()].sort( (a, b) => a.at.localeCompare(b.at) || a.table.localeCompare(b.table) || a.seq - b.seq, ); diff --git a/packages/bus/tests/message-filter.test.mjs b/packages/bus/tests/message-filter.test.mjs new file mode 100644 index 000000000..1845a3ebe --- /dev/null +++ b/packages/bus/tests/message-filter.test.mjs @@ -0,0 +1,189 @@ +// Row 205 (193b1a, #1703, row 193 design 5.7, sage-2288): a user's messages are private from the +// business owner, reader sessions and the tester on trail and replies; the run's governance is not. +// Nothing makes a user session before FED R3, so a user's thread and its messages are written to the +// store as R3's thread.send and the run's reply will write them. +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 { randomUUID } from 'node:crypto'; +import { Store } from '../src/store.mjs'; +import { Broker, REPLIES } from '../src/broker.mjs'; +import { fixture } from './spec-fixtures.mjs'; + +const AT = '2026-10-21T00:00:00.000Z'; +const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } }); +const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; + +// A thread owned by `owner`, on the business's assistant instance. +function threadOf(store, project, owner, name) { + const id = randomUUID(); + store.run( + 'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', + id, AT, 'demo', project, 'assistant', owner, name, + ); + return id; +} +// A message with its thread row, its routed delivery and one event about it. +function put(store, { thread = null, from_role, from_run, to_role, in_reply_to = null, decision = null, body }) { + const id = randomUUID(); + store.run( + 'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + id, AT, 'demo', from_role, from_run, to_role, 'INFO', in_reply_to, decision, null, body, + ); + if (thread) store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', id, thread); + store.run('INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)', id, AT, 'routed', null, null, null); + store.run( + 'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)', + randomUUID(), AT, 'demo', 'action.allowed', from_role, from_run, id, '{}', + ); + return id; +} +// From alice to the instance, as R3's thread.send writes it. +const fromAlice = (store, thread, body, extra = {}) => + put(store, { thread, from_role: 'user', from_run: 'user:alice', to_role: 'assistant', body, ...extra }); +// The run's reply to alice, as R3's `to: 'user'` path writes it. +const toAlice = (store, thread, body, extra = {}) => + put(store, { thread, from_role: 'assistant', from_run: 'assistant-run', to_role: 'user', body, ...extra }); + +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'bus-message-filter-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + 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', ...within(['message.send']) }, assistant: { definition: 'assistant', ...within(['message.send']) } }, + }, + }, + }); + const caps = { + human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), + reader: b.bindReader({ business: 'demo' }), + tester: b.bindTester({ business: 'demo' }), + }; + const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); + const project = call('human', 'project.create', { name: 'shared work' }).id; + const U = threadOf(store, project, 'user:alice', 'alice'); + return { b, store, caps, call, project, U }; +} +const sessions = ['human', 'reader', 'tester']; +// What a caller sees of a call: its result, or its refusal's code and message. +const answer = (fn) => { + try { + return JSON.stringify({ result: fn() }); + } catch (e) { + return JSON.stringify({ code: e.code, message: e.message }); + } +}; + +test('trail on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester', (t) => { + const { store, call, U } = setup(t); + const A = fromAlice(store, U, 'alice: my salary is private'); + const unknown = randomUUID(); + for (const who of sessions) { + const hidden = answer(() => call(who, 'trail', { subject: A })); + assert.equal(hidden, answer(() => call(who, 'trail', { subject: unknown })), who); + assert.equal(hidden, JSON.stringify({ result: [] }), who); + } +}); + +test('replies on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester', (t) => { + const { store, call, U } = setup(t); + const A = fromAlice(store, U, 'alice: hello'); + toAlice(store, U, 'hello alice', { in_reply_to: A }); + const unknown = randomUUID(); + for (const who of sessions) { + const hidden = answer(() => call(who, 'replies', { id: A })); + assert.equal(hidden, answer(() => call(who, 'replies', { id: unknown })), who); + assert.equal(JSON.parse(hidden).code, 'message-not-found', who); + } +}); + +test('trail on a decision keeps the decision and drops the user thread messages, their deliveries and their events', (t) => { + const { b, store, call, U } = setup(t); + const run = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: U }); + b.request(run, { verb: 'role.claim' }); + const d = b.request(run, { + verb: 'decision.raise', + args: { action: 'deploy', question: 'Ship it?', options, recommendation: 'yes', blocking: false }, + }).id; + const hidden = toAlice(store, U, 'alice, I asked the owner about shipping', { decision: d }); + const shown = put(store, { from_role: 'assistant', from_run: 'assistant-run', to_role: 'human', body: 'Ship it, owner?', decision: d }); + for (const who of sessions) { + const rows = call(who, 'trail', { subject: d }); + assert.ok(rows.some((r) => r.table === 'decisions' && r.id === d), `${who}: the decision row shows`); + assert.ok(rows.some((r) => r.table === 'messages' && r.id === shown), `${who}: the owner's message shows`); + assert.ok(rows.some((r) => r.table === 'deliveries' && r.message === shown), `${who}: its delivery shows`); + assert.ok(rows.some((r) => r.table === 'events' && r.subject === shown), `${who}: its event shows`); + assert.equal(rows.filter((r) => r.table === 'messages' && r.id === hidden).length, 0, `${who}: no hidden message`); + assert.equal(rows.filter((r) => r.table === 'deliveries' && r.message === hidden).length, 0, `${who}: no hidden delivery`); + assert.equal(rows.filter((r) => r.table === 'events' && r.subject === hidden).length, 0, `${who}: no hidden event`); + assert.equal(JSON.stringify(rows).includes('alice, I asked'), false, `${who}: the body appears nowhere`); + } +}); + +test('replies leaves out other users thread messages and still fills a page', (t) => { + const { store, call, U } = setup(t); + const P = put(store, { from_role: 'human', from_run: 'human-cli', to_role: 'chief', body: 'Status, everyone?' }); + for (let i = 0; i < REPLIES; i++) toAlice(store, U, `hidden ${i}`, { in_reply_to: P }); + const shown = put(store, { from_role: 'chief', from_run: 'chief-run', to_role: 'human', body: 'All green.', in_reply_to: P }); + for (const who of sessions) assert.deepEqual(call(who, 'replies', { id: P }).map((m) => m.id), [shown], who); +}); + +test('messages in the business human and tester threads are not hidden (row 177 unchanged)', (t) => { + const { store, call, project } = setup(t); + const H = threadOf(store, project, 'jason', 'mine'); + const T = threadOf(store, project, 'tester', 'probe'); + const h = put(store, { thread: H, from_role: 'human', from_run: 'human-cli', to_role: 'assistant', body: 'from jason' }); + const x = put(store, { thread: T, from_role: 'tester', from_run: 'tester-cli', to_role: 'assistant', body: 'from tester' }); + const hr = put(store, { thread: H, from_role: 'assistant', from_run: 'r', to_role: 'human', body: 'to jason', in_reply_to: h }); + const xr = put(store, { thread: T, from_role: 'assistant', from_run: 'r', to_role: 'tester', body: 'to tester', in_reply_to: x }); + for (const who of sessions) { + for (const id of [h, x]) assert.ok(call(who, 'trail', { subject: id }).some((r) => r.table === 'messages' && r.id === id), `${who} ${id}`); + assert.deepEqual(call(who, 'replies', { id: h }).map((m) => m.id), [hr], who); + assert.deepEqual(call(who, 'replies', { id: x }).map((m) => m.id), [xr], who); + } +}); + +// Row 193's user sentence, one assertion per item: "The business owner sees your thread's agent's +// decisions, inbox entries, claims, tasks and project files, but not your messages." +test('governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user messages', async (t) => { + const f = await fixture(t, { roles: { assistant: { definition: 'assistant', ...within(['message.send', 'record.write']) } } }); + const project = (await f.call(f.human, 'project.create', { name: 'alice work' })).id; + const U = threadOf(f.store, project, 'user:alice', 'alice'); + const run = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: U }); + await f.call(run, 'role.claim'); + const A = fromAlice(f.store, U, 'alice: please write up my plan'); + toAlice(f.store, U, 'alice, done', { in_reply_to: A }); + await f.call(run, 'record.write', { project: 'stack', path: 'docs/alice-plan.md', record: '# Plan\n' }); + const d = (await f.call(run, 'decision.raise', { action: 'deploy', question: 'Ship the plan?', options, recommendation: 'yes', blocking: false })).id; + f.store.run( + 'INSERT INTO task_snapshots(at,business,task_ref,updated,digest,fields,source,via,read_at,role,run) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + AT, 'demo', 'vikunja:3/47', AT, 'a'.repeat(64), JSON.stringify({ bucket: 1, done: false }), 'self', null, null, 'assistant', 'assistant-run', + ); + + const inbox = await f.call(f.human, 'inbox'); + assert.ok(inbox.some((x) => x.id === d), 'inbox entries: the run raise is in the owner inbox'); + const agents = await f.call(f.human, 'agents'); + assert.ok(agents.some((c) => c.role === 'assistant' && c.holder_run === 'assistant-run'), 'claims: the run claim shows'); + const tasks = await f.call(f.human, 'tasks'); + assert.ok(tasks.some((x) => x.task_ref === 'vikunja:3/47' && x.run === 'assistant-run'), 'tasks: the run task write shows'); + const file = await f.call(f.human, 'project.file', { project: 'stack', path: 'docs/alice-plan.md' }); + assert.equal(file.content, '# Plan\n', 'project files: the run record shows'); + await f.call(f.human, 'decision.resolve', { id: d, choice: 'yes' }); + const recent = await f.call(f.human, 'decisions', { list: 'recent' }); + assert.ok(recent.some((x) => x.id === d), 'decisions: the closed raise shows in recent'); + + assert.deepEqual(await f.call(f.human, 'trail', { subject: A }), [], 'messages: trail hides the user message'); + await assert.rejects(Promise.resolve().then(() => f.call(f.human, 'replies', { id: A })), { code: 'message-not-found' }, 'messages: replies hides it'); + const trail = await f.call(f.human, 'trail', { subject: d }); + assert.ok(trail.some((r) => r.table === 'decisions' && r.id === d), 'decisions: the trail keeps the decision'); + assert.equal(JSON.stringify(trail).includes('alice'), false, 'messages: no user body on the decision trail'); +});