bus, business: hard waits reach only the human, and only the chief of staff removes the assistant (row 139, #1633)
Row 139 round 2, Dewey's candidate a4c5657f (14 paths) on5661bd233. Filbert approved round 2 (comment 28786) after round 1 changes. Dewey's gate: 14 runs rc 0. Mutants m1-m9 killed. Filbert ran 7 files singly: 0 fail, 0 skipped. The broker's GATED and the business's GATED_ONLY are now the hard waits. A cited gated decision goes to the domain arbiter unless it is a hard wait, which always goes to the human. Under Jason's ruling Q169, a role.revoke of the assistant is a hard wait. Its end gets past END_PROTECTED only for the chief of staff, or when the cited decision was resolved by the human through the CLI. PRD version 1.6 records Q169. Mos approved the 1.6 text, including the REQ-ROLE-2 sentence on the chief's end and revoke. scripts/test-land-work.sh adds the not-human cases for arbiter-resolved merges. land-work's read.mjs is unchanged. Row 137 lands second, so it carries the gated-merge refusal and the host land op's arbiter acceptance. Making an end or revoke stop an assistant run, and configuring the assistant, are row 155 (#1649). Sage's landing gate ran on26b78d483plus the candidate: 25 steps, all rc 0 (15 packages, 10 script suites), 19:23:57Z to 19:28:28Z, network sage-r139_default removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# PRD: Mosaic Stack
|
||||
|
||||
- Status: approved, version 1.5. Jason approved draft 0.4 unchanged as
|
||||
- Status: approved, version 1.6. Jason approved draft 0.4 unchanged as
|
||||
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
|
||||
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
|
||||
on 2026-10-14. They approved version 1.2, the chief of staff, the
|
||||
@@ -13,8 +13,14 @@
|
||||
yes; request b28979c9). Version 1.5 records Jason's Q167 ruling and
|
||||
his added Q167 text, relayed by Mos on 2026-10-15. Under Q167 Jason's
|
||||
confirmation of the text is the approval, so there is no in-Stack
|
||||
prd.approve for it. It is never edited in place. Changes are a new
|
||||
version.
|
||||
prd.approve for it. Version 1.6 records Jason's ruling Q169 of
|
||||
2026-10-16, on who removes and who configures the assistant. It is
|
||||
never edited in place. Changes are a new version.
|
||||
- Version 1.6 source, Jason's ruling Q169 (DECISIONS, brain repo,
|
||||
2026-10-16): "The only agent that should be able to remove the
|
||||
assistant role is the chief of staff. The COS and the user should have
|
||||
the ability to (re)configure the assistant role and seat name. The
|
||||
user via WebUI or CLI, the COS via the proper tooling."
|
||||
- Version 1.5 source, Jason's words relayed by Mos on 2026-10-15:
|
||||
- Q167: "Me having to approve every decision within the Stack is not
|
||||
working for me. I gave a directive, I want it to be done." and "Use
|
||||
@@ -175,7 +181,14 @@ parent requirement is refused.
|
||||
claim takes a lock and logs it, and releasing the role or ending the run
|
||||
frees it. The chief of staff may end another role's run, and that act is
|
||||
logged with its reason. Anyone else who revokes a stuck holder needs a
|
||||
resolved decision, and the revoke cites it.
|
||||
resolved decision, and the revoke cites it. The assistant is the
|
||||
exception here too (version 1.6, Q169): the chief of staff is the only
|
||||
agent that removes it. The chief of staff removes it by ending its
|
||||
runs, and that end needs no decision. A revoke of the assistant, the
|
||||
chief's included, is a hard wait under REQ-WAIT-1. Anyone else's revoke
|
||||
of the assistant must cite a decision the human resolved through the
|
||||
CLI. A decision an arbiter resolved doesn't count, and that revoke is
|
||||
refused.
|
||||
- **REQ-ROLE-3.** An organization file declares the role instances,
|
||||
the arbiters and the credential references. The user and the chief of
|
||||
staff write it. The chief of staff's writes go through a broker verb
|
||||
@@ -193,7 +206,11 @@ parent requirement is refused.
|
||||
|
||||
The schema can also declare CEO, CFO and the other C-suite roles. The
|
||||
assistant's name is set in the organization file. Jason's may be named
|
||||
Mos.
|
||||
Mos. Since version 1.6 (Q169), the user and the chief of staff can
|
||||
configure the assistant's role entry and its seat name: the user
|
||||
through the WebUI or the CLI, the chief of staff through its broker
|
||||
tools, which log each change. No other role can, and a change can't
|
||||
remove the assistant's protection under REQ-ROLE-2.
|
||||
|
||||
Added in version 1.5 (Q167):
|
||||
|
||||
@@ -342,6 +359,9 @@ Added in version 1.5 (Q167).
|
||||
exactly these actions. `git.merge.protected`, `policy.change`,
|
||||
`prd.approve` and `role.revoke` leave those lists and route by
|
||||
REQ-DEC-2. REQ-ROLE-2 still requires a resolved decision for a revoke.
|
||||
Since version 1.6 (Q169), a `role.revoke` of the assistant is a hard
|
||||
wait: its raise goes to the human, cited or not, and no arbiter
|
||||
resolves it. Every other cited revoke routes by REQ-DEC-2.
|
||||
- **REQ-WAIT-2.** No role file may grant a hard-wait action, at any
|
||||
class, and the validator refuses a file that does. A change to the
|
||||
hard-waits list, or a grant of a hard-wait action to a role, is itself
|
||||
@@ -859,6 +879,26 @@ only hard waits in his inbox.
|
||||
made with git outside the Console is a later row.
|
||||
- REQ-LAND-1's refusal of a gated merge decision is pinned in rows 137
|
||||
and 139. Row 139 carries it, or row 137 if 139 lands first.
|
||||
- Version 1.6:
|
||||
- The 1.5 text of REQ-ROLE-2 ended at "and the revoke cites it." The
|
||||
1.5 text of REQ-ROLE-4 ended at "Jason's may be named Mos." The 1.5
|
||||
text of REQ-WAIT-1 ended at "REQ-ROLE-2 still requires a resolved
|
||||
decision for a revoke."
|
||||
- Removing the assistant, in this version, means ending or revoking
|
||||
its runs. Deleting the instance from the organization file is not
|
||||
covered.
|
||||
The protected definitions are `END_PROTECTED` in
|
||||
`packages/bus/src/broker.mjs`, which holds `assistant`.
|
||||
- Configuring the assistant covers its organization entry's `vars`
|
||||
and `tracker`, and its `definition` among those the broker treats as
|
||||
the assistant. `holder` and `credentials` are not covered.
|
||||
- Where each part is built: the refusal and the hard wait are row 139
|
||||
(#1633). Configuring the assistant and its seat name, and making the
|
||||
chief of staff's end or a human-approved revoke stop an assistant
|
||||
run, are row 155 (#1649), using row 141's (#1635) instance names.
|
||||
Until row 155 lands, the assistant's runs hold thread claims that
|
||||
the end and revoke paths don't read, so neither can stop one. The
|
||||
human stops one with `mosaic stop <run>`.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
|
||||
+18
-9
@@ -138,9 +138,9 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
|---|---|
|
||||
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
|
||||
| `role.release` | No args; only its current holder releases. |
|
||||
| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run and the assistant's are `role-end-refused`; any other role sending `reason` gets `decision-required`. |
|
||||
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). |
|
||||
| `organization.set` | `{revision,limits?,models?,instances?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `<definition>.<instance>`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits` and `limits.set` stay human-only. |
|
||||
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and a gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
|
||||
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and an uncited gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
|
||||
| `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). |
|
||||
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
|
||||
| `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. A human withdraw sends the raising role a DECISION message; the raising run's own withdraw sends none. |
|
||||
@@ -227,8 +227,8 @@ sends it again.
|
||||
|
||||
`role.revoke` doesn't reach a thread run. A thread instance has no `role_claims` row, so a
|
||||
decision to revoke it names no holder run, and using that decision refuses `decision-mismatch`.
|
||||
The chief of staff's reason path refuses `role-end-refused`, since the assistant is protected
|
||||
(row 123). A stuck thread run is stopped instead: the launcher stops it
|
||||
The chief of staff's reason path gets past the assistant's protection (Q169) and then refuses
|
||||
`not-held`. Row 155 (#1649) makes both paths stop the thread runs. A stuck thread run is stopped instead: the launcher stops it
|
||||
once the thread is idle, the human stops it with `mosaic stop <run>`, and the run's end
|
||||
releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
|
||||
|
||||
@@ -274,9 +274,17 @@ RESULT, INFO, DECISION and REACTION. `staff.request` also writes STAFF-REQUEST (
|
||||
|
||||
Routing is immutable at raise: within-role closes atomically with `choice`,
|
||||
cross-role routes to the configured delivery arbiter (or technical arbiter with
|
||||
`domain:'technical'`). If that arbiter is the raiser, resolution routes to human
|
||||
while the decision keeps its cross-role class. Gated routes to human. Unlisted actions are gated and the
|
||||
nine gated-only actions cannot appear in authority maps. A blocking decision
|
||||
`domain:'technical'`). A gated raise that cites the project's current spec commit routes the same way,
|
||||
to the arbiter for its domain (PRD 1.5 REQ-DEC-2, row 139, #1633). If that arbiter is the raiser,
|
||||
resolution routes to human while the decision keeps its class. An uncited gated raise routes to human.
|
||||
Unlisted actions are gated. The hard waits (`HARD_WAITS`, REQ-WAIT-1: `spend`, `data.delete`,
|
||||
`network.expose`, `account.create`, `credential.mint`, `message.external`, `git.push.protected`,
|
||||
`deploy`) always route to human, cited or not, and cannot appear in authority maps. An action is a hard
|
||||
wait unless the broker's `ROUTED` list names it (REQ-WAIT-3), and both lists match the business
|
||||
vocabulary. `git.merge.protected`, `policy.change`, `prd.approve` and `role.revoke` left the gated-only
|
||||
list in 1.5, so a role file may grant them. Under Jason's ruling Q169 (PRD 1.6), a `role.revoke` whose
|
||||
target's definition is in `END_PROTECTED` (the assistant) is still gated at every class and routes to
|
||||
human, cited or not, so no arbiter resolves it. A blocking decision
|
||||
requires a strict task ref. `role.launch` is gated unless the business launch
|
||||
block's `by` names the acting role and refused when launch state is revoked. S6 still owns launch allowlists,
|
||||
capacity and actual spawning.
|
||||
@@ -675,8 +683,9 @@ human version. A later docs commit does not change the spec's file commit.
|
||||
spec_commit together. The broker reads the current committed snapshot and
|
||||
refuses a different commit (stale-spec), unconfirmed file (spec-unconfirmed),
|
||||
or incomplete citation (invalid-spec-citation). Missing files and unsafe
|
||||
repositories retain the project reader's refusal codes. Uncited raises keep the
|
||||
existing synchronous validity and routing rules. Decisions store spec_path and
|
||||
repositories retain the project reader's refusal codes. A cited gated raise that
|
||||
isn't a hard wait routes to the arbiter for its domain (REQ-DEC-2). Uncited raises
|
||||
keep the existing synchronous validity and routing rules. Decisions store spec_path and
|
||||
spec_commit; later Git commits never reopen or rewrite settled decisions.
|
||||
|
||||
`record.write {project,path,record,decision?}` uses the shared package writer.
|
||||
|
||||
+62
-15
@@ -26,21 +26,41 @@ export const ACTIONS = Object.freeze([
|
||||
'credential.mint',
|
||||
'spend',
|
||||
'deploy',
|
||||
'data.delete',
|
||||
'network.expose',
|
||||
'account.create',
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
]);
|
||||
const GATED = new Set([
|
||||
'credential.mint',
|
||||
// PRD 1.5 REQ-WAIT-3 (row 139, #1633): every action is a hard wait unless listed here, so a new
|
||||
// action stays one until a reviewed commit names it routed. Matches the business vocabulary's ROUTED.
|
||||
const ROUTED = new Set([
|
||||
'task.create',
|
||||
'task.assign',
|
||||
'task.schedule',
|
||||
'task.update.assigned',
|
||||
'task.close',
|
||||
'task.reassign',
|
||||
'task.scope.change',
|
||||
'task.priority.change',
|
||||
'git.push.working',
|
||||
'git.merge.protected',
|
||||
'git.push.protected',
|
||||
'deploy',
|
||||
'spend',
|
||||
'message.external',
|
||||
'review.request',
|
||||
'review.verdict',
|
||||
'record.write',
|
||||
'message.send',
|
||||
'role.launch',
|
||||
'role.revoke',
|
||||
'role.end',
|
||||
'organization.change',
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'role.revoke',
|
||||
'decision.resolve.technical',
|
||||
]);
|
||||
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
const GATED = new Set(HARD_WAITS);
|
||||
const CLOSED = "('resolved','withdrawn','expired')";
|
||||
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
|
||||
export const RECENT = 50;
|
||||
@@ -104,6 +124,8 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test
|
||||
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
|
||||
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances'];
|
||||
// Row 123 (#1617): role.end never reaches the assistant (the user's seat) or the caller's own run.
|
||||
// Row 139 (#1633), Jason's ruling Q169 (PRD 1.6): only the chief of staff, or a decision the human
|
||||
// resolved through the CLI, ends or revokes one (#protectedEnd). A raise to revoke one is a hard wait.
|
||||
const END_PROTECTED = ['assistant'];
|
||||
const VERDICTS = ['approve', 'changes'];
|
||||
// Row 140 (#1634, REQ-COS-1): a STAFF-REQUEST goes from an instance of STAFF_FROM to the business's
|
||||
@@ -464,9 +486,28 @@ export class Broker {
|
||||
);
|
||||
return eid;
|
||||
}
|
||||
#classification(s, action) {
|
||||
// PRD 1.6 REQ-WAIT-1: a hard wait goes to the human at every class, cited or not. Jason's ruling
|
||||
// Q169 makes a role.revoke whose target's definition is in END_PROTECTED one, so no arbiter
|
||||
// resolves a revoke of the assistant.
|
||||
#hardWait(s, action, target) {
|
||||
if (GATED.has(action)) return true;
|
||||
return action === 'role.revoke' && END_PROTECTED.includes(this.#business(s.business).roles[target]?.definition);
|
||||
}
|
||||
// Jason's ruling Q169 (row 139): an END_PROTECTED instance is ended or revoked only by the chief
|
||||
// of staff, or with a decision the human resolved through the CLI (r, the resolution). A decision
|
||||
// an arbiter resolved is refused, even one raised by a build that routed it to the arbiter.
|
||||
#protectedEnd(s, target, r = null) {
|
||||
const b = this.#business(s.business);
|
||||
if (!END_PROTECTED.includes(b.roles[target]?.definition)) return;
|
||||
if (b.roles[s.role]?.definition === STAFF_TO || r?.via === 'cli') return;
|
||||
fail('role-end-refused');
|
||||
}
|
||||
#classification(s, action, target = null) {
|
||||
if (!ACTIONS.includes(action)) fail('unknown-action');
|
||||
if (GATED.has(action) || (action === 'role.launch' && this.#business(s.business).launch?.by !== s.role))
|
||||
if (
|
||||
this.#hardWait(s, action, target) ||
|
||||
(action === 'role.launch' && this.#business(s.business).launch?.by !== s.role)
|
||||
)
|
||||
return 'gated';
|
||||
const a = this.#business(s.business).roles[s.role]?.authority;
|
||||
return a?.withinRole.includes(action)
|
||||
@@ -614,7 +655,7 @@ export class Broker {
|
||||
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
|
||||
)
|
||||
fail('launch-revoked');
|
||||
const cls = this.#classification(s, action);
|
||||
const cls = this.#classification(s, action, target);
|
||||
if (cls === 'within-role' && !decision) return { class: cls };
|
||||
if (!decision) fail('decision-required');
|
||||
const d = this.#decision(s, decision),
|
||||
@@ -637,6 +678,7 @@ export class Broker {
|
||||
)
|
||||
fail('decision-mismatch');
|
||||
if (r?.op !== 'resolved' || r.choice !== context.approvalChoice) fail('decision-not-approved');
|
||||
if (action === 'role.revoke') this.#protectedEnd(s, target, r);
|
||||
if (action === 'role.revoke' && context.holderRun !== this.#holder(s.business, target)?.holder_run)
|
||||
fail('decision-mismatch');
|
||||
return { class: cls, decision: d.id };
|
||||
@@ -1127,8 +1169,8 @@ export class Broker {
|
||||
if ('decision' in a) fail('invalid-request');
|
||||
string(a.reason, 500);
|
||||
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
|
||||
const b = this.#business(s.business);
|
||||
if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
|
||||
if (a.role === s.role) fail('role-end-refused');
|
||||
this.#protectedEnd(s, a.role);
|
||||
} else {
|
||||
identifier(a.decision);
|
||||
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
|
||||
@@ -1162,7 +1204,7 @@ export class Broker {
|
||||
);
|
||||
this.#agent(s);
|
||||
if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec) fail('invalid-spec-citation');
|
||||
const cls = this.#classification(s, a.action);
|
||||
const cls = this.#classification(s, a.action, a.target);
|
||||
string(a.question);
|
||||
if (typeof a.blocking !== 'boolean') fail('invalid-request');
|
||||
if (a.blocking && !a.task_ref) fail('task-required');
|
||||
@@ -1183,8 +1225,13 @@ export class Broker {
|
||||
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
|
||||
const domain = a.domain ?? 'delivery';
|
||||
if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain');
|
||||
const proposedRoute = cls === 'gated' ? 'human' : cls === 'cross-role' ? b.arbiters[domain] : s.role;
|
||||
const route = cls === 'cross-role' && proposedRoute === s.role ? 'human' : proposedRoute;
|
||||
// PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit
|
||||
// goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any
|
||||
// hard wait go to the human. readSpec already refused a stale or unconfirmed citation.
|
||||
const arbitrated =
|
||||
cls === 'cross-role' || (cls === 'gated' && spec !== null && !this.#hardWait(s, a.action, a.target));
|
||||
const proposedRoute = arbitrated ? b.arbiters[domain] : cls === 'gated' ? 'human' : s.role;
|
||||
const route = arbitrated && proposedRoute === s.role ? 'human' : proposedRoute;
|
||||
// Row 133 (#1627): whoever closes the decision messages the raiser, so the route must be
|
||||
// allowed to message the raising role before anything is stored. A within-role decision is
|
||||
// routed to the raiser's own role and closes in this call.
|
||||
|
||||
@@ -171,12 +171,14 @@ test('REQ-ROLE-2: a reason is required, bounded, and never sent with a decision'
|
||||
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', reason: 'x'.repeat(500) }), { revoked: true });
|
||||
});
|
||||
|
||||
test('REQ-COS-1: the chief of staff cannot end its own run or the assistant\'s through role.end', (t) => {
|
||||
test('REQ-COS-1: the chief of staff cannot end its own run; its end of the assistant gets past the protection', (t) => {
|
||||
const { store, call, lastClaim, allowed } = setup(t);
|
||||
for (const role of ['chief', 'assistant'])
|
||||
assert.throws(() => call('chief', 'role.revoke', { role, reason: 'no' }), /role-end-refused/, role);
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'chief', reason: 'no' }), /role-end-refused/);
|
||||
assert.deepEqual(lastClaim('chief'), { op: 'claim', decision: null, reason: null, by: 'chief-run' });
|
||||
// Row 121 (#1615): the assistant's run holds its thread, so its claim is in thread_claims.
|
||||
// Jason's ruling Q169 (row 139): the chief of staff may end the assistant. Row 121 (#1615): the
|
||||
// assistant's run holds its thread, so its claim is in thread_claims, the end finds no role
|
||||
// claim and refuses not-held, and the run goes on. Row 155 (#1649) makes the end stop it.
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
assert.deepEqual(
|
||||
{ ...store.get('SELECT op, holder_run, by FROM thread_claims ORDER BY seq DESC LIMIT 1') },
|
||||
{ op: 'claim', holder_run: 'assistant-run', by: 'assistant-run' },
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
|
||||
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../business/src/vocabulary.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
|
||||
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
|
||||
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
|
||||
'git.push.protected', 'deploy'];
|
||||
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
|
||||
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
|
||||
|
||||
test('REQ-WAIT-1: the broker hard waits are exactly the list, and match the business vocabulary', () => {
|
||||
assert.deepEqual([...HARD_WAITS].sort(), [...WAITS].sort());
|
||||
assert.deepEqual([...HARD_WAITS], [...GATED_ONLY]);
|
||||
assert.deepEqual([...ACTIONS], [...VOCABULARY]);
|
||||
for (const action of LEFT) assert.ok(ACTIONS.includes(action) && !HARD_WAITS.includes(action), action);
|
||||
});
|
||||
|
||||
test('REQ-WAIT-2: a business granting a hard wait at either class is refused; the actions that left are grantable', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hard-waits-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority } } } });
|
||||
for (const action of WAITS) {
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action);
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action);
|
||||
}
|
||||
assert.ok(new Broker({ store, businesses: business({ withinRole: [], crossRole: LEFT }) }));
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
|
||||
const f = await fixture(t);
|
||||
for (const action of WAITS) {
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
|
||||
const d = await f.raise({ action, ...args });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
|
||||
}
|
||||
}
|
||||
const d = await f.raise({ action: 'data.delete', ...cite(f) });
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
f.agent('cto');
|
||||
for (const action of LEFT) {
|
||||
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
|
||||
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: 'reviewer' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser, and the human cannot resolve it', async t => {
|
||||
const f = await fixture(t, { roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
f.agent('pm');
|
||||
const d = await f.raise({ action: 'role.revoke', target: 'pm', ...cite(f) });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/);
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, roles: {
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
|
||||
} });
|
||||
const cto = f.agent('cto'), gate = f.agent('merge-gate');
|
||||
const merge = { action: 'git.merge.protected', question: 'Merge?', options: [{ key: 'yes', text: 'Merge' }, { key: 'no', text: 'Hold' }],
|
||||
recommendation: 'yes', blocking: false, domain: 'technical' };
|
||||
const d = await f.call(gate, 'decision.raise', { ...merge, ...cite(f) });
|
||||
assert.deepEqual(routed(f, d), { class: 'cross-role', route_to: 'cto' });
|
||||
f.call(cto, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT via FROM decision_events WHERE decision=?', d.id).via, 'broker');
|
||||
assert.deepEqual(routed(f, f.call(gate, 'decision.raise', merge)), { class: 'cross-role', route_to: 'cto' });
|
||||
assert.deepEqual(routed(f, await f.raise({ ...merge, ...cite(f) })), { class: 'gated', route_to: 'cto' });
|
||||
assert.deepEqual(routed(f, f.raise(merge)), { class: 'gated', route_to: 'human' });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an arbiter raising its own goes to the human', async t => {
|
||||
const f = await fixture(t);
|
||||
const old = cite(f);
|
||||
f.save('# Newer\n');
|
||||
await assert.rejects(f.raise({ action: 'policy.change', ...old }), { code: 'stale-spec' });
|
||||
const edit = f.save('# Agent edit\n', 'coder.coder');
|
||||
await assert.rejects(f.raise({ action: 'policy.change', ...old, spec_commit: edit }), { code: 'spec-unconfirmed' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
const current = f.save('# Current\n');
|
||||
const own = await f.call(f.reviewer, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
});
|
||||
|
||||
// Jason's ruling Q169 (PRD 1.6): only the chief of staff, or a decision the human resolved through
|
||||
// the CLI, gets past END_PROTECTED. A raise to revoke the assistant goes to the human even when
|
||||
// cited; any other cited revoke routes as REQ-DEC-2 says. The assistant's runs hold thread claims,
|
||||
// so neither path can stop one yet (row 155, #1649), and the assistant tests assert the protection only.
|
||||
const revoke = (f, cap, role, extra = {}) => f.call(cap, 'decision.raise', { action: 'role.revoke', target: role,
|
||||
question: 'Revoke?', options: [{ key: 'yes', text: 'Revoke' }, { key: 'no', text: 'Keep' }], recommendation: 'no',
|
||||
blocking: false, ...extra });
|
||||
const grants = withinRole => ({ authority: { withinRole: ['message.send', ...withinRole], crossRole: [] } });
|
||||
const seats = { assistant: { definition: 'assistant', ...grants([]) }, pm: { definition: 'pm', ...grants([]) } };
|
||||
|
||||
test('Q169: the chief of staff\'s end of the assistant is not refused role-end-refused; its own run and another ender\'s are', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats,
|
||||
chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief'), lead = f.agent('lead');
|
||||
// Past the protection the end reads role_claims, and the assistant holds none (row 155).
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'chief', reason: 'own run' }), /role-end-refused/);
|
||||
assert.throws(() => f.call(lead, 'role.revoke', { role: 'assistant', reason: 'not the chief' }), /role-end-refused/);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed'").n, 0);
|
||||
});
|
||||
|
||||
test('Q169: another role\'s cited revoke of the assistant, resolved by an arbiter, is refused', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
// A raise for it goes to the human, so an arbiter can't resolve one raised now.
|
||||
assert.deepEqual(routed(f, await revoke(f, f.coder, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
|
||||
// One raised while the instance's definition wasn't protected went to the arbiter. The same
|
||||
// store under a business that now makes it the assistant still refuses it.
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } };
|
||||
const later = new Broker({ store: f.store, businesses: { demo: now } });
|
||||
const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' });
|
||||
assert.throws(() => later.request(coder, { verb: 'role.revoke', args: { role: 'pm', decision: d.id } }), /role-end-refused/);
|
||||
});
|
||||
|
||||
test('Q169: a revoke of the assistant citing a decision the human resolved is not refused by the protection', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
const d = await revoke(f, f.coder, 'assistant', cite(f));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
// Past the protection, the recorded holder run (null) and the role_claims one (undefined)
|
||||
// mismatch, because the assistant's runs hold thread claims (row 155).
|
||||
assert.throws(() => f.call(f.coder, 'role.revoke', { role: 'assistant', decision: d.id }), /decision-mismatch/);
|
||||
});
|
||||
|
||||
test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbiter, and executes', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
f.agent('pm');
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
test('Q169: a role granted role.revoke at either class still sends a revoke of the assistant to the human', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats,
|
||||
lead: { definition: 'lead', authority: { withinRole: [], crossRole: ['role.revoke'] } },
|
||||
boss: { definition: 'boss', ...grants(['role.revoke']) } } });
|
||||
const lead = f.agent('lead'), boss = f.agent('boss');
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'reviewer' });
|
||||
const own = await revoke(f, boss, 'assistant', { choice: 'yes' });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0);
|
||||
// The revoke's authority check classifies the same way: the decision is open, not mismatched.
|
||||
assert.throws(() => f.call(boss, 'role.revoke', { role: 'assistant', decision: own.id }), /decision-not-approved/);
|
||||
assert.deepEqual(routed(f, await revoke(f, boss, 'pm', { choice: 'yes' })), { class: 'within-role', route_to: 'boss' });
|
||||
});
|
||||
@@ -7,15 +7,16 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' } } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const authority = grants => ({ withinRole: grants, crossRole: [] });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
const business = { id: 'demo', human: 'jason', arbiters,
|
||||
projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: {
|
||||
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
|
||||
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
|
||||
...roles,
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project: adapter ? {
|
||||
@@ -38,5 +39,5 @@ export async function fixture(t, { adapter = true } = {}) {
|
||||
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Spec');
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
return { root, store, business, broker, repo, commit, coder, reviewer, human, call, raise, save };
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, human, call, raise, save };
|
||||
}
|
||||
|
||||
@@ -48,10 +48,17 @@ in this repository, or `$MOSAIC_ROLES_DIR` if set.
|
||||
empty.
|
||||
- `authority` names actions from the closed list in `src/vocabulary.mjs`.
|
||||
`withinRole` actions run without asking. `crossRole` actions need the
|
||||
arbiter. Any action not listed is gated: it goes to Jason. The nine
|
||||
always-gated actions (`deploy`, `spend`, `git.push.protected`,
|
||||
`git.merge.protected`, `credential.mint`, `message.external`,
|
||||
`policy.change`, `prd.approve`, `role.revoke`) can't appear in either list.
|
||||
arbiter. Any action not listed is gated: it goes to Jason, or to the
|
||||
arbiter for its domain when the raise cites the project's current spec
|
||||
commit (PRD 1.5 REQ-DEC-2). The eight hard waits in `GATED_ONLY`
|
||||
(REQ-WAIT-1: `spend`, `data.delete`, `network.expose`, `account.create`,
|
||||
`credential.mint`, `message.external`, `git.push.protected`, `deploy`)
|
||||
always go to Jason and can't appear in either list (REQ-WAIT-2).
|
||||
`GATED_ONLY` is every action that `ROUTED` doesn't name, so an action
|
||||
added to the vocabulary is a hard wait until a PRD says otherwise and the
|
||||
commit adds it to `ROUTED` (REQ-WAIT-3). `git.merge.protected`,
|
||||
`policy.change`, `prd.approve` and `role.revoke` left the list in 1.5
|
||||
(row 139, #1633).
|
||||
- Gitea scopes are `read:<category>` or `write:<category>`, one level per
|
||||
category. `all` and `admin` are refused. Vikunja scopes map a route group
|
||||
to verbs, and only the pairs in `VIKUNJA_GRANTABLE` pass.
|
||||
@@ -134,7 +141,7 @@ terminates role instances, owns the structure, takes work from the
|
||||
assistant and never talks to the user. It holds `message.send` and
|
||||
`role.launch` within role (decision 139), plus `role.end` and
|
||||
`organization.change` (row 123): it ends a run with a reason and no decision
|
||||
(`role.revoke` stays gated for every role), and it changes the organization
|
||||
(no shipped role is granted `role.revoke`, so it stays gated), and it changes the organization
|
||||
file through the broker's `organization.set`, limited to the keys the human's
|
||||
`limits.set` takes; the harness's `end_run` and `change_organization` tools
|
||||
call them (row 132). It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
|
||||
@@ -29,32 +29,59 @@ export const ACTIONS = Object.freeze([
|
||||
"role.revoke",
|
||||
// Row 123 (#1617, REQ-COS-1): the chief of staff ends a run with a reason and no decision,
|
||||
// and changes the organization file through the shared write path. Neither is gated-only,
|
||||
// so the grant lives in a role file; role.revoke itself stays gated for every role.
|
||||
// so the grant lives in a role file. No shipped role is granted role.revoke, so it stays gated.
|
||||
"role.end",
|
||||
"organization.change",
|
||||
"credential.mint",
|
||||
"spend",
|
||||
"deploy",
|
||||
// PRD 1.5 REQ-WAIT-1 (row 139, #1633): new hard waits. data.delete deletes data; network.expose
|
||||
// opens a listener, port, tunnel, DNS record or proxy route reachable from outside the host;
|
||||
// account.create opens an external account.
|
||||
"data.delete",
|
||||
"network.expose",
|
||||
"account.create",
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
]);
|
||||
|
||||
// Always gated: a role file may not list these under withinRole or
|
||||
// crossRole. role.revoke is here because lead decision 46 (6.7) keeps every
|
||||
// revoke gated in slice 1.
|
||||
export const GATED_ONLY = Object.freeze([
|
||||
"credential.mint",
|
||||
// The actions that are not hard waits. PRD 1.5 REQ-WAIT-3: an action added
|
||||
// to ACTIONS is a hard wait until a PRD version says it isn't, so a new
|
||||
// action joins this list only by the reviewed commit that names it routed.
|
||||
// git.merge.protected, policy.change, prd.approve and role.revoke left the
|
||||
// hard waits in 1.5 and route by REQ-DEC-2.
|
||||
export const ROUTED = Object.freeze([
|
||||
"task.create",
|
||||
"task.assign",
|
||||
"task.schedule",
|
||||
"task.update.assigned",
|
||||
"task.close",
|
||||
"task.reassign",
|
||||
"task.scope.change",
|
||||
"task.priority.change",
|
||||
"git.push.working",
|
||||
"git.merge.protected",
|
||||
"git.push.protected",
|
||||
"deploy",
|
||||
"spend",
|
||||
"message.external",
|
||||
"review.request",
|
||||
"review.verdict",
|
||||
"record.write",
|
||||
"message.send",
|
||||
"role.launch",
|
||||
"role.revoke",
|
||||
"role.end",
|
||||
"organization.change",
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"role.revoke",
|
||||
"decision.resolve.technical",
|
||||
]);
|
||||
|
||||
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
|
||||
// cited spec or not. A role file may not list these under withinRole or
|
||||
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
|
||||
// account.create, credential.mint, message.external, git.push.protected and
|
||||
// deploy; a test pins the list.
|
||||
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
|
||||
|
||||
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
|
||||
// accepts for tasks.
|
||||
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// PRD 1.5 REQ-WAIT-1 to REQ-WAIT-3 (row 139, #1633): the hard waits in the
|
||||
// closed vocabulary, and the role validator's refusal of a grant of one.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ACTIONS, GATED_ONLY, ROUTED } from "../src/vocabulary.mjs";
|
||||
import { validateRoleDocument } from "../src/index.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
|
||||
"git.push.protected", "deploy"];
|
||||
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
|
||||
const role = (authority) => {
|
||||
const dir = tmp();
|
||||
writeFileSync(join(dir, "r.md"), "# contract\n");
|
||||
const doc = { roleVersion: 2, name: "r", title: "R", contract: "r.md", tools: ["read"], network: "none", authority,
|
||||
credentials: [{ service: "gitea", scopes: ["read:issue"] }] };
|
||||
return validateRoleDocument(doc, join(dir, "r.json"));
|
||||
};
|
||||
|
||||
test("REQ-WAIT-1: GATED_ONLY is exactly the hard waits; the new actions are in the vocabulary", () => {
|
||||
assert.deepEqual([...GATED_ONLY].sort(), [...WAITS].sort());
|
||||
for (const action of ["data.delete", "network.expose", "account.create"]) assert.ok(ACTIONS.includes(action), action);
|
||||
for (const action of LEFT) assert.ok(ACTIONS.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
|
||||
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
|
||||
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
test("REQ-WAIT-2: a role listing a hard wait at either class is refused; the actions that left can be granted", () => {
|
||||
for (const action of WAITS) {
|
||||
assert.throws(() => role({ withinRole: [action], crossRole: [] }), /always gated/, action);
|
||||
assert.throws(() => role({ withinRole: [], crossRole: [action] }), /always gated/, action);
|
||||
}
|
||||
assert.deepEqual(role({ withinRole: [], crossRole: ["git.merge.protected"] }).authority.crossRole,
|
||||
["git.merge.protected"]);
|
||||
assert.deepEqual(role({ withinRole: LEFT.slice(1), crossRole: [] }).authority.withinRole, LEFT.slice(1));
|
||||
});
|
||||
@@ -99,7 +99,10 @@ so its manifest lists both in `typed` and nothing in `untyped`:
|
||||
|
||||
- `end_run` (role.end holders) sends `role.revoke` with `role` and a
|
||||
`reason` of 1 to 500 characters, never a `decision`. The broker refuses
|
||||
the caller's own run and the assistant's with `role-end-refused`. A
|
||||
the caller's own run with `role-end-refused`. Since row 139 (Jason's
|
||||
ruling Q169) the chief of staff's end of the assistant gets past that
|
||||
check and refuses `not-held`, because the assistant's runs hold thread
|
||||
claims; row 155 (#1649) makes it stop them and updates the tool's text. A
|
||||
`role.end` holder's prompt names the tool in place of the "only the human
|
||||
stops a run" line; every other role keeps that line.
|
||||
- `change_organization` (organization.change holders) sends
|
||||
|
||||
@@ -28,7 +28,8 @@ const businesses = {
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
// Row 132 (#1628): the chief of staff's grants and the assistant it can't end.
|
||||
// Row 132 (#1628): the chief of staff's grants and the assistant. Since row 139 (Q169) the
|
||||
// chief may end the assistant, and the end refuses not-held until row 155 (#1649).
|
||||
chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change"], crossRole: [] } },
|
||||
assistant: { definition: "assistant", authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
},
|
||||
@@ -401,7 +402,7 @@ test("end_run and change_organization reach the broker with the tool's arguments
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
'{"revoked":true}',
|
||||
"refused: role-end-refused",
|
||||
"refused: role-end-refused",
|
||||
"refused: not-held",
|
||||
'{"revision":"' + "b".repeat(64) + '"}',
|
||||
"refused: invalid-request",
|
||||
"refused: revision-conflict",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"prd": "docs/prd/mosaic-stack.md",
|
||||
"version": "1.5",
|
||||
"version": "1.6",
|
||||
"requirements": [
|
||||
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
|
||||
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per thread for the assistant; the chief of staff may end a run, others need a decision" },
|
||||
|
||||
@@ -21,7 +21,7 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
|
||||
assert.equal(ids.length, 58);
|
||||
assert.deepEqual(list.requirements.map((r) => r.id), ids);
|
||||
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.5']);
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.6']);
|
||||
});
|
||||
|
||||
test('the committed list loads frozen', () => {
|
||||
|
||||
@@ -122,6 +122,10 @@ const biz = variant === 'other-business' ? 'other' : 'fx';
|
||||
const ref = `work/vikunja-${p}-${t}`;
|
||||
const task = `vikunja:${p}/${t}`;
|
||||
const action = variant === 'not-a-merge' ? 'git.push.protected' : MERGE;
|
||||
// Row 139 (#1633, PRD 1.5 REQ-DEC-2): a merge decision the arbiter resolved, raised cross-role by
|
||||
// merge-gate or gated with a spec citation by a coder. land-work still lands only a human's.
|
||||
const arbiter = { 'arbiter-cross': ['merge-gate', 'cross-role'], 'arbiter-cited': ['coder', 'gated'] }[variant];
|
||||
const raiser = arbiter?.[0] ?? 'pm';
|
||||
const question =
|
||||
{
|
||||
'two-refs': `Squash-merge ${ref} and work/vikunja-7-99 into refactor?`,
|
||||
@@ -135,6 +139,7 @@ const rows = [
|
||||
{
|
||||
table: 'decisions', id: D, business: biz, project: null, action, question, options,
|
||||
...(variant === 'task-ref-disagrees' ? { task_ref: 'vikunja:7/99' } : {}),
|
||||
...(arbiter ? { raised_by_role: raiser, class: arbiter[1], route_to: 'cto' } : {}),
|
||||
},
|
||||
];
|
||||
// An earlier merge decision on the same ref, withdrawn before D was raised:
|
||||
@@ -146,7 +151,7 @@ if (variant === 'earlier-merge') {
|
||||
}
|
||||
if (variant !== 'no-raise')
|
||||
rows.push({
|
||||
table: 'events', business: biz, kind: 'action.allowed', actor_role: 'pm', actor_run: 'rfixture', subject: D,
|
||||
table: 'events', business: biz, kind: 'action.allowed', actor_role: raiser, actor_run: 'rfixture', subject: D,
|
||||
body: { decision: D, action, operation: 'decision.raise',
|
||||
task_ref: variant === 'raise-task-disagrees' ? 'vikunja:7/99' : null,
|
||||
target: variant === 'target-main' ? 'main' : 'refactor', approvalChoice: 'yes', holderRun: null },
|
||||
@@ -154,14 +159,14 @@ if (variant !== 'no-raise')
|
||||
const choice = variant === 'chose-no' ? 'no' : 'yes';
|
||||
if (variant === 'withdrawn') rows.push({ table: 'decision_events', decision: D, op: 'withdrawn', by: 'pm', choice: null, via: null });
|
||||
else if (variant !== 'open') {
|
||||
rows.push({ table: 'decision_events', decision: D, op: 'resolved', by: variant === 'by-agent' ? 'pm' : 'jason', choice,
|
||||
via: variant === 'via-webui' ? 'webui' : 'cli' });
|
||||
rows.push({ table: 'decision_events', decision: D, op: 'resolved', by: variant === 'by-agent' ? 'pm' : arbiter ? 'cto' : 'jason', choice,
|
||||
via: variant === 'via-webui' ? 'webui' : arbiter ? 'broker' : 'cli' });
|
||||
const answer = {
|
||||
table: 'events', business: biz, kind: 'human.input', subject: D,
|
||||
actor_role: variant === 'answer-role' ? 'pm' : null, actor_run: variant === 'answer-run' ? 'rfixture' : null,
|
||||
body: { kind: 'answer', decision: D, op: 'resolved', choice: variant === 'answer-choice' ? 'no' : choice },
|
||||
};
|
||||
if (variant !== 'no-answer') rows.push(answer);
|
||||
if (variant !== 'no-answer' && !arbiter) rows.push(answer);
|
||||
if (variant === 'two-answers') rows.push({ ...answer });
|
||||
}
|
||||
const later = {
|
||||
@@ -262,7 +267,7 @@ check "openBus opens the database read-only" "$rc"
|
||||
for v in "decision-not-found:other-business" "decision-not-found:no-raise" "not-a-merge:not-a-merge" \
|
||||
"not-resolved:open" "not-resolved:withdrawn" "not-human:by-agent" "not-human:via-webui" \
|
||||
"not-human:no-answer" "not-human:answer-role" "not-human:answer-run" "not-human:answer-choice" \
|
||||
"not-human:two-answers" "not-merge-option:chose-no" "work-ref:two-refs" "work-ref:bad-ref" \
|
||||
"not-human:two-answers" "not-human:arbiter-cross" "not-human:arbiter-cited" "not-merge-option:chose-no" "work-ref:two-refs" "work-ref:bad-ref" \
|
||||
"work-ref:task-ref-disagrees" "work-ref:raise-task-disagrees" "target-branch:target-main" "superseded:superseded-question" \
|
||||
"superseded:superseded-task" "superseded:superseded-by" "no-verdict:no-verdict" \
|
||||
"not-approved:changes-later" "not-approved:short-commit" "not-approved:wrong-task-body"; do
|
||||
|
||||
Reference in New Issue
Block a user