diff --git a/packages/bus/tests/broker.test.mjs b/packages/bus/tests/broker.test.mjs index 3e908d702..d5d53d9e6 100644 --- a/packages/bus/tests/broker.test.mjs +++ b/packages/bus/tests/broker.test.mjs @@ -4,6 +4,9 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; 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) => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; 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'); } }); + +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'); +}); diff --git a/packages/business/tests/resolve.test.mjs b/packages/business/tests/resolve.test.mjs index 0995eb5c2..75ac2925d 100644 --- a/packages/business/tests/resolve.test.mjs +++ b/packages/business/tests/resolve.test.mjs @@ -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", () => { + // pm, the role with two cross-role actions: the limit keeps one and drops the other. 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"); - assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] }); - assert.equal(classify(coder, "task.reassign"), "gated"); - assert.equal(classify(coder, "task.scope.change"), "cross"); - assert.equal(classify(coder, "review.request"), "gated"); + 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.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, "deploy"), "gated"); assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross"); diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs index 1a401307b..00628f804 100644 --- a/packages/business/tests/role.test.mjs +++ b/packages/business/tests/role.test.mjs @@ -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", () => { const auth = (name) => loadRole(REPO_ROLES, name).authority; 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.deepEqual(auth("reviewer").crossRole, []); assert.ok(auth("pm").withinRole.includes("role.launch")); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index b520dd4cb..31606131d 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -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")); }); +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) => { const pm = resolvedFor(t, "pm", (d) => delete d.launch); assert.equal(pm.launch, null); diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs index f5621f7b1..c9ea98a6e 100644 --- a/packages/tasks/tests/verbs.test.mjs +++ b/packages/tasks/tests/verbs.test.mjs @@ -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. await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer')); 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: 'toString' }), refusedWith('unknown-role')); await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-unassigned')); diff --git a/packages/tasks/tests/world.mjs b/packages/tasks/tests/world.mjs index d50e96ea0..5085e7798 100644 --- a/packages/tasks/tests/world.mjs +++ b/packages/tasks/tests/world.mjs @@ -42,7 +42,7 @@ export const AUTHORITY = { }, coder: { 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: [] }, cto: { diff --git a/roles/coder.json b/roles/coder.json index 2a7325a16..04220271f 100644 --- a/roles/coder.json +++ b/roles/coder.json @@ -7,7 +7,7 @@ "network": "api-only", "authority": { "withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"], - "crossRole": ["task.reassign", "task.scope.change"] + "crossRole": ["task.scope.change"] }, "credentials": [ {