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]>
This commit is contained in:
2026-10-11 18:49:09 -05:00
co-authored by Claude Opus 5.5
parent fb619befd0
commit 0453da7e10
7 changed files with 50 additions and 9 deletions
+26
View File
@@ -4,6 +4,9 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { Store } from '../src/store.mjs'; import { Store } from '../src/store.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 { Broker } = await import('../src/broker.mjs').catch((e) => { const { Broker } = await import('../src/broker.mjs').catch((e) => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e; throw e;
@@ -354,3 +357,26 @@ test('both arbiters require human resolution when their cross-role route is them
assert.equal(b.authorize(cap, action, { decision: d.id, target: 'vikunja:3/41' }).class, 'cross-role'); assert.equal(b.authorize(cap, action, { decision: d.id, target: 'vikunja:3/41' }).class, 'cross-role');
} }
}); });
test('the shipped coder role raises task.reassign as gated to the human; task.scope.change stays cross-role', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-roles-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const business = validateBusinessDocument(businessDoc(root, 'demo'), 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 { b, agent, human } = setup(t, { demo: busBusiness(business, resolved) });
const c = agent('coder');
call(b, c, 'role.claim');
const reassign = raise(b, c, 'task.reassign', { target: 'vikunja:3/7' });
assert.equal(reassign.class, 'gated');
assert.equal(reassign.route_to, 'human');
const scope = raise(b, c, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/7' });
assert.equal(scope.class, 'cross-role');
assert.equal(scope.route_to, 'cto');
// Approval passes the broker; the task verb still refuses a coder with field-writer (packages/tasks).
call(b, human, 'decision.resolve', { id: reassign.id, choice: 'yes' });
assert.equal(b.authorize(c, 'task.reassign', { decision: reassign.id, target: 'vikunja:3/7' }).class, 'gated');
});
+9 -7
View File
@@ -122,21 +122,23 @@ test("limits.authority without role.launch leaves the launcher with no launch bl
}); });
test("limits.authority narrows cross-role actions too", () => { 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) => { const s = setup((d) => {
d.roles.coder.vars["limits.authority"] = ["task.update.assigned", "git.push.working", "task.scope.change"]; d.roles.pm.vars["limits.authority"] = ["task.create", "task.close", "task.priority.change"];
}); });
const coder = resolve(s, "coder"); const pm = resolve(s, "pm");
assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] }); assert.deepEqual(pm.limits.authority, { withinRole: ["task.create", "task.close"], crossRole: ["task.priority.change"] });
assert.equal(classify(coder, "task.reassign"), "gated"); assert.equal(classify(pm, "task.priority.change"), "cross");
assert.equal(classify(coder, "task.scope.change"), "cross"); assert.equal(classify(pm, "task.scope.change"), "gated");
assert.equal(classify(coder, "review.request"), "gated"); assert.equal(classify(pm, "task.assign"), "gated");
}); });
test("classify", () => { test("classify", () => {
const s = setup(); const s = setup();
const coder = resolve(s, "coder"); const coder = resolve(s, "coder");
assert.equal(classify(coder, "git.push.working"), "within"); assert.equal(classify(coder, "git.push.working"), "within");
assert.equal(classify(coder, "task.reassign"), "cross"); 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, "task.close"), "gated");
assert.equal(classify(coder, "deploy"), "gated"); assert.equal(classify(coder, "deploy"), "gated");
assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross"); assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross");
+2
View File
@@ -61,6 +61,8 @@ test("shipped role scopes match addendum B section 2 and the SR runbook", () =>
test("shipped authority follows the note's table", () => { test("shipped authority follows the note's table", () => {
const auth = (name) => loadRole(REPO_ROLES, name).authority; const auth = (name) => loadRole(REPO_ROLES, name).authority;
assert.ok(auth("coder").withinRole.includes("git.push.working")); assert.ok(auth("coder").withinRole.includes("git.push.working"));
// Assignee is pm-only (decision 90): a coder grant of task.reassign could never succeed.
assert.deepEqual(auth("coder").crossRole, ["task.scope.change"]);
assert.ok(!auth("reviewer").withinRole.includes("git.push.working")); assert.ok(!auth("reviewer").withinRole.includes("git.push.working"));
assert.deepEqual(auth("reviewer").crossRole, []); assert.deepEqual(auth("reviewer").crossRole, []);
assert.ok(auth("pm").withinRole.includes("role.launch")); assert.ok(auth("pm").withinRole.includes("role.launch"));
+8
View File
@@ -35,6 +35,14 @@ test("a coder gets no launch, no resolve_decision, and no task tools without a t
assert.ok(!untyped.includes("message.send")); assert.ok(!untyped.includes("message.send"));
}); });
test("a coder with a tracker gets its own task tools and no task_reassign", (t) => {
const coder = resolvedFor(t, "coder");
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
"send_message", "task_scope_change", "task_update_assigned",
"raise_decision", "list_agents", "inbox", "trail", "list_tasks",
]);
});
test("launch only when the business's launch block names the instance as launcher", (t) => { test("launch only when the business's launch block names the instance as launcher", (t) => {
const pm = resolvedFor(t, "pm", (d) => delete d.launch); const pm = resolvedFor(t, "pm", (d) => delete d.launch);
assert.equal(pm.launch, null); assert.equal(pm.launch, null);
+3
View File
@@ -93,6 +93,9 @@ test('assign and reassign move the role bots and record task.assigned', async (t
// The field-writer check runs before authority, so the refused call consumes nothing. // The field-writer check runs before authority, so the refused call consumes nothing.
await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer')); await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer'));
assert.equal(allowed(w, 'task.assign').length, 0); assert.equal(allowed(w, 'task.assign').length, 0);
// Assignee stays pm-only (decision 90); the coder role holds no task.reassign grant, and the verb refuses it anyway.
await assert.rejects(w.call(coder, 'task.reassign', { task_ref, role: 'reviewer' }), refusedWith('field-writer'));
assert.equal(allowed(w, 'task.reassign').length, 0);
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'cto' }), refusedWith('unknown-role')); await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'cto' }), refusedWith('unknown-role'));
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'toString' }), refusedWith('unknown-role')); await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'toString' }), refusedWith('unknown-role'));
await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-unassigned')); await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-unassigned'));
+1 -1
View File
@@ -42,7 +42,7 @@ export const AUTHORITY = {
}, },
coder: { coder: {
withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'],
crossRole: ['task.reassign', 'task.scope.change'], crossRole: ['task.scope.change'],
}, },
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] }, reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
cto: { cto: {
+1 -1
View File
@@ -7,7 +7,7 @@
"network": "api-only", "network": "api-only",
"authority": { "authority": {
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"], "withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
"crossRole": ["task.reassign", "task.scope.change"] "crossRole": ["task.scope.change"]
}, },
"credentials": [ "credentials": [
{ {