row 206 r2 (#1704): rebased on 275 r3 957a9bfe0; share verbs need collaborator, viewer reads a share (duckworth-0169 C1, C2)

This commit is contained in:
jason.woltje committed 2026-10-22 12:30:42 -05:00
1 parent 01ed9b839a
commit d4fbce5014
3 files changed
+47 -18

No files matched your search

+24 -15
View File
@@ -6,16 +6,25 @@ sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789.
## Base and chain
- Started on aca16404c (sage-2789) on branch launchpad/row206.
- 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 (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).
- Started on aca16404c (sage-2789) on branch launchpad/row206; 631a256ba pushed there (sage-2854).
- Round 2: rebased onto Morgana's 275 r3, 957a9bfe0 (morgana-0143, which carries duckworth-0166
B1), on the new branch launchpad/row206-r275. No force push of the old branch.
- After 253 lands, rebase onto its tip under a new branch name.
- 275 gives `VIEWER_VERBS`, `userLevel(verb)` and verb-aware `#granted`/`#grantedThread`, and
`#userGrants` keeps a live grant of any level, so a viewer binds. On the rebase:
- USER_VERBS keeps 275's block and gains the three share verbs. They are not in VIEWER_VERBS,
so `userLevel` asks collaborator and a viewer is refused each `access-refused` (design 4.3).
- `#threadRead`'s owner path passes `thread.messages` to `#grantedThread`, so a viewer owner
reads its own thread at the viewer level (duckworth-0169 C1). Share and unshare pass `verb`;
shares passes `thread.shares`.
- Two conflicts, both in broker.mjs: the USER_VERBS block and the `thread.messages` line (275's
`#grantedThread(s, 'thread.messages', #thread(...))` gives way to `#threadRead`, which now
holds that check). user-principal.test.mjs merged clean.
- C2 sweep on 957a9bfe0: 275 touches agents/morgana/work/queue-275/BUILD.md, broker.mjs,
user-principal.test.mjs and viewer.test.mjs. Overlap with 206 is broker.mjs and
user-principal.test.mjs, both handled above. viewer.test.mjs has no schema-version or hash
pins, and its "every verb off the user list" test reads USER_VERBS, so the share verbs drop
out of it on their own.
- 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).
@@ -70,17 +79,17 @@ sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789.
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.
7. `thread.shares` for a user goes through `#grantedThread`, so it needs the collaborator grant
to still cover the thread, like share and unshare. Design 4.3 says "own threads" with no grant
note; at this base every user is a collaborator, so the two agree until 275.
7. `thread.shares` for a user goes through `#grantedThread` at the collaborator level, like share
and unshare, so a viewer is refused it. Design 4.3 lists shares as "refused" for viewers.
## Tests
New: tests/thread-shares.test.mjs, 15 tests: the read flow and the write refusals, `*`, rule 2,
New: tests/thread-shares.test.mjs, 16 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 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.
share (Q2), the viewer column (C1: a viewer reads a share, its share verbs refuse,
and a viewer target binds and reads), 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).
+2 -1
View File
@@ -309,7 +309,8 @@ access document the broker reads then, so a removed, expired or unreadable grant
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
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`). Tests: `tests/thread-shares.test.mjs`.
the share verbs (`tester-refused`), and a viewer is refused them `access-refused`; a viewer
reads a thread shared with it. Tests: `tests/thread-shares.test.mjs`.
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
+21 -2
View File
@@ -202,21 +202,40 @@ test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a
});
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
const { access, call, code, base, A, p1, rows } = setup(t);
const { b, caps, access, call, code, base, A, p1, rows, read } = setup(t);
// carol's grant covers p2 only; dave has none; erin's is expired.
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
for (const to of ['user:carol', 'user:dave', 'user:erin'])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
assert.equal(rows(), 0);
// A viewer grant is enough to be shared with, though at this base it can't bind a session.
// A viewer grant is enough to be shared with, and the viewer reads the share (row 275).
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
caps.vic = b.bindUser({ business: 'demo', user: 'vic' });
assert.deepEqual(read('vic', A), ['alice: hello']);
// An unshare checks no coverage: an uncovered target's share can still be ended.
access.doc.grants = base();
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
assert.equal(rows(), 2);
});
test('a viewer reads a thread shared with it, and share, unshare and shares refuse a viewer access-refused (design 4.3)', (t) => {
const { access, call, code, base, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
// bob drops to viewer and still reads the share.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('bob', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// alice drops to viewer too: she reads her own thread, and each share verb on it refuses.
access.doc.grants = base().map((g) => ({ ...g, level: 'viewer' }));
assert.deepEqual(read('alice', A), ['alice: hello']);
for (const [verb, args] of [['thread.share', { to: '*' }], ['thread.unshare', { to: 'user:bob' }], ['thread.shares', {}]])
assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb);
assert.equal(rows(), 1);
// Rule 3 asks any level, so alice's viewer grant keeps the thread readable to bob.
assert.deepEqual(read('bob', A), ['alice: hello']);
});
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
const { call, code, A, H, rows } = setup(t);
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])