Files
jason.woltjeandClaude Sonnet 5.5 f758a8e927 researcher: row 120 round 2 packet (review paths gated, rebased on 66ea40580)
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]>
2026-10-14 20:58:25 -05:00

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).

  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:

"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).