Files
stack/packages/organization/tests/workflow-roles.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

151 lines
10 KiB
JavaScript

// 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\./);
});