row 206 (193b2, #1704): sage-2818 rulings, only a human principal matches a share
#readable returns false for an agent session, so a `*` share no longer shows a user's message in an agent's trail. New tests: a sharee's session.list refuses (Q1), and an agent bound to the shared thread, a chief run and the tester match no share (Q2). BUILD.md records the Q1 follow-up, Q2-Q4 and the live --migrate as Mos's go under Q173. README names the agent rule.
This commit is contained in:
1 parent
16b9c6df6f
commit
631a256ba4
4 files changed
+75
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -817,9 +817,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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user