83 lines
4.8 KiB
JavaScript
83 lines
4.8 KiB
JavaScript
// RR (#1873): resolution checks current role rights, and callers cannot choose a merge/policy arbiter.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { loadRole } from '../../organization/src/index.mjs';
|
|
import { REPO_ROLES } from '../../organization/tests/helpers.mjs';
|
|
import { fixture } from './spec-fixtures.mjs';
|
|
import { grantDecision } from './host-fixtures.mjs';
|
|
|
|
async function setup(t, { shared = false } = {}) {
|
|
const roles = Object.fromEntries(['cto', 'pm', 'chief-of-staff'].map(definition => {
|
|
const role = loadRole(REPO_ROLES, definition);
|
|
return [definition === 'chief-of-staff' ? 'chief' : definition, { definition, authority: structuredClone(role.authority) }];
|
|
}));
|
|
roles.coder = { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: ['task.scope.change', 'git.merge.protected', 'policy.change'] } };
|
|
const f = await fixture(t, { roles, arbiters: { technical: 'cto', delivery: shared ? 'cto' : 'pm' } });
|
|
return { ...f, roles, cto: f.agent('cto'), pm: f.agent('pm'), chief: f.chief };
|
|
}
|
|
|
|
for (const [domain, resolver] of [['technical', 'cto'], ['delivery', 'pm']]) {
|
|
test(`RR: ${domain} arbiter that loses its right refuses not-resolver`, async t => {
|
|
const f = await setup(t);
|
|
const d = f.raise({ action: 'task.scope.change', domain });
|
|
f.business.roles[resolver].authority.withinRole = ['message.send'];
|
|
f.broker.reloadBusiness('demo', f.business);
|
|
assert.throws(() => f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }), error => {
|
|
assert.equal(error.code, 'not-resolver');
|
|
assert.match(error.reason, new RegExp(`${resolver}.*${resolver}.*decision\\.resolve\\.${domain}`));
|
|
return true;
|
|
});
|
|
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', d.id).n, 0);
|
|
});
|
|
}
|
|
|
|
test('RR: a shared arbiter cannot resolve a delivery decision using only its technical right', async t => {
|
|
const f = await setup(t, { shared: true });
|
|
const d = f.raise({ action: 'task.scope.change', domain: 'delivery' });
|
|
assert.equal(d.route_to, 'cto');
|
|
assert.throws(() => f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
|
});
|
|
|
|
test('RR: merge raised with delivery still routes to CTO and resolves (acceptance 314 step 5)', async t => {
|
|
const f = await setup(t);
|
|
const d = await f.raise({ action: 'git.merge.protected', domain: 'delivery', project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
|
assert.equal(d.route_to, 'cto');
|
|
assert.throws(() => f.call(f.pm, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
|
f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', d.id).op, 'resolved');
|
|
});
|
|
|
|
for (const resolver of ['chief', 'pm']) {
|
|
test(`RR: ${resolver} named delivery arbiter resolves policy.change even when raised technical`, async t => {
|
|
const f = await setup(t); f.business.arbiters.delivery = resolver;
|
|
f.broker.reloadBusiness('demo', f.business);
|
|
const d = f.raise({ action: 'policy.change', domain: 'technical' });
|
|
assert.equal(d.route_to, resolver);
|
|
f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
assert.equal(f.store.get('SELECT by FROM decision_events WHERE decision=?', d.id).by, `${resolver}-run`);
|
|
});
|
|
}
|
|
|
|
test('RR: the chief holds both rights when named for both domains', async t => {
|
|
const f = await setup(t); f.business.arbiters = { technical: 'chief', delivery: 'chief' };
|
|
f.broker.reloadBusiness('demo', f.business);
|
|
for (const action of ['git.merge.protected', 'policy.change']) {
|
|
const d = f.raise({ action }); assert.equal(d.route_to, 'chief');
|
|
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
}
|
|
});
|
|
|
|
// Old raise evidence did not preserve domain. Current assignments cannot prove the old domain.
|
|
test('RR: remapping arbiters cannot let a legacy decision resolve with only the other domain right', async t => {
|
|
const f = await setup(t);
|
|
const id = grantDecision(f.store, { action: 'task.scope.change', route: 'pm', domain: null, close: null });
|
|
f.business.arbiters = { technical: 'pm', delivery: 'cto' };
|
|
f.business.roles.pm.authority.withinRole = ['message.send', 'decision.resolve.technical'];
|
|
f.broker.reloadBusiness('demo', f.business);
|
|
assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' });
|
|
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0);
|
|
// Human recovery closes the old route by withdrawing it, before the raiser creates a new decision.
|
|
f.call(f.human, 'decision.withdraw', { id });
|
|
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', id).op, 'withdrawn');
|
|
});
|