# Row 120 (#1614): the broker limits who the assistant sends to and hears from Researcher, 2026-10-15. Brief: `docs/plans/2026-10-14_prd-1.2-build.md`, "The broker limits who the assistant sends to and hears from". Requirement: REQ-ASSIST-2 (`docs/prd/mosaic-stack.md:170`). Reviewer: Filbert. Gate owner: Sage. Gate text: "Filbert approves on #1614 naming the candidate manifest; Sage's full gate rerun at the landing target; both directions refused and logged, a role without the field unchanged". ## Round 2 (2026-10-15) Base: `66ea40580e3090674100292497a01f7c75c15ef3` (refactor tip, includes row 122 at 7b5b682ae). `row120.patch` is the candidate diff against it, 6 files; `git apply --check` passes at the canonical HEAD (66ea40580), and no commit from 66ea40580 to HEAD touches a candidate path. Candidate manifest digest: `ae7515b97bb92fdde63e9d2e6fd8473700d3e0c12d9e70fff67e06129fbac1a9`. The Round 1 text below is kept; where it says 85540a74e, 9 tests, 19 mutants or the Round 1 gate counts, this section supersedes it. The Round 1 patch and outputs are in commit 07a967aec. **Filbert F1 (comment 28539).** `#reviewMessage`, reached by `review.request` and `review.verdict`, stored a message to a role without meeting the assistant's lists, even after a human approved the gating decision. **Ruling (comment 28540), option (a):** widen the row to the review paths. Done by calling the existing `#checkMessaging` in each case before anything is recorded; no new code path, `#reviewMessage` itself is untouched, nothing else in `broker.mjs` widens. | Where | Check | Refusal | |---|---|---| | `review.request`, after the stale/unknown-commit block, before the message id | `#checkMessaging(s, a.to)` | `message-recipient-refused` when the sender's `sendTo` omits the reviewer; `message-sender-refused` when the reviewer's `receiveFrom` omits the sender | | `review.verdict`, after `verdict-exists`, before the message id | `#checkMessaging(s, r.from)` (the verdict is a message to the role that asked) | same two codes | A refusal throws inside the request transaction, so no `REVIEW-REQUEST` or `REVIEW-RESULT` message, no `review.requested` or `review.verdict` event and no authority is consumed. The catch writes `action.refused` with the code and the actor role. **Tests added to `messaging.test.mjs` (now 12).** 1. The assistant's `review.request` to a reviewer outside its `sendTo` is refused as `message-recipient-refused` and logged, even after a human resolves the `review.request` decision `yes`. Run twice: the decision is unconsumed, no message, no event, `action.allowed` count unchanged. 2. A `review.verdict` to the assistant from a role outside its `receiveFrom` is refused as `message-sender-refused` and logged, with no `REVIEW-RESULT`, no `review.verdict` event and no authority consumed. The request is made by a first broker on the same store with permissive definitions (a request that predates the lists); the reviewer releases the role; a second broker with the real definitions takes the verdict. 3. A coder-to-reviewer request and the verdict back are unchanged (roles without the field), no refusals. **Mutants added.** m16 removes the `review.request` check (caught by test 1). m17 removes the `review.verdict` check (caught by test 2). m1, m2, m3, m7, m8 and m15a also fail the new tests. Total now 21 mutants plus 3 controls; all 21 are caught, none survived. Names and per-mutant output, return code and patch are in `mutants/`; `mutants-run.txt` is the run log. Controls: messaging 12/12, role 14/14, assistant 8/8. **Gate on the rebased tree** (slot sage-0284, decision 136, `suites.sh`; outputs in `suites/`): | Run | Result | |---|---| | business | 88 / 88 | | bus | 171 / 171 | | harness | 84 / 84 | | cli | 195 / 195 | | webui | 112 / 112 | | tasks | 92 / 92 | | conversation | 182 / 182 | | seat | 132 / 132 | | `scripts/test-task.sh` | 98 passed, 0 failed | No flake from #258, #261, #262 or #263 occurred. (My first gate launch stopped at the busy check: it flagged my own shell, whose cwd was the bare scratch directory. Nothing ran; I reran from inside the worktree.) **Merge with row 122, checked by hand.** 122 added the `organization` constructor argument and a limits/limits.set block before the store transaction. This row's constructor loop, `#checkMessaging` and the three call sites sit beside those hunks with no overlap. 122's one-line change to `assistant.test.mjs` merged cleanly. The five touched single files passed on the rebased tree before the full gate. **Noted follow-up, not gated here (comment 28540).** The DECISION message that `decision.resolve` inserts to the raising role still doesn't go through `#checkMessaging`. Today the assistant holds no decision authority and its `crossRole` is `[]`, so it can't be the raiser or the target of one. If a role with a `receiveFrom` list ever gains decision authority, gate that insert too. **Files.** `row120.patch`, `candidate-manifest.sha256`, `build.md`, `suites.sh`, `suites/`, `mutants.sh`, `mutants-run.txt`, `mutants/`, `packet-manifest.sha256`. ## Round 1 Base: 85540a74e. `row120.patch` applies to it by `git apply --cached --check`, and to the canonical checkout's HEAD at packet time (9f1c14e57, `git apply --check` passes). No commit between 85540a74e and 9f1c14e57 touches a candidate path. Row 122 also edits `packages/bus/src/broker.mjs` (constructor `organization` argument, a limits block before `#store.transaction`). It lands first (sage-0270). This row's constructor check sits after the existing assignments inside the per-business loop, and its `message.send` edit stays inside that case. After 122 lands I rebase, rerun the full gate and report; expect at most a textual neighbourhood conflict in the constructor. ## What changes A role file may carry an optional `messaging` object: `sendTo` and `receiveFrom`, each a list of **role definition names** plus `human` for the user. The broker enforces whatever the role file says and hard-codes no role name. Only `roles/assistant.json` carries the field today: ```json "messaging": { "sendTo": ["human", "chief-of-staff"], "receiveFrom": ["human", "chief-of-staff"] } ``` | File | Change | |---|---| | `packages/business/src/role.mjs` | `V2_KEYS` gains `messaging` (optional; `V2_REQUIRED` keeps the presence check). New `checkMessaging`: object, only `sendTo` and `receiveFrom`, each a distinct list of ids, `tester` refused (never a role). The loaded role gains `messaging` (`null` when absent; v1 files stay `null` and reject the key). | | `packages/bus/src/broker.mjs` | Constructor refuses a malformed `definitions[*].messaging` with `invalid-messaging`. New `#checkMessaging(s, to)`, called in `message.send` after the `unknown-role` check. Sender side first (`message-recipient-refused`), then target side (`message-sender-refused`). | | `roles/assistant.json` | adds the `messaging` object above. | | `packages/business/tests/role.test.mjs` | v1 role has `messaging === null`; new test for the field's shape, the refusals, and `tester`. | | `packages/business/tests/assistant.test.mjs` | `APPROVED` gains the frozen lists; new test that the assistant is limited and no other shipped role is. | | `packages/bus/tests/messaging.test.mjs` (new) | 9 broker tests (below). | ### Why the broker reads `business.definitions` Broker "roles" are business instance names (`chief`, `assistant`); each has `roles[x].definition`, the role definition name. A role file can't know a business's instance names, so the lists name definitions and the broker maps an instance to its definition. The business record handed to the broker is a structured clone of the loaded business and already carries `definitions`, so `busBusiness`, `resolve.mjs`, the digests and the launcher are untouched. No re-pin and no digest change come from this row. A business record with no `definitions` is unlimited; test 8 pins that. Alternative rejected: have `busBusiness` copy the field onto `roles[x]`. It works, but `business.mjs` is outside the row's path list. ### Rules, as tested - Sender with a `sendTo` list: the recipient's definition must be listed, `human` for the user. The tester is not a definition, so a limited role can't send to `tester` (a reply to the tester is blocked the same way). - Recipient with a `receiveFrom` list: the sender's definition must be listed; `human` for the user. The tester is never listed, so the tester's send to the assistant is refused. This is the tester default in the packet. - The sender check runs before the target check, with separate codes. - The human and the tester are checked on the target side only. - A role with no `messaging` is unchanged on both sides. - A refusal throws inside the request transaction: no message is stored, no `action.allowed` is consumed, and the catch writes `action.refused {code}` with the actor role and no request content. ### Filbert carry-in "The broker must refuse an assistant send to any role other than the user and the chief of staff, with both directions tested as refused and logged, and a role without the field unchanged." Read as: enforce what `roles/assistant.json` lists, hard-code no name (Sage accepted this in sage-0270). Sender direction: the assistant to pm, coder, scribe, warden and the tester, each refused and logged. Target direction: pm, coder and the tester to the assistant, each refused and logged. Allowed: assistant to human and chief, and human and chief to the assistant. ## Tests (`messaging.test.mjs`, 9) Reads the shipped `roles/assistant.json`. Fixture instances: chief, assistant, pm, coder, scribe (send list only), warden (receive list only); `bus.tester: true`. 1. The assistant sends to the user and to the chief of staff only (allowed sends, both ways). 2. An assistant send to any other role, or to the tester, is refused and logged (nothing stored, nothing consumed). 3. Only the user and the chief of staff may send to the assistant; the tester is refused by default. 4. A sender-side list alone refuses a send to a role that has no list. 5. A target-side list alone refuses a send from a role that has no list. 6. The sender list is checked before the target list. 7. Roles without the field behave exactly as before. 8. A business with no role definitions at all is unlimited. 9. A malformed `messaging` field refuses at boot (`invalid-messaging`). ## Gate (decision 136, targeted; slot held per sage-0272) Worktree `/mnt/storage/scratch/researcher/q120` at 85540a74e plus `row120.patch`. Outputs are in `suites/`. | Run | Result | |---|---| | business | 80 / 80 | | bus | 165 / 165 | | harness | 84 / 84 | | cli | 191 / 191 | | webui | 108 / 108 | | tasks | 92 / 92 | | conversation | 182 / 182 | | seat | 132 / 132 | | `scripts/test-task.sh` | 98 passed, 0 failed | No flake from #258, #261, #262 or #263 recurred. ## Mutants (`mutants.sh`, `mutants-run.txt`, `mutants/`) Three controls pass (messaging 9/9, role 14/14, assistant 8/8). 19 mutants, each one exact-once edit, each run against the named test files. All 19 are caught; none survived. Per-mutant output, return code and patch are in `mutants/`. | Mutant | Edit | Caught by | |---|---|---| | m1 | drop the sender-side check | messaging (3 tests) | | m2 | drop the target-side check | messaging (2 tests) | | m3 | drop the refusal log | messaging (5 tests) | | m4 | sender reads `receiveFrom` | messaging (2 tests) | | m5 | target reads `sendTo` | messaging (1 test) | | m6 | tester exempt from the target list | messaging (2 tests) | | m7 | role with no field refused on the sender side | messaging (4 tests) | | m8 | role with no field refused on the target side | messaging (5 tests) | | m9 | target matched by instance name | messaging (1 test) | | m10 | constructor shape unchecked | messaging (boot test) | | m11 | validator accepts `tester` | role test | | m12a, m12b | loader drops the field | role test; assistant test | | m13a, m13b | assistant `sendTo` widened with pm | messaging; assistant | | m14a, m14b | assistant `receiveFrom` widened with coder | messaging; assistant | | m15a, m15b | assistant file unlimited | messaging; assistant | ## Assumptions - Entries are role definition names, not instance names. An org that renames the instance `chief` still limits the assistant to the instance whose definition is `chief-of-staff`. The caveat: two instances of one definition are treated alike. - `tester` can't be listed in a role file; it is refused by default wherever a list exists. - Limiting any other shipped role is a policy change of its own. The chief of staff and PM stay open, and `assistant.test.mjs` pins that. ## Out of scope and follow-ups - "In the thread" (the assistant sends in the user's thread) lands in row 121. - A DECISION message that `decision.resolve` posts to the raising role doesn't go through `message.send`, so this row doesn't gate it. The assistant holds no decision authority, so it can't be the target of one today. If a role ever gains a `receiveFrom` list and decision authority, revisit. - Row 122's limits block and this row's checks are independent (limits count, messaging filters). ## Files in this packet `row120.patch`, `candidate-manifest.sha256` (the 6 candidate files), `build.md`, `suites.sh`, `suites/`, `mutants.sh`, `mutants-run.txt`, `mutants/`, `packet-manifest.sha256` (digests of the packet files).