PRD 1.6 REQ-ROLE-2 gains Mos's two sentences (sage-0545): the chief ends the assistant with no decision, and any revoke of it is a hard wait. Checked against broker.mjs; the code already does both. New candidate: patch a5f21ead, manifest a4c5657f. Both PRD-reading tests pass; repro OK. Co-Authored-By: Claude Opus 5.5 <[email protected]>
814 lines
51 KiB
Diff
814 lines
51 KiB
Diff
diff --git a/docs/prd/mosaic-stack.md b/docs/prd/mosaic-stack.md
|
|
index bb4ddcd20..794a39bbb 100644
|
|
--- a/docs/prd/mosaic-stack.md
|
|
+++ b/docs/prd/mosaic-stack.md
|
|
@@ -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
|
|
|
|
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
|
index 1db7e067e..ce9a5b15d 100644
|
|
--- a/packages/bus/README.md
|
|
+++ b/packages/bus/README.md
|
|
@@ -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.
|
|
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index 031634bb8..61ba1e4bd 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -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.
|
|
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
|
|
index 6f9235943..6de389662 100644
|
|
--- a/packages/bus/tests/chief-of-staff.test.mjs
|
|
+++ b/packages/bus/tests/chief-of-staff.test.mjs
|
|
@@ -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' },
|
|
diff --git a/packages/bus/tests/hard-waits.test.mjs b/packages/bus/tests/hard-waits.test.mjs
|
|
new file mode 100644
|
|
index 000000000..e540fd9b3
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/hard-waits.test.mjs
|
|
@@ -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' });
|
|
+});
|
|
diff --git a/packages/bus/tests/spec-fixtures.mjs b/packages/bus/tests/spec-fixtures.mjs
|
|
index eafd30fb9..e24b27f94 100644
|
|
--- a/packages/bus/tests/spec-fixtures.mjs
|
|
+++ b/packages/bus/tests/spec-fixtures.mjs
|
|
@@ -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 };
|
|
}
|
|
diff --git a/packages/business/README.md b/packages/business/README.md
|
|
index e5ca85610..504fbd14d 100644
|
|
--- a/packages/business/README.md
|
|
+++ b/packages/business/README.md
|
|
@@ -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
|
|
diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs
|
|
index a63316935..05d09d626 100644
|
|
--- a/packages/business/src/vocabulary.mjs
|
|
+++ b/packages/business/src/vocabulary.mjs
|
|
@@ -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"]);
|
|
diff --git a/packages/business/tests/hard-waits.test.mjs b/packages/business/tests/hard-waits.test.mjs
|
|
new file mode 100644
|
|
index 000000000..57132425d
|
|
--- /dev/null
|
|
+++ b/packages/business/tests/hard-waits.test.mjs
|
|
@@ -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));
|
|
+});
|
|
diff --git a/packages/harness/README.md b/packages/harness/README.md
|
|
index fcc33ab30..c304d38be 100644
|
|
--- a/packages/harness/README.md
|
|
+++ b/packages/harness/README.md
|
|
@@ -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
|
|
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
|
|
index 1361cf030..e409f43b0 100644
|
|
--- a/packages/harness/tests/runner.test.mjs
|
|
+++ b/packages/harness/tests/runner.test.mjs
|
|
@@ -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",
|
|
diff --git a/packages/tasks/requirements.json b/packages/tasks/requirements.json
|
|
index f4d9f35c7..ade2b774d 100644
|
|
--- a/packages/tasks/requirements.json
|
|
+++ b/packages/tasks/requirements.json
|
|
@@ -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" },
|
|
diff --git a/packages/tasks/tests/requirements.test.mjs b/packages/tasks/tests/requirements.test.mjs
|
|
index 90409e131..dada58304 100644
|
|
--- a/packages/tasks/tests/requirements.test.mjs
|
|
+++ b/packages/tasks/tests/requirements.test.mjs
|
|
@@ -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', () => {
|
|
diff --git a/scripts/test-land-work.sh b/scripts/test-land-work.sh
|
|
index 1bfe402db..d689e0e14 100644
|
|
--- a/scripts/test-land-work.sh
|
|
+++ b/scripts/test-land-work.sh
|
|
@@ -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
|