# Row 205 (193b1, #1703): viewer verbs, board refusals and the message filter 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 2: Sage's rulings (sage-2288) are in section 7. Sections 1-6 are d1 as sent (morgana-0063). ## 1. The dependency gap The brief chains 193b1 on FED R3, FED R4 and row 196's verifier. Only row 196 exists (7247d80c7). On 02da6a2ae: - R3 and R4 aren't filed. The FED brief (`docs/plans/2026-10-20_federation-r1-r2-r5.md`:12-13) files R3 "after R1 and row 196" and R4 after R3, R5 and 193a. No `*/fed-r3` branch is on origin. - Row 196's `verifyPrincipal` returns the `user` kind, and server.mjs accepts the `user` envelope, but runtime.mjs wires only `authenticateHuman` and `authenticateTester`. The broker has no `bindUser`, no `s.user`, no per-request grant read and no collaborator verb list. FED 3.6 and row R3 own all of these. - So nothing makes a user session today, and no thread is owned by `user:` except through a direct store write. 193b1 has two halves that depend on R3 in different ways. | Item | Needs a user session (R3) | Testable on 02da6a2ae | |---|---|---| | Viewer verb list (4.3) | yes: it extends R3's collaborator list | no | | Per-grant level checks via `covers(..., {level})` | yes: R3's grant read | no | | Board reads refused to users (5.5) | yes | no | | `trail` and `replies` filter (5.7) | no: it filters owner, reader and tester sessions | yes, with seeded `user:` threads | | `#readable` owner-only | no | yes | | Governance pin test (owner side) | no: the owner reads decisions, inbox, agents, tasks and `project.file` | yes, with a seeded user-thread run | | Mutants 6 (viewer `thread.send`), 10 (user allowed `trail`) | yes | no | | Mutants 11, 12 (trail, replies filter removed) | no | yes | | Mutants 1, 3 (`#readable` rules 1 and 3 dropped) | see 4 | see 4 | ## 2. Recommendation: split 205 into 205a now and 205b after R3 - **205a, the message filter.** `trail` and `replies` hide messages in other users' threads from the owner, reader sessions and the tester, with `#readable` owner-only. Plus the owner-side governance pin test and mutants 11 and 12. No R3 dependency, no schema change. - **205b, viewer verbs and board refusals.** Built on R3's candidate, as Q177 chains a row on its parent's candidate. The viewer list, the level checks, users refused on board reads, the user-session half of the pin test, mutants 6 and 10. Why split. The filter is the item the boundary orders first: "No user thread goes live before 193b1's message filter has landed" (mos-1021 B2, FED R8). Landing it before R3 means R3 and every later row can't create a user thread that the owner can read on `trail`. Waiting for R3 holds the filter behind a row that isn't filed. The other option, building all of 193b1 against R3's named interface as 193a did with R1, doesn't work here: 193a only called R1's exported `covers`, while 193b1 would have to write `bindUser`, the grant read and the collaborator list itself, which is R3's scope and owner. Tradeoff: two rows and two reviews in place of one, and 205b rebases onto R3. The split is a brief change, so it's Sage's to rule. ## 3. 205a design ### 3.1 The hidden-message check One broker method, `#hiddenMessage(s, id)`: ``` SELECT t.owner FROM message_threads mt JOIN threads t ON t.id = mt.thread WHERE mt.message = ? AND t.business = ? ``` A message is hidden when that owner starts with `user:`, isn't `#owner(s)`, and `#readable(s, t)` is false. A message with no thread row, or in a thread owned by the business human or `tester`, is never hidden, so row 177's behaviour is unchanged. A reader session has no `#owner`, so every user thread's messages are hidden from it. `#readable(s, t)` in 205a returns false. Before 193b2's schema 3j there is no `thread_shares` table, so rule 1 (a share in force) can't hold, and the function is false for anyone but the thread's owner (5.7: "b1 hides every message in another user's thread"). It is written as its own method so 193b2 adds the share rule and rules 2-4 in one place. ### 3.2 `trail` - If `subject` is a hidden message id, return `[]`. Design 5.7 says `message-not-found`, "as an unknown id does", but `trail` on an unknown id returns `[]` today (it has no not-found check). A different code for a hidden id would confirm the id exists, which is what 5.7 wants to prevent, so I follow its reason over its code. Question 3. - After `#trail` collects its rows, drop: `messages` rows that are hidden, `deliveries` rows whose `message` is hidden, and `events` rows whose `subject` is a hidden message id. This covers each way `#trail` reaches a message: by id, by `decision`, and through `task.created` → `human.input` → `body.message`. - Decision rows, decision events, task snapshots, run events and role claims stay. 5.7 puts them on the governance side. ### 3.3 `replies` A hidden `id` fails `message-not-found`, and hidden messages are dropped from the result. The `LIMIT` applies before the filter today; the filter moves into the SQL (`AND NOT EXISTS` a thread owned by another `user:`) so a page of hidden replies doesn't return short. For 205a that SQL form is exact because `#readable` is false; 193b2 adds the share exception to the same clause. ### 3.4 Tests (packages/bus/tests/message-filter.test.mjs, new) Fixtures seed a project, a thread owned by `user:alice` with messages in `message_threads`, a delivery and an event on one of them, and a decision raised by that thread's run that cites the message. Sessions: the business human, a reader session and the tester. - `trail` on a hidden message id: `[]` for all three sessions, the same as an unknown id. - `trail` on the decision: the decision row and its events show; the user's message, its delivery and its events don't. - `trail` on a human-owned thread's message: unchanged (row 177 control). - `replies` on a hidden id: `message-not-found`. `replies` on a visible id with hidden and visible replies: only the visible ones, and a full page when hidden replies come first. - Governance pin, owner side: for the user-thread run, `decisions {list: 'recent'}`, `inbox`, `agents`, `tasks` and `project.file` each show its item, and `trail` and `replies` hide the user's message body. Each item in the user sentence is one named assertion. ### 3.5 Mutants | Mutant | Edit | Named test | |---|---|---| | 11 | the `trail` filter removed | trail on the decision hides the user's message, delivery and events | | 12 | the `replies` filter removed | replies hides other users' thread messages | | 11b | the hidden-subject early return removed | trail on a hidden message id returns [] | | 1 | `#readable` returns true | governance pin: trail and replies hide the user body | ## 4. Mutants 1 and 3 Design section 8 lists mutants 1 (rule 1 dropped) and 3 (rule 3 dropped) for b1. With no shares table, rule 1 is always false, so `#readable` is false whatever rules 2-4 say. Dropping rule 3 changes nothing a test can see until 193b2 adds shares, and rules 2 and 3 need R3's grant read. So: - Mutant 1 in 205a is "`#readable` returns true", killed by the pin test above. - Mutant 3 moves to 193b2, where rule 1 can hold and rule 3 decides. This is a second brief change for Sage to rule on. ## 5. Gate (Sage's slot, on CLEAR only) The brief's gate: Duckworth approves the manifest; the bus suite and every `scripts/test-*.sh` green on Sage's gate; the mutants killed. The new test file whole, the touched existing files whole, then the bus suite, then each mutant on its named test with a restore check. umask 022, TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`. No test touches the live data root, config, units, slot files, any live access.json or `~/.mosaic`. ## 6. Questions for Sage 1. Split 205 into 205a (filter, now) and 205b (viewer verbs and board refusals, after R3's candidate)? 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.