diff --git a/agents/launchpad/work/queue-206/BUILD.md b/agents/launchpad/work/queue-206/BUILD.md index 6c26e9fc2..da4d899f1 100644 --- a/agents/launchpad/work/queue-206/BUILD.md +++ b/agents/launchpad/work/queue-206/BUILD.md @@ -10,10 +10,15 @@ sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789. - 206 chains on 275 (193b1b, viewer verbs and board refusals), which Morgana is building. I rebase onto Morgana's 275 sha when it comes, and onto 253's landing under a new branch name. - At this base a user binds only with a collaborator grant (`#userGrants`, USER_LEVEL). Viewer - sessions arrive with 275, so the viewer column of design 4.3 is not testable here. On the 275 + sessions arrive with 275, so the viewer column of design 4.3 is not testable here (sage-2818 + Q4: the viewer column waits on the 275 rebase; 275 moves once more for Duckworth's B1, + duckworth-0166). On the 275 rebase, USER_VERBS and the viewer gate merge with the three share verbs, and the share verbs must refuse a viewer (design 4.3: share, unshare and shares are "refused" for viewers). +- The live `--migrate` (3l→3m on the running bus) is not part of this build. It is Mos's go under + Q173, with a backup first and a STATE report after (mos-1310 item 4). + ## What changed - Schema 3l→3m. `thread_shares` as design 5.2, its index and three append-only triggers, in @@ -41,16 +46,27 @@ sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789. 1. Label 3l→3m, not 3i-3j (design) or 3l (brief title). 264a took 3k-3l, and labels go by landing order (sage-2789). -2. A share covers every session of the thread. The 264a comment on `session.list` says "there - are no shares (Q7)"; I left `session.list` owner-only, so a sharee reads any session it can - name but can't list them. Flagged for a ruling: either a sharee gets `session.list`, or the - Console names the sessions some other way. -3. A reader session matches shares as the business human. Design 4.3 says reader sessions "stay - owner-only", and 5.5 says "messages in other users' unshared threads hidden" for human, reader - and tester. I read that as: the reader sees what the owner sees, shares to the owner included. -4. Design 5.6's "30 s timer" and `access.reload` have no path at this base: runtime.mjs:263 - passes `access: () => loadAccess(file, context)`, read on every request. The tests cover the - per-request read (removed, expired, out of scope and unreadable), which is the stricter case. +2. A share covers every session of the thread, and `session.list` stays owner-only (sage-2818 + Q1, from Sage's Q7 ruling, agents/sage/work/queue-264/RULINGS.md:72-77). broker.mjs keeps its + "no shares (Q7)" refusal: a sharee reads any session it can name but can't list them. A test + holds the refusal (human `thread-not-owned`, reader `read-only`, user `access-refused`). + **Follow-up**, bound to whichever of row 193 or the share wiring lands second: wire the sharee's + `session.list`. The bar is T1 sessions line 7 and REQ-USER-4 (candidate PRD 1509c7cc5, + docs/prd/mosaic-stack.md:1027-1052): read-only, and it needs current grants, the thread + owner's included. +3. A reader session matches shares as the business human (sage-2818 Q2: accepted, for the human + principal only). Design 4.3 says reader sessions "stay owner-only", and 5.5 says "messages in + other users' unshared threads hidden" for human, reader and tester. The reader sees what the + owner sees, shares to the owner included. Q2 also found a gap in the first tip (16b9c6df6): an + agent session has no owner, but it matched a `*` share, so a shared user message showed in an + agent's trail. `#readable` now returns false unless the session is the business human, a + reader or a user. A test binds an assistant run to the shared thread and a chief run with no + thread, and checks that neither (nor the tester) sees the message after a `*` share and a share + to the owner. +4. Access is read per request (sage-2818 Q3). Design 5.6's "30 s timer" and `access.reload` have + no path at this base: runtime.mjs:263 passes `access: () => loadAccess(file, context)`, read on + every request. The tests cover the per-request read (removed, expired, out of scope and + unreadable), which is the stricter case. 5. Rule 3 asks the owner's grant for any level, like rule 2 (design: "still has a live grant covering t"). A user owner lowered to viewer keeps the thread readable to its sharees. 6. An unshare checks no coverage, so an owner can end a share whose target lost its grant. @@ -60,10 +76,11 @@ sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789. ## Tests -New: tests/thread-shares.test.mjs, 13 tests: the read flow and the write refusals, `*`, rule 2, +New: tests/thread-shares.test.mjs, 15 tests: the read flow and the write refusals, `*`, rule 2, rule 3 (with trail for human and reader), latest-row state, share-not-covered, target checks, lost owner coverage, tester and reader refusals (18 `action.refused` events, no rows), trail and -replies, sessions, the 3l→3m migration and the 3l engine's refusal of a 3m store. +replies, sessions, the sharee's `session.list` refusal (Q1), agents and the tester matching no +share (Q2), the 3l→3m migration and the 3l engine's refusal of a 3m store. Pins moved 3l→3m: account-, chief-of-staff, comment-, gitea-, grant-, name-, spec-, usage- and thread-migration, sessions, schema (23 tables, 89 triggers) and user-principal (USER_VERBS). @@ -91,6 +108,9 @@ test must fail on an assertion; a thrown error or a refusal before the assertion to user:carol must refuse). - M9. In `thread.share`/`thread.unshare`, replace `this.#thread(s, a.thread)` with `this.#threadRow(s, a.thread)`. Killed by the first test (bob's share of A to `*` must refuse). +- Q2 probe (not a design mutant). In `#readable`, delete the human-principal line + (`if (!s.human && !s.reader && !s.user) return false;`). Killed by "only a human principal + matches a share" (the agent's trail shows the message after the `*` share). ## Run limits diff --git a/packages/bus/README.md b/packages/bus/README.md index 840edcb44..cd9d63a2e 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -301,7 +301,10 @@ A session may read a thread it doesn't own only when all four hold (row 193 desi 3. a thread owned by a user is still covered by that user's grant; 4. neither the caller nor the owner is the tester. -A reader session reads as the business human. The check runs on every request against the +Only a human principal matches a share: the business human, a reader session as that human, or +a user. An agent session matches none, `*` included, even when it is bound to the thread. +A reader session reads as the business human. A sharee can't call `session.list` on the +thread; it stays owner-only until row 193's share wiring. The check runs on every request against the access document the broker reads then, so a removed, expired or unreadable grant or an unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`, `thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index b375de2db..d5a6dcbec 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -827,9 +827,12 @@ export class Broker { } // Row 206 (193b2, design 5.4): may a session read thread `t` it doesn't own? Rule 4 first: a // tester's thread is never shared, and a tester is never a share's target and holds no grant. A - // reader session is the owner's board view, so it reads what is shared with the owner. + // reader session is the owner's board view, so it reads what is shared with the owner. Only a + // human principal (the business human, a reader as that human, a user) matches a share: an agent + // session, even one bound to the thread, matches none, `*` included (sage-2818 Q2). #readable(s, t) { if (s.tester || t.owner === 'tester') return false; + if (!s.human && !s.reader && !s.user) return false; const me = s.reader ? this.#business(s.business).human : this.#owner(s); // Rule 1: a share in force to the caller or to everyone. if (!this.#shared(t.id, me) && !this.#shared(t.id, '*')) return false; diff --git a/packages/bus/tests/thread-shares.test.mjs b/packages/bus/tests/thread-shares.test.mjs index d197952f3..968f2e708 100644 --- a/packages/bus/tests/thread-shares.test.mjs +++ b/packages/bus/tests/thread-shares.test.mjs @@ -304,6 +304,40 @@ test('a share covers every session of the thread', (t) => { assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned'); }); +test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => { + const { call, code, A, H } = setup(t); + call('alice', 'thread.share', { thread: A, to: 'jason' }); + call('human', 'thread.share', { thread: H, to: '*' }); + assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned'); + assert.equal(code('reader', 'session.list', { thread: A }), 'read-only'); + for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who); + // The owners still list their own. + assert.equal(call('human', 'session.list', { thread: H }).length, 1); +}); + +test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => { + const { b, caps, call, code, A, H, sent } = setup(t); + // The assistant run serving alice's thread, and a chief run with no thread. + caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A }); + caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); + for (const who of ['agent', 'chief']) call(who, 'role.claim'); + const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent); + for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`); + call('alice', 'thread.share', { thread: A, to: '*' }); + call('alice', 'thread.share', { thread: A, to: 'jason' }); + // The human principals now read it; the agents and the tester see what they saw before. + for (const who of ['human', 'reader']) assert.equal(seen(who), true, who); + for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`); + for (const who of ['agent', 'chief']) { + assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who); + assert.equal(code(who, 'replies', { id: sent }), 'human-required', who); + } + assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned'); + assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found'); + call('human', 'thread.share', { thread: H, to: '*' }); + assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned'); +}); + // The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table. const AT = '2026-10-22T00:00:00.000Z'; const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');