Files
stack/packages/business/tests/resolve.test.mjs
T
jason.woltjeandClaude Opus 5.5 0453da7e10 feat(roles): coder drops the task.reassign grant the broker always refuses (#1567, row 73)
roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"]
to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the
writer check before it looks at authority, so the grant could never be
used. A coder's task.reassign now classifies as gated and goes to the
human. Backed by lead decision 90.

Tests: business role and resolve (the narrowing test moves to the pm,
which still holds two cross-role actions), a bus test on the shipped role
files, a harness test that a coder with a tracker gets no task_reassign,
the tasks fixture, and a verbs line showing a coder's task.reassign
refuses field-writer with no action.allowed. mutants.sh is Rocko's packet
rerun script, already committed.

Author: Rocko (packet agents/rocko/work/queue-73/, commit 2137dd7f).
Candidate manifest sha256 bf4e5ed6, base 324c064b, no drift on the
candidate paths through 8a42e819.
Approval: Dewey, round 1, comment 27506 on #1567.
Gate (Sage, /tmp/sage-g73 on 8a42e819, 23:42-23:48Z): tasks 71, bus 81,
business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed;
every scripts/test-*.sh exit 0, 0 failed.

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

197 lines
9.9 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import {
BusinessError, classify, projectFilePath, resolveInstance, systemVars, validateBusinessDocument, validateProjectDocument,
} from "../src/index.mjs";
import { businessDoc, REPO_ROLES, systemFor, tmp } from "./helpers.mjs";
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
function setup(mutate, projectDoc) {
const root = tmp();
const doc = businessDoc(root);
mutate?.(doc);
const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
const project = projectDoc
? validateProjectDocument({ projectVersion: 1, id: "stack", ...projectDoc }, projectFilePath(doc.projects.stack.root))
: null;
return { root, business, project, system: systemFor(root) };
}
const resolve = (s, instance) => resolveInstance({ system: s.system, business: s.business, project: s.project, instance });
test("systemVars flattens the validated config", () => {
const vars = systemVars({ environment: "development", dataRoot: "/d", execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" } });
assert.deepEqual({ ...vars }, {
environment: "development", dataRoot: "/d", "execution.backend": "docker",
"execution.provider": "zai", "execution.model": "m", "execution.adapter": "pi",
});
refuses(() => systemVars({ environment: "development", dataRoot: "relative", execution: { backend: "b", provider: "p", model: "m", adapter: "a" } }), /dataRoot must be an absolute path/);
});
test("precedence: system, business, project, project role, agent", () => {
const s = setup((d) => {
d.roles.coder.vars.model = "agent-model";
}, {
vars: { "tracker.pollSeconds": 20, "tracker.project": 3 },
roles: { coder: { vars: { "tracker.pollSeconds": 15 } } },
});
const coder = resolve(s, "coder");
assert.equal(coder.project, "stack");
assert.equal(coder.vars["tracker.pollSeconds"], 15);
assert.equal(coder.provenance["tracker.pollSeconds"], "project:stack:roles.coder");
assert.equal(coder.vars["tracker.project"], 3);
assert.equal(coder.provenance["tracker.project"], "project:stack");
assert.equal(coder.vars["tracker.baseUrl"], "http://127.0.0.1:3456");
assert.equal(coder.provenance["tracker.baseUrl"], "business:acme");
assert.equal(coder.vars.model, "agent-model");
assert.equal(coder.provenance.model, "business:acme:roles.coder");
assert.equal(coder.vars["execution.adapter"], "mock");
assert.equal(coder.provenance["execution.adapter"], "system");
assert.equal(coder.provenance["tracker.reconcileMinutes"], "default");
const pm = resolve(s, "pm");
assert.equal(pm.vars["tracker.pollSeconds"], 20);
assert.equal(pm.provenance["tracker.pollSeconds"], "project:stack");
const noProject = resolveInstance({ system: s.system, business: s.business, instance: "pm" });
assert.equal(noProject.project, null);
assert.equal(noProject.vars["tracker.pollSeconds"], 60);
});
test("limits narrow the definition and never widen it", () => {
const s = setup((d) => {
d.vars["limits.network"] = "open";
d.roles.cto.vars["limits.tools"] = ["read", "grep", "bash", "write"];
d.roles.reviewer.vars = { "limits.tools": ["read", "write"], "limits.authority": ["review.verdict", "task.close"] };
}, { roles: { cto: { vars: { "limits.tools": ["read", "bash", "write", "edit"] } } } });
const coder = resolve(s, "coder");
assert.equal(coder.limits.network, "none");
assert.deepEqual(coder.provenance["limits.network"], ["business:acme", "business:acme:roles.coder"]);
assert.equal(resolve(s, "pm").limits.network, "api-only");
const cto = resolve(s, "cto");
assert.deepEqual(cto.limits.tools, ["read", "write", "bash"]);
assert.deepEqual(cto.provenance["limits.tools"], ["project:stack:roles.cto", "business:acme:roles.cto"]);
const reviewer = resolve(s, "reviewer");
assert.deepEqual(reviewer.limits.tools, ["read"]);
assert.deepEqual(reviewer.limits.authority, { withinRole: ["review.verdict"], crossRole: [] });
assert.equal(classify(reviewer, "message.send"), "gated");
assert.equal(classify(reviewer, "task.close"), "gated");
});
test("role.launch stays within-role only for the instance the launch block names", () => {
const pm = resolve(setup(), "pm");
assert.equal(classify(pm, "role.launch"), "within");
assert.deepEqual(pm.launch.instances, ["coder", "reviewer"]);
assert.equal(resolve(setup(), "cto").launch, null);
const noLaunch = resolve(setup((d) => { delete d.launch; }), "pm");
assert.equal(classify(noLaunch, "role.launch"), "gated");
assert.equal(noLaunch.launch, null);
// A second pm instance is the launcher, so the first one may not launch.
const two = setup((d) => {
d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } };
d.launch.by = "pm2";
});
assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated");
assert.equal(resolve(two, "pm").launch, null);
assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within");
assert.equal(resolve(two, "pm2").launch.by, "pm2");
});
test("limits.authority without role.launch leaves the launcher with no launch block", () => {
for (const layer of ["agent", "project"]) {
const authority = ["task.create", "task.assign", "message.send"];
const s = layer === "agent"
? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; })
: setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } });
const pm = resolve(s, "pm");
assert.equal(classify(pm, "role.launch"), "gated", layer);
assert.equal(pm.launch, null, layer);
}
});
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"];
});
const pm = resolve(s, "pm");
assert.deepEqual(pm.limits.authority, { withinRole: ["task.create", "task.close"], 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");
});
test("classify", () => {
const s = setup();
const coder = resolve(s, "coder");
assert.equal(classify(coder, "git.push.working"), "within");
assert.equal(classify(coder, "task.scope.change"), "cross");
assert.equal(classify(coder, "task.reassign"), "gated");
assert.equal(classify(coder, "task.close"), "gated");
assert.equal(classify(coder, "deploy"), "gated");
assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross");
assert.equal(classify(resolve(s, "cto"), "decision.resolve.technical"), "within");
refuses(() => classify(coder, "task.delete"), /unknown action/);
});
test("the record carries what the broker and launcher need", () => {
const s = setup();
const pm = resolve(s, "pm");
assert.deepEqual(Object.keys(pm).sort(), [
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "tracker", "vars",
]);
assert.equal(pm.contract, join(REPO_ROLES, "pm.md"));
assert.equal(pm.holder, "sage");
assert.deepEqual(pm.tracker, { bot: "bot-acme-pm", botId: 3 });
assert.deepEqual(Object.keys(pm.credentials).sort(), ["gitea", "vikunja"]);
assert.match(pm.credentials.gitea.file, /pm-gitea\.token$/);
assert.ok(Object.isFrozen(pm.limits.authority.withinRole));
});
test("digest: key order doesn't matter, any value change does", () => {
const root = tmp();
const file = join(root, "businesses", "acme.json");
const load = (mutate) => {
const doc = businessDoc(root);
mutate?.(doc);
return validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
};
const system = systemFor(root);
const digest = (business, instance = "coder", sys = system) => resolveInstance({ system: sys, business, instance }).digest;
const base = digest(load());
assert.match(base, /^[0-9a-f]{64}$/);
assert.equal(digest(load()), base);
assert.equal(digest(load((d) => { d.roles.coder.vars = { "limits.network": "none", harness: "pi" }; })), base);
assert.equal(digest(load((d) => { d.vars = Object.fromEntries(Object.entries(d.vars).reverse()); })), base);
assert.notEqual(digest(load((d) => { d.roles.coder.vars.harness = "claude-code"; })), base);
assert.notEqual(digest(load((d) => { d.roles.coder.holder = "rocko"; })), base);
assert.notEqual(digest(load((d) => { d.roles.coder.credentials.gitea.rotateBy = "2098-01-01"; })), base);
assert.notEqual(digest(load(), "coder", { ...system, "execution.model": "other" }), base);
assert.notEqual(digest(load(), "reviewer"), base);
});
test("refusals", () => {
const s = setup();
refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "ghost" }), /declares no role instance "ghost"/);
refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "constructor" }), /declares no role instance/);
const misnamed = setup(undefined, {});
const wrongId = validateProjectDocument({ projectVersion: 1, id: "other" }, misnamed.project.file);
refuses(() => resolveInstance({ system: misnamed.system, business: misnamed.business, project: wrongId, instance: "pm" }), /isn't declared under that id/);
const elsewhere = validateProjectDocument({ projectVersion: 1, id: "stack" }, "/elsewhere/.mosaic/project.json");
refuses(() => resolveInstance({ system: s.system, business: s.business, project: elsewhere, instance: "pm" }), /isn't declared under that id/);
const extraRole = setup(undefined, { roles: { ghost: { vars: {} } } });
refuses(() => resolve(extraRole, "pm"), /sets vars for role instance ghost/);
});