From 62de3376051ff8400e6a21160ddf87c0394a41fc Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 21 Oct 2026 20:07:16 -0500 Subject: [PATCH] morgana: FED R3 r2 (dewey-0777): role key user refused at the broker load check and by the instance writer (N1, one test each), #threadFor user clause listed as an equivalent mutant, 193b1a filter mutant not applicable, M10/M11 and the M3x two-walls run in the kit; unrun --- agents/morgana/work/fed-r3/BUILD.md | 47 +++++++++++++------ packages/bus/src/broker.mjs | 4 +- packages/bus/tests/user-principal.test.mjs | 21 +++++++++ packages/business/src/instance-settings.mjs | 7 +-- .../business/tests/instance-settings.test.mjs | 2 + 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/agents/morgana/work/fed-r3/BUILD.md b/agents/morgana/work/fed-r3/BUILD.md index 00db2158b..22b503843 100644 --- a/agents/morgana/work/fed-r3/BUILD.md +++ b/agents/morgana/work/fed-r3/BUILD.md @@ -33,6 +33,13 @@ branch name. No schema change. Nothing has run yet: every test run waits for Sag - `#waiting` returns null for `to: 'user'`, so no notice is posted. - `user` is reserved: `name.set` refuses it (`name-is-instance`) and message.send never resolves it as a name. - `#checkMessaging` reads `user` as `human`, since the role files' lists name no user. +- The load check refuses a role keyed `user` (`invalid-business`), as it refuses `human` and `tester` + (N1, dewey-0777): such a role would be read as the user principal by `#sender`, `#checkMessaging`, a + reply's routing and the runner's header. + +`packages/business/src/instance-settings.mjs` (r2, dewey-0777; outside the brief's manifest, for Sage to rule +on): the instance writer refuses an instance named `user` (`invalid-request`), as it refuses `human` and +`tester`, so it can't write a business the bus would then refuse to load. `packages/bus/src/client.mjs`: a `user` option, sent as the envelope's `user`. @@ -67,9 +74,13 @@ nothing." The human, tester and assistant senders get no such line. | revoked and expired grant refused on next request | a removed or expired grant refuses the next request access-revoked ... (also downgraded to viewer, file unreadable) | | refusals recorded | a user refusal is recorded as action.refused by user: | | a user named like the human refused | a user named like the business human, the tester or a principal word is refused ... | +| N1: role key `user` refused (r2) | a business with a role keyed user refuses invalid-business | `packages/harness/tests/runner.test.mjs`: the user header, exact string, and its absence for the other senders. +`packages/business/tests/instance-settings.test.mjs` (r2): row 165's validation test also refuses instance +`user`. + Row 196's `principal.test.mjs` already covers the user kind of `verifyPrincipal` (agent parent, non-Console parent, kind confusion are its mutants). No runtime test of `authenticateUser` or user-cli yet: the stub `authenticateUser` path needs a real Console parent, which is R6's harness. @@ -84,7 +95,7 @@ so the cap-taking launcher and git ops (`authorize`, `checkPush`, `recordPush`, | Branch | For a user | |---|---| | `#agent`, `#human` | `#human` refuses a user (`human-required`); every listed verb skips it with `!s.user`. `#agent` is reached only from `names` for a non-human, non-tester, non-user session. | -| `#threadFor` | refuses a user (item 3). Not reachable: message.send is off the list. | +| `#threadFor` | refuses a user (item 3). Not reachable: message.send and the review verbs are off the list. An equivalent mutant; the line stays. | | `#testerOwns`, `#testerThread` | `s.tester` false and `s.user` sessions have no thread: not taken. | | `#owner`, `#sender`, `#event` | changed: `user:`, `user`, actor columns. | | `#userThread` | new; false for a user session itself. | @@ -96,7 +107,7 @@ so the cap-taking launcher and git ops (`authorize`, `checkPush`, `recordPush`, | `message.send`, `message.receive`, `message.read`, `messages`, `replies`, `trail`, `decisions`, `credentials`, `state`, `usage`, `decision.withdraw`, `assistant.stop`, `name.set`, `project.create`, `role.claim` | off the list: not reachable. | | `project.list`, `thread.create`, `thread.list`, `thread.open`, `thread.send`, `thread.messages`, `names` | changed as above. | -## Mutants (each must fail its named test in user-principal.test.mjs) +## Mutants (each must fail its named test, in user-principal.test.mjs unless named) | Mutant | Edit | Named test | |---|---|---| @@ -106,25 +117,30 @@ so the cap-taking launcher and git ops (`authorize`, `checkPush`, `recordPush`, | M2c | names filter dropped | R3 leak: names shows granted instances only | | M2d | `#grantedThread` check dropped | R3 leak: thread.list shows ... never others | | M2e | thread.create grant check dropped | R3 leak: thread.create needs the project and the thread instance granted ... | -| M3x | `message.send` added to `USER_VERBS` (stand-in, see below) | every verb off the collaborator list refuses ... | +| M3x | `message.send` added to `USER_VERBS` (list mutant) | every verb off the collaborator list refuses ... | | M4 | the reply-to check dropped | a reply to: user without a matching in_reply_to is refused user-reply-required | | M5 | the business human dropped from bindUser's reserved list | a user named like the business human ... | -| M6x | `trail` and `replies` added to `USER_VERBS` (stand-in, see below) | every verb off the collaborator list refuses ... | +| M6x | `trail` and `replies` added to `USER_VERBS` (second list mutant) | every verb off the collaborator list refuses ... | | M7 | user-thread-human check dropped | a user thread run cannot message the owner ..., B9 | | M8 | `to === 'user'` dropped from `#waiting` | the run reply to a user message reaches ... with no waiting and no notice | | M9 | thread.send writes `human.input` for a user | thread.send is from user: and writes no human.input | +| M10 | the load check accepts a role keyed `user` | a business with a role keyed user refuses invalid-business | +| M11 | instance-settings accepts instance `user` | instance-settings.test.mjs: row 165 refuses each validation failure ... | -Two mutants named in the design and the brief can't be killed as written, and need a ruling from Sage and Dewey: -1. "#threadFor's user refusal removed": no user request reaches `#threadFor`. Its only callers are - message.send and `#reviewMessage` (review.request and review.verdict), all off the list, and a user would fail message.send's run branch before - it. M3x, opening message.send to a user, is the stand-in; the line stays as defence in depth. -2. The brief's "drops the 193b1a filter for user sessions": a user can't call trail or replies, so a user - sees no filtered read for the mutant to change. M6x, opening trail and replies to a user, is the stand-in. +Rulings from Dewey's early look (dewey-0777) on the two mutants the design and brief name: +1. "#threadFor's user refusal removed" is an equivalent mutant. No user request reaches `#threadFor`: its + only callers are message.send and `#reviewMessage` (review.request and review.verdict), all off the list. + The line stays as defence in depth. M3x stays as a list mutant. The kit also runs the reply-chain test + under M3x as the two-walls evidence: with the access-refused assertion turned into a log line, it records + the code alice's send gets (expected `human-required`, from `#human`, since a user has no run) and still + asserts bob's thread holds one message. +2. The brief's "drops the 193b1a filter for user sessions" is not applicable: a user can't call trail or + replies, so no filtered read reaches a user. M6x is a second list mutant, not a stand-in. ## Notes -- N1: a business role keyed `user` would be shadowed by the user principal in message.send. The business - loader doesn't refuse that key. Follow-up for R6, or a one-line loader check if Dewey wants it here. +- N1: fixed in r2 (dewey-0777). The broker's load check and the instance writer refuse `user`, one test each + (M10, M11). The instance-settings change is outside the brief's manifest; Sage rules on keeping it in R3. - N2: server.mjs answers a `user` envelope with no `authenticateUser` as `human-required`, the existing default for a non-tester kind. The runtime always passes `authenticateUser`, so only a bare test server sees it. @@ -133,12 +149,13 @@ Two mutants named in the design and the brief can't be killed as written, and ne The kit is /mnt/storage/scratch/morgana-r3 (`slot-r3.sh`, `mutate.py`, `busy.sh`), run as `bash busy.sh && CAND= bash slot-r3.sh` on Sage's CLEAR. It runs these files whole: user-principal, principal, human, tester, tester-threads, tester-writes, threads, names, messaging, messages, decisions, -decision-route, broker, transport, peer-transport, process, message-filter and harness runner. Then the bus -and harness suites, then the thirteen mutants, each on its named test and restored after each. It uses +decision-route, broker, transport, peer-transport, process, message-filter, harness runner and business +instance-settings. Then the bus, harness and business suites, then the fifteen mutants, each on its named +test and restored after each, then the M3x two-walls run. It uses umask 022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gr3XXXX`, refuses unless HEAD is the candidate and the tree is clean, and stops after two unexpected failures (mos-0823). Sage's gate adds every `scripts/test-*.sh`. -Static check: `node --check` on every changed source and test file, run from `/`. All parse. All thirteen +Static check: `node --check` on every changed source and test file, run from `/`. All parse. All fifteen mutant anchors match exactly once. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 3ef7f96f3..b6bcd1248 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -356,7 +356,9 @@ export class Broker { if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters)) fail('invalid-business'); for (const role of Object.keys(b.roles)) { - if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business'); + // FED R3 (dewey-0777): a role keyed `user` would be read as the user principal by #sender, + // #checkMessaging, a reply's routing and the runner's header. + if (!id(role) || role === 'human' || role === 'tester' || role === 'user') fail('invalid-business'); const a = b.roles[role].authority; if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole)) fail('invalid-authority'); diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs index 07defc294..c001d2a39 100644 --- a/packages/bus/tests/user-principal.test.mjs +++ b/packages/bus/tests/user-principal.test.mjs @@ -318,6 +318,27 @@ test('R3: a user refusal is recorded as action.refused by user:', (t) => { assert.deepEqual(rows.map((r) => [r.actor_role, JSON.parse(r.body).code]), [['user', 'access-refused'], ['user', 'access-revoked']]); }); +test('R3: a business with a role keyed user refuses invalid-business', (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-boot-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const businesses = { + demo: { + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: { + chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, + user: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, + }, + }, + }; + assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' }); + delete businesses.demo.roles.user; + assert.ok(new Broker({ store, businesses }), 'the same business without it loads'); +}); + test('R3: name.set refuses the name user', (t) => { const { code } = setup(t); assert.equal(code('human', 'name.set', { instance: 'assistant', name: 'user' }), 'name-is-instance'); diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index 50cc36edc..fd8c4192e 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -85,9 +85,10 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null }) } const { instance, op } = args; if (!id(instance) || (op !== "add" && op !== "change")) throw new OrganizationError("invalid-request"); - // The broker refuses a business whose roles name human or tester (broker.mjs, the load check), - // so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1). - if (instance === "human" || instance === "tester") { + // The broker refuses a business whose roles name human, tester or user (broker.mjs, the load + // check), so an instance under any of them would stop the bus at its next start (row 165 r2, + // Dewey F1; FED R3, dewey-0777). + if (instance === "human" || instance === "tester" || instance === "user") { throw new OrganizationError("invalid-request", `${instance} names the bus's own sessions, never a role instance`); } if (Object.hasOwn(args, "definition") && !id(args.definition)) throw new OrganizationError("invalid-request"); diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs index a6c8fe665..f85c290b6 100644 --- a/packages/business/tests/instance-settings.test.mjs +++ b/packages/business/tests/instance-settings.test.mjs @@ -140,6 +140,8 @@ test("row 165 refuses each validation failure and leaves the file's bytes alone" // Dewey F1: the broker refuses a business whose roles name human or tester, so the writer does too. refuses(f, { instance: "human", op: "add", definition: "merge-gate" }, "invalid-request"); refuses(f, { instance: "tester", op: "add", definition: "merge-gate" }, "invalid-request"); + // FED R3 (dewey-0777): and user, the granted user's principal. + refuses(f, { instance: "user", op: "add", definition: "merge-gate" }, "invalid-request"); const one = fixture(doc => { doc.launch.instances = ["coder"]; }); refuses(one, { instance: "coder", op: "change", launch: false }, "BusinessError"); const none = fixture(doc => { delete doc.launch; });