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, base0f715a054plus build.patch df0fec543dba. Gate on806ab89dfplus 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]>
This commit is contained in:
1 parent
c682d5f7ad
commit
03bbd2c981
21 files changed
+572
-9
No files matched your search
@@ -0,0 +1,99 @@
|
||||
// Row 149 (#1643, REQ-ROLE-5, REQ-DEC-5): the shipped merge-gate and security-reviewer roles on the
|
||||
// broker. merge-gate raises the merge itself in the technical domain, the technical arbiter resolves
|
||||
// it, and only merge-gate's own instance and thread can use the approval. security-reviewer answers
|
||||
// a review with no message.send grant: the verdict is its own path to the role that asked.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { busBusiness } from '../src/business.mjs';
|
||||
import { resolveInstance, validateBusinessDocument } from '../../business/src/index.mjs';
|
||||
import { businessDoc, REPO_ROLES, systemFor } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const TASK = 'vikunja:3/7';
|
||||
const A = 'a'.repeat(40);
|
||||
const options = [
|
||||
{ key: 'yes', text: 'Land it' },
|
||||
{ key: 'no', text: 'Hold it' },
|
||||
];
|
||||
|
||||
// businessDoc's arbiters are pm (delivery) and cto (technical); the two new instances are added by
|
||||
// definition name, with no credentials or tracker bot.
|
||||
function setup(t, mutate = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-workflow-roles-'));
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.roles['merge-gate'] = { definition: 'merge-gate' };
|
||||
doc.roles['security-reviewer'] = { definition: 'security-reviewer' };
|
||||
const business = validateBusinessDocument(doc, join(root, 'businesses', 'demo.json'), { rolesDir: REPO_ROLES });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(business.roles))
|
||||
resolved[instance] = resolveInstance({ system: systemFor(root), business, instance });
|
||||
const config = busBusiness(business, resolved);
|
||||
mutate(config);
|
||||
const store = new Store(root);
|
||||
const b = new Broker({ store, businesses: { demo: config } });
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const caps = {};
|
||||
for (const role of ['merge-gate', 'security-reviewer', 'coder', 'cto', 'pm']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
b.request(caps[role], { verb: 'role.claim', args: {} });
|
||||
}
|
||||
const call = (role, verb, args = {}) => b.request(caps[role], { verb, args });
|
||||
const raise = (role, action, extra = {}) =>
|
||||
call(role, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'yes', blocking: false, ...extra });
|
||||
return { b, store, caps, call, raise };
|
||||
}
|
||||
|
||||
test("merge-gate's technical raise is cross-role, goes to the cto, and only merge-gate can use the approval", (t) => {
|
||||
const { b, store, caps, call, raise } = setup(t);
|
||||
const d = raise('merge-gate', 'git.merge.protected', { domain: 'technical', target: TASK });
|
||||
assert.equal(d.class, 'cross-role');
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-not-approved/);
|
||||
call('cto', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
const close = store.get("SELECT from_role, to_role FROM messages WHERE class='DECISION' AND decision=?", d.id);
|
||||
assert.deepEqual({ ...close }, { from_role: 'cto', to_role: 'merge-gate' });
|
||||
// The cto resolved it but didn't raise it, so the approval isn't the cto's to use.
|
||||
assert.throws(() => b.authorize(caps.cto, 'git.merge.protected', { decision: d.id, target: TASK }), /decision-mismatch/);
|
||||
assert.equal(b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }).class, 'cross-role');
|
||||
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) => {
|
||||
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');
|
||||
// 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']);
|
||||
});
|
||||
|
||||
test("the cto must be able to message merge-gate, or the technical raise is refused (row 133)", (t) => {
|
||||
const { raise, store } = setup(t, (config) => {
|
||||
const lists = config.definitions['merge-gate'].messaging;
|
||||
config.definitions['merge-gate'].messaging = { ...lists, receiveFrom: lists.receiveFrom.filter((r) => r !== 'cto') };
|
||||
});
|
||||
assert.throws(() => raise('merge-gate', 'git.merge.protected', { domain: 'technical', target: TASK }), /decision-route-refused/);
|
||||
assert.equal(store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('security-reviewer answers a review with its verdict alone; it holds no message.send', (t) => {
|
||||
const { b, store, caps, call } = setup(t);
|
||||
b.checkPush(caps.coder, TASK);
|
||||
b.recordPush(caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-3-7', commit: A, previous: null });
|
||||
const r = call('coder', 'review.request', { task_ref: TASK, commit: A, to: 'security-reviewer' });
|
||||
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', r.message).to_role, 'security-reviewer');
|
||||
assert.throws(() => call('security-reviewer', 'message.send', { to: 'coder', body: 'looks fine' }), /decision-required/);
|
||||
const v = call('security-reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'No security findings.' });
|
||||
const m = store.get('SELECT from_role, to_role, class FROM messages WHERE id=?', v.message);
|
||||
assert.deepEqual({ ...m }, { from_role: 'security-reviewer', to_role: 'coder', class: 'REVIEW-RESULT' });
|
||||
// It can't push or merge on its own.
|
||||
assert.throws(() => b.checkPush(caps['security-reviewer'], TASK), /decision-required/);
|
||||
assert.throws(() => b.authorize(caps['security-reviewer'], 'git.merge.protected', { target: TASK }), /decision-required/);
|
||||
});
|
||||
@@ -175,6 +175,51 @@ these roles goes live. `packages/business/tests/chief-of-staff.test.mjs` and
|
||||
through `loadBusiness`; `role.test.mjs` loads every file in `roles/`
|
||||
through the validator.
|
||||
|
||||
### Security-reviewer and merge-gate instances
|
||||
|
||||
Two roles finish the coding workflow next to `coder` and `reviewer`
|
||||
(REQ-ROLE-5; row 149, #1643).
|
||||
|
||||
The `security-reviewer` role (`roles/security-reviewer.json`, contract
|
||||
`roles/security-reviewer.md`) reviews a candidate for security only. It
|
||||
holds `review.verdict` within role and nothing else: read-only tools
|
||||
(read, grep, find, ls), no shell, no write tools, no credentials. The
|
||||
verdict reaches the role that asked through `review.verdict` itself, so the
|
||||
role needs no `message.send`. Without it the runner's automatic reply to
|
||||
a message is refused; the verdict still arrives.
|
||||
|
||||
The `merge-gate` role (`roles/merge-gate.json`, contract
|
||||
`roles/merge-gate.md`) lands approved work. Its one grant is
|
||||
`git.merge.protected` as a cross-role action, the grant row 137's land op
|
||||
checks. It raises the merge decision itself with domain `technical`,
|
||||
because only the raising instance and thread can use an approval
|
||||
(REQ-DEC-5), and lands once the technical arbiter resolves it. It has the
|
||||
same read-only tools and no credentials.
|
||||
|
||||
Both declare messaging lists. The technical arbiter must be able to
|
||||
message merge-gate, or the raise is refused (row 133): the shipped lists
|
||||
include `cto`, so a business whose technical arbiter is another
|
||||
definition has to change merge-gate's lists by a reviewed commit. Because
|
||||
of those lists, neither role can be named in `arbiters`; the business
|
||||
file is refused.
|
||||
|
||||
No verb adds a role instance. `organization.set`'s `instances` only sets
|
||||
the model of an instance the business file already declares
|
||||
(`launch-settings.mjs`), so the human adds both entries by editing the
|
||||
business file, then runs `validate`. No shipped business declares either
|
||||
role yet; adding them to a live business is a separate step with its own
|
||||
go. The entries are:
|
||||
|
||||
```json
|
||||
"merge-gate": { "definition": "merge-gate" },
|
||||
"security-reviewer": { "definition": "security-reviewer" }
|
||||
```
|
||||
|
||||
`packages/business/tests/workflow-roles.test.mjs` checks both against
|
||||
their approved lists, the arbiter refusal and the proactive line in every
|
||||
contract; `packages/bus/tests/workflow-roles.test.mjs` checks the merge
|
||||
route and the verdict path on the broker.
|
||||
|
||||
### Project file
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Row 149 (#1643, REQ-ROLE-5): the security-reviewer and merge-gate roles, and the proactive line in
|
||||
// every role contract. security-reviewer holds read-only tools and the verdict; merge-gate holds
|
||||
// git.merge.protected cross-role and nothing else, so every landing needs a decision it raised.
|
||||
// A widened file fails here. Fixtures live in a temp directory and nothing reads the real config.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ACTIONS, BusinessError, GATED_ONLY, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const APPROVED = Object.freeze({
|
||||
"security-reviewer": {
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: { withinRole: ["review.verdict"], crossRole: [] },
|
||||
credentials: [],
|
||||
// The verdict goes back to the role that asked; coder and cto hold review.request.
|
||||
messaging: {
|
||||
sendTo: ["human", "chief-of-staff", "pm", "cto", "coder"],
|
||||
receiveFrom: ["human", "chief-of-staff", "pm", "cto", "coder"],
|
||||
},
|
||||
},
|
||||
"merge-gate": {
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: { withinRole: [], crossRole: ["git.merge.protected"] },
|
||||
credentials: [],
|
||||
// cto, the technical arbiter in businessDoc, closes merge-gate's raise and so must reach it (row 133).
|
||||
messaging: {
|
||||
sendTo: ["human", "chief-of-staff", "pm", "cto"],
|
||||
receiveFrom: ["human", "chief-of-staff", "pm", "cto"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const NAMES = Object.keys(APPROVED);
|
||||
const WRITES = ["write", "edit", "bash", "web"];
|
||||
const PROACTIVE = /Within\s+your\s+grants,\s+act\s+on\s+what\s+you\s+see\s+without\s+waiting\s+to\s+be\s+asked,\s+and\s+raise\s+a\s+decision\s+or\s+send\s+a\s+message\s+for\s+anything\s+outside\s+them\./;
|
||||
|
||||
function fixture(mutate = () => {}) {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root, "acme");
|
||||
for (const name of NAMES) doc.roles[name] = { definition: name };
|
||||
mutate(doc);
|
||||
writeJson(join(root, "businesses", "acme.json"), doc);
|
||||
return { root, business: loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }) };
|
||||
}
|
||||
const resolved = ({ root, business }, instance) => resolveInstance({ system: systemFor(root), business, instance });
|
||||
|
||||
test("both definitions load, with a contract file, and are exactly the approved lists", () => {
|
||||
for (const name of NAMES) {
|
||||
const role = loadRole(REPO_ROLES, name);
|
||||
assert.equal(role.roleVersion, 2);
|
||||
assert.equal(role.contract, `${name}.md`);
|
||||
assert.ok(existsSync(join(REPO_ROLES, `${name}.md`)), name);
|
||||
const { title, ...rest } = JSON.parse(readFileSync(join(REPO_ROLES, `${name}.json`), "utf8"));
|
||||
assert.equal(typeof title, "string");
|
||||
assert.deepEqual(rest, { roleVersion: 2, name, contract: `${name}.md`, ...APPROVED[name] }, name);
|
||||
assert.match(readFileSync(join(REPO_ROLES, `${name}.md`), "utf8"), new RegExp(`^# Role contract: ${name}\\n`), name);
|
||||
}
|
||||
});
|
||||
|
||||
test("security-reviewer has no write or shell tool and classifies only the verdict as its own", () => {
|
||||
const role = loadRole(REPO_ROLES, "security-reviewer");
|
||||
for (const tool of WRITES) assert.ok(!role.tools.includes(tool), tool);
|
||||
const record = resolved(fixture(), "security-reviewer");
|
||||
for (const tool of WRITES) assert.ok(!record.limits.tools.includes(tool), tool);
|
||||
assert.deepEqual(record.credentials, {});
|
||||
for (const action of ACTIONS) assert.equal(classify(record, action), action === "review.verdict" ? "within" : "gated", action);
|
||||
});
|
||||
|
||||
test("merge-gate's only grant is git.merge.protected cross-role, and it has no write tool", () => {
|
||||
const role = loadRole(REPO_ROLES, "merge-gate");
|
||||
assert.deepEqual(role.authority, { withinRole: [], crossRole: ["git.merge.protected"] });
|
||||
for (const tool of WRITES) assert.ok(!role.tools.includes(tool), tool);
|
||||
const record = resolved(fixture(), "merge-gate");
|
||||
assert.deepEqual(record.credentials, {});
|
||||
for (const action of ACTIONS) assert.equal(classify(record, action), action === "git.merge.protected" ? "cross" : "gated", action);
|
||||
});
|
||||
|
||||
test("neither role can be an arbiter: its messaging lists refuse the business file (row 133)", () => {
|
||||
for (const name of NAMES) {
|
||||
for (const kind of ["technical", "delivery"]) {
|
||||
assert.throws(() => fixture((doc) => (doc.arbiters[kind] = name)),
|
||||
(e) => e instanceof BusinessError && e.message.includes(`arbiters.${kind} names ${name}, whose role definition ${name} declares messaging lists`),
|
||||
`${kind} ${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every role contract carries the proactive line in its Authority section", () => {
|
||||
const contracts = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".md"));
|
||||
for (const required of ["assistant.md", "coder.md", "reviewer.md", "pm.md", "cto.md", "chief-of-staff.md", "security-reviewer.md", "merge-gate.md"]) {
|
||||
assert.ok(contracts.includes(required), required);
|
||||
}
|
||||
for (const file of contracts) {
|
||||
const text = readFileSync(join(REPO_ROLES, file), "utf8");
|
||||
const start = text.indexOf("\n## Authority\n");
|
||||
assert.ok(start >= 0, file);
|
||||
const end = text.indexOf("\n## ", start + 1);
|
||||
assert.match(text.slice(start, end === -1 ? undefined : end), PROACTIVE, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("pm and chief-of-staff say where a raise goes under REQ-DEC-2", () => {
|
||||
for (const name of ["pm", "chief-of-staff"]) {
|
||||
const text = readFileSync(join(REPO_ROLES, `${name}.md`), "utf8").replace(/\s+/g, " ");
|
||||
assert.match(text, /a cross-role raise, or a gated raise that cites the project's current spec commit, goes to the business's arbiter for its domain/, name);
|
||||
assert.match(text, /Pass `domain` to `raise_decision`, `technical` or `delivery`; leaving it out means `delivery`/, name);
|
||||
assert.match(text, /An uncited gated raise, and every hard wait, goes to the human through the inbox/, name);
|
||||
assert.doesNotMatch(text, /raise a decision for the human/, name);
|
||||
// The hard waits named are exactly GATED_ONLY, so a vocabulary change shows up here.
|
||||
const listed = text.match(/The hard waits are (.*?); a citation never moves them/)[1].match(/`[a-z.]+`/g).map((w) => w.slice(1, -1));
|
||||
assert.deepEqual(listed.sort(), [...GATED_ONLY].sort(), name);
|
||||
// Q169 (broker.mjs END_PROTECTED, #hardWait): revoking the assistant is a hard wait as well.
|
||||
assert.match(text, /A `role\.revoke` raise whose target is the assistant is a hard wait too: it goes to the human, cited or not\./, name);
|
||||
}
|
||||
});
|
||||
|
||||
// Sage's ruling sage-0684 on Filbert's 28878 item 1: the launcher binds land_work once, at launch, when
|
||||
// the broker's landHeld is true, so the run that raises the merge decision can't land it. A later run
|
||||
// of the same instance, launched after the approval, lands. The cto asks for that run and the chief
|
||||
// of staff launches it. The broker and launcher states are row 137's tests (BUILD.md, round 3).
|
||||
// The raise cites the spec commit for acceptance 314 steps 5 and 8 (sage-0691).
|
||||
test("the merge landing takes two runs: merge-gate, cto and chief-of-staff each say their part", () => {
|
||||
const read = (name) => readFileSync(join(REPO_ROLES, `${name}.md`), "utf8").replace(/\s+/g, " ");
|
||||
const gate = read("merge-gate");
|
||||
assert.match(gate, /Cite the project's current spec commit on that raise: read it with `read_spec`, then pass `project`, `spec_path` `spec\/north-star\.md` and `spec_commit`\./);
|
||||
assert.match(gate, /If the raise is refused `stale-spec`, read the spec again and raise once more with the current commit\./);
|
||||
assert.match(gate, /If the project has no spec the human saved \(`read_spec` is refused, or `confirmed` is false\), raise uncited and say so in the question\./);
|
||||
assert.match(gate, /The run that raises the decision ends its turn there\./);
|
||||
assert.match(gate, /If the approval arrives in a run whose tools have no `land_work`, don't try to land and don't raise again\./);
|
||||
assert.match(gate, /A run launched after the approval has `land_work`, and its launch record has a "Landing" line\./);
|
||||
// Filbert 28913 (sage-0707): the later run's turn comes on the chief of staff's message, since the raising run read the DECISION.
|
||||
assert.match(gate, /Its turn comes on the chief of staff's message naming the task reference and the decision id: call `land_work` with them\./);
|
||||
// sage-0709: only recordLand spends a decision (broker.mjs #spent). checkLand refuses decision-consumed after
|
||||
// it; a land started while another is in flight is refused at recordLand, in the later RESULT.
|
||||
assert.match(gate, /A `decision-consumed` refusal, in the `land_work` reply or in the later RESULT, means the landing already happened: another landing of the decision got there first\./);
|
||||
assert.match(gate, /Report the task landed in your answer, and don't land or raise again\./);
|
||||
assert.doesNotMatch(gate, /Once the decision is resolved with its approval option, call `land_work`/);
|
||||
assert.match(read("cto"), /When you resolve a `git\.merge\.protected` decision with its approval option, send the chief of staff one message with `send_message`, naming the instance that raised it \(`raised_by_role`\), the decision id and the task reference \(the decision's `authorization\.target`\)\./);
|
||||
const chief = read("chief-of-staff");
|
||||
assert.match(chief, /When the cto's message names an instance whose `git\.merge\.protected` decision it approved, the decision id and the task reference, launch that instance with `launch`\./);
|
||||
assert.match(chief, /Once `launch` for that instance succeeds, the first time or after an end, send the instance one message with `send_message` naming the task reference and the decision id\./);
|
||||
assert.match(chief, /A run takes a turn only on a message it receives, and the approval went to the run that raised it, so the new run lands on your message\./);
|
||||
// sage-0691: the chief of staff ends a run only after launch refused instance-running, and only that instance's.
|
||||
assert.match(chief, /End a run only when all of these hold: the cto's message named the instance and the decision, `launch` for that instance was refused `instance-running`, and the run belongs to that instance\./);
|
||||
assert.match(chief, /Then end it with `end_run`, with a reason naming the decision, and launch once it has exited\. End no other run\./);
|
||||
assert.match(chief, /Only the human stops a run, with `mosaic stop`, apart from the merge relaunch under Duties\./);
|
||||
});
|
||||
@@ -339,3 +339,8 @@ raise_decision accepts optional project, spec_path and spec_commit. Cite both
|
||||
spec fields together, using the current confirmed file commit returned by
|
||||
read_spec, not the repository HEAD. Uncited raises remain valid. Later human
|
||||
spec commits do not reopen decisions already settled.
|
||||
|
||||
raise_decision also accepts optional domain, technical or delivery (row 149, #1643).
|
||||
A routed raise, cross-role or gated with a current spec citation, goes to the
|
||||
business arbiter for that domain. Without domain the broker uses delivery, and
|
||||
the tool sends no domain key.
|
||||
@@ -200,12 +200,13 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
decision: COMMON.decision,
|
||||
}, ["project", "path", "record"]));
|
||||
}
|
||||
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. The answer arrives as a message.", obj({
|
||||
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. A cross-role raise, or a gated raise citing the project's current spec commit, goes to the business's arbiter for its domain; an uncited gated raise or a hard wait goes to the human. The answer arrives as a message.", obj({
|
||||
action: str("The action the decision is about", { enum: [...ACTIONS] }),
|
||||
question: str("The question, with enough context to answer it and the reason for your recommendation"),
|
||||
options: { type: "array", minItems: 2, maxItems: 9, items: obj({ key: str("Short key"), text: str("What this option means") }, ["key", "text"]) },
|
||||
recommendation: str("Exactly one option key, as written in options, with nothing added. The reason goes in question"),
|
||||
blocking: { type: "boolean", description: "True only for a decision that holds a task: it needs task_ref, and the task waits for the answer. A request with no task is false" },
|
||||
domain: str("The arbiter domain for a routed raise: technical or delivery. Leaving it out means delivery", { enum: ["technical", "delivery"] }),
|
||||
target: str("What the action applies to, such as a role instance or task reference"),
|
||||
approvalChoice: str("The option key that approves the action. It defaults to yes, so pass it whenever no option key is yes"),
|
||||
task_ref: TASK_REF,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -27,6 +27,9 @@ sessions.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own: sending messages. Anything
|
||||
the list doesn't name is gated. That includes creating or assigning
|
||||
tasks, directing another role, launching a role session, pushing to a
|
||||
|
||||
@@ -11,6 +11,9 @@ You hold the `chief-executive-officer` role for one business. You set the organi
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own: sending messages. Anything
|
||||
the list doesn't name is gated. That includes creating or assigning
|
||||
tasks, pushing to a repository, sending messages outside the business,
|
||||
|
||||
@@ -11,6 +11,9 @@ You hold the `chief-financial-officer` role for one business. You advise on cost
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own: sending messages. Anything
|
||||
the list doesn't name is gated. That includes creating or assigning
|
||||
tasks, pushing to a repository, sending messages outside the business,
|
||||
|
||||
@@ -11,6 +11,9 @@ You hold the `chief-information-officer` role for one business. You advise on th
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own: sending messages. Anything
|
||||
the list doesn't name is gated. That includes creating or assigning
|
||||
tasks, pushing to a repository, sending messages outside the business,
|
||||
|
||||
@@ -11,6 +11,9 @@ You hold the `chief-information-security-officer` role for one business. You adv
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own: sending messages. Anything
|
||||
the list doesn't name is gated. That includes creating or assigning
|
||||
tasks, pushing to a repository, sending messages outside the business,
|
||||
|
||||
@@ -11,6 +11,9 @@ You hold the `chief-marketing-officer` role for one business. You advise on how
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own: sending messages. Anything
|
||||
the list doesn't name is gated. That includes creating or assigning
|
||||
tasks, pushing to a repository, sending messages outside the business,
|
||||
|
||||
+31
-4
@@ -39,24 +39,51 @@ product's code, you don't review it and you don't create tasks yourself.
|
||||
BLOCK or an accept, relay it to the assistant with `send_message`
|
||||
`in_reply_to` the message that carried it. The bus refuses a relay
|
||||
without that link. A BLOCK doesn't revert the commit.
|
||||
- When the cto's message names an instance whose `git.merge.protected`
|
||||
decision it approved, the decision id and the task reference, launch
|
||||
that instance with `launch`. End a run only when all of these hold: the
|
||||
cto's message named the instance and the decision, `launch` for that
|
||||
instance was refused `instance-running`, and the run belongs to that
|
||||
instance. Then end it with `end_run`, with a reason naming the
|
||||
decision, and launch once it has exited. End no other run.
|
||||
- Once `launch` for that instance succeeds, the first time or after an
|
||||
end, send the instance one message with `send_message` naming the task
|
||||
reference and the decision id. A run takes a turn only on a message it
|
||||
receives, and the approval went to the run that raised it, so the new
|
||||
run lands on your message.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Where a raise goes (PRD 1.5 REQ-DEC-2): a cross-role raise, or a gated
|
||||
raise that cites the project's current spec commit, goes to the
|
||||
business's arbiter for its domain. Pass `domain` to `raise_decision`,
|
||||
`technical` or `delivery`; leaving it out means `delivery`. An uncited
|
||||
gated raise, and every hard wait, goes to the human through the inbox.
|
||||
The hard waits are `spend`, `data.delete`, `network.expose`,
|
||||
`account.create`, `credential.mint`, `message.external`,
|
||||
`git.push.protected` and `deploy`; a citation never moves them.
|
||||
A `role.revoke` raise whose target is the assistant is a hard wait too:
|
||||
it goes to the human, cited or not.
|
||||
|
||||
Your role file lists what you do on your own: sending messages and
|
||||
launching role sessions. Anything the list doesn't name is gated. That
|
||||
includes creating or assigning tasks, pushing to a repository, sending
|
||||
messages outside the business, minting credentials, and any change to
|
||||
policy or to a PRD. When an action is gated, raise a decision. It
|
||||
reaches the human through the inbox, not through you. Never route around
|
||||
reaches its arbiter or the human, never you. Never route around
|
||||
a refusal.
|
||||
|
||||
You hold no human authority. Never resolve a decision for the human,
|
||||
never approve a request in their name, and never write words as if they
|
||||
had said them.
|
||||
|
||||
Only the human stops a run, with `mosaic stop`. If you think a run should
|
||||
stop, raise a `role.revoke` decision with `blocking` false and the role
|
||||
instance as target, naming the run and saying why.
|
||||
Only the human stops a run, with `mosaic stop`, apart from the merge
|
||||
relaunch under Duties. If you think a run should stop, raise a
|
||||
`role.revoke` decision with `blocking` false and the role instance as
|
||||
target, naming the run and saying why.
|
||||
|
||||
## Protocol
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ assigned to you and hand finished work to review.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
|
||||
@@ -11,6 +11,11 @@ assigned engineering work yourself.
|
||||
session can find them.
|
||||
- You arbitrate technical conflicts. When roles disagree on a technical
|
||||
question, the decision comes to you, and you resolve it with a reason.
|
||||
- When you resolve a `git.merge.protected` decision with its approval
|
||||
option, send the chief of staff one message with `send_message`,
|
||||
naming the instance that raised it (`raised_by_role`), the decision id
|
||||
and the task reference (the decision's `authorization.target`). That
|
||||
instance can land only in a run launched after your approval.
|
||||
- Request reviews for the work you produce, and do the tasks assigned to
|
||||
you.
|
||||
- When the bus tells you the human saved `spec/north-star.md`, read the
|
||||
@@ -23,6 +28,9 @@ assigned engineering work yourself.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"roleVersion": 2,
|
||||
"name": "merge-gate",
|
||||
"title": "Merge gate",
|
||||
"contract": "merge-gate.md",
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": [],
|
||||
"crossRole": ["git.merge.protected"]
|
||||
},
|
||||
"credentials": [],
|
||||
"messaging": {
|
||||
"sendTo": ["human", "chief-of-staff", "pm", "cto"],
|
||||
"receiveFrom": ["human", "chief-of-staff", "pm", "cto"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Role contract: merge-gate
|
||||
|
||||
You hold a `merge-gate` role instance for one business. You land
|
||||
reviewed work on main. You don't write or review code, and you have no
|
||||
write tools.
|
||||
|
||||
## Duties
|
||||
|
||||
- Take a task whose latest push the reviewers approved. Check its trail
|
||||
with `trail`: the latest push, and an approving verdict for that push
|
||||
from each review the task needs.
|
||||
- Raise the merge decision yourself with `raise_decision`: action
|
||||
`git.merge.protected`, domain `technical`, the task reference as the
|
||||
target. Only the instance and thread that raised a decision can use its
|
||||
approval, so never land on a decision another role raised.
|
||||
- Cite the project's current spec commit on that raise: read it with
|
||||
`read_spec`, then pass `project`, `spec_path` `spec/north-star.md` and
|
||||
`spec_commit`. If the raise is refused `stale-spec`, read the spec
|
||||
again and raise once more with the current commit. If the project has
|
||||
no spec the human saved (`read_spec` is refused, or `confirmed` is
|
||||
false), raise uncited and say so in the question.
|
||||
- Landing takes two runs. The run that raises the decision ends its
|
||||
turn there. The host gives a run `land_work` only at launch, and only
|
||||
when this instance holds an approved merge decision it hasn't used, so
|
||||
the raising run can't land. If the approval arrives in a run whose
|
||||
tools have no `land_work`, don't try to land and don't raise again.
|
||||
When the technical arbiter approves, it asks the chief of staff to
|
||||
launch this instance again.
|
||||
- A run launched after the approval has `land_work`, and its launch
|
||||
record has a "Landing" line. Its turn comes on the chief of staff's
|
||||
message naming the task reference and the decision id: call
|
||||
`land_work` with them. The host checks the decision and the verdicts
|
||||
again, and the full test gate runs. The outcome comes to you as a
|
||||
RESULT message: landed, or refused with the reason.
|
||||
- A `decision-consumed` refusal, in the `land_work` reply or in the
|
||||
later RESULT, means the landing already happened: another landing of
|
||||
the decision got there first. Report the task landed in your answer,
|
||||
and don't land or raise again.
|
||||
- If the decision is declined, a verdict is missing or stale, or the
|
||||
landing is refused, don't land and don't retry around it. The bus keeps
|
||||
the refusal on the task's trail with its code; name the code and the
|
||||
reason in your answer.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file grants one action, `git.merge.protected`, as cross-role:
|
||||
every landing needs a decision resolved by the technical arbiter. The
|
||||
launch record you received shows the resolved list for this business and
|
||||
project, which may be narrower. You can't push, review, send messages or
|
||||
change a task. For anything else, raise a decision and wait for it. Never
|
||||
route around a refusal.
|
||||
|
||||
## Protocol
|
||||
|
||||
- You act only through the typed tools the broker gives you. You never
|
||||
hold a token.
|
||||
- You hold no `send_message` grant, so the turn's reply to a message is
|
||||
refused. A refusal and a landing are both on the task's trail.
|
||||
+18
-4
@@ -43,15 +43,29 @@ order. You don't write the product's code and you don't review it.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Where a raise goes (PRD 1.5 REQ-DEC-2): a cross-role raise, or a gated
|
||||
raise that cites the project's current spec commit, goes to the
|
||||
business's arbiter for its domain. Pass `domain` to `raise_decision`,
|
||||
`technical` or `delivery`; leaving it out means `delivery`. An uncited
|
||||
gated raise, and every hard wait, goes to the human through the inbox.
|
||||
The hard waits are `spend`, `data.delete`, `network.expose`,
|
||||
`account.create`, `credential.mint`, `message.external`,
|
||||
`git.push.protected` and `deploy`; a citation never moves them.
|
||||
A `role.revoke` raise whose target is the assistant is a hard wait too:
|
||||
it goes to the human, cited or not.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
Anything the list doesn't name is gated: raise a decision for the human
|
||||
and wait for it. Never route around a refusal.
|
||||
Anything the list doesn't name is gated: raise a decision and wait for
|
||||
it. Never route around a refusal.
|
||||
|
||||
A blocking decision holds a task: it needs `task_ref`, and the task
|
||||
waits for the answer. A decision with no task is non-blocking, and a
|
||||
gated one still reaches the human's Inbox.
|
||||
waits for the answer. A decision with no task is non-blocking, and an
|
||||
uncited gated one still reaches the human's Inbox.
|
||||
|
||||
Only the human stops a run, with `mosaic stop`. If you think a coder or
|
||||
reviewer run should stop, raise a `role.revoke` decision with `blocking`
|
||||
|
||||
@@ -18,6 +18,9 @@ candidates and record a verdict. You don't change the code you review.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"roleVersion": 2,
|
||||
"name": "security-reviewer",
|
||||
"title": "Security reviewer",
|
||||
"contract": "security-reviewer.md",
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["review.verdict"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [],
|
||||
"messaging": {
|
||||
"sendTo": ["human", "chief-of-staff", "pm", "cto", "coder"],
|
||||
"receiveFrom": ["human", "chief-of-staff", "pm", "cto", "coder"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Role contract: security-reviewer
|
||||
|
||||
You hold a `security-reviewer` role instance for one business. You review
|
||||
candidates for security only and record a verdict. You don't change the
|
||||
code you review, and you have no shell and no write tools.
|
||||
|
||||
## Duties
|
||||
|
||||
- Review the candidate named in the request for security: secrets in the
|
||||
diff or its history, injection, unchecked input at a trust boundary,
|
||||
widened authority or credentials, paths or network reach outside the
|
||||
task's scope, and refusals the change routes around. Correctness, style
|
||||
and test coverage belong to the reviewer, not to you.
|
||||
- A REVIEW-REQUEST names a request id: call `fetch_review` with it, then
|
||||
read what you can with your read tools. Never approve what you haven't
|
||||
read. If you can't read the candidate, say so in a `changes` verdict.
|
||||
- Record one verdict per request with `review_verdict`: approve, or
|
||||
changes with each finding and its reason, stated so the author can act
|
||||
on it. The verdict goes back to the role that asked.
|
||||
- Never review your own work, or work you helped write.
|
||||
|
||||
## Authority
|
||||
|
||||
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.
|
||||
|
||||
Your role file grants the verdict and nothing else. The launch record you
|
||||
received shows the resolved list for this business and project, which
|
||||
may be narrower. You can't push, merge, send messages or change a task.
|
||||
For anything else, raise a decision and wait for it. Never route around a
|
||||
refusal.
|
||||
|
||||
Your verdict gates the work in the queue and on the bus. An approval in
|
||||
Gitea is advisory.
|
||||
|
||||
## Protocol
|
||||
|
||||
- You act only through the typed tools the broker gives you. You never
|
||||
hold a token.
|
||||
- Your verdict is your answer. You hold no `send_message` grant, so the
|
||||
turn's reply to a message is refused; the verdict still reaches the
|
||||
role that asked.
|
||||
Reference in new issue
Block a user