Files
jason.woltjeandClaude Opus 5.5 03bbd2c981 roles, harness: security-reviewer and merge-gate roles, proactive contracts (row 149, #1643)
New roles:
- roles/security-reviewer.json and .md: read tools only, review.verdict
  within its role, no cross-role grant and no credentials, one verdict per
  request.
- roles/merge-gate.json and .md: read tools only, cross-role
  git.merge.protected only. It raises with domain technical and the task
  ref, and lands with land_work once the decision resolves, the verdicts
  approve and the gate passes. The chief of staff messages the relaunched
  instance with the task ref and decision id (round 3 finding 28913).

Contracts:
- All 13 roles/*.md carry the proactive line in their Authority section.
- pm and chief-of-staff carry the REQ-DEC-2 routing paragraph. Its eight
  hard waits are pinned to GATED_ONLY by a test.

Harness: raise_decision takes an optional domain, technical or delivery,
defaulting to delivery. The tool description says where each kind of raise
goes. end_run is unchanged (row 155).

Tests are new files only: business and bus workflow-roles tests and
harness raise-domain. Builder Dewey; Filbert approved round 4 (comment
28929) on manifest 6b4af9c2685b, base 0f715a054 plus build.patch
df0fec543dba.

Gate on 806ab89df plus the candidate, 16:34:57Z to 16:39:43Z: 2027
package tests and 422 script selftests (plus the queue suite's 156 node
tests), 0 failed. Doc-only follow-ups: business README:194-196 and the
launch.instances doc line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 11:40:05 -05:00

54 lines
3.3 KiB
JavaScript

// Row 149 (#1643): raise_decision takes an optional domain, technical or delivery, so a raise can
// reach arbiters.technical. The tool sends what it is given; the broker defaults to delivery.
import test from 'node:test';
import assert from 'node:assert/strict';
import { typedTools, callTool } from '../src/tools.mjs';
import { resolvedFor, fakeToolSocket } from './helpers.mjs';
import { fixture } from '../../bus/tests/spec-fixtures.mjs';
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
test('raise_decision offers domain as technical or delivery only, optional, and says where a raise goes', t => {
for (const role of ['coder', 'reviewer', 'pm', 'chief']) {
const raise = typedTools({ resolved: resolvedFor(t, role) }).find(x => x.name === 'raise_decision');
const { domain } = raise.parameters.properties;
assert.equal(domain.type, 'string');
assert.deepEqual(domain.enum, ['technical', 'delivery']);
for (const other of ['Technical', 'DELIVERY', 'human', 'security', '']) assert.ok(!domain.enum.includes(other), other);
assert.match(domain.description, /Leaving it out means delivery/);
assert.ok(!raise.parameters.required.includes('domain'));
assert.equal(raise.parameters.additionalProperties, false);
assert.match(raise.description, /A cross-role raise, or a gated raise citing the project's current spec commit, goes to the business's arbiter for its domain/);
assert.match(raise.description, /an uncited gated raise or a hard wait goes to the human/);
}
});
test('raise_decision forwards domain to decision.raise, and a call without it sends none', async t => {
const f = await fixture(t, {
arbiters: { technical: 'cto', delivery: 'pm' },
roles: {
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: ['task.reassign'] } },
cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
});
const byName = new Map(typedTools({ resolved: { definition: 'coder', limits: { tools: [], authority: {
withinRole: ['message.send'], crossRole: ['task.reassign'],
} } } }).map(tool => [tool.name, tool]));
const server = await fakeToolSocket(t, f.root, (name, args) =>
f.broker.request(f.coder, { verb: byName.get(name).verb, args }));
const base = { action: 'task.reassign', question: 'Hand T-1 to another coder?', options, recommendation: 'yes', blocking: false, target: 'T-1' };
const technical = await callTool(server.path, 'raise_decision', { ...base, domain: 'technical' });
const delivery = await callTool(server.path, 'raise_decision', { ...base, domain: 'delivery' });
const omitted = await callTool(server.path, 'raise_decision', base);
assert.deepEqual([technical.route_to, delivery.route_to, omitted.route_to], ['cto', 'pm', 'pm']);
assert.equal(server.calls[0].args.domain, 'technical');
assert.equal(server.calls[1].args.domain, 'delivery');
assert.equal(Object.hasOwn(server.calls[2].args, 'domain'), false);
// The harness doesn't check its own schema, so a value outside it reaches the broker, which refuses it.
await assert.rejects(callTool(server.path, 'raise_decision', { ...base, domain: 'security' }), /invalid-domain/);
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 3);
});