From 7de9cfccc3dc158d5905e36e45904afd493563df Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 16:30:59 -0600 Subject: [PATCH 1/2] feat(organization): enforce arbiter domain resolve rights (#1873) --- packages/bus/src/broker.mjs | 24 +++++- packages/bus/tests/chief-of-staff.test.mjs | 4 +- packages/bus/tests/citation-helpers.mjs | 4 +- packages/bus/tests/citation-runtime.test.mjs | 4 +- packages/bus/tests/console-decide.test.mjs | 6 +- packages/bus/tests/credential-verbs.test.mjs | 12 +-- .../bus/tests/daily-quota-runtime.test.mjs | 8 +- packages/bus/tests/decision-close.test.mjs | 4 +- packages/bus/tests/decision-route.test.mjs | 4 +- packages/bus/tests/decisions.test.mjs | 4 +- packages/bus/tests/grant-enroll.test.mjs | 2 +- .../bus/tests/grant-scope-runtime.test.mjs | 4 +- packages/bus/tests/hard-waits.test.mjs | 42 +++++----- packages/bus/tests/host-fixtures.mjs | 4 +- packages/bus/tests/host-run.test.mjs | 8 +- packages/bus/tests/instances-verb.test.mjs | 2 +- packages/bus/tests/land.test.mjs | 10 +-- packages/bus/tests/launcher-released.test.mjs | 2 +- .../bus/tests/library-credential.test.mjs | 6 +- packages/bus/tests/library-lookups.test.mjs | 8 +- packages/bus/tests/library-sync.test.mjs | 4 +- packages/bus/tests/library-write.test.mjs | 4 +- packages/bus/tests/library.test.mjs | 8 +- packages/bus/tests/lifecycle-verbs.test.mjs | 4 +- packages/bus/tests/limit-stop.test.mjs | 4 +- packages/bus/tests/limits-runtime.test.mjs | 4 +- packages/bus/tests/limits.test.mjs | 6 +- packages/bus/tests/message-citation.test.mjs | 2 +- packages/bus/tests/model-catalogue.test.mjs | 4 +- .../tests/model-hire-ownership-fixture.mjs | 2 +- packages/bus/tests/model-keys.test.mjs | 2 +- packages/bus/tests/model-orphan-hire.test.mjs | 2 +- packages/bus/tests/model-pending-fixture.mjs | 2 +- packages/bus/tests/model-proxy.test.mjs | 2 +- .../bus/tests/model-revocation-fixture.mjs | 2 +- packages/bus/tests/names.test.mjs | 2 +- packages/bus/tests/p5-key-context.test.mjs | 2 +- packages/bus/tests/p5-runtime.test.mjs | 4 +- packages/bus/tests/p5-telemetry-fixture.mjs | 2 +- packages/bus/tests/plugins-verb.test.mjs | 6 +- .../bus/tests/processing-runtime.test.mjs | 4 +- packages/bus/tests/processing.test.mjs | 2 +- packages/bus/tests/project-archive.test.mjs | 2 +- packages/bus/tests/project-await.test.mjs | 2 +- packages/bus/tests/project-runtime.test.mjs | 2 +- .../provider-materialization-guard.test.mjs | 2 +- .../bus/tests/provider-repoint-guard.test.mjs | 2 +- packages/bus/tests/proxy-page.test.mjs | 4 +- packages/bus/tests/publish.test.mjs | 4 +- packages/bus/tests/reload.test.mjs | 4 +- packages/bus/tests/resolve-rights.test.mjs | 79 +++++++++++++++++++ packages/bus/tests/room-check.test.mjs | 2 +- packages/bus/tests/routes-verb.test.mjs | 8 +- packages/bus/tests/single-use.test.mjs | 4 +- packages/bus/tests/spec-fixtures.mjs | 7 +- packages/bus/tests/spec-review.test.mjs | 2 +- packages/bus/tests/staff-request.test.mjs | 6 +- packages/bus/tests/system-type.test.mjs | 2 +- packages/bus/tests/tester-writes.test.mjs | 8 +- packages/bus/tests/usage-broker.test.mjs | 2 +- packages/bus/tests/usage-runtime.test.mjs | 2 +- packages/bus/tests/workflow-roles.test.mjs | 4 +- packages/cli/tests/access.test.mjs | 2 +- packages/cli/tests/instances.test.mjs | 2 +- packages/cli/tests/launcher.test.mjs | 2 +- packages/cli/tests/publish-socket.test.mjs | 4 +- packages/cli/tests/tester-reads.test.mjs | 4 +- packages/cli/tests/tester-threads.test.mjs | 4 +- packages/harness/tests/bundle.test.mjs | 10 +-- packages/harness/tests/runner.test.mjs | 14 ++-- packages/harness/tests/tools.test.mjs | 3 +- .../organization/src/organization-config.mjs | 4 + packages/organization/src/role.mjs | 5 +- packages/organization/src/vocabulary.mjs | 2 + .../tests/chief-of-staff.test.mjs | 6 +- .../organization/tests/composition.test.mjs | 23 ++++-- packages/organization/tests/csuite.test.mjs | 2 +- .../organization/tests/hard-waits.test.mjs | 2 +- .../tests/resolve-rights.test.mjs | 52 ++++++++++++ packages/organization/tests/role.test.mjs | 6 +- .../tests/archive-unknown-browser.test.mjs | 2 +- packages/webui/tests/proxy-canary.test.mjs | 4 +- packages/webui/tests/routes-browser.test.mjs | 8 +- packages/webui/tests/routes.test.mjs | 2 +- packages/webui/tests/workspace-tasks.test.mjs | 2 +- roles/chief-of-staff.json | 2 +- roles/pm.json | 2 +- roles/pm.md | 2 + 88 files changed, 364 insertions(+), 191 deletions(-) create mode 100644 packages/bus/tests/resolve-rights.test.mjs create mode 100644 packages/organization/tests/resolve-rights.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index e86f943ca..1dc2767af 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -50,6 +50,7 @@ export const ACTIONS = Object.freeze([ 'policy.change', 'prd.approve', 'decision.resolve.technical', + 'decision.resolve.delivery', // Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below. 'library.write', ]); @@ -78,6 +79,7 @@ const ROUTED = new Set([ 'policy.change', 'prd.approve', 'decision.resolve.technical', + 'decision.resolve.delivery', // PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks. 'role.hire', 'role.fire', @@ -88,6 +90,8 @@ const ROUTED = new Set([ export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action))); const GATED = new Set(HARD_WAITS); const CLOSED = "('resolved','withdrawn','expired')"; +// RR (#1873): these actions never take their domain from the raising agent. +const DECISION_DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' }); // How many closed decisions `decisions` with list 'recent' returns, newest close first. export const RECENT = 50; // How many replies `replies` returns, oldest first. @@ -5061,6 +5065,20 @@ export class Broker { ); return evidence ? JSON.parse(evidence.body) : null; } + #checkResolver(s, d) { + const b = this.#business(s.business), role = b.roles[s.role]; + const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain; + // Pre-RR evidence has no domain. Assignments may have changed since raise, + // so require both possible rights rather than infer one from today's file. + const domains = domain === undefined ? ['technical', 'delivery'] : [domain]; + for (const kind of domains) { + if (!['technical', 'delivery'].includes(kind)) fail('not-resolver'); + const right = `decision.resolve.${kind}`; + if (!role?.authority?.withinRole.includes(right)) { + throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`); + } + } + } // Whether an executor already used the decision. decision.raise records context, not permission // consumed by an executor. #spent(business, decision) { @@ -7064,8 +7082,8 @@ export class Broker { if (!choices.includes(a.recommendation)) fail('invalid-recommendation'); const approvalChoice = a.approvalChoice ?? 'yes'; if (!choices.includes(approvalChoice)) fail('invalid-approval-choice'); - const domain = a.domain ?? 'delivery'; - if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain'); + if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain'); + const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery'; // PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit // goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any // hard wait go to the human. readSpec already refused a stale or unconfirmed citation. @@ -7147,6 +7165,7 @@ export class Broker { decision: did, action: a.action, operation: 'decision.raise', + domain, task_ref: a.task_ref ?? null, target: a.target ?? null, approvalChoice, @@ -7213,6 +7232,7 @@ export class Broker { else { this.#agent(s); if (s.role !== d.route_to) fail('not-resolver'); + if (verb === 'decision.resolve') this.#checkResolver(s, d); } const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn'; if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice)) diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs index f9bb79c44..fc8ce683c 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -282,7 +282,7 @@ test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set const outside = [ { launch: { by: 'coder' } }, { human: 'mallory' }, - { arbiters: { technical: 'coder', delivery: 'coder' } }, + { arbiters: { technical: 'chief', delivery: 'chief' } }, { roles: {} }, { actor: 'jason' }, { 'launch.by': 'coder' }, @@ -1026,7 +1026,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-')); const rolesDir = rolesCopy(root); // A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED. - writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n'); + writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n'); writeJson(join(rolesDir, 'aide.json'), { roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [], diff --git a/packages/bus/tests/citation-helpers.mjs b/packages/bus/tests/citation-helpers.mjs index 2a5964951..381c22de2 100644 --- a/packages/bus/tests/citation-helpers.mjs +++ b/packages/bus/tests/citation-helpers.mjs @@ -18,8 +18,8 @@ export function citationWorld(t, { definition = 'coder', library = true, guard: writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } })); const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, ...(library ? { projects: { books: map } } : {}), - arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } }; + arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } }; const limits = () => { try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); } catch { return resolveReplyLimits({ readable: false }); } diff --git a/packages/bus/tests/citation-runtime.test.mjs b/packages/bus/tests/citation-runtime.test.mjs index 8e3619209..e98c59d49 100644 --- a/packages/bus/tests/citation-runtime.test.mjs +++ b/packages/bus/tests/citation-runtime.test.mjs @@ -18,8 +18,8 @@ test('C2-06 runtime reads changed and unreadable reply limits without restarting // Runtime takes the host's broker projection, not the business validator's unresolved role entries. // No agent action is needed here: use the same empty-authority shape as limits-runtime.test.mjs. const business = { id: 'acme', human: 'jason', projects: checked.projects, - arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; + arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir, system: systemFor(root) } }); t.after(() => runtime.close()); const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); diff --git a/packages/bus/tests/console-decide.test.mjs b/packages/bus/tests/console-decide.test.mjs index 343d9fabe..fbe2b57da 100644 --- a/packages/bus/tests/console-decide.test.mjs +++ b/packages/bus/tests/console-decide.test.mjs @@ -48,8 +48,8 @@ function setup(t) { human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { - chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, - pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } }, + chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } }, + pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } }, }, vars: { 'bus.tester': true }, }; @@ -227,7 +227,7 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => { const f = setup(t); for (const route of ['pm', 'chief']) { - const id = grantDecision(f.store, { action: 'prd.approve', route, close: null }); + const id = grantDecision(f.store, { action: 'policy.change', route, close: null }); refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible'); refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible'); f.call(route, 'decision.resolve', { id, choice: 'yes' }); diff --git a/packages/bus/tests/credential-verbs.test.mjs b/packages/bus/tests/credential-verbs.test.mjs index 7b30f2368..789fdf3f3 100644 --- a/packages/bus/tests/credential-verbs.test.mjs +++ b/packages/bus/tests/credential-verbs.test.mjs @@ -30,8 +30,8 @@ async function setup(t, { storeRoot = true } = {}) { const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service); // The bus's resolved business carries the same references; a store one names its derived file. const demo = { - id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, - roles: { + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } }, reviewer: { ...role, credentials: doc.roles.reviewer.credentials }, }, @@ -58,7 +58,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t const calls = []; const b = new Broker({ store: s, - businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } }, + businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } }, credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }), }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); @@ -97,7 +97,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] }); assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]); // No lifecycle without the runtime's store. - const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } }); + const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } }); const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/); }); @@ -182,8 +182,8 @@ test('#1886: a store root that exists with loose modes still refuses start', asy mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 }); chmodSync(join(root, 'secrets', 'agents'), 0o750); const demo = { - id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {}, - roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } }, + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {}, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } }, }; await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/); }); diff --git a/packages/bus/tests/daily-quota-runtime.test.mjs b/packages/bus/tests/daily-quota-runtime.test.mjs index 0970d1041..f89edaf7b 100644 --- a/packages/bus/tests/daily-quota-runtime.test.mjs +++ b/packages/bus/tests/daily-quota-runtime.test.mjs @@ -24,8 +24,8 @@ async function setup(t, limit = 0) { writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); const bus = await startBroker({ dataRoot: join(root, 'data'), - businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } }, + businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) }; @@ -76,8 +76,8 @@ test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); - const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } }); + const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } }); const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' }; for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries()) assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account)); diff --git a/packages/bus/tests/decision-close.test.mjs b/packages/bus/tests/decision-close.test.mjs index 3164d027b..86b27ac9d 100644 --- a/packages/bus/tests/decision-close.test.mjs +++ b/packages/bus/tests/decision-close.test.mjs @@ -13,8 +13,8 @@ const options = [ { key: 'no', text: 'Decline' }, ]; const roles = { - pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, - cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, + pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send'], crossRole: [] } }, + cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } }, }; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } }; diff --git a/packages/bus/tests/decision-route.test.mjs b/packages/bus/tests/decision-route.test.mjs index 6de29df6c..3e87796e7 100644 --- a/packages/bus/tests/decision-route.test.mjs +++ b/packages/bus/tests/decision-route.test.mjs @@ -22,8 +22,8 @@ const options = [ ]; const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } }; const roles = { - pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } }, - chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, + pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } }, + chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, // Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter. coder: { definition: 'coder', ...pusher }, diff --git a/packages/bus/tests/decisions.test.mjs b/packages/bus/tests/decisions.test.mjs index 069e2ef4c..da79f9434 100644 --- a/packages/bus/tests/decisions.test.mjs +++ b/packages/bus/tests/decisions.test.mjs @@ -13,8 +13,8 @@ const options = [ { key: 'no', text: 'Decline' }, ]; const roles = { - pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } }, - cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, + pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } }, + cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } }, }; const businesses = { diff --git a/packages/bus/tests/grant-enroll.test.mjs b/packages/bus/tests/grant-enroll.test.mjs index 6586e538f..fb675e9cc 100644 --- a/packages/bus/tests/grant-enroll.test.mjs +++ b/packages/bus/tests/grant-enroll.test.mjs @@ -48,7 +48,7 @@ function setup(t, { keygen, check, hang = false } = {}) { arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, - pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } }, + pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } }, }, vars: { 'bus.tester': true }, }; diff --git a/packages/bus/tests/grant-scope-runtime.test.mjs b/packages/bus/tests/grant-scope-runtime.test.mjs index 4465e9904..3664b7891 100644 --- a/packages/bus/tests/grant-scope-runtime.test.mjs +++ b/packages/bus/tests/grant-scope-runtime.test.mjs @@ -17,8 +17,8 @@ test('row 298 runtime: human scope approval writes the file and organization his const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); mkdirSync(join(root, 'data'), { mode: 0o700 }); runtime = await startBroker({ dataRoot: join(root, 'data'), - businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, + businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) }, }); const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); diff --git a/packages/bus/tests/hard-waits.test.mjs b/packages/bus/tests/hard-waits.test.mjs index 8a2ec361a..e68bd83b5 100644 --- a/packages/bus/tests/hard-waits.test.mjs +++ b/packages/bus/tests/hard-waits.test.mjs @@ -27,8 +27,8 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th const root = mkdtempSync(join(tmpdir(), 'hard-waits-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); - const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { definition: 'coder', authority } } } }); + const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority } } } }); for (const action of WAITS) { assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action); assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action); @@ -54,19 +54,19 @@ test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbite } } const d = await f.raise({ action: 'data.delete', ...cite(f) }); - assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/); + assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/); f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }); }); test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => { - const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, - roles: { cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } } } }); + const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } } } }); f.agent('cto'); for (const action of LEFT) { const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) }; assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action); - assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: 'cto' }, action); - assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: 'reviewer' }, action); + assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action); + assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action); } }); @@ -74,15 +74,15 @@ test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser, const f = await fixture(t, { roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } }); f.agent('pm'); const d = await f.raise({ action: 'role.revoke', target: 'pm', ...cite(f) }); - assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' }); + assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' }); assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/); - f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }); + f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }); assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true }); }); test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => { - const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, roles: { - cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } }, + const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, + cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } }, 'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } }, } }); const cto = f.agent('cto'), gate = f.agent('merge-gate'); @@ -106,7 +106,7 @@ test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an await assert.rejects(f.raise({ action: 'policy.change', ...old, spec_commit: edit }), { code: 'spec-unconfirmed' }); assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0); const current = f.save('# Current\n'); - const own = await f.call(f.reviewer, 'decision.raise', { action: 'policy.change', question: 'Change?', + const own = await f.call(f.chief, 'decision.raise', { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false, project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current }); assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' }); @@ -136,7 +136,7 @@ const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind= test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', async t => { const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } }); - const chief = f.agent('chief'), lead = f.agent('lead'); + const chief = f.chief, lead = f.agent('lead'); // No thread is held yet, so there is nothing to end. assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/); const a = held(f, 'a-run'), b = held(f, 'b-run'); @@ -158,11 +158,11 @@ test('Q169: the chief of staff\'s end of the assistant stops every thread run; i test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => { const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } }); - const chief = f.agent('chief'); + const chief = f.chief; for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) { const d = await revoke(f, chief, 'assistant', args); assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args)); - assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/); + assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/); } // Resolved by the human, the chief's revoke citing it stops the thread run, once. const a = held(f, 'a-run'); @@ -181,8 +181,8 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite // One raised while the instance's definition wasn't protected went to the arbiter. The same // store under a business that now makes it the assistant still refuses it. const d = await revoke(f, f.coder, 'pm', cite(f)); - assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' }); - f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }); + assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' }); + f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }); const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } }; const later = new Broker({ store: f.store, businesses: { demo: now } }); const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' }); @@ -192,7 +192,7 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', async t => { const f = await fixture(t, { roles: seats }); const d = await revoke(f, f.coder, 'assistant', cite(f)); - assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/); + assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/); f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }); // Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the // revoke reaches the thread claims and stops the held one. @@ -205,8 +205,8 @@ test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbi const f = await fixture(t, { roles: seats }); f.agent('pm'); const d = await revoke(f, f.coder, 'pm', cite(f)); - assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' }); - f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }); + assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' }); + f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }); assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true }); }); @@ -216,7 +216,7 @@ test('Q169: a role granted role.revoke at either class still sends a revoke of t boss: { definition: 'boss', ...grants(['role.revoke']) } } }); const lead = f.agent('lead'), boss = f.agent('boss'); assert.deepEqual(routed(f, await revoke(f, lead, 'assistant', cite(f))), { class: 'gated', route_to: 'human' }); - assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'reviewer' }); + assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'chief' }); const own = await revoke(f, boss, 'assistant', { choice: 'yes' }); assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' }); assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0); diff --git a/packages/bus/tests/host-fixtures.mjs b/packages/bus/tests/host-fixtures.mjs index 2fc641078..8edd45168 100644 --- a/packages/bus/tests/host-fixtures.mjs +++ b/packages/bus/tests/host-fixtures.mjs @@ -12,7 +12,7 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli // A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null // for an open decision. -export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) { +export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) { const id = randomUUID(); store.run( 'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', @@ -21,7 +21,7 @@ export function grantDecision(store, { business = 'demo', action = 'credential.m store.run( "INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)", randomUUID(), AT, business, 'chief', 'chief-run', id, - JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }), + JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }), ); if (close) store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null); diff --git a/packages/bus/tests/host-run.test.mjs b/packages/bus/tests/host-run.test.mjs index 3670f29db..2070328b7 100644 --- a/packages/bus/tests/host-run.test.mjs +++ b/packages/bus/tests/host-run.test.mjs @@ -572,8 +572,8 @@ test('T27: the bus start sweeps /secrets/run before it listens, and a await new Promise((r) => dead.on('close', r)); writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`); const demo = { - id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } }, + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } }, }; const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }); const bus = await start(); @@ -640,8 +640,8 @@ test('M3: a host_grants record gives Credentials a store reference at start, and mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700); const demo = { - id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } }, + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } }, }; const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }); const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.')); diff --git a/packages/bus/tests/instances-verb.test.mjs b/packages/bus/tests/instances-verb.test.mjs index cb9ed99f4..93c114062 100644 --- a/packages/bus/tests/instances-verb.test.mjs +++ b/packages/bus/tests/instances-verb.test.mjs @@ -65,7 +65,7 @@ test('row 165 runtime: an add and a change reach the organization file with the const rolesDir = rolesCopy(root); writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); mkdirSync(join(root, 'data'), { mode: 0o700 }); - const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const file = join(root, 'businesses/demo.json'); diff --git a/packages/bus/tests/land.test.mjs b/packages/bus/tests/land.test.mjs index 52343932b..87f98915c 100644 --- a/packages/bus/tests/land.test.mjs +++ b/packages/bus/tests/land.test.mjs @@ -26,7 +26,7 @@ const A = 'a'.repeat(40); const B = 'b'.repeat(40); const C = 'c'.repeat(40); const roles = { - pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, + pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } }, reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } }, 'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } }, @@ -221,15 +221,15 @@ test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land', assert.deepEqual([...w.merges(), ...w.events('action.refused')], []); }); -test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => { - const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } }); +test('a cross-role merge that routes to the human, because the cto lander is its own arbiter, lands on the human\'s CLI answer', (t) => { + const w = setup(t, { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { ...roles, cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [MERGE] } } } }); w.push(A); w.approve(A); - const d = w.raise(); + const d = w.raise('cto'); assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' }); assert.throws(() => w.resolve(d), /human-required/); w.resolve(d, 'yes', 'human'); - const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id }); + const { ticket, by, via } = w.b.checkLand(w.caps.cto, { task_ref: TASK, decision: d.id }); assert.deepEqual([by, via], ['jason', 'cli']); w.b.recordLand(ticket, w.land); assert.equal(w.merges()[0].body.class, 'cross-role'); diff --git a/packages/bus/tests/launcher-released.test.mjs b/packages/bus/tests/launcher-released.test.mjs index 6fb6e15c2..2ac749701 100644 --- a/packages/bus/tests/launcher-released.test.mjs +++ b/packages/bus/tests/launcher-released.test.mjs @@ -119,7 +119,7 @@ const TASK = 'vikunja:1/2'; test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => { const w = setup(t, { - pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, + pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } }, coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } }, reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } }, 'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } }, diff --git a/packages/bus/tests/library-credential.test.mjs b/packages/bus/tests/library-credential.test.mjs index 68552ff50..f27420d48 100644 --- a/packages/bus/tests/library-credential.test.mjs +++ b/packages/bus/tests/library-credential.test.mjs @@ -43,9 +43,9 @@ async function until(fn, ms = 10000) { } const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({ - id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, + id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, // sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent. - roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } }, + roles: { cto: { definition: 'cto', authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } }, projects: { books: { ...mappingEntry(), user: 'books-bot' } }, ...(instances ? { launch: { instances } } : {}), }); @@ -77,7 +77,7 @@ function world(t, options) { // chief-of-staff instance, fired by boss. The real read grant targets the library project books. function verbDoc(url, root) { return { - id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' }, + id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { boss: { definition: 'pm', authority: chiefAuthority }, // sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference. diff --git a/packages/bus/tests/library-lookups.test.mjs b/packages/bus/tests/library-lookups.test.mjs index 06a0930ca..f539abe8d 100644 --- a/packages/bus/tests/library-lookups.test.mjs +++ b/packages/bus/tests/library-lookups.test.mjs @@ -31,8 +31,8 @@ async function world(t, { projects, adapter, claimed = true, now = () => NOW, fi const f = scratchRepo(t, files), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot); const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, - projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; + projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; const context = { business, project: 'books', dataRoot }; let mirror = null, state = null, fetches = 0; if (business.projects.books?.kind === 'library') { @@ -222,8 +222,8 @@ test('real socket awaits lookup results and rechecks rejected query arguments', test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => { const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot); const business = { id: 'acme', human: 'jason', plugins: { harness: ['pi'] }, projects: { books: mappingEntry() }, - arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; + arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } }; const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close()); const mirror = library.mirrorPath({ business, project: 'books', dataRoot }); git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served'); diff --git a/packages/bus/tests/library-sync.test.mjs b/packages/bus/tests/library-sync.test.mjs index f5d1cf6e2..49802fdd0 100644 --- a/packages/bus/tests/library-sync.test.mjs +++ b/packages/bus/tests/library-sync.test.mjs @@ -21,7 +21,7 @@ function world(t, adapter) { const root = mkdtempSync(join(tmpdir(), 'bus-library-sync-')), store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, - projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } }; + projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } }; const broker = new Broker({ store, businesses: { acme: business }, library: adapter }); const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-sync-agent', harness: 'pi' }); @@ -81,7 +81,7 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac mkdirSync(dataRoot, { mode: 0o700 }); mkdirSync(join(configDir, 'secrets', 'acme'), { recursive: true, mode: 0o700 }); writeFileSync(join(configDir, 'secrets', 'acme', library.SLOT), TOKEN + '\n', { mode: 0o600 }); - const business = { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, + const business = { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, projects: { books: { ...mappingEntry(), user: 'books-bot' }, quiet: mappingEntry() } }; const calls = []; // A git that succeeds without fetching: the remote-tracking ref stays absent, so each pass fails. diff --git a/packages/bus/tests/library-write.test.mjs b/packages/bus/tests/library-write.test.mjs index c3a7343bf..24a337a72 100644 --- a/packages/bus/tests/library-write.test.mjs +++ b/packages/bus/tests/library-write.test.mjs @@ -234,8 +234,8 @@ test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never u const pushes = []; const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); }; const initial = (await bare.state()).lastFetch; - const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, - arbiters: { technical: 'coder', delivery: 'coder' } }; + const business = { ...bare.context.business, human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, + arbiters: { technical: 'chief', delivery: 'chief' } }; const runtime = await startBroker({ dataRoot: bare.context.dataRoot, businesses: { acme: business }, librarySync: { configDir, command, intervalMs: 60000 } }); try { diff --git a/packages/bus/tests/library.test.mjs b/packages/bus/tests/library.test.mjs index 6af2fd338..c2d143b8e 100644 --- a/packages/bus/tests/library.test.mjs +++ b/packages/bus/tests/library.test.mjs @@ -16,7 +16,7 @@ function world(t, adapter = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-library-')), store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, - projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } }; + projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } }; const broker = new Broker({ store, businesses: { acme: business }, library: adapter }); const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-agent', harness: 'pi' }); @@ -68,7 +68,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre const f = repo(t), dataRoot = join(f.root, 'data'), token = join(f.root, 'fixture-token'); mkdirSync(dataRoot, { mode: 0o700 }); writeFileSync(token, 'made-up-fixture-token-123456', { mode: 0o600 }); const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry(), stack: { root: f.repo } }, - arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } }; + arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } }; const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); await runtime.close(); const mirror = library.mirrorPath({ business, project: 'books', dataRoot }); @@ -81,7 +81,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a socket', async t => { const f = repo(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); - const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } }; + const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } }; const mirror = join(dataRoot, 'projects/acme/books'); mkdirSync(mirror, { recursive: true }); writeFileSync(join(mirror, 'keep'), 'made-up note'); await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }), { code: 'library-git' }); @@ -92,7 +92,7 @@ test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a s test('C2-04 runtime: real library adapters reach the socket and lock waits leave other bus reads responsive', { timeout: 15000 }, async t => { const f = repo(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); - const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } }; + const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } }; const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close()); const cap = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); diff --git a/packages/bus/tests/lifecycle-verbs.test.mjs b/packages/bus/tests/lifecycle-verbs.test.mjs index f76c637d7..ce771b42c 100644 --- a/packages/bus/tests/lifecycle-verbs.test.mjs +++ b/packages/bus/tests/lifecycle-verbs.test.mjs @@ -26,7 +26,7 @@ const doc = (extra = {}) => ({ retired: ['old'], ...extra, }); -const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } }; +const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 'only-there': none } }; function setup(t, { organization = null, testerOn = undefined, processing = undefined } = {}) { const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-')); @@ -349,7 +349,7 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename const { transaction } = f.b.lifecycleDelete({ business: 'demo', request }); f.done(request, { transaction, refusal: 'workspace-rename-failed' }); // The bus reloads after the commit, as the runtime does. - const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } }); + const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'chief', delivery: 'chief' } }); delete next.roles.pm; f.b.reloadBusiness('demo', next); const writes = w.calls.length; diff --git a/packages/bus/tests/limit-stop.test.mjs b/packages/bus/tests/limit-stop.test.mjs index 2cec880c4..a8359f152 100644 --- a/packages/bus/tests/limit-stop.test.mjs +++ b/packages/bus/tests/limit-stop.test.mjs @@ -13,8 +13,8 @@ import { Broker } from '../src/broker.mjs'; const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); const businesses = () => ({ demo: { - id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, }, diff --git a/packages/bus/tests/limits-runtime.test.mjs b/packages/bus/tests/limits-runtime.test.mjs index 30f6f6491..5f69d5315 100644 --- a/packages/bus/tests/limits-runtime.test.mjs +++ b/packages/bus/tests/limits-runtime.test.mjs @@ -20,8 +20,8 @@ async function setup(t, plugins, limit = 0) { writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); const bus = await startBroker({ dataRoot: join(root, 'data'), - businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } }, + businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) }; diff --git a/packages/bus/tests/limits.test.mjs b/packages/bus/tests/limits.test.mjs index fe3389018..f678b2109 100644 --- a/packages/bus/tests/limits.test.mjs +++ b/packages/bus/tests/limits.test.mjs @@ -8,7 +8,7 @@ import {Broker} from '../src/broker.mjs'; import {Store} from '../src/store.mjs'; test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{ const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[]; - const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}}); + const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}}); const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}}; for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/); // Row 175 (#1673): the tester reads limits; limits.set stays the human's. @@ -19,7 +19,7 @@ test('limits binds actor from human proof and rejects role, reader, a tester set test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{ const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {readFileSync}=await import('node:fs'); const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700}); - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}}); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}}); t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed'); }); @@ -28,7 +28,7 @@ test('uncertain organization receipts leave bus startup and reads alone but refu const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700}); const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7; const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8'); - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});}); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});}); const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=bus.broker.request(human,{verb:'limits'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes); // The operator reconciles the fixture to the recorded old whole document. writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1); diff --git a/packages/bus/tests/message-citation.test.mjs b/packages/bus/tests/message-citation.test.mjs index 7994cc205..0bfeeb741 100644 --- a/packages/bus/tests/message-citation.test.mjs +++ b/packages/bus/tests/message-citation.test.mjs @@ -15,7 +15,7 @@ function fixture(t, cross = false) { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []), - cto: role(), + cto: role(['decision.resolve.technical']), coder: role(), }, }); diff --git a/packages/bus/tests/model-catalogue.test.mjs b/packages/bus/tests/model-catalogue.test.mjs index 7425c8aff..81dd2b476 100644 --- a/packages/bus/tests/model-catalogue.test.mjs +++ b/packages/bus/tests/model-catalogue.test.mjs @@ -46,7 +46,7 @@ test('launch admission refreshes selection and refuses a removed model and incom writeJson(join(root,'businesses/demo.json'),doc); mkdirSync(join(root,'data'),{mode:0o700}); let present=true, reads=0; const service={invalidate(){},read:async(_,{fresh})=>{assert.equal(fresh,true);reads++;return {status:'ready',models:present?[{module:'cliproxy',upstream:'zai',model:'fixture-id',available:true,harnesses:['pi']}]:[]};}}; - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close()); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close()); const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); const revision=bus.broker.request(human,{verb:'limits'}).revision; const {checkedTransport}=await import('../../harness/src/bundle.mjs'); @@ -78,7 +78,7 @@ test('a different enabled fixture provider supplies legacy mapping and admission const makeProviderLoader=()=>({load:async()=>({refused:[]}),snapshot:()=>[{name:'second-fixture',module}]}); const offered={key:'fixture-model',module:'second-fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture',available:true,reason:null,harnesses:['pi']}; const modelCatalogue={read:async()=>({status:'ready',models:[offered],notices:[]}),invalidate(){}}; - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close()); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close()); const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=await bus.broker.request(human,{verb:'routes'}); assert.equal(view.catalogue.legacy['fixture-legacy'].module,'second-fixture'); const answer=await bus.modelAdmission({business:'demo',instance:'coder',modelKey:'fixture-legacy',harness:'pi',revision:view.revision,entry:doc.launch.models['fixture-legacy'],provider:'fixture',model:'fixture-model',account:null,route:'primary',transport}); diff --git a/packages/bus/tests/model-hire-ownership-fixture.mjs b/packages/bus/tests/model-hire-ownership-fixture.mjs index 9561b9d39..02737a5fe 100644 --- a/packages/bus/tests/model-hire-ownership-fixture.mjs +++ b/packages/bus/tests/model-hire-ownership-fixture.mjs @@ -27,7 +27,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');}); const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;}); // The established scratch aide pattern only; repository roles/policy stay untouched. - for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});} + for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});} const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']}); const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles}; const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel); diff --git a/packages/bus/tests/model-keys.test.mjs b/packages/bus/tests/model-keys.test.mjs index c9b8cb362..4f588b00d 100644 --- a/packages/bus/tests/model-keys.test.mjs +++ b/packages/bus/tests/model-keys.test.mjs @@ -176,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac async function bootBusiness(f, binding = {}) { const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo'); // Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED. - fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n'); + fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n'); writeJson(join(rolesDir, 'aide.json'), { roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [], diff --git a/packages/bus/tests/model-orphan-hire.test.mjs b/packages/bus/tests/model-orphan-hire.test.mjs index d46cffaff..857a2a63f 100644 --- a/packages/bus/tests/model-orphan-hire.test.mjs +++ b/packages/bus/tests/model-orphan-hire.test.mjs @@ -15,7 +15,7 @@ async function hireFixture(t,{revoking=false}={}){ if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}old.credentials.close(); const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo'); // Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED. - fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]}); + fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]}); const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']}; const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600}); const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance}); diff --git a/packages/bus/tests/model-pending-fixture.mjs b/packages/bus/tests/model-pending-fixture.mjs index 9f8ecb850..404337bc6 100644 --- a/packages/bus/tests/model-pending-fixture.mjs +++ b/packages/bus/tests/model-pending-fixture.mjs @@ -22,7 +22,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){ const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700}); const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']}; // Existing row331 scratch aide pattern only; repository role authority unchanged. - fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']}); + fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']}); const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);} fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]}); const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel); diff --git a/packages/bus/tests/model-proxy.test.mjs b/packages/bus/tests/model-proxy.test.mjs index e4fd74bee..97a2f8c44 100644 --- a/packages/bus/tests/model-proxy.test.mjs +++ b/packages/bus/tests/model-proxy.test.mjs @@ -7,7 +7,7 @@ import { join } from 'node:path'; import { Broker } from '../src/broker.mjs'; import { Store } from '../src/store.mjs'; const role={authority:{withinRole:['message.send'],crossRole:[]}}; -function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};} +function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};} test('proxy.init only accepts path-bound outside-agent human; refuses agent/tester/reader',async t=>{const calls=[];const f=fixture(t,async a=>{calls.push(a);return {port:a.port,binary:'/fixture'};});for(const cap of [f.tester,f.agent])assert.throws(()=>f.b.request(cap,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='human-required');assert.throws(()=>f.b.request(f.reader,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='read-only');assert.equal(calls.length,0);assert.deepEqual(await f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management',port:18432}}),{port:18432,binary:'/fixture'});assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);}); for(const [code,args]of [['proxy-key-invalid',{key:'a\nb'}],['proxy-config-invalid',{key:'p1-management',port:8317}],['invalid-request',{key:'p1-management',binary:'/forged'}]])test(`proxy.init ${code}`,t=>{const f=fixture(t,()=>{throw Error('must not call');});assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args}),e=>e.code===code);}); test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='not-configured');}); diff --git a/packages/bus/tests/model-revocation-fixture.mjs b/packages/bus/tests/model-revocation-fixture.mjs index 4e76442fb..7a6b3b020 100644 --- a/packages/bus/tests/model-revocation-fixture.mjs +++ b/packages/bus/tests/model-revocation-fixture.mjs @@ -51,7 +51,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo let boot,writer,chief,human,busBoots=0; if(runtime){ const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide'); - fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc); + fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc); const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name}); boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir}); stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);}; diff --git a/packages/bus/tests/names.test.mjs b/packages/bus/tests/names.test.mjs index c4885a40a..5d836f1c6 100644 --- a/packages/bus/tests/names.test.mjs +++ b/packages/bus/tests/names.test.mjs @@ -24,7 +24,7 @@ const demo = () => ({ demo: { id: 'demo', human: 'jason', - arbiters: { technical: 'lead', delivery: 'chief' }, + arbiters: { technical: 'chief', delivery: 'chief' }, roles, definitions: { reviewer: {} }, vars: { 'bus.tester': true }, diff --git a/packages/bus/tests/p5-key-context.test.mjs b/packages/bus/tests/p5-key-context.test.mjs index 7c0c05697..7c5f2e05a 100644 --- a/packages/bus/tests/p5-key-context.test.mjs +++ b/packages/bus/tests/p5-key-context.test.mjs @@ -9,7 +9,7 @@ import { createLimits, modelUsageKey } from '../src/limits.mjs'; const key='012345abcdef'; test('P5 broker preserves original plugin subject and derives internal run context from authenticated native caller',async t=>{ const root=mkdtempSync(join(tmpdir(),'p5-context-')),store=new Store(root),asked=[];t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});}); - const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}}, + const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}}, limits:{mustStop:async(...args)=>{asked.push(args);return {stop:false};}},usage:{collect:async()=>({recorded:true}),view:()=>({})}}); const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); const cap=b.bindLaunch({business:'demo',role:'coder',run:'native-run',harness:'pi',provider:'fixture',model:'fake'});b.request(cap,{verb:'role.claim'}); diff --git a/packages/bus/tests/p5-runtime.test.mjs b/packages/bus/tests/p5-runtime.test.mjs index 6eed7adbf..dabb4bb80 100644 --- a/packages/bus/tests/p5-runtime.test.mjs +++ b/packages/bus/tests/p5-runtime.test.mjs @@ -30,7 +30,7 @@ test('P5 real runtime binds stack usage to issued key, daily stops at stack coun }); await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus; t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});}); - bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, + bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}); assert.equal(calls.some(p=>p.includes('usage/queue')),false); const model=await bus.modelPreflight({business:'demo',role:'coder'}),keyId=hash(model.key); @@ -77,7 +77,7 @@ test('R4 real runtime: a quota spent on A and moved to B with no reset still ref }); await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus; t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});}); - bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, + bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}); const stored=()=>JSON.parse(readFileSync(join(config,'businesses/demo.json'),'utf8')).launch.limits.tokens.keys; const may=async()=>(await bus.mayLaunch({business:'demo',instance:'coder',provider:'fixture',model:'fake'})).allow; diff --git a/packages/bus/tests/p5-telemetry-fixture.mjs b/packages/bus/tests/p5-telemetry-fixture.mjs index a9e61bac1..6d126e45d 100644 --- a/packages/bus/tests/p5-telemetry-fixture.mjs +++ b/packages/bus/tests/p5-telemetry-fixture.mjs @@ -64,7 +64,7 @@ export async function telemetryFixture(t, { foreign = false, hold = false, waitM }); await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); let bus=null,startPromise=null,closePromise=null; - const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, + const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}}, organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value))); const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}}; const close=()=>bus?(closePromise??=bus.close()):Promise.resolve(); diff --git a/packages/bus/tests/plugins-verb.test.mjs b/packages/bus/tests/plugins-verb.test.mjs index c0c2f45f7..88eadbd55 100644 --- a/packages/bus/tests/plugins-verb.test.mjs +++ b/packages/bus/tests/plugins-verb.test.mjs @@ -37,7 +37,7 @@ test('runtime: the switch changes the organization file, the receipt carries the const rolesDir = rolesCopy(root); writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); mkdirSync(join(root, 'data'), { mode: 0o700 }); - const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const file = join(root, 'businesses/demo.json'); @@ -72,7 +72,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and const rolesDir = rolesCopy(root); writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } }); mkdirSync(join(root, 'data'), { mode: 0o700 }); - const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); @@ -97,7 +97,7 @@ test('plugins read advertises loadable harnesses even when switched off', async const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true})); const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']}; writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700}); - const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close()); + const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); const view=host.broker.request(human,{verb:'plugins'}); assert.deepEqual(view.plugins.harness,['claude-code']);assert.ok(view.available.harness.includes('pi'));assert.ok(view.available.harness.includes('claude-code')); diff --git a/packages/bus/tests/processing-runtime.test.mjs b/packages/bus/tests/processing-runtime.test.mjs index 0b84a44b8..3af3ce3d3 100644 --- a/packages/bus/tests/processing-runtime.test.mjs +++ b/packages/bus/tests/processing-runtime.test.mjs @@ -10,8 +10,8 @@ import { Client } from '../src/client.mjs'; import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs'; import { readControl } from '../../processing-control/src/state.mjs'; -const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, - roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } }; +const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } }; async function broker(root) { const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] }); const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); diff --git a/packages/bus/tests/processing.test.mjs b/packages/bus/tests/processing.test.mjs index 59c32f3fa..7b0006933 100644 --- a/packages/bus/tests/processing.test.mjs +++ b/packages/bus/tests/processing.test.mjs @@ -13,7 +13,7 @@ const state = await import('../../processing-control/src/state.mjs').catch(e => throw e; }); const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, - arbiters: { technical: 'coder', delivery: 'coder' }, roles: { + arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } }, } } }; function fixture(t, extra = {}) { diff --git a/packages/bus/tests/project-archive.test.mjs b/packages/bus/tests/project-archive.test.mjs index 3449c4850..ac5653aca 100644 --- a/packages/bus/tests/project-archive.test.mjs +++ b/packages/bus/tests/project-archive.test.mjs @@ -17,7 +17,7 @@ import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mj const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8')); const AT = '2026-11-05T00:00:00.000Z'; const business = { - id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' }, + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } }, chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, diff --git a/packages/bus/tests/project-await.test.mjs b/packages/bus/tests/project-await.test.mjs index 3e44cd437..6b925e42f 100644 --- a/packages/bus/tests/project-await.test.mjs +++ b/packages/bus/tests/project-await.test.mjs @@ -10,7 +10,7 @@ function fixture(t, project, secretCheck = () => {}) { const root = mkdtempSync(join(tmpdir(), 'project-await-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); - const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { + const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } }, } }; const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck }); diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index 425f67c9a..9d3e756f6 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -64,7 +64,7 @@ test('real startBroker injects the project adapter for socket reads and bound-au t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); }); // Row 65: the bound run's harness is switched on. const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } }, - arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { + arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] }, } } }; const initialized = await initProject({ business, project: 'stack', dataRoot: root }); diff --git a/packages/bus/tests/provider-materialization-guard.test.mjs b/packages/bus/tests/provider-materialization-guard.test.mjs index 7c8152e63..25ea939d6 100644 --- a/packages/bus/tests/provider-materialization-guard.test.mjs +++ b/packages/bus/tests/provider-materialization-guard.test.mjs @@ -15,7 +15,7 @@ test('role must not materialize a new catalogue key by submitting legacy entry s const offered={key:'zai-fixture-new',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-new',label:'Fixture',available:true,reason:null,harnesses:['pi']}; let status='ready'; const modelCatalogue={invalidate(){},read:async()=>({status,models:[offered],notices:[]})}; - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close()); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close()); const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close()); const allowed=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n; const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); diff --git a/packages/bus/tests/provider-repoint-guard.test.mjs b/packages/bus/tests/provider-repoint-guard.test.mjs index 26c25a12d..83e476e21 100644 --- a/packages/bus/tests/provider-repoint-guard.test.mjs +++ b/packages/bus/tests/provider-repoint-guard.test.mjs @@ -20,7 +20,7 @@ async function fixture(t, options={}) { if(options.fallback)Object.assign(doc.roles.coder.vars,{'fallback.harness':'pi','fallback.model':'existing-alias','fallback.thinking':'low'}); writeJson(file,doc);fs.mkdirSync(join(root,'data'),{mode:0o700}); const modelCatalogue={invalidate(){},read:async()=>({status:'ready',models:[offer],notices:[]})}; - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close()); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close()); const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close()); const count=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n; const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); diff --git a/packages/bus/tests/proxy-page.test.mjs b/packages/bus/tests/proxy-page.test.mjs index b5ab04e41..63a1de3df 100644 --- a/packages/bus/tests/proxy-page.test.mjs +++ b/packages/bus/tests/proxy-page.test.mjs @@ -18,8 +18,8 @@ function setup(t, proxyPage = async () => ({ ok: true })) { access: () => ({ accessVersion: 1, revision: 1, users: [], grants: [ { id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['coder'], level: 'collaborator' }, ] }), - businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' }, - vars: { 'bus.tester': true }, roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } }, + businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' }, + vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } }, }); const caps = { human: broker.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }), diff --git a/packages/bus/tests/publish.test.mjs b/packages/bus/tests/publish.test.mjs index 088994bfd..68bdb0e4c 100644 --- a/packages/bus/tests/publish.test.mjs +++ b/packages/bus/tests/publish.test.mjs @@ -24,9 +24,9 @@ function businesses(upstream = 'https://git.example/mosaic/stack') { demo: { id: 'demo', human: 'jason', - arbiters: { technical: 'reviewer', delivery: 'reviewer' }, + arbiters: { technical: 'chief', delivery: 'chief' }, projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } }, - roles: { + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] }, second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] }, plain: { authority: pusher }, diff --git a/packages/bus/tests/reload.test.mjs b/packages/bus/tests/reload.test.mjs index fa8f86600..0f5d54b55 100644 --- a/packages/bus/tests/reload.test.mjs +++ b/packages/bus/tests/reload.test.mjs @@ -211,13 +211,13 @@ test('T14 reloadBusiness refuses an unknown business, a changed human and a docu const root = mkdtempSync(join(tmpdir(), 'reload-unit-')); t.after(() => rmSync(root, { recursive: true, force: true })); const role = { authority: { withinRole: [], crossRole: [] } }; - const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } }; + const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } }; const b = new Broker({ store: new Store(root), businesses: { demo } }); assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business'); assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business'); assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business'); assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority'); assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business'); - b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } }); + b.reloadBusiness('demo', { ...demo, roles: { ...demo.roles, 'sec-1': role } }); assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' })); }); diff --git a/packages/bus/tests/resolve-rights.test.mjs b/packages/bus/tests/resolve-rights.test.mjs new file mode 100644 index 000000000..7780314f9 --- /dev/null +++ b/packages/bus/tests/resolve-rights.test.mjs @@ -0,0 +1,79 @@ +// RR (#1873): resolution checks current role rights, and callers cannot choose a merge/policy arbiter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadRole } from '../../organization/src/index.mjs'; +import { REPO_ROLES } from '../../organization/tests/helpers.mjs'; +import { fixture } from './spec-fixtures.mjs'; +import { grantDecision } from './host-fixtures.mjs'; + +async function setup(t, { shared = false } = {}) { + const roles = Object.fromEntries(['cto', 'pm', 'chief-of-staff'].map(definition => { + const role = loadRole(REPO_ROLES, definition); + return [definition === 'chief-of-staff' ? 'chief' : definition, { definition, authority: structuredClone(role.authority) }]; + })); + roles.coder = { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: ['task.scope.change', 'git.merge.protected', 'policy.change'] } }; + const f = await fixture(t, { roles, arbiters: { technical: 'cto', delivery: shared ? 'cto' : 'pm' } }); + return { ...f, roles, cto: f.agent('cto'), pm: f.agent('pm'), chief: f.chief }; +} + +for (const [domain, resolver] of [['technical', 'cto'], ['delivery', 'pm']]) { + test(`RR: ${domain} arbiter that loses its right refuses not-resolver`, async t => { + const f = await setup(t); + const d = f.raise({ action: 'task.scope.change', domain }); + f.business.roles[resolver].authority.withinRole = ['message.send']; + f.broker.reloadBusiness('demo', f.business); + assert.throws(() => f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }), error => { + assert.equal(error.code, 'not-resolver'); + assert.match(error.reason, new RegExp(`${resolver}.*${resolver}.*decision\\.resolve\\.${domain}`)); + return true; + }); + assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', d.id).n, 0); + }); +} + +test('RR: a shared arbiter cannot resolve a delivery decision using only its technical right', async t => { + const f = await setup(t, { shared: true }); + const d = f.raise({ action: 'task.scope.change', domain: 'delivery' }); + assert.equal(d.route_to, 'cto'); + assert.throws(() => f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' }); +}); + +test('RR: merge raised with delivery still routes to CTO and resolves (acceptance 314 step 5)', async t => { + const f = await setup(t); + const d = await f.raise({ action: 'git.merge.protected', domain: 'delivery', project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit }); + assert.equal(d.route_to, 'cto'); + assert.throws(() => f.call(f.pm, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' }); + f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }); + assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', d.id).op, 'resolved'); +}); + +for (const resolver of ['chief', 'pm']) { + test(`RR: ${resolver} named delivery arbiter resolves policy.change even when raised technical`, async t => { + const f = await setup(t); f.business.arbiters.delivery = resolver; + f.broker.reloadBusiness('demo', f.business); + const d = f.raise({ action: 'policy.change', domain: 'technical' }); + assert.equal(d.route_to, resolver); + f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }); + assert.equal(f.store.get('SELECT by FROM decision_events WHERE decision=?', d.id).by, `${resolver}-run`); + }); +} + +test('RR: the chief holds both rights when named for both domains', async t => { + const f = await setup(t); f.business.arbiters = { technical: 'chief', delivery: 'chief' }; + f.broker.reloadBusiness('demo', f.business); + for (const action of ['git.merge.protected', 'policy.change']) { + const d = f.raise({ action }); assert.equal(d.route_to, 'chief'); + f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }); + } +}); + +// Old raise evidence did not preserve domain. Current assignments cannot prove the old domain. +test('RR: remapping arbiters cannot let a legacy decision resolve with only the other domain right', async t => { + const f = await setup(t); + const id = grantDecision(f.store, { action: 'task.scope.change', route: 'pm', domain: null, close: null }); + f.business.arbiters = { technical: 'pm', delivery: 'cto' }; + f.business.roles.pm.authority.withinRole = ['message.send', 'decision.resolve.technical']; + f.broker.reloadBusiness('demo', f.business); + assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' }); + assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0); +}); diff --git a/packages/bus/tests/room-check.test.mjs b/packages/bus/tests/room-check.test.mjs index 51307a1ee..636469747 100644 --- a/packages/bus/tests/room-check.test.mjs +++ b/packages/bus/tests/room-check.test.mjs @@ -28,7 +28,7 @@ test('C2-10c check hydration preserves unchecked rows and one broadcast with two const messaging = JSON.parse(readFileSync(new URL('../../../roles/companion.json', import.meta.url))).messaging; const role = { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } }; const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', vars: {}, - arbiters: { technical: 'bean', delivery: 'bean' }, roles: { bean: role, chet: role }, definitions: { companion: { messaging } } } } }); + arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, bean: role, chet: role }, definitions: { companion: { messaging } } } } }); const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const call = (verb, args) => broker.request(human, { verb, args }); const project = call('project.create', { name: 'Room' }).id; diff --git a/packages/bus/tests/routes-verb.test.mjs b/packages/bus/tests/routes-verb.test.mjs index 24d238aa6..5abac21f6 100644 --- a/packages/bus/tests/routes-verb.test.mjs +++ b/packages/bus/tests/routes-verb.test.mjs @@ -110,7 +110,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the const file = join(root, 'businesses/demo.json'); writeJson(file, doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); - const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(() => bus.close()); const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); @@ -157,7 +157,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s const file = join(root, 'businesses/demo.json'); writeJson(file, doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); const offered={ key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture-id',available:true,reason:null,harnesses:['pi'] }; const catalogues={ read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate:()=>{} }; - const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues }); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues }); t.after(() => bus.close()); const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); const before=await bus.broker.request(human,{verb:'routes'}); @@ -179,7 +179,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${ let mode='absent'; const offered={key:'fixture-persisted',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture',available:true,reason:null,harnesses:['pi']}; const catalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:mode==='absent'?[]:[{...offered,harnesses:mode==='incompatible'?['claude-code']:['pi']}],notices:[]}),invalidate(){}}; - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close()); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close()); const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); const cap=writer==='organization.set'?bus.broker.bindLaunch({business:'demo',role:'chief',run:'provider-chief',harness:'pi'}):human; if(writer==='organization.set')bus.broker.request(cap,{verb:'role.claim'}); @@ -201,7 +201,7 @@ test('legacy routes follow enabled module mappings while unchanged unavailable s writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700}); let state='ready';const offered={key:'zai-fixture-two',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-two',label:'fixture',available:true,reason:null,harnesses:['pi']}; const catalogue={read:async()=>({status:state,observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}}; - const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close()); + const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close()); const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const request=(verb,args)=>bus.broker.request(human,{verb,args}); let view=await request('routes');assert.deepEqual(view.catalogue.legacy['legacy-one'],{module:'cliproxy',upstream:'claude',model:'fixture-one'}); const pick=(harness,model,fallback=null)=>({harness,model,thinking:'low',fallback}); diff --git a/packages/bus/tests/single-use.test.mjs b/packages/bus/tests/single-use.test.mjs index 1ca595a79..541c366b3 100644 --- a/packages/bus/tests/single-use.test.mjs +++ b/packages/bus/tests/single-use.test.mjs @@ -12,8 +12,8 @@ const businesses = { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } }, - cto: { authority: { withinRole: [], crossRole: [] } }, - pm: { authority: { withinRole: [], crossRole: [] } }, + cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, + pm: { authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, }, }, }; diff --git a/packages/bus/tests/spec-fixtures.mjs b/packages/bus/tests/spec-fixtures.mjs index 7114df7d9..728431e0a 100644 --- a/packages/bus/tests/spec-fixtures.mjs +++ b/packages/bus/tests/spec-fixtures.mjs @@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs'; import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs'; export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim(); const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; -export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) { +export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-spec-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); @@ -16,6 +16,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: { coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) }, reviewer: { definition: 'reviewer', authority: authority(['message.send']) }, + chief: { definition: 'chief-of-staff', authority: authority(['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']) }, ...roles, } }; const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root }); @@ -31,7 +32,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role }); broker.request(cap, { verb: 'role.claim' }); return cap; }; - const coder = agent('coder'), reviewer = agent('reviewer'); + const coder = agent('coder'), reviewer = agent('reviewer'), chief = agent('chief'); const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const call = (cap, verb, args = {}) => broker.request(cap, { verb, args }); const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options, @@ -42,5 +43,5 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech git(repo, '-c', `user.name=${author}`, '-c', 'user.email=fixture@mosaic.invalid', 'commit', '-m', 'Spec'); return git(repo, 'rev-parse', 'HEAD'); }; - return { root, store, business, broker, repo, commit, agent, coder, reviewer, human, call, raise, save }; + return { root, store, business, broker, repo, commit, agent, coder, reviewer, chief, human, call, raise, save }; } diff --git a/packages/bus/tests/spec-review.test.mjs b/packages/bus/tests/spec-review.test.mjs index 2c5c16350..c98d00b94 100644 --- a/packages/bus/tests/spec-review.test.mjs +++ b/packages/bus/tests/spec-review.test.mjs @@ -19,7 +19,7 @@ async function setup(t, { running = ['chief', 'pm', 'cto'], thread = true } = {} cto: { definition: 'cto', ...within }, assistant: { definition: 'assistant', ...within }, } }); f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } }; - f.cap = Object.fromEntries(running.map(role => [role, f.agent(role)])); + f.cap = Object.fromEntries(running.map(role => [role, role === 'chief' ? f.chief : f.agent(role)])); if (thread) { const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id; f.threadId = f.call(f.human, 'thread.create', { project, name: 'alpha' }).id; diff --git a/packages/bus/tests/staff-request.test.mjs b/packages/bus/tests/staff-request.test.mjs index bc8981764..18c62c482 100644 --- a/packages/bus/tests/staff-request.test.mjs +++ b/packages/bus/tests/staff-request.test.mjs @@ -139,7 +139,7 @@ test('REQ-COS-1: the request goes to the business launcher, and only when that i // launcher `chief`, so a check on the name passes there; these use names that differ from the definitions. test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the pm definition can ask, an instance `pm` of the coder definition can\'t', (t) => { const lead = setup(t, { - arbiters: { technical: 'lead', delivery: 'chief' }, + arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: authority.chief }, lead: { definition: 'pm', authority: authority.pm }, @@ -172,9 +172,9 @@ test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the p test('REQ-COS-1: the launcher is judged by definition: an instance `boss` of the chief-of-staff definition receives, a `chief` of the coder definition gives no-chief-of-staff', (t) => { const boss = setup(t, { - arbiters: { technical: 'pm', delivery: 'boss' }, + arbiters: { technical: 'pm', delivery: 'chief' }, launch: { by: 'boss', instances: ['coder'], max: { opus: 2, sonnet: 2 } }, - roles: { + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, boss: { definition: 'chief-of-staff', authority: authority.chief }, pm: { definition: 'pm', authority: authority.pm }, coder: { definition: 'coder', ...none }, diff --git a/packages/bus/tests/system-type.test.mjs b/packages/bus/tests/system-type.test.mjs index 26f81d409..97b980758 100644 --- a/packages/bus/tests/system-type.test.mjs +++ b/packages/bus/tests/system-type.test.mjs @@ -91,7 +91,7 @@ test('runtime reads the current organization file for project selections and con doc.plugins = { 'system-type': ['coding'] }; writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); - const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } }; + const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } }; const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } }); t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); diff --git a/packages/bus/tests/tester-writes.test.mjs b/packages/bus/tests/tester-writes.test.mjs index cfaa4a100..c46d76e6a 100644 --- a/packages/bus/tests/tester-writes.test.mjs +++ b/packages/bus/tests/tester-writes.test.mjs @@ -46,8 +46,8 @@ async function runtime(t) { dataRoot: join(root, 'data'), businesses: { demo: { - id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, - roles: { coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } }, + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } }, }, }, organization: { dir: root, rolesDir, system: systemFor(root) }, @@ -166,8 +166,8 @@ function broker(t, vars = { 'bus.tester': true }) { store, businesses: { demo: { - id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}), - roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } }, + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}), + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } }, }, }, }); diff --git a/packages/bus/tests/usage-broker.test.mjs b/packages/bus/tests/usage-broker.test.mjs index c1556b7c5..a3368f826 100644 --- a/packages/bus/tests/usage-broker.test.mjs +++ b/packages/bus/tests/usage-broker.test.mjs @@ -8,7 +8,7 @@ import { Broker } from '../src/broker.mjs'; test('REQ-LIMIT-4 usage protocol binds run identity, requires a delivered turn and isolates businesses', async t => { const root = mkdtempSync(join(tmpdir(), 'usage-broker-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); - const businesses = { demo: { id: 'demo', human: 'jason', roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } }; + const businesses = { demo: { id: 'demo', human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } } }; const recorded = []; const broker = new Broker({ store, businesses, usage: { collect: async (...args) => { recorded.push(args); return { recorded: true }; }, diff --git a/packages/bus/tests/usage-runtime.test.mjs b/packages/bus/tests/usage-runtime.test.mjs index 69f31a8ba..02c060250 100644 --- a/packages/bus/tests/usage-runtime.test.mjs +++ b/packages/bus/tests/usage-runtime.test.mjs @@ -12,7 +12,7 @@ test('REQ-PLUG-1 runtime reaches transcript only through loader, over the real b t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); }); const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true }); const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'], harness: ['pi'] }, - roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } }; + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } }; runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{ // Row 65: the launch names where its harness keeps the session files. business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model', transcript: dir, diff --git a/packages/bus/tests/workflow-roles.test.mjs b/packages/bus/tests/workflow-roles.test.mjs index d9669d6c6..ae42855ed 100644 --- a/packages/bus/tests/workflow-roles.test.mjs +++ b/packages/bus/tests/workflow-roles.test.mjs @@ -65,10 +65,10 @@ test("merge-gate's technical raise is cross-role, goes to the cto, and only merg assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-consumed/); }); -test("merge-gate has no message.send, and without a domain its raise goes to the delivery arbiter", (t) => { +test("merge-gate has no message.send, and without a domain its merge raise still goes to the technical arbiter", (t) => { const { call, raise } = setup(t); assert.throws(() => call('merge-gate', 'message.send', { to: 'cto', body: 'landed' }), /decision-required/); - assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'pm'); + assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'cto'); // Every other action is gated for merge-gate, so an uncited raise goes to the human. const push = raise('merge-gate', 'git.push.working', { domain: 'technical', target: TASK }); assert.deepEqual([push.class, push.route_to], ['gated', 'human']); diff --git a/packages/cli/tests/access.test.mjs b/packages/cli/tests/access.test.mjs index 8fa9b8340..1e0eba7d9 100644 --- a/packages/cli/tests/access.test.mjs +++ b/packages/cli/tests/access.test.mjs @@ -290,7 +290,7 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing", const config = join(root, "config"); const bus = await startBroker({ dataRoot: f.dataRoot, - businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, + businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: config, rolesDir: f.roles, system: systemFor(root) }, }); t.after(() => bus.close()); diff --git a/packages/cli/tests/instances.test.mjs b/packages/cli/tests/instances.test.mjs index 29b6de7e7..f421e8458 100644 --- a/packages/cli/tests/instances.test.mjs +++ b/packages/cli/tests/instances.test.mjs @@ -33,7 +33,7 @@ async function setup(t, { stub = false, hook = null } = {}) { const bus = await startBroker({ dataRoot: f.dataRoot, businesses: stub - ? { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } } + ? { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } } : boot.businesses, organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization, }); diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index 0105db26a..1cef07688 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -445,7 +445,7 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f test("a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none", { skip, timeout: 30000 }, async (t) => { const asked = []; const held = [{ instance: "chief", decision: "d1", task_ref: "vikunja:1/2" }, { instance: "coder", decision: "d2", task_ref: "vikunja:1/3" }]; - const answering = (holders) => ({ wrap: (host) => Object.create(host, { op: { value: async (m) => (m.op === "landHolders" ? (asked.push(m), holders) : host.op(m)) } }) }); + const answering = (holders) => ({ threadTickMs: 3600000, wrap: (host) => Object.create(host, { op: { value: async (m) => (m.op === "landHolders" ? (asked.push(m), holders) : host.op(m)) } }) }); const landing = "- Landing: once your merge decision is approved, land_work it with the task."; const read = (f, run, file) => readFileSync(join(f.dataRoot, "launches", "acme", run, file), "utf8"); diff --git a/packages/cli/tests/publish-socket.test.mjs b/packages/cli/tests/publish-socket.test.mjs index 608aa09b4..1cc236e36 100644 --- a/packages/cli/tests/publish-socket.test.mjs +++ b/packages/cli/tests/publish-socket.test.mjs @@ -90,9 +90,9 @@ async function setup(t, { publishDeadlineMs } = {}) { credentials: { gitea: { store: true, file: join(root, "secrets", "agents", "demo", user, "gitea"), rotateBy: "2099-01-01" } }, }); const demo = { - id: "demo", human: "jason", arbiters: { technical: "reviewer", delivery: "reviewer" }, + id: "demo", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, projects: { stack: { root: project, remote: `${gitea.origin}/mosaic/stack` } }, - roles: { + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: agent("coder"), second: agent("second"), reviewer: { authority: { withinRole: ["message.send"], crossRole: [] } }, diff --git a/packages/cli/tests/tester-reads.test.mjs b/packages/cli/tests/tester-reads.test.mjs index c675c4846..49dab5e5b 100644 --- a/packages/cli/tests/tester-reads.test.mjs +++ b/packages/cli/tests/tester-reads.test.mjs @@ -18,8 +18,8 @@ const none = { authority: { withinRole: ["message.send"], crossRole: [] } }; const inAgent = (env) => io({ ...env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" }); const bus = (vars = { "bus.tester": true }) => ({ acme: { - id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, ...(vars ? { vars } : {}), - roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } }, + id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}), + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } }, }, }); diff --git a/packages/cli/tests/tester-threads.test.mjs b/packages/cli/tests/tester-threads.test.mjs index 994633459..d9a04367c 100644 --- a/packages/cli/tests/tester-threads.test.mjs +++ b/packages/cli/tests/tester-threads.test.mjs @@ -281,8 +281,8 @@ function mosaic(args, env) { test("row 184: scripts/mosaic runs thread create, threads, talk --thread and thread read as the tester through tester-cli.mjs", async (t) => { const { root, f, org } = await orgFixture(t); const none = { authority: { withinRole: ["message.send"], crossRole: [] } }; - const businesses = { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, vars: { "bus.tester": true }, - roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } } } }; + const businesses = { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, vars: { "bus.tester": true }, + roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } } } }; const rt = await startBroker({ dataRoot: org.root, businesses, organization: { dir: join(root, ".config", "mosaic-test"), rolesDir: f.roles, system: systemFor(root) } }); t.after(() => rt.close()); const human = rt.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }); diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index b0638a42f..fc9769689 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -193,17 +193,17 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest' // Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed. // Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing -// the chief holds is untyped and the prompt has no untyped line. Row 194 (#1692, PRD 1.7 REQ-COS-3) -// granted role.hire and role.fire, with hire and fire. -test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing untyped, for either harness", (t) => { +// the chief held then was untyped. Row 194 (#1692, PRD 1.7 REQ-COS-3) +// granted role.hire and role.fire, with hire and fire. RR adds two resolve rights; TI owns their tools. +test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and resolve rights as untyped, for either harness", (t) => { for (const harness of ["pi", "claude-code"]) { const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" })); const b = build(t, r); const prefix = harness === "pi" ? "" : "mcp__mosaic__"; assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness); - assert.deepEqual(b.manifest.untyped, [], harness); + assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness); assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /); - assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/); + assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/); } }); diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs index 973672c6c..690194fa1 100644 --- a/packages/harness/tests/runner.test.mjs +++ b/packages/harness/tests/runner.test.mjs @@ -23,15 +23,15 @@ const businesses = { demo: { id: "demo", human: "jason", - arbiters: { technical: "pm", delivery: "pm" }, + arbiters: { technical: "chief", delivery: "pm" }, launch: { by: "pm", instances: ["coder"], models:{}, limits:{} }, roles: { - pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } }, + pm: { authority: { withinRole: ["message.send", "role.launch", "decision.resolve.delivery"], crossRole: [] } }, coder: { authority: { withinRole: ["message.send"], crossRole: [] } }, // Row 132 (#1628): the chief of staff's grants and the assistant. Since row 139 (Q169) the // chief may end the assistant; with no thread run holding a claim the end refuses not-held. // Row 155 (#1649) adds row 141's instance.name for set_name. - chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change", "instance.name"], crossRole: [] } }, + chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change", "instance.name", "decision.resolve.technical", "decision.resolve.delivery"], crossRole: [] } }, assistant: { definition: "assistant", authority: { withinRole: ["message.send"], crossRole: [] } }, }, }, @@ -829,8 +829,10 @@ test("a run in a landing turn isn't released for a second held decision until it const earlier = ctx.broker.bindLaunch({ business: "demo", role: "coder", run: "coder-0", harness: "pi" }); ctx.call(earlier, "role.claim"); const d1 = ctx.call(earlier, "decision.raise", merge("vikunja:1/2")); - assert.equal(d1.route_to, "pm"); - ctx.call(ctx.pm, "decision.resolve", { id: d1.id, choice: "yes" }); + const chief = ctx.broker.bindLaunch({ business: "demo", role: "chief", run: "chief-run", harness: "pi" }); + ctx.call(chief, "role.claim"); + assert.equal(d1.route_to, "chief"); + ctx.call(chief, "decision.resolve", { id: d1.id, choice: "yes" }); ctx.broker.endLaunch({ business: "demo", run: "coder-0", reason: "exited" }); const file = join(ctx.runDir, "session.json"); writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, "utf8")), landing: [{ decision: d1.id, task_ref: "vikunja:1/2" }] })); @@ -843,7 +845,7 @@ test("a run in a landing turn isn't released for a second held decision until it await until(() => existsSync(started)); // d2 is resolved during the landing turn, so the landing pass lists it for the running holder. const d2 = ctx.call(ctx.cap, "decision.raise", merge("vikunja:1/3")); - ctx.call(ctx.pm, "decision.resolve", { id: d2.id, choice: "yes" }); + ctx.call(chief, "decision.resolve", { id: d2.id, choice: "yes" }); assert.deepEqual(ctx.broker.landHolders("demo").map((h) => [h.instance, h.decision]), [["coder", d1.id], ["coder", d2.id]]); const release = () => ctx.broker.landRelease({ business: "demo", run: "coder-run", decision: d2.id }); assert.deepEqual(release(), { released: false, landing: true }); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index 209436df2..a5cc63f54 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -58,7 +58,8 @@ test("the chief of staff gets launch, send_message, end_run, hire, fire, change_ // Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted // instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped. Row 194 (#1692, // PRD 1.7 REQ-COS-3) granted role.hire and role.fire, with hire and fire. - assert.deepEqual(untypedActions(chief, tools), []); + // RR grants resolve rights; TI owns their typed tool exposure. + assert.deepEqual(untypedActions(chief, tools), ["decision.resolve.technical", "decision.resolve.delivery"]); }); // Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required, diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index 30b640a2e..f11c2cab8 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -267,6 +267,10 @@ export function validateOrganizationDocument(document, file, { rolesDir }) { if (definitions[definition].messaging) { refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} declares messaging lists`); } + const right = `decision.resolve.${kind}`; + if (!definitions[definition].authority.withinRole.includes(right)) { + refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} lacks ${right}`); + } arbiters[kind] = name; } diff --git a/packages/organization/src/role.mjs b/packages/organization/src/role.mjs index 2db4aada5..c002ab437 100644 --- a/packages/organization/src/role.mjs +++ b/packages/organization/src/role.mjs @@ -4,7 +4,7 @@ // ceilings. Version 1 files still load: they carry no authority, so every // vocabulary action is gated for them. -import { existsSync, lstatSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { refuse } from "./errors.mjs"; import { @@ -155,6 +155,9 @@ function checkContract(contract, file) { refuse(`role contract not found: ${path}`); } if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`); + if (!/^## Authority[\t ]*\r?$/m.test(readFileSync(path, "utf8"))) { + refuse(`role contract ${path} requires a ## Authority section`); + } return path; } diff --git a/packages/organization/src/vocabulary.mjs b/packages/organization/src/vocabulary.mjs index 3e6e4d971..66f482299 100644 --- a/packages/organization/src/vocabulary.mjs +++ b/packages/organization/src/vocabulary.mjs @@ -58,6 +58,7 @@ export const ACTIONS = Object.freeze([ "policy.change", "prd.approve", "decision.resolve.technical", + "decision.resolve.delivery", // Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library. // Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role // to the creative roles only. @@ -93,6 +94,7 @@ export const ROUTED = Object.freeze([ "policy.change", "prd.approve", "decision.resolve.technical", + "decision.resolve.delivery", // PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks. "role.hire", "role.fire", diff --git a/packages/organization/tests/chief-of-staff.test.mjs b/packages/organization/tests/chief-of-staff.test.mjs index 88ce6400b..8f4bb7f61 100644 --- a/packages/organization/tests/chief-of-staff.test.mjs +++ b/packages/organization/tests/chief-of-staff.test.mjs @@ -15,11 +15,11 @@ import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from ". // Decision 104's list plus role.launch (decision 139) plus role.end and // organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name // (row 141, REQ-MSG-2, sage-0589), plus role.hire and role.fire (row 194, PRD 1.7 REQ-COS-3, -// sage-1471, mos-1089). A role file that differs from it fails. +// sage-1471, mos-1089), plus both resolve rights (RR, #1873). A role file that differs fails. const APPROVED = Object.freeze({ tools: ["read", "grep", "find", "ls"], network: "api-only", - authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }), + authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire", "decision.resolve.technical", "decision.resolve.delivery"]), crossRole: Object.freeze([]) }), // Row 285 (#1790, mos-1309 item 5, sage-2915): gitea for the library read grant, read scopes only. credentials: Object.freeze([Object.freeze({ service: "gitea", scopes: Object.freeze(["read:repository", "read:user"]) })]), // Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve. @@ -91,7 +91,7 @@ test("the resolved record matches the approved list, so a widened file fails", ( test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => { const record = resolved(loadFixture()); - const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]; + const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire", "decision.resolve.technical", "decision.resolve.delivery"]; for (const action of ACTIONS) { assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action); } diff --git a/packages/organization/tests/composition.test.mjs b/packages/organization/tests/composition.test.mjs index f7366f7a0..0e7f6afdf 100644 --- a/packages/organization/tests/composition.test.mjs +++ b/packages/organization/tests/composition.test.mjs @@ -137,14 +137,21 @@ test("role.hire and role.fire refuse", () => { // Step 5. test("a decision.resolve action comes back as a flagged line", () => { - const { flagged } = run(base({ authority: { withinRole: ["message.send"], crossRole: ["decision.resolve.technical"] } })); - assert.deepEqual(flagged, [ - { field: "authority.crossRole", value: "decision.resolve.technical", reasons: [FLAG_ABOVE_CHIEF, FLAG_RESOLVE] }, - ]); - // Held by the chief: still flagged as a resolve right, and only that. - const chief = { ...CHIEF, authority: { withinRole: [...CHIEF.authority.withinRole, "decision.resolve.technical"], crossRole: [] } }; - const held = run(base({ authority: { withinRole: ["decision.resolve.technical"], crossRole: [] } }), CORE, chief); - assert.deepEqual(held.flagged, [{ field: "authority.withinRole", value: "decision.resolve.technical", reasons: [FLAG_RESOLVE] }]); + for (const domain of ["technical", "delivery"]) { + const action = `decision.resolve.${domain}`; + const without = { ...CHIEF, authority: { ...CHIEF.authority, + withinRole: CHIEF.authority.withinRole.filter(value => value !== action) } }; + const input = base({ authority: { withinRole: ["message.send"], crossRole: [action] } }); + assert.deepEqual(run(input, CORE, without).flagged, [ + { field: "authority.crossRole", value: action, reasons: [FLAG_ABOVE_CHIEF, FLAG_RESOLVE] }, + ]); + // RR gives the shipped chief both rights. Each still flags, without above-chief. + assert.deepEqual(run(input).flagged, [ + { field: "authority.crossRole", value: action, reasons: [FLAG_RESOLVE] }, + ]); + const held = run(base({ authority: { withinRole: [action], crossRole: [] } })); + assert.deepEqual(held.flagged, [{ field: "authority.withinRole", value: action, reasons: [FLAG_RESOLVE] }]); + } }); // Step 6. diff --git a/packages/organization/tests/csuite.test.mjs b/packages/organization/tests/csuite.test.mjs index fb836e2e7..c1d6205bb 100644 --- a/packages/organization/tests/csuite.test.mjs +++ b/packages/organization/tests/csuite.test.mjs @@ -89,7 +89,7 @@ test("limits can narrow a C-suite role but never widen it", () => { test("any unknown key, tool, action or version in a role file is refused", () => { const base = JSON.parse(readFileSync(join(REPO_ROLES, "chief-executive-officer.json"), "utf8")); const root = tmp(); - writeFileSync(join(root, "chief-executive-officer.md"), "# contract\n"); + writeFileSync(join(root, "chief-executive-officer.md"), "# contract\n\n## Authority\n"); const check = (extra) => validateRoleDocument({ ...base, ...extra }, join(root, "chief-executive-officer.json")); assert.equal(check({}).name, "chief-executive-officer"); for (const extra of [{ arbiter: "technical" }, { launch: {} }, { tools: ["read", "browser"] }, { roleVersion: 3 }, { authority: { withinRole: ["message.send", "deploy.prod"], crossRole: [] } }]) { diff --git a/packages/organization/tests/hard-waits.test.mjs b/packages/organization/tests/hard-waits.test.mjs index 4b10c17a1..04d68072a 100644 --- a/packages/organization/tests/hard-waits.test.mjs +++ b/packages/organization/tests/hard-waits.test.mjs @@ -13,7 +13,7 @@ const WAITS = ["spend", "data.delete", "network.expose", "account.create", "cred const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"]; const role = (authority) => { const dir = tmp(); - writeFileSync(join(dir, "r.md"), "# contract\n"); + writeFileSync(join(dir, "r.md"), "# contract\n\n## Authority\n"); const doc = { roleVersion: 2, name: "r", title: "R", contract: "r.md", tools: ["read"], network: "none", authority, credentials: [{ service: "gitea", scopes: ["read:issue"] }] }; return validateRoleDocument(doc, join(dir, "r.json")); diff --git a/packages/organization/tests/resolve-rights.test.mjs b/packages/organization/tests/resolve-rights.test.mjs new file mode 100644 index 000000000..53d1b07e0 --- /dev/null +++ b/packages/organization/tests/resolve-rights.test.mjs @@ -0,0 +1,52 @@ +// RR (#1873): the file writer refuses arbiters without their domain's right. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { organizationWriter } from '../src/organization.mjs'; +import { loadRole, validateBusinessDocument } from '../src/index.mjs'; +import { businessDoc, REPO_ROLES, rolesCopy, tmp, writeJson } from './helpers.mjs'; + +for (const [domain, instance, definition] of [['technical', 'cto', 'cto'], ['delivery', 'pm', 'pm']]) { + test(`RR: naming ${instance} without decision.resolve.${domain} refuses when written`, t => { + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const rolesDir = rolesCopy(root), file = join(root, 'businesses/acme.json'); + writeJson(file, businessDoc(root)); + const writer = organizationWriter({ dir: root, rolesDir }); + const { document, revision } = writer.read('acme'), before = readFileSync(file, 'utf8'); + const roleFile = join(rolesDir, `${definition}.json`), role = JSON.parse(readFileSync(roleFile, 'utf8')); + role.authority.withinRole = role.authority.withinRole.filter(a => a !== `decision.resolve.${domain}`); + writeJson(roleFile, role); + assert.throws(() => writer.write('acme', document, { revision, actor: 'jason' }), error => { + assert.match(error.message, new RegExp(`arbiters\\.${domain}.*${instance}.*role definition ${definition}.*decision\\.resolve\\.${domain}`)); + return true; + }); + assert.equal(readFileSync(file, 'utf8'), before); + }); +} + +test('RR: a coder named as arbiter refuses when written', t => { + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const file = join(root, 'businesses/acme.json'); writeJson(file, businessDoc(root)); + const writer = organizationWriter({ dir: root, rolesDir: REPO_ROLES }); + const { document, revision } = writer.read('acme'), before = readFileSync(file, 'utf8'); + document.arbiters.delivery = 'coder'; + assert.throws(() => writer.write('acme', document, { revision, actor: 'jason' }), /arbiters\.delivery.*coder.*role definition coder.*decision\.resolve\.delivery/); + assert.equal(readFileSync(file, 'utf8'), before); +}); + +test('RR: a cross-role resolve grant cannot qualify an arbiter', t => { + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const rolesDir = rolesCopy(root), roleFile = join(rolesDir, 'cto.json'); + const role = JSON.parse(readFileSync(roleFile, 'utf8')); + role.authority.withinRole = role.authority.withinRole.filter(a => a !== 'decision.resolve.technical'); + role.authority.crossRole.push('decision.resolve.technical'); writeJson(roleFile, role); + assert.throws(() => validateBusinessDocument(businessDoc(root), join(root, 'businesses/acme.json'), { rolesDir }), /cto.*decision\.resolve\.technical/); +}); + +test('RR: a version 2 contract without its Authority section refuses', t => { + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const rolesDir = rolesCopy(root); + writeFileSync(join(rolesDir, 'coder.md'), '# Role contract: coder\n\n## Duties\nRead only.\n'); + assert.throws(() => loadRole(rolesDir, 'coder'), /coder.*## Authority/); +}); diff --git a/packages/organization/tests/role.test.mjs b/packages/organization/tests/role.test.mjs index 690ef71d2..5e495e6f9 100644 --- a/packages/organization/tests/role.test.mjs +++ b/packages/organization/tests/role.test.mjs @@ -15,7 +15,7 @@ function refuses(fn, pattern, exitCode = 2) { }); } -function scratchRole(overrides = {}, contract = "# contract\n") { +function scratchRole(overrides = {}, contract = "# contract\n\n## Authority\n") { const dir = tmp(); if (contract !== null) writeFileSync(join(dir, "r.md"), contract); const doc = { @@ -243,7 +243,7 @@ test("the six creative roles load: read tools, message.send and library.write, g test("M1: a creative role file wider than read and answer is refused", () => { const creative = (name, overrides) => { const dir = tmp(); - writeFileSync(join(dir, `${name}.md`), "# contract\n"); + writeFileSync(join(dir, `${name}.md`), "# contract\n\n## Authority\n"); const doc = { roleVersion: 2, name, title: "C", contract: `${name}.md`, tools: ["read"], network: "none", authority: { withinRole: ["message.send"], crossRole: [] }, credentials: [], @@ -284,7 +284,7 @@ const REFUSED_FOR_HIRE = ACTIONS.filter((action) => !HIRE_ALLOWED.includes(actio function hireDir(definitions) { const dir = tmp(); for (const [name, doc] of Object.entries(definitions)) { - writeFileSync(join(dir, `${name}.md`), "# contract\n"); + writeFileSync(join(dir, `${name}.md`), "# contract\n\n## Authority\n"); writeJson(join(dir, `${name}.json`), doc === 1 ? { roleVersion: 1, name, tools: ["read"] } : { roleVersion: 2, name, title: name, contract: `${name}.md`, tools: ["read"], network: "none", authority: { withinRole: doc.withinRole ?? ["message.send"], crossRole: doc.crossRole ?? [] }, credentials: [], diff --git a/packages/webui/tests/archive-unknown-browser.test.mjs b/packages/webui/tests/archive-unknown-browser.test.mjs index d0686a38a..ea664952a 100644 --- a/packages/webui/tests/archive-unknown-browser.test.mjs +++ b/packages/webui/tests/archive-unknown-browser.test.mjs @@ -30,7 +30,7 @@ test('a failed archive lookup cannot hide the real broker refusal of an archived const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const roles = { assistant: { definition:'assistant', authority:{withinRole:['message.send'],crossRole:[]} } }; - const broker = new Broker({ store, businesses:{ demo:{ id:'demo', human:'jason', arbiters:{technical:'assistant',delivery:'assistant'}, roles, definitions:{assistant:{}}, projects:{} } } }); + const broker = new Broker({ store, businesses:{ demo:{ id:'demo', human:'jason', arbiters:{technical:'chief',delivery:'chief'}, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...roles }, definitions:{assistant:{}}, projects:{} } } }); const cap = broker.bindHuman({ business:'demo', human:'jason', via:'cli', outsideAgent:true }); const call = (verb, args = {}) => broker.request(cap, { verb, args }); const project = call('project.create', { name:'archive test' }).id; diff --git a/packages/webui/tests/proxy-canary.test.mjs b/packages/webui/tests/proxy-canary.test.mjs index 32c2f34d4..db3fb7816 100644 --- a/packages/webui/tests/proxy-canary.test.mjs +++ b/packages/webui/tests/proxy-canary.test.mjs @@ -19,7 +19,7 @@ test('API-key canary reaches fake once and zero responses/logs/receipts/store fi const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const b = new Broker({ store, proxyPage: (...args) => fake.page.request(...args), - businesses: { demo: { id: 'demo', human: 'owner', roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } } }); + businesses: { demo: { id: 'demo', human: 'owner', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } } } }); const cap = b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }); const logs = [], output = []; const auth = await createSsoSession(t, { host: 'console.example', owner: { sub: 'owner-sub', name: 'owner' } }); @@ -57,7 +57,7 @@ test('OAuth URLs, state and code stay out of broker records and management recei const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const b = new Broker({ store, proxyPage: (...args) => fake.page.request(...args), - businesses: { demo: { id: 'demo', human: 'owner', roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } } }); + businesses: { demo: { id: 'demo', human: 'owner', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } } } }); const cap = b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }); const call = (verb, args = {}) => b.request(cap, { verb, args }); const login = await call('proxy.login.start', { provider: 'claude' }); diff --git a/packages/webui/tests/routes-browser.test.mjs b/packages/webui/tests/routes-browser.test.mjs index 0f4d4ca9f..b9f16d02a 100644 --- a/packages/webui/tests/routes-browser.test.mjs +++ b/packages/webui/tests/routes-browser.test.mjs @@ -31,7 +31,7 @@ test('row 65 Settings: the routing table lists every instance, saves a harness, const dir=join(root,'config'),file=join(dir,'businesses','acme.json'); const host=await startBroker({ dataRoot:f.dataRoot, - businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}}, + businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}}, organization:{dir,rolesDir:f.roles,system:systemFor(root)}, }); t.after(()=>host.close()); @@ -161,7 +161,7 @@ test('Routing offers catalogue models only for compatible harnesses and saves a const dir=join(root,'config'),file=join(dir,'businesses','acme.json'); const offered={key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'z.ai / fixture-id',available:true,reason:null,harnesses:['pi']}; const modelCatalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}}; - const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close()); + const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true}); const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args})); const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')}; @@ -187,7 +187,7 @@ test('Routing offers catalogue models only for compatible harnesses and saves a test('Routing switch writes the model-provider setting and keeps other modules', {timeout:60000}, async t => { const root=tmp(t,'provider-switch-'), f=fixture(root,'acme'); const dir=join(root,'config'),file=join(dir,'businesses','acme.json'); - const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)}});t.after(()=>host.close()); + const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)}});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true}); const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args})); const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')}; @@ -230,7 +230,7 @@ test('enabling a discovered harness refreshes Routing and offers its compatible const dir=join(root,'config'); const offered={key:'fixture-pi',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-pi',label:'Fixture pi',available:true,reason:null,harnesses:['pi']}; const modelCatalogue={read:async()=>({status:'ready',observedAt:'2026-11-07T02:24:02.021Z',models:[offered],notices:[]}),invalidate(){}}; - const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close()); + const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args})); const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture')},routes:()=>call('routes')},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close());const settings=await call('plugins'); diff --git a/packages/webui/tests/routes.test.mjs b/packages/webui/tests/routes.test.mjs index fe87c5f1a..eacef8d40 100644 --- a/packages/webui/tests/routes.test.mjs +++ b/packages/webui/tests/routes.test.mjs @@ -45,7 +45,7 @@ test('row 65 Console: the signed-in human saves a route through a real broker; e const dir = join(root, 'config'); const host = await startBroker({ dataRoot: f.dataRoot, - businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, + businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir, rolesDir: f.roles, system: systemFor(root) }, }); t.after(() => host.close()); diff --git a/packages/webui/tests/workspace-tasks.test.mjs b/packages/webui/tests/workspace-tasks.test.mjs index c26dd63a9..4a32c07a5 100644 --- a/packages/webui/tests/workspace-tasks.test.mjs +++ b/packages/webui/tests/workspace-tasks.test.mjs @@ -45,7 +45,7 @@ function call(target, { method = 'GET', path, headers = {}, body }) { const AUTHORITY = { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] }; async function bus(t, id, { taskTracker = null } = {}) { const root = scratch(t, 'tb-'); - const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'reviewer', delivery: 'coder' }, roles: { coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } }; + const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } }; const store = new Store(root); const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) }); const adapter = await storeAdapter()({ broker, businesses }); diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json index 539e1d134..f1cfb02f7 100644 --- a/roles/chief-of-staff.json +++ b/roles/chief-of-staff.json @@ -6,7 +6,7 @@ "tools": ["read", "grep", "find", "ls"], "network": "api-only", "authority": { - "withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"], + "withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire", "decision.resolve.technical", "decision.resolve.delivery"], "crossRole": [] }, "credentials": [{"service": "gitea", "scopes": ["read:repository", "read:user"]}], diff --git a/roles/pm.json b/roles/pm.json index b1d549640..d887e4428 100644 --- a/roles/pm.json +++ b/roles/pm.json @@ -7,7 +7,7 @@ "network": "api-only", "skills": ["ms-unslop", "ms-goal", "ms-communications"], "authority": { - "withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send", "record.write"], + "withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send", "record.write", "decision.resolve.delivery"], "crossRole": ["task.priority.change", "task.scope.change"] }, "credentials": [ diff --git a/roles/pm.md b/roles/pm.md index 50ac2268c..4574a6b00 100644 --- a/roles/pm.md +++ b/roles/pm.md @@ -90,6 +90,8 @@ order. You don't write the product's code and you don't review it. ## Authority +Resolve delivery decisions when the organization names you its delivery arbiter. + Within your grants, act on what you see without waiting to be asked, and raise a decision or send a message for anything outside them. -- 2.54.0 From e9a079fcc44f8b173e5dae648b65744b0515e40e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 16:51:03 -0600 Subject: [PATCH 2/2] fix(organization): validate narrowed arbiter resolve authority (#1873) --- packages/bus/tests/resolve-rights.test.mjs | 3 ++ packages/harness/tests/request-tools.test.mjs | 2 +- packages/harness/tests/tools.test.mjs | 4 +-- .../organization/src/organization-config.mjs | 4 ++- .../tests/resolve-rights.test.mjs | 34 +++++++++++++++++++ packages/organization/tests/resolve.test.mjs | 4 +-- 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/bus/tests/resolve-rights.test.mjs b/packages/bus/tests/resolve-rights.test.mjs index 7780314f9..d98e75a57 100644 --- a/packages/bus/tests/resolve-rights.test.mjs +++ b/packages/bus/tests/resolve-rights.test.mjs @@ -76,4 +76,7 @@ test('RR: remapping arbiters cannot let a legacy decision resolve with only the f.broker.reloadBusiness('demo', f.business); assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' }); assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0); + // Human recovery closes the old route by withdrawing it, before the raiser creates a new decision. + f.call(f.human, 'decision.withdraw', { id }); + assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', id).op, 'withdrawn'); }); diff --git a/packages/harness/tests/request-tools.test.mjs b/packages/harness/tests/request-tools.test.mjs index ab033698e..cf69b9263 100644 --- a/packages/harness/tests/request-tools.test.mjs +++ b/packages/harness/tests/request-tools.test.mjs @@ -55,7 +55,7 @@ for (const harness of ['pi', 'claude-code']) { } test('PM intake writes and staff request follow the existing message.send grant', t => { - const muted = bundle(t, 'pm', 'pi', doc => { doc.roles.pm.vars['limits.authority'] = ['task.assign']; }); + const muted = bundle(t, 'pm', 'pi', doc => { doc.roles.pm.vars['limits.authority'] = ['task.assign', 'decision.resolve.delivery']; }); assert.deepEqual(muted.tools.filter(intake).map(t => t.name).sort(), reads.toSorted()); assert.ok(!muted.tools.some(t => t.name === 'request_staff')); }); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index a5cc63f54..8b1083272 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -105,7 +105,7 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" })); assert.ok(!names(typedTools({ resolved: assistant })).includes("request_staff")); // A pm without message.send is offered no request_staff, as for send_message. - const mute = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["task.assign"])); + const mute = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["task.assign", "decision.resolve.delivery"])); assert.ok(!names(typedTools({ resolved: mute, tracker: true })).includes("request_staff")); // The pm still has no launch tool, and the chief of staff's launch offer is its limits, not a request. assert.ok(!names(tools).includes("launch")); @@ -241,7 +241,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with assert.match(list.description, /task_create takes only these/); assert.match(list.description, /Product intake always needs an approved product snapshot/); assert.ok(!names(typedTools({ resolved: pm, arbiter: true })).includes("list_requirements")); - const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"])); + const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign", "decision.resolve.delivery"])); const without = names(typedTools({ resolved: noCreate, tracker: true })); assert.ok(without.includes("task_assign") && !without.includes("task_create") && !without.includes("list_requirements")); const create = tools.find((x) => x.name === "task_create").parameters.properties; diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index f11c2cab8..8b717234c 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -268,7 +268,9 @@ export function validateOrganizationDocument(document, file, { rolesDir }) { refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} declares messaging lists`); } const right = `decision.resolve.${kind}`; - if (!definitions[definition].authority.withinRole.includes(right)) { + const narrowed = [vars, roles[name].vars].some(layer => + layer["limits.authority"] !== undefined && !layer["limits.authority"].includes(right)); + if (!definitions[definition].authority.withinRole.includes(right) || narrowed) { refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} lacks ${right}`); } arbiters[kind] = name; diff --git a/packages/organization/tests/resolve-rights.test.mjs b/packages/organization/tests/resolve-rights.test.mjs index 53d1b07e0..f79bc333d 100644 --- a/packages/organization/tests/resolve-rights.test.mjs +++ b/packages/organization/tests/resolve-rights.test.mjs @@ -50,3 +50,37 @@ test('RR: a version 2 contract without its Authority section refuses', t => { writeFileSync(join(rolesDir, 'coder.md'), '# Role contract: coder\n\n## Duties\nRead only.\n'); assert.throws(() => loadRole(rolesDir, 'coder'), /coder.*## Authority/); }); + +for (const [domain, instance] of [['technical', 'cto'], ['delivery', 'pm']]) { + for (const layer of ['agent', 'business', 'intersection']) { + test(`RR: ${layer} limits removing ${instance}'s ${domain} resolve right refuse when written`, t => { + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const file = join(root, 'businesses/acme.json'); writeJson(file, businessDoc(root)); + const writer = organizationWriter({ dir: root, rolesDir: REPO_ROLES }); + const { document, revision } = writer.read('acme'), before = readFileSync(file, 'utf8'); + const right = `decision.resolve.${domain}`; + const rights = ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']; + if (layer === 'agent') document.roles[instance].vars['limits.authority'] = ['message.send']; + else document.vars['limits.authority'] = rights.filter(value => value !== right); + if (layer === 'intersection') document.roles[instance].vars['limits.authority'] = rights; + assert.throws(() => writer.write('acme', document, { revision, actor: 'jason' }), error => { + assert.match(error.message, new RegExp(`arbiters\\.${domain}.*${instance}.*role definition ${instance}.*decision\\.resolve\\.${domain}`)); + return true; + }); + assert.equal(readFileSync(file, 'utf8'), before); + }); + } +} + +test('RR: narrowed arbiters retaining their domain rights can be written', t => { + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const file = join(root, 'businesses/acme.json'); writeJson(file, businessDoc(root)); + const writer = organizationWriter({ dir: root, rolesDir: REPO_ROLES }); + const { document, revision } = writer.read('acme'); + document.vars['limits.authority'] = ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']; + for (const [domain, instance] of [['technical', 'cto'], ['delivery', 'pm']]) { + document.roles[instance].vars['limits.authority'] = ['message.send', `decision.resolve.${domain}`]; + } + writer.write('acme', document, { revision, actor: 'jason' }); + assert.deepEqual(writer.read('acme').document.arbiters, document.arbiters); +}); diff --git a/packages/organization/tests/resolve.test.mjs b/packages/organization/tests/resolve.test.mjs index 2fa106053..57a0a2e68 100644 --- a/packages/organization/tests/resolve.test.mjs +++ b/packages/organization/tests/resolve.test.mjs @@ -138,10 +138,10 @@ test("limits.authority without role.launch leaves the launcher with no launch bl test("limits.authority narrows cross-role actions too", () => { // pm, the role with two cross-role actions: the limit keeps one and drops the other. const s = setup((d) => { - d.roles.pm.vars["limits.authority"] = ["task.create", "task.close", "task.priority.change"]; + d.roles.pm.vars["limits.authority"] = ["task.create", "task.close", "task.priority.change", "decision.resolve.delivery"]; }); const pm = resolve(s, "pm"); - assert.deepEqual(pm.limits.authority, { withinRole: ["task.create", "task.close"], crossRole: ["task.priority.change"] }); + assert.deepEqual(pm.limits.authority, { withinRole: ["task.create", "task.close", "decision.resolve.delivery"], crossRole: ["task.priority.change"] }); assert.equal(classify(pm, "task.priority.change"), "cross"); assert.equal(classify(pm, "task.scope.change"), "gated"); assert.equal(classify(pm, "task.assign"), "gated"); -- 2.54.0