Candidate diff and manifest against 66ea40580; messaging suite now 12 tests; gate green; 21/21 mutants caught (m16, m17 added). No push. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
13 KiB
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).
- The assistant's
review.requestto a reviewer outside itssendTois refused asmessage-recipient-refusedand logged, even after a human resolves thereview.requestdecisionyes. Run twice: the decision is unconsumed, no message, no event,action.allowedcount unchanged. - A
review.verdictto the assistant from a role outside itsreceiveFromis refused asmessage-sender-refusedand logged, with noREVIEW-RESULT, noreview.verdictevent 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. - 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:
"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
sendTolist: the recipient's definition must be listed,humanfor the user. The tester is not a definition, so a limited role can't send totester(a reply to the tester is blocked the same way). - Recipient with a
receiveFromlist: the sender's definition must be listed;humanfor 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
messagingis unchanged on both sides. - A refusal throws inside the request transaction: no message is stored, no
action.allowedis consumed, and the catch writesaction.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.
- The assistant sends to the user and to the chief of staff only (allowed sends, both ways).
- An assistant send to any other role, or to the tester, is refused and logged (nothing stored, nothing consumed).
- Only the user and the chief of staff may send to the assistant; the tester is refused by default.
- A sender-side list alone refuses a send to a role that has no list.
- A target-side list alone refuses a send from a role that has no list.
- The sender list is checked before the target list.
- Roles without the field behave exactly as before.
- A business with no role definitions at all is unlimited.
- A malformed
messagingfield 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
chiefstill limits the assistant to the instance whose definition ischief-of-staff. The caveat: two instances of one definition are treated alike. testercan'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.mjspins 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.resolveposts to the raising role doesn't go throughmessage.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 areceiveFromlist 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).