Compare commits

..
52 Commits
Author SHA1 Message Date
jason.woltje e9a079fcc4 fix(organization): validate narrowed arbiter resolve authority (#1873) 2026-11-10 16:51:03 -06:00
jason.woltje 7de9cfccc3 feat(organization): enforce arbiter domain resolve rights (#1873) 2026-11-10 16:33:21 -06:00
jason.woltje c3c192677d test(webui): the layout reload test loads the page through navigate (#1873)
The mark from the previous commit kept the wait off the old page, but
the wait could still start on it and be killed by the reload
("Inspected target navigated or closed", once in ten runs). navigate()
waits for the load; a query makes the same URL load again.
2026-11-10 16:01:01 -06:00
jason.woltje 95ee19cc71 fix(webui): a task dialog closed during the conflict read stays closed (#1873)
Review fix. dialog.close() clears `open` at once but queues the close
event that empties the dialog. A conflict read that resumed in between
still found the old form connected and reopened the dialog, and the
queued event then emptied it: an open modal with nothing in it. The
check is now the dialog's own `open` state and that it still holds the
form. A browser test closes the dialog and releases the read's body in
one task; it fails on the previous commit.
2026-11-10 16:01:01 -06:00
jason.woltje 07dba7f2f5 test(webui): the layout reload test waits for the new page (#1873)
The wait after location.reload() could pass on the board that was
unloading, and the next read then found no board (seen once in ten
runs under load). A mark set before the reload goes with the old page,
so the wait now passes only on the reloaded one.
2026-11-10 16:01:01 -06:00
jason.woltje ceee80d5b7 fix(webui): task board conflict shows the refusal only with the current task (#1873)
A product race, not only a test race. On task-conflict the dialog wrote
the refusal into the old form, turned its buttons back on and only then
read the current task. For the length of that read the form showed the
refusal beside the old task and a Save went out against the old digest.
The conflict test read the title in that window and failed under load.

The old form now stays disabled with "Reading the current task…" until
the read answers; the refusal and the reopened form appear together. A
read a poll overtook no longer reopens on the board's old read; the
dialog reads the task itself. If the task cannot be read the form keeps
only Cancel and takes no save. A dialog closed during the read stays
closed, and a form whose Save is disabled refuses a submit sent past it.
2026-11-10 16:01:01 -06:00
jason.woltje fa9bedb09a fix(webui): prefix project ids in side-by-side panes (#1873)
The requirements page drew project-request-select, project-north-star
and project-prd with fixed ids, so the same project in both panes put
each id in the document twice. mountContent takes the pane's idPrefix
(pane-a-, pane-b-) and every id and id lookup on the page follows it;
the full view passes none and keeps its ids.

The dashboard was already prefixed and the Agents page draws no ids.
A new browser test opens the same dashboard, Agents page and project
pages in both panes and checks for duplicate ids and for labels, ARIA
references and in-page links that leave their pane.
2026-11-10 16:01:01 -06:00
jason.woltje 67d6c9ed84 fix(organization): composition review round, receiveFrom and one JSON read (#1873)
Review finding 1: step 7 compares messaging.receiveFrom with the chief's as
well as sendTo, since the broker checks the recipient's list too.
Review finding 2: checkComposition reads the role and the chief once, as a JSON
copy; a getter, proxy, toJSON, cycle or BigInt that throws refuses with a fixed
message, so the thrown text never reaches the caller.
2026-11-10 15:50:15 -06:00
jason.woltje c8dbd3f6ec feat(organization): composition validator and primitives registry (#1873)
Roles round 2, slice P1. checkComposition(role, registry, chief, { assertClean })
runs the design's seven steps in order against a registry from buildRegistry
(core vocabulary plus a caller-passed plugin set, empty by default) and returns
the flagged lines: every decision.resolve.* action and everything above the
chief's own resolved role. Refusals name the field and position, quote only
registered names, and never quote instructions or personality text.

COMPOSITION_BOUNDS holds the length bounds the design leaves open. Nothing
calls checkComposition yet; HIRE_ALLOWED and unhireableAction stay until A4.
2026-11-10 15:50:15 -06:00
jason.woltje 8f7de8c4d3 fix(cli): redact config and organization JSON parse refusals (#1873) 2026-11-10 15:39:53 -06:00
jason.woltje 6f0c72a02f fix(organization): redact model selections and JSON parser diagnostics (#1873) 2026-11-10 15:39:53 -06:00
jason.woltje 257a156845 feat(organization): add grantable org.hire right (#1873) 2026-11-10 15:34:55 -06:00
jason.woltje b6e52e1b52 docs: roles design round 2 review fixes (#1873) 2026-11-10 15:26:24 -06:00
jason.woltje 3bcdee0926 docs: roles design round 2, rails layers and autonomy levels (#1873) 2026-11-10 15:26:24 -06:00
jason.woltje 9f0fcf8690 test(organization): pin route refusal variable names (#1873) 2026-11-10 21:21:36 +00:00
jason.woltje c7b588289f fix(organization): keep var values out of refusals (#1873) 2026-11-10 21:21:36 +00:00
jason.woltje aa9f936690 fix(webui): P2 system types in a side-by-side pane only show, never switch (#1873)
U5 draws the organization dashboard into a side-by-side pane, and the
System types section came with it: Enable and Disable buttons that wrote
to the selected organization whatever the pane showed, a heading id
repeated in both panes, and a Settings Open button that focused the
first pane's section. A pane now shows its own organization's types
with no switch and says to change them from the full view, as U5 does
for the bus restart. The heading id carries the pane's prefix, Open
focuses the section in its own dashboard, and the click handler and
the post-write repaint ignore panes.
2026-11-10 21:11:14 +00:00
jason.woltje 49a8d2983e test(webui): answer the project system-types read in the W15 integration fixture (#1873) 2026-11-10 21:11:14 +00:00
jason.woltje d630f815c2 fix(webui): P2 review fixes for system types (#1873)
Recheck the session after the read so a sign-out dispatches no write.
Take the enabled set and its revision from one plugins read. Let the
owner clear an inactive-only selection when nothing is enabled. Keep
role tools as the hard ceiling in the toolset design, name only
vocabulary tools in the mapping, and intersect mission and task over
the final set.
2026-11-10 21:11:14 +00:00
jason.woltje 14f1b3b2b1 feat(webui): P2 system types on the org dashboard and project page, toolset design (#1873)
The org dashboard lists the installed system-type modules with their own
labels and descriptions, marks which are enabled, and gives the owner an
enable or disable control through plugins.set behind the owner fence, with
the revision and the transaction receipt. The project page selects from the
organization's enabled set through project.system-types.set and shows a
disabled selection as inactive, as system-types.console reports. Both routes
go through the per-org Console call path and its admission checks.

docs/plans/2026-11-10_system-type-toolsets-design.md proposes how a launch
turns a project's active system types into toolsets under the role, mission
and task ceilings. Design only; no roles/ or contract change is applied.
2026-11-10 21:11:14 +00:00
jason.woltje f98f278fbb docs: roles design review fixes (#1873)
Route merge, policy, hire, fire and credential decisions to the human and
keep merge and policy cross-role only. A person-started hire is a decision
record. Restructure the usability table by who acts, list template roles
explicitly, add the arbiter threat row and slice tests.
2026-11-10 15:00:43 -06:00
jason.woltje c6e341e92e docs: roles, rails and hire as a decision design (#1873)
Roles become templates plus organization copies built from known parts. A hire is a decision the chief raises and a human with org.hire approves. Rails listed with their enforcement points. Proposal only: no change to AGENTS.md, roles/, contracts/ or code.
2026-11-10 14:50:04 -06:00
jason.woltje 50be333552 fix(organization): address accounts review and rollback edge cases (#1873) 2026-11-10 20:43:00 +00:00
jason.woltje 3bb0f70acc feat(organization): add locked instance accounts store and CLI (#1873) 2026-11-10 20:43:00 +00:00
jason.woltje 52a82a4e56 test(webui): U5 Agents panes keep their own project choice (#1873)
The rebase onto U3 brought in A1's "Available in a project" chooser. Its
change handler kept one renderer and wrote into the first table in the
document, keyed by the page's organization. The rebase resolution keys
the renderer and the choice by each grid's organization and redraws the
table in the chooser's own section. This test covers it: before that
change, pane b's choice never redrew pane b.
2026-11-10 14:07:05 -06:00
jason.woltje 5f3706e807 fix(webui): U5 pane view state survives a project repaint (#1873)
A pane keeps its request choice and PRD disclosure on the pane, per
organization, instead of on the project content element. A new
conversation changes the project header, which replaces that element;
the choice now survives it.
2026-11-10 14:07:05 -06:00
jason.woltje 5f57008aed fix(webui): U5 review fixes for side by side panes (#1873)
One observer per pane element, disconnected on leaving the page, so a
closed and reopened pane rewrites links for its current organization.
Project reads check the pane generation and organization before any
DOM write. Request selection and expanded PRDs are kept per pane.
A late navigation focuses its heading only when focus has not moved to
the other pane. Project pages in a pane use a compact layout with
container queries.
2026-11-10 14:07:05 -06:00
jason.woltje bc3e0a9f49 feat(webui): U5 side by side, two panes across organizations and projects (#1873)
The Side by side page now opens two panes. Each pane shows an organization
dashboard, an agents grid or a project page, chosen from a picker that lists
every organization and project. Pane state lives in the URL and is kept per
viewer. A separator resizes the panes by pointer or arrow keys, closing a
pane goes back to one, and each pane keeps its own focus and scroll through
polling. A pane whose organization's bus is down says so while the other
pane keeps working. On a phone a two-way switch shows one pane at a time.

Panes are read-only. Conversation, room, settings, queue, docs and inbox
pages open in full view; chat.js is a single-instance page with global ids.
2026-11-10 14:07:05 -06:00
jason.woltje a71da41146 fix(webui): U4b re-check fixes, conflict recovery and partial empty state (#1873)
A conflict on a task another writer moved out of the board's server
filter now reads that task from its organization unfiltered, so the
dialog reopens on the current task. An empty result names the
organizations whose tasks were not read instead of saying there are
no tasks.
2026-11-10 20:04:49 +00:00
jason.woltje 4c74e7ea49 fix(webui): U4b review fixes for the task board (#1873)
- Edit and archive require the digest the board read (expect); the route
  refuses a write without it.
- The edit dialog saves against the task it showed, so a poll that reads a
  newer task turns a stale save into task-conflict; the dialog reopens on the
  current task.
- An answer the board cannot read, or one marked sent=unknown, is shown as an
  open outcome, not as a refusal.
- The per-org cap applies after the filters and keeps the most recently
  changed tasks; projects, metrics and the inbox count every task. The board
  sends project, state, assignee and label filters to the server.
- A poll keeps the focus, caret and scroll the person has at the moment it
  answers, not when it started.
- An org whose tasks read fails shows "tasks not read" and is not writable.
- browser.test.mjs pins tasks-board.js in the script order.
2026-11-10 20:04:49 +00:00
jason.woltje ac3a863bec feat(webui): U4b one task board across organizations (#1873)
Replaces the Tasks placeholder with sample 06: every organization's
tasks read through its own bus and tagged with the org, Kanban and List
with the layout remembered per viewer, and org, project, state,
assignee and label filters. A down org keeps its U1 state.

Writes (create, move, edit, archive) go to POST /api/workspace/tasks,
behind the hook fence, owner only and --send only. Each write resolves
only the org it names and calls that org's bus with the person's store
verbs, so the bus's human check, tracker check and digest check all
apply. Refusals reach the person with what was and was not written.

A Vikunja organization's tasks are read through the tasks adapter and
shown read only; the store verbs refuse them with tracker-not-store.
2026-11-10 20:04:49 +00:00
jason.woltje af7d330101 docs: clarify host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje db75994f82 docs: define accounts host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje ac977d833b test(webui): retry the projects grouping click while the nav repaints (#1873)
The workspace nav repaints after async organization and project reads.
Under load (gate at load 14) a repaint landed between the flat-grouping
wait and the org click, and the click found no button. The click now
retries inside the wait until the page shows org grouping.
2026-11-10 13:46:18 -06:00
jason.woltje aa087e7380 fix(webui): U3 review fixes, figures from failed reads say not read (#1873)
A project row's sessions, open tasks and needs-you figures come from
reads beyond the project list; when those failed in the row's
organization the cells now say not read instead of none or 0, and a
note names the organization. Dashboard per-organization figures and
Done this week count only organizations that made the tile's reads, a
dashboard with no answering organization shows no totals, and empty
lists say not read when no organization could make the read. Sessions
now names each session's organization for screen readers. The
org-archive test waits for the setup steps, which render after the
editor title.
2026-11-10 13:35:49 -06:00
jason.woltje 68fd66903b feat(webui): U3 dashboard, projects and inbox across organizations (#1873)
In organization mode #/ is the cross-organization dashboard (sample 02),
#/projects lists every organization's projects with sort, filters and the
shared By org / Flat grouping (sample 04), and #/inbox lists decisions
across organizations with per-organization decide paths and terminal
commands carrying --org and the full id (sample 05). The per-organization
board moves to #/board and the organization's own project list to #/chat,
which keeps the project surface. Reads go through workspace-data.js; a
read an organization could not make says not read, never zero. Polling
repaints in place and keeps focus, caret and scroll.
2026-11-10 13:35:49 -06:00
jason.woltje a7b2199390 test(bus): pin person handlers to their vocabulary (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje 4c495647cd feat(organization): define org rights and role bundles (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje f5cc712a59 feat(bus): export and pin person task verbs (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje 821fbe8e9b test(webui): wait for the bus run button after open (#1873)
open() waits only for the editor title; the setup step enables the bus run
button after its async /setup read. Under load the assert ran first and
failed (A1 final gate, 2 of 4 runs). Wait for the enabled button instead.
2026-11-10 13:30:30 -06:00
jason.woltje d247b4eb61 fix(agents): A1 review fixes for scope, opt-outs and refusal text (#1873)
Two independent reviews (Sol, daybreak-blue) asked for changes.

- An organization's own assistant instance is scoped to that
  organization. Only the root assistant, which no union holds, is
  cross-organization.
- A root opt-out or project exclude whose name a more specific level
  defines is no longer listed as unavailable.
- A malformed root agents.json refuses with "not valid JSON" only; the
  parser's text, which can quote the file's bytes, is dropped.
- A refused root or project var names its key, never its value; an id
  check's quote of a root value is dropped too. A value can be a secret
  typed in the wrong place, and these refusals reach the Console.
- The root assistant's row shows the model the root file sets.

Tests cover each case, with a sentinel value checked absent from the
route body. The design doc records the behavior.
2026-11-10 13:29:11 -06:00
jason.woltje e358886826 feat(agents): A1 agent levels, a read-only union and the Agents grid (#1873)
Phase A slice A1, no authority change. Agents are defined at three levels:
the user's root (<configDir>/agents.json), the organization (roles) and the
project (projects.<p>.agents in the organization file). A project's agents
are their union, the most specific level winning.

- organization: agent-levels.mjs loads and checks the root file (no link,
  owner uid, no group or other write) and project entries (define, narrow
  intersect keys only, exclude), and resolves the union. The organization
  file checks project agents for every reader and leaves them out of the
  loaded business, so buses see what they saw before. Old-path re-export
  in packages/business.
- webui: GET /api/agents/levels, owner only, with no host path, credential
  reference or var other than the model in its body. display.js leaves it
  unscoped. SERVER_PROJECT_KEYS holds agents.
- Agents grid: the level band, a project picker, and per agent its level,
  the file that defines it, memory, status and model, with narrowed keys,
  overrides and what the project does not get.
- Design: docs/plans/2026-11-10_agent-levels-design.md. The roles/ change is
  a proposal outside this branch.
2026-11-10 13:29:11 -06:00
jason.woltje 01b79e1db4 docs(plans): accounts design draft 3, threat row count (#1873) 2026-11-10 13:03:07 -06:00
jason.woltje 0a75cc82f3 docs(plans): accounts design draft 3, luna review fixes (#1873)
Member task writes use the person verbs tasks.* instead of the agent task
path. A member's console cap never carries the org's human, and only the
decide handlers and request reads gain a member branch. VERB_RIGHTS pins
every BOARD_VERBS entry. request.write is reserved, decide excludes
credential mints, and the lead-confirm list becomes decided choices.
2026-11-10 13:02:37 -06:00
jason.woltje c66cd58846 docs(plans): accounts design draft 3, rights-based membership (#1873)
Applies Jason's three rulings of 2026-11-10. Named rights, role bundles
and per-member grants replace the fixed owner and member split. A member
holds org.view, thread.read and board.read by default. decide and
task.write are grantable. The rights table lands first as slice M1.
2026-11-10 12:46:18 -06:00
jason.woltje 69af2fe94b docs(plans): accounts design draft 2, review fix round (#1873)
Applies the luna review (3 changes) and the sol review (10 changes):
per-role transport table with member reads limited to threads, ownsOrg
over every subject of the owning account, memberGrant adapter, verified
snapshot read after the check, authGen session invalidation, one lock
order, random org incarnation, explicit invite callback exception, a
build stamp that refuses the dev owner in stamped copies, and a rollback
that keeps OIDC. Member board reads and task writes move to slice M8
behind an owner ruling.
2026-11-10 12:32:06 -06:00
jason.woltje 4106bf7344 docs(plans): accounts, membership and invites design, Phase M (#1873) 2026-11-10 12:13:32 -06:00
jason.woltje 8630b8dfc0 test(webui): wait for both sidebar lists after the reload (#1873) 2026-11-10 12:01:55 -06:00
jason.woltje 5eb6dda049 fix(webui): U2b review fixes for the workspace dashboard and agents (#1873)
- Agent activity comes from the bus agents read (the state for the run
  holding the role); with no reported state a live agent shows "running",
  never working or idle.
- A session's project and task is the thread its live run claims
  (thread.claims), not the agent's first listed conversation.
- The dashboard reads an organization only after /api/orgs lists it as
  valid; otherwise it shows "Organization unavailable" and reads nothing.
  Agents reads the selected business only for a listed organization.
- A failed conversation read says "Conversation unavailable", not that
  none exists.
- A reported model gets a Change link to Settings, Routing, in the
  dashboard and agents tables.
2026-11-10 11:32:19 -06:00
jason.woltje 8710cc0e1e feat(webui): U2 unified workspace sidebar, org dashboard and agents grid (#1873)
Finishes the U2 WIP (37b0891a3) to samples 01, 03 and 08 of the
2026-11-09 unified workspace set:
- workspace.js: sidebar Organizations and Projects groups (collapsible,
  state remembered), the org dashboard, the Agents levels.
- workspace-data.js: every read these screens make, GET no-store,
  redirect manual; a refused read shows unavailable for that part only.
- shell.css: the html[data-organizations] .ws-* block from the sample.
- bus.js: org-mode sidebar order, pinned Settings, no project tree under
  Projects, focus kept across repaint.
- Honest empty states where the Console has no data (KPIs, metrics, kind).
- Tests: unified-workspace-browser (10), picker/delete/switch browser tests
  follow the sidebar, browser.test script pin.
2026-11-10 11:32:19 -06:00
jason.woltje 364f1ae7a6 fix(tasks): U4a review fixes for the built-in task store (#1873)
Six review findings, one round:
- A selected task-tracker module that fails to load or has no factory refuses boot with tracker-config.
- The expect digest covers the task revision, so archive and unarchive invalidate an old token.
- Position exhaustion and an occupied explicit position refuse task-position-full; no rebalancing yet.
- The store adapter reads roles from the broker's current business document (Broker.taskRoles), so a role added by reload works.
- Only newly requested assignees are checked against live roles; stored ones survive edits, archive and delete.
- Human tasks.create requires a requirement citation (REQ-AREA-N); tasks.edit accepts one.
2026-11-09 19:55:25 -06:00
jason.woltje 9b7bf11afb feat(tasks): U4a, a task store built into each organization (#1873)
PRD 1.20 REQ-TASK-1, REQ-PLUG-1, REQ-WS-8. Each organization keeps its
tasks in its own bus through the new task-tracker plug-in kind. The
default module is the built-in store; Vikunja is the optional module
and a Vikunja organization keeps today's path.

- bus schema 3u to 3v (migration 3u-3v.sql): store_tasks, append-only,
  one row per write, each citing its events row; seven guard triggers.
- broker: the eight role verbs on the store with the slice 1 field
  table checked twice (module, then a before/after field diff); the
  person's tasks.create/edit/archive/unarchive/delete as human.input
  events; the tasks read returns store rows in board order (group,
  position), filtered and paged like tracker rows.
- runtime: each business gets its module at start (organization file
  plugin, else vikunja with a tracker entry, else store); a store
  business with a Vikunja tracker refuses the boot (tracker-config).
- plugins: task-tracker kind with store and vikunja modules.
- cli: the launcher's tracker flag covers store organizations; trail
  prints store writes.
- pins: schema.test counts, the bus migration tests, stack-release #811
  and T46 (store_tasks on the always list; COUNTED unchanged),
  budget-supervisor accepts 3v.

Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
2026-11-09 19:25:44 -06:00
200 changed files with 13392 additions and 417 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,361 @@
# Agent levels and the cross-organization assistant (Phase A design, #1873)
Status: design for lane org-a1, 2026-11-10. Slice A1 (read-only store, union
resolution, Agents grid) is built on branch `org-a1`. Everything that gives an
agent a new capability is in the roles proposal and the later slices listed in
section 9; none of it is applied.
Direction: PRD 1.20 draft 2 (REQ-ASSIST-1, REQ-USER-1 amendment, REQ-AGENT-1
to REQ-AGENT-3, REQ-COS-3, REQ-DEC-2, REQ-ROLE-3), owner direction rulings 4
and 11, the U0 approval and the addendum in
`docs/plans/2026-11-09_unified-workspace-owner-direction.md`, and the role model
in `roles/` and `docs/plans/2026-09-02_atomic-mosaic-foundation.md`.
Owner words: "Agents are configured at three levels: the user's root, the
organization and the project. A project's agents are the union of all three.
The user's main interaction agent (the assistant) is the one exception to
isolation. [...] Every other agent is isolated to the organization it works
in." And: "the COS should be able to hire and fire agents to fulfill the
user's needs."
## 1. Terms
- **Definition**: a named agent entry at one level. It names a role
definition in `roles/` (`definition`) and may carry vars. It never names a
credential, a holder, a tracker bot, an identity or a publish grant at the
root or project level.
- **Role definition**: `roles/<name>.json`, versioned and changed only by
reviewed commits (invariant 7). A level chooses among role definitions and
never creates one, so no level can grant an authority that `roles/` does
not already hold.
- **Copy**: a root definition as it runs inside one organization. Each
organization runs its own copy, and each copy has that organization's
memory, files and credentials.
- **Union**: the agents available in one project of one organization.
## 2. Where each level lives
| Level | File | Writer today | Owner |
|---|---|---|---|
| root | `<configDir>/agents.json`, next to the instance's system config | the user, by hand (no writer in A1) | the instance's OS user |
| org | `roles` in `<configDir>/businesses/<org>.json` (unchanged) | existing org writers | the organization |
| project | `projects.<project>.agents` in the same organization file | the owner's `updateOrg` only; no agent writer can reach it | the organization |
### Root: `<configDir>/agents.json`
`configDir` is the directory of the instance's system config
(`~/.config/mosaic-<instance>/`). The file sits outside the data root, so it is
outside every organization root (`<dataRoot>/org-runtime/<org>`) and every
bus, and no bus or sandbox mount reaches it. It is per instance, as the system
config is (invariant 2). When accounts arrive (Phase M), it moves to a
per-principal path under the same directory; the format stays.
```json
{
"agentsVersion": 1,
"agents": {
"assistant": { "definition": "assistant", "vars": { "model": "opus-5.5" } },
"reviewer": { "definition": "reviewer", "excludeOrgs": ["home"] },
"researcher": { "definition": "researcher" }
}
}
```
- Keys: `definition` (required), `vars` (agent layer, `checkVars(..., "agent")`),
`excludeOrgs` (distinct organization ids; "reviewer turned off for Home" in
sample 08). Any other key refuses.
- The file must be a regular file opened without following a link, owned by
the process uid and not writable by group or other, the same checks as the
organization file. A missing file means no root agents. Anything else that
is wrong refuses the whole root level (section 6).
- Refused at the root: a role definition that is version 1 or missing, the
`chief-of-staff` definition (each organization's chief is its own,
REQ-COS-3), an `assistant` definition under any name but `assistant`, and
`credentials`, `holder`, `tracker`, `identity`, `publish`, `hosts`.
### Organization: `roles`
Unchanged. Every organization instance stays as it is, validated by
`validateBusinessDocument`, with its credential map, tracker bot and identity.
### Project: `projects.<project>.agents`
The project level is in the organization file, not in the project repository.
The repository's `.mosaic/project.json` can be written by any agent that can
push to the project, so a definition there would let an agent create agents.
The organization file is reachable only by the trusted writer, and the
existing projects fence (`organization-projects-protected` in
`organization.mjs`) already refuses every general write that changes
`projects`. No agent writer can add, change or remove a project agent. The
owner's `updateOrg` can, and the Console's organization editor holds the key
on the server (`SERVER_PROJECT_KEYS`), so the browser draft cannot change it in
A1.
```json
"projects": {
"spring-launch": {
"root": "...",
"agents": {
"social-scheduler": { "definition": "pm", "vars": { "model": "haiku-5.5" } },
"coder": { "vars": { "limits.network": "none" } },
"reviewer": { "exclude": true }
}
}
}
```
Each entry has exactly one form:
- **define**: `{ "definition", "vars"? }`. Agent-layer vars. Not
`chief-of-staff`, not `assistant`.
- **narrow**: `{ "vars" }` only. Every key must be an intersect key
(`limits.tools`, `limits.network`, `limits.authority`), checked at the
project layer. A project narrows an inherited agent and never widens it.
- **exclude**: `{ "exclude": true }`. The inherited agent is not available in
this project.
`validateBusinessDocument` checks each entry's shape and role definition for
every reader (bus, Console, CLI). It does not copy `agents` into the loaded
business object, so a bus built from the file sees exactly what it saw before.
A project that is deleted takes its agents with it, and so does a deleted
organization.
Library projects (`kind: "library"`) keep their own mapping check and take no
`agents` key.
This is an additive, optional key in the organization file. It is not a bus
store (SQLite) schema change: no migration, no new table, no bus verb.
## 3. Union resolution
For organization O and project P:
1. Start with the root agents, minus any whose `excludeOrgs` names O.
2. Lay the organization's `roles` over them. An org instance with the same
name as a root agent replaces it entirely; the root entry is recorded as an
override of that agent.
3. Lay P's entries over the result:
- **define** replaces any inherited agent of that name; the inherited
entry is recorded as an override.
- **narrow** merges vars through `mergeVars`, inherited layer first and the
project layer after it, so limits only intersect. The keys it narrowed
are recorded.
- **exclude** removes the agent and records who removed it.
Rules:
- The most specific level that defines a name wins, and the whole entry
wins: definition and vars, never a field-by-field mix.
- A narrow or exclude entry naming an agent that no lower level provides
refuses. A typo never silently does nothing.
- A project entry of any form naming the organization's chief (an instance
whose definition is `chief-of-staff`) or the assistant refuses. A project
cannot remove, narrow or replace the chief or the assistant.
- Two levels never merge vars, apart from a project narrowing. Root vars stay
with the root definition, and an org instance of the same name brings its
own.
- The result for each agent: name, definition, level, source (which file),
the overrides it shadows, the keys narrowed, and its scope. Every agent in a
union is scoped to O, an organization's own `assistant` instance included.
Only the root assistant, which no union holds, is cross-organization.
- The union also lists what O or P does not get: root agents turned off for
O and agents P excluded. A name that a more specific level then defines is
available under that definition and is not listed.
Implementation: `packages/organization/src/agent-levels.mjs`
(`loadRootAgents`, `checkRootAgents`, `checkProjectAgents`,
`resolveAgentLevels`, `projectUnion`). It is pure apart from the one root file
read.
## 4. Isolation of every non-assistant agent
Every agent except the root assistant resolves to scope `{ org: O }` in the
organization it runs in, whichever level defined it. The scope fixes three
things:
- **Memory and records**: under the organization's root,
`<dataRoot>/org-runtime/O/...`, on that organization's bus, as every org
instance has today. A root agent's copy in O and its copy in O2 share
nothing: no thread, no record, no session file.
- **Files**: the project workspaces of O only. The sandbox mounts come from
O's project mapping, never from another organization's.
- **Credentials**: only from O's credential map. A root definition carries
none (refused, section 2). In A1 a root or project agent is shown as
unbound: it has no credential and cannot be launched, because the bus
builds instances from `roles` alone and the loaded business object does not
carry project agents.
When root copies run (slice A3), each copy becomes a bus instance in O under
O's writer, with O's credential bindings, through the same `checkRoles` path an
org instance takes. A copy never reads the root file at run time; it gets its
definition when O's bus boots.
## 5. The assistant across organizations
### Reads
The assistant reads another organization only through the Console's
membership-checked cross-organization read: `organizations.members(session)`
(`packages/webui/src/organizations.mjs`), the read `GET /api/workspace` uses.
Each organization is admitted by `showOrg` (owner check, registry digest,
document validation) and `resolveOrg` (marker, modes) before any transport is
built, and each bus read goes over that organization's own socket with a
deadline. The assistant process gets no org root path, no bus socket path and
no file mount of any organization. It calls a read broker that the Console
holds for the owner's session. A direct read of another organization's bus
files does not exist as a code path: the assistant sandbox mounts only its own
memory directory.
### Shared memory
The assistant's one memory lives at `<dataRoot>/assistant/` (mode 0700, owned
by the instance user). It sits inside the instance data root and outside every
`org-runtime/<org>` root, so deleting or archiving an organization never
touches it, and no organization's agents can mount it. It holds the
assistant's notes and session files. It never holds a copy of an
organization's records; what the assistant learns from an organization stays
in that organization, plus whatever the assistant chose to write in its own
memory.
### Authority
REQ-ASSIST-1: the assistant talks with the user and hands each organization's
work to that organization's chief. It does not direct agents, create tasks or
launch sessions. Its role keeps `message.send` and `record.write` only. The
roles proposal (section 8) adds no action to it.
### A1 reality
Today each organization file declares its own `assistant` instance. A1 shows
those as org agents. A root `assistant` entry, if the user writes one, shows in
the band as the root assistant with cross-organization scope. Nothing reads
across organizations for it yet.
## 6. Fail-closed boundaries
| Boundary | A1 behavior | Later slices |
|---|---|---|
| Root file (new) | `O_NOFOLLOW` regular file, process uid, no group or other write; any problem refuses the whole root level and the grid says so; a missing file is "no root agents". The refusal names the file and the key at fault, never a value from the file: parser text and a var check's quote of the refused value are dropped, since a value can be a secret typed in the wrong place. Project var refusals drop the value the same way. | The root writer accepts only the human session (section 7). |
| Organization file `projects.<p>.agents` (new key) | Checked by `validateBusinessDocument` for every reader; a bad entry refuses the whole organization file, as any bad key does. Not copied into the loaded business, so buses gain nothing. The projects fence keeps every agent writer out. | A project-agent writer verb, with its own guard, like `addProject`. |
| Union | A narrow or exclude naming nothing refuses that project; touching the chief or assistant refuses that project; the grid shows the refusal for that project. | A launch resolver refuses to launch any agent in a project whose union refuses. |
| Console read `GET /api/agents/levels` (new) | Owner only (granted users and the tester get `owner-only`, 403); organizations come only from `organizations.members(session)`; the body carries ids, names, role definition names, model var and level. It never carries a path, a credential reference or a var other than `model` and the narrowed key names. | Unchanged. |
| Org isolation | Root and project agents are not instances on any bus; nothing reads across organizations except the existing Console read. | Each copy is materialized by O's own writer and runs under O's credentials. |
| Assistant memory | Not built. | `<dataRoot>/assistant/` with 0700; the sandbox mounts it and nothing else. |
| Roles | No change in commits. | The proposal patch, applied only after Jason approves it. |
A root file that refuses hides the root level everywhere rather than showing a
partial list: an exclusion that did not parse must not let an excluded agent
appear.
## 7. Chief-of-staff hire and fire, per level
Today `roles/chief-of-staff.json` lists `role.hire` and `role.fire` within its
role, with `"hireable": []`: it can hire nothing. Each level adds a write path;
none exists in A1.
- **Organization level** (REQ-COS-3): the chief hires or fires an
organization instance through the organization's trusted writer, for a role
in its `hireable` list. Each hire lands in the action trail. The user can
override any setting at any time through REQ-ROLE-3.
- **Project level**: the chief adds or removes a project entry through a new
writer verb that changes only `projects.<p>.agents` (a guard like
`addProject`'s), for a role in `hireable`. The projects fence still refuses
every other change.
- **Root level** (U0 ruling): the chief cannot write the root file. It raises
an inbox decision (REQ-DEC-2) in its own organization that names the role
definition, the agent name and the reason. The user approves or denies it in
the Console inbox. On approval the Console, acting with the human's session,
writes the root file through a root writer that refuses any actor other than
the signed-in owner (`human-required`). A denied or expired decision changes
nothing. A fire works the same way. The chief never learns the file's path.
`hireable` decides what can be hired at all; widening it is a `roles/` change
and needs Jason's reviewed commit. The proposal (section 8) names the roles.
## 8. Authority changes proposed, not applied
The patch is at `/mnt/storage/scratch/mosaic-lead/reports/org-a1-roles-proposal.patch`.
It is never applied on this branch. It applies with `git apply` on
`origin/refactor` and needs a reviewed commit that Jason approves.
What it changes:
- `roles/chief-of-staff.json`: `hireable` goes from `[]` to `coder` and the
five advisory chiefs (`chief-executive-officer`, `chief-financial-officer`,
`chief-information-officer`, `chief-information-security-officer`,
`chief-marketing-officer`). These are the only shipped definitions that pass
today's hire check: version 2, not creative, and every action on
`HIRE_ALLOWED`. The chief can then hire at the organization level with the
existing `hire` verb. The project level (A4) uses the same list.
- `roles/chief-of-staff.md`: drops "your empty hireable list", names the list,
says a hired coder has no credential until the human binds one, and says the
chief hires only in its own organization, never at the root.
- `packages/organization/tests/chief-of-staff.test.mjs` and `role.test.mjs`:
the tests that pin the shipped chief file move to the new list, and the
contract test pins the new sentence. Two bus test comments that said "shipped chief hireable:[]" are
reworded. These go in the same commit, or the organization suite fails.
What it leaves alone, and why:
- `roles/assistant.json`: no change. The cross-organization read is the
Console's read broker acting for the owner's session (section 5), not a
role action, so the assistant needs no new grant. It keeps `message.send`
and `record.write`. Adding an action would widen it for no gain.
- `roles/assistant.md`: no change yet. Its "one user in one business" text
stays true until A6 builds the root assistant; the contract text for the
cross-organization read and the shared memory lands with A6, in that
slice's own reviewed commit.
- Root-level hire text in `chief-of-staff.md` lands with A5, when the inbox
decision exists. A contract sentence for a tool the chief doesn't have would
invite it to try.
- `HIRE_ALLOWED`: unchanged. `reviewer`, `security-reviewer` (`review.verdict`),
`pm` (`task.create`), `cto` and `merge-gate` stay unhireable. Making any of
them hireable is a separate vocabulary decision, not part of this proposal.
## 9. Phase A slices after the roles change is approved
1. **A2, root and project writers.** Owner-only root file writer (human
session, trail entry, revision check) and the project-agent writer verb
under the projects fence, both refusing every agent actor. Settings,
Agents gets the edit forms.
2. **A3, root copies run.** Each organization's bus boot materializes its
root copies as instances through `checkRoles`, with the organization's
credential bindings; unbound copies stay unlaunchable and say why. Project
agents launch only in their project.
3. **A4, chief hire and fire at org and project level**, bounded by the
approved `hireable` list, trailed.
4. **A5, root-level hire and fire as inbox decisions**, with the owner's
approval writing the root file through A2's writer.
5. **A6, the root assistant.** `<dataRoot>/assistant/` memory, the Console
read broker over `organizations.members`, and the per-organization
`assistant` instances retired in favor of the one root assistant.
6. **A7, the Agents grid's write controls** (model Change, override markers,
turn off for an organization), each a trailed human action.
## 10. Slice A1 as built
- **Store and union**: `packages/organization/src/agent-levels.mjs`, with the
old-path re-export `packages/business/src/agent-levels.mjs` that the rename
r2 stage B test requires of every organization module.
`validateBusinessDocument` checks `projects.<p>.agents` and drops it from
the loaded business.
- **Read**: `GET /api/agents/levels` (`packages/webui/src/agent-levels-read.mjs`),
wired in `cli.mjs` with `loadRoot` reading `<configDir>/agents.json`. The
route is instance-wide, like `/api/workspace`, so `display.js` leaves it
unscoped: its fetch wrapper would otherwise rewrite it to
`/api/orgs/<id>/agents/levels`, which does not exist.
- **Console editor**: `SERVER_PROJECT_KEYS` holds `agents`, so a browser draft
of the organization file keeps the server's value. The CLI's full-document
path (`mosaic org update --from`) is the owner's and can set project agents;
A1 adds no other writer.
- **Agents grid** (sample 08): the level band shows the root agents and their
per-organization opt-outs, each organization's instances, and each
project's own definitions. Below it, a project picker chooses which union
the table shows: agent, level, defined in, memory, status, model. Root and
project agents read "not running", because nothing launches them yet. The
root assistant's status reads "status not reported" and its model is the
one the root file sets, shown as configured. A
narrowed agent names the keys, an override names what it replaces, and a
line under the table lists what this project does not get and why. A
refused root file, organization or project shows its reason in place of
its rows. The grid has no write controls; those are A7.
@@ -0,0 +1,671 @@
# Roles, rails and hiring
Date: 2026-11-10. Lane org-roles-design, issue #1873. Status: proposed, doc
only. Nothing here changes `AGENTS.md`, `roles/`, `contracts/`, rights policy
or code. The slices in section 13 that touch role or rights policy land only
with Jason's approval.
Round 2 follows ruling 13. Merge and policy decisions stay with the arbiter
agent. The rails split into system rails and role rails (section 3), and a
per-organization autonomy setting decides where each class of decision goes
(section 9).
Read with `docs/plans/2026-11-10_agent-levels-design.md` (levels, isolation,
Phase A) and `docs/plans/2026-11-10_accounts-design.md` (rights, host trust
boundary). This design replaces agent-levels section 7 and the A1 roles patch,
which widened the chief's `hireable` list. That patch is withdrawn and nothing
here extends it.
Line numbers are against `origin/refactor` at `f98f278fb`. Its code is the
same as at `50be33355`.
## 1. Rulings
Jason, 2026-11-10:
1. Who hires: the chief asks, a human approves. A hire is a decision, not a direct write.
2. Which roles, verbatim: "Chief should be capable of hiring ANY role with any capacity. This will include custom roles invented by the user and the chief at any time."
3. No cap on the number of hires.
4. Timing: lands with A4.
5. Templates, verbatim: "Mosaic Stack should ship role templates for the most common roles. These stay within the system as templates. Chief and user have the capability to import the roles from template and customize them as needed. New roles outside of the supplied templates must follow a set of rules from within the system to prevent an absolute destruction of the system's workings. We need a set of primitives available to the agent roles to allow for adherence. A new role constructed by chief and the user must plugin and follow the rules with the RBAC and other tools available. The agent will have an arbitrary set of tools specified by chief and/or the user. The agent will also have a personality and other traits specified by the user. The system must remain unopinionated upon the opinionated basal code provided. We provide the operational rails and allow an open system to build upon it."
6. Ceiling: the approval is the ceiling. A hire may carry grants above the chief's own, but the decision lists those grants separately and flags them.
7. Approver: a new grantable right `org.hire` (owner yes, member no) in the M1 rights table.
8. Only the chief raises hires. No hire chains: a hired agent cannot hire.
9. Rails, summarized (full text in the lead tracker; ask nothing, design from this): a base of inalienable rails the system needs to function, with everything else customizable on top. The smallest working system is user, assistant and chief; the chief staffs the rest from the user's plan. Start with the fewest agents, add agents as roles are needed, and keep configurations apart with organizations and projects. Full extensibility: modules and plugins, preconfigured systems for any use (software factory, writing workshop, video production, business, home, health), installed from GitHub, a zip or another source, and maybe a marketplace later. Flexibility comes from a set of primitives controlled by routing, agent configuration and plugins. Jason: "The Venn diagram for usability and security/rails needs to be understood."
10. Credentials: the hire decision lists each credential and scope the agent needs; approving the decision binds them.
11. Template updates: an imported role is a frozen copy. When the shipped template changes, the system offers a diff; it never changes the copy by itself.
12. Personality: the chief may draft personality and traits inside the hire decision. After the hire only a human changes them.
13. Trust, verbatim, answering whether merge and policy decisions should go to a person instead of the arbiter agent: "I need to be able to trust the agents to do their jobs. The agent role should establish the rails they operate upon. Tooling and instructions should be clear. We are aiming for a move from "Human in the loop" to "Human near the loop" to "Human out of the loop" operations. We are currently HEAVILY in Human in the loop." They stay with the arbiter.
Also binding, Jason 2026-11-07: the system is modules joined by documented
calls. Swapping or disabling one must not break core, and adding one must not
need a core rewrite (`docs/plans/2026-11-07_organizations-owner-rulings.md:43-47`).
## 2. Today
- A role is `roles/<name>.json` plus its `.md` contract. Version 2 allows
only known keys, so an unknown field such as `personality` refuses
(`packages/organization/src/role.mjs:21,190`). Tools come from one closed
core list (`role.mjs:24-29`, `packages/organization/src/vocabulary.mjs:142`).
Actions come from `ACTIONS` (`vocabulary.mjs:11-65`); an unknown one
refuses, and so does one outside `ROUTED`, because those are hard waits the
human always decides (`role.mjs:42-43`, `vocabulary.mjs:109`).
- The chief hires by writing directly. `instance.hire` checks that the caller
is the instance named in `launch.by` (`packages/bus/src/broker.mjs:6193`)
and holds `role.hire`, refuses kept names (`broker.mjs:6200`), then runs
`#hireable` (`broker.mjs:6291-6306`). That needs a version 2 definition whose
every action is on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`, checked by
`unhireableAction` at `vocabulary.mjs:127`), named in the chief's own
`hireable` list, and not in `launch.noHire`.
- The shipped chief's list is empty (`roles/chief-of-staff.json:13`), so the
chief can hire nothing today. Its contract says so
(`roles/chief-of-staff.md:17`).
- Creative definitions are human-only. The writer refuses them for the chief
(`packages/organization/src/instance-settings.mjs:365-367`) and the role
check refuses them in a `hireable` list (`role.mjs:86-97`).
- A hire writes `roles[name] = { definition, vars? }` and appends the name to
`launch.instances` (`instance-settings.mjs:354-391`). Its vars may set only
`model` and `limits.*` (`broker.mjs:216-221`). So `launch` refuses the new
instance with `no-route` until a human sets harness, model and thinking
(`packages/harness/src/bundle.mjs:96`, `roles/chief-of-staff.md:53-58`).
- Fire revokes the instance's service credentials and delists it
(`instance-settings.mjs:396-414`).
- Rights for people are a separate table: `RIGHTS`, the `ROLE_RIGHTS`
bundles and `canGrant` (`packages/organization/src/org-rights.mjs:6-17,21-26,122-129`).
It has no hire right yet.
## 3. The rails
Rails come in two layers. System rails hold for every role at every autonomy
level, and only a reviewed commit changes one. Role rails are what each role's
definition says its agent may do. Ruling 13 asks for both: agents trusted to
do their jobs, inside rails their role sets.
### System rails
Nothing changes these while the system runs: no role, template, plugin,
approval or autonomy setting. Each row says where the code enforces the rail
today and which slice closes the gap.
| # | Rail | Enforced today | Gap, and the slice that closes it |
|---|---|---|---|
| R1 | Secrets never enter the repository, images, role files, decision text or the trail. A credential reaches an agent only through a human-approved binding, scoped and revocable. | Message and record bodies pass `credentials.assertClean` (`packages/bus/src/runtime.mjs:833`, `broker.mjs:840,1313,2081`). Credential verbs are human-only (`broker.mjs:6144`). `credential.mint` decisions route to the human (`broker.mjs:2266-2268,2368`). Fire revokes (`instance-settings.mjs:396`). | Role definitions, personality text and hire decisions are not scanned. P1 runs the same check on every string in a composition and a hire decision. |
| R2 | The trail and run records are write-once. | `events` and `decision_events` refuse update, delete and replace (`packages/bus/src/migrations/3b.sql:131-132,137-138,144,147`). | None. Hire, deny, expiry, binding, setting and reverse entries use the same tables (A4, AU). |
| R3 | Fail closed: an unknown action, tool, right, plugin kind, role field or autonomy value refuses. | Role keys, actions and tools (`role.mjs:190,42,24-29`). Rights (`org-rights.mjs:122-129`). Plugin kinds (`packages/plugins/src/kinds.mjs:147-148`). | Inline roles and plugin-declared tools and actions have no check yet. P1 and PL1. AU refuses an unknown class or level. |
| R4 | Hard waits stay with the human. A role can never hold an action outside `ROUTED`. | `vocabulary.mjs:109`, `role.mjs:43`. This covers `git.push.protected`, `git.push.remote`, `host.sudo`, `credential.mint`, `spend`, `deploy` and `data.delete` (`vocabulary.mjs:22,26,29,49,50,51,55`). | The brief's system-rail list did not name it, and "any capacity" must not dissolve it. A hard wait leaves the list only when a PRD version moves it into `ROUTED` (REQ-WAIT-3, `vocabulary.mjs:67-69`), never through the autonomy setting. P1 keeps it for every composition. |
| R5 | No agent widens its own authority. No agent resolves a decision it raised, a decision whose subject is itself or a definition it runs, or anything that changes the autonomy setting or a rail. | An arbiter's own raise goes to the human (`broker.mjs:7075`). PRD approval refuses an arbiter that is the PM or an author (`broker.mjs:1060`). | Rewritten in round 2. Round 1 also sent merge and policy decisions to a person; ruling 13 reverses that, and REQ-DEC-2 stays as ratified. Nothing checks a decision's subject against its resolver today. AU adds that check, which sends such a decision to a person. |
| R6 | Every agent runs sandboxed as the instance uid, with only the tools its role names. A tool exists only if core or an installed, enabled plugin declares it. | bwrap with user, pid and ipc namespaces and a read-only root (`packages/seat/src/sandbox.mjs:76,176`). Tools are a closed core list (`vocabulary.mjs:142`) and the typed bus tools are fixed in core (`packages/harness/src/tools.mjs:96`). | No plugin declares a tool today. PL1 adds plugin-declared tools. A sandbox escape holds the instance uid, which equals root for the instance (accounts design, "Host trust boundary"). |
| R7 | Organizations are isolated. Cross-organization reads go only through the assistant's Console read broker. | Each organization has its own bus and every non-assistant agent's scope is one organization (agent-levels design section 4). | The read broker is not built. It is A6. Until then nothing reads across organizations. |
| R8 | No grant beyond the granter, except a flagged line the owner approves. This holds at every autonomy level. | People: `canGrant` subset (`org-rights.mjs:122-129`). Agents: a hire is bounded by `HIRE_ALLOWED` and the chief's list (`broker.mjs:6303-6305`). | A4 replaces that bound with the approval itself. The broker computes the flagged lines; the chief cannot omit them. |
| R9 | No hire chains. Only the chief raises hires, and a hired agent cannot hire. | `role-not-chief` (`broker.mjs:6193`). `role.hire` is not on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`). | From ruling 8. P1 refuses `role.hire` and `role.fire` in any hired composition, so the rail survives `HIRE_ALLOWED` going away. |
| R10 | Plugin code runs with the host's authority, so installing one is an instance-level human act. An organization's setting cannot move it. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | The loader says a module "runs with the host's authority" (`loader.mjs:5`). Install from GitHub or a zip has to keep that visible. No hash pin exists. PL1. |
| R11 | System config is written only by bootstrap. | `scripts/bootstrap.sh:5`, AGENTS.md invariant 2. | None. Nothing in this design writes system config. |
| R12 | Rails change only by reviewed commit, and an organization's autonomy setting only by its owner. | The vocabulary and `roles/` change by reviewed commit (AGENTS.md invariant 7). | New in round 2. The setting does not exist yet; AU adds it as an owner-only Console verb. |
Round 1 changed R5 and R6 from the brief. Round 2 rewrites R5 again and adds
R12.
### Role rails
A role's definition is its rails. Each part says what that agent may do, and
the broker enforces it:
| Part | What it says | Enforced today | Gap |
|---|---|---|---|
| `tools` | The tools the agent has | Typed bus tools follow the held actions (`tools.mjs:97`); built-ins follow the policy (`packages/harness/src/bundle.mjs:368`) | None |
| `authority.withinRole` | Actions the agent takes alone | The broker's classification (`broker.mjs:7048`) | None |
| `authority.crossRole` | Actions it raises to its domain's arbiter | `broker.mjs:7072-7075` | The raiser picks the domain (`broker.mjs:7067`), so it picks its arbiter. RR fixes the domain for merge and policy. |
| `decision.resolve.<domain>` in `withinRole` | Which decisions it may resolve for other agents | Only for PRD approval (`broker.mjs:1059`) | An ordinary resolve checks only that the caller's role is the route (`broker.mjs:7212-7215`). The organization's `arbiters` map names the arbiter, not its role (`org-template.json:5`), and the default arbiter, the chief, holds no resolve right (`roles/chief-of-staff.json:8-9`). RR makes an arbiter hold the right for its domain. |
| `credentials` | Each service and scope | Bound by a person only (`broker.mjs:6144`) | None |
| `messaging` | Whom it may message | `#checkMessaging` (`broker.mjs:7082`) | None |
**The validator carries them.** P1's `checkComposition` checks every part
against the registry, and treats every `decision.resolve.*` action as a
flagged line, because it lets the holder decide for other agents.
**The instructions state them plainly.** The contract keeps the duties and
says what to do on a refusal. The list of what the agent may do is generated
from the resolved definition into its prompt (section 10), so the wording
cannot drift from what the broker enforces. Every shipped contract has an
`## Authority` section today; RR makes the role check refuse a version 2
contract without one (`role.mjs:146-157`).
## 4. The primitives
A role is a composition of these parts and nothing else.
**Core set.** Shipped in `vocabulary.mjs`, versioned, changed only by reviewed
commit:
- `tools`: core built-ins and `web` (`vocabulary.mjs:142`).
- `network`: one of `NETWORKS`.
- `authority.withinRole` and `authority.crossRole`: actions from `ROUTED`
(`vocabulary.mjs:73-102`).
- `credentials`: a known service with grantable scopes. Gitea `admin` and
`all` are never granted (`GITEA_SCOPE_CATEGORIES`).
- `messaging.sendTo` and `messaging.receiveFrom`.
- `skills`.
- `instructions`: the contract text, today the `.md` file.
- `personality`: new. Bounded text fields (summary, traits, voice), no
authority. Ruling 5 and ruling 12.
**Plugin-declared set.** From an enabled plugin's manifest (section 7):
tools, actions and credential services. A plugin action is always routed. A
plugin cannot declare a hard wait, cannot reuse a core name, and every name
carries the plugin's prefix (`<plugin>.<name>`).
**The validator.** P1 adds `checkComposition(role, registry, chief)`. It
replaces `HIRE_ALLOWED` and `unhireableAction` at hire time. It:
1. refuses any unknown key, as `role.mjs:190` does;
2. refuses any action, tool, network, service or scope the registry lacks,
where the registry is the core set plus the plugins enabled in that
organization;
3. refuses any action outside `ROUTED` or a plugin's routed set (R4);
4. refuses `role.hire` and `role.fire` (R9);
5. returns every `decision.resolve.*` action as a flagged line (role rails);
6. refuses any string that fails `assertClean` (R1), and any field over its
length bound;
7. returns the flagged lines: every action, tool, network, credential scope
and messaging target the chief's own resolved role does not hold (R8).
The validator accepts every composition that passes. There is no allowlist of
roles and no list of hireable definitions.
**What "any role with any capacity" means.** The chief can propose any name,
any mix of registered tools and routed actions, any credential scope a service
grants, any personality, any number of agents. A human approves each one.
**What it cannot mean.** A hired agent cannot hold a hard wait, hire, resolve
its own request or a decision about itself, read another organization, name a tool nothing declares,
carry a secret in its text, or run outside the sandbox. A new capability that
needs new enforcement, for example a new kind of external write, needs a
plugin that declares and enforces it. A name with no code behind it gives an
agent nothing.
## 5. Templates and organization roles
**Templates.** The shipped files in `roles/` become templates. They stay
there, versioned by reviewed commit. A template's identity is its name plus
the sha256 of its JSON and contract together, so no new field goes into
`roles/`. Plugins may ship templates too (section 7).
**Organization roles.** An organization's own definitions live in its file
under a new `definitions` key, one entry per name:
```json
"definitions": {
"release-writer": {
"rev": 3,
"from": { "template": "coder", "sha256": "<64 hex>" },
"role": { "tools": ["read", "edit"], "authority": { "withinRole": ["record.write"] } },
"instructions": "...",
"personality": { "summary": "...", "traits": ["..."], "voice": "..." }
}
}
```
An instance names `definition: "org:release-writer"` instead of a template
name. Project agents use the organization's definitions. Root agents keep
their own `definitions` in `<configDir>/agents.json`.
- **Import** copies a template into `definitions` with `from` set. The copy is
frozen: nothing changes it except an approved change.
- **Customize** edits the copy. **Invent** writes a definition with no `from`.
Both pass `checkComposition` before they are written.
- **Who writes.** A human holding `org.hire` drafts or edits a definition in
the Console. A draft no agent uses does nothing. A change to a definition a
hired agent uses takes effect only through a change decision (section 6).
The chief proposes a definition only inside a decision.
- **Template changes.** When a template's current hash differs from a copy's
`from.sha256`, the Console shows a diff and offers apply or keep. Apply is a
change decision (section 6). Nothing applies by itself (ruling 11).
- **Versioning.** `rev` counts up on every write. Every hire and change
decision records the definition's `rev` and hash.
- **A running agent.** Its sandbox, tools and contract are fixed at launch
from the bundle. A change that narrows authority takes effect at the broker
on the next call. Anything else takes effect at the next launch, and the
Console shows the agent as running an older `rev`.
- **Creative templates** are templates like the others. They still get no
host grant (`broker.mjs:2415`).
## 6. Hire as a decision
One decision action covers hire, fire and change, at every level.
**Fields.** The chief supplies:
| Field | Meaning |
|---|---|
| `op` | `hire`, `fire` or `change` |
| `level` | `org`, `project` (with the project name) or `root` |
| `name` | the agent's instance name, unused, not kept or retired |
| `role` | `{ template, sha256 }`, `{ definition, rev }` or `{ inline }` |
| `credentials` | each service and scope the agent needs |
| `personality` | the chief's draft |
| `route` | a proposed harness, model and thinking level |
| `reason` | 1 to 500 characters |
The broker adds, and the chief cannot set or edit:
- `flagged`: the lines `checkComposition` returns above the chief's own role;
- `credentialLines`: one line per service and scope;
- `digest`: a hash over the whole decision, as the Console resolution path
already checks (`broker.mjs:7200-7204`);
- `expiresAt`: seven days.
**Raise.** Among agents, only the instance in `launch.by` may raise one, as
today (`broker.mjs:6193`). It routes by the organization's autonomy setting
for authority changes (section 9). At the default, in the loop, it goes to a
person, so the arbiter path at `broker.mjs:7074` does not apply. A hire needs no
approval count and has no cap (ruling 3). Spend stays bounded by the
organization's limits.
**Approve, in the loop.** A human whose membership in that organization holds
`org.hire` approves in the Console, against the current digest. Two limits,
at every autonomy level:
- a decision with any flagged line or any credential line needs the owner.
This mirrors `decide` stopping short of credential mints (accounts design,
section 3 "Rights"). A member with `org.hire` approves plain hires.
- a root-level decision needs the instance owner, since the root file covers
every organization (agent-levels section 7).
The human may change the route, narrow the role or drop credential lines
before approving. Widening means a new decision.
**Near and out of the loop.** Only a plain decision moves: no flagged line, no
credential line, organization or project level. An instance whose role holds
`decision.resolve.authority` resolves it. It is never the chief that raised it
and never the agent the decision names (R5). Near the loop, the owner is told
and can reverse it (section 9).
**What approval writes.** One writer call, with the human as actor and the
decision id in the context:
- org level: the organization writer, extended from `prepareHire`
(`instance-settings.mjs:354`). It writes the definition, the instance with
its route, and `launch.instances`;
- project level: the A2 project-agent writer, behind the projects fence;
- root level: the A2 root writer.
Credential bindings then go through the existing human-only credential path
(`broker.mjs:6144`). Until every binding succeeds, `launch` refuses the agent
with `credential-unbound` and names the missing one, as A3 does for unbound
copies. Because approval sets the route, launch no longer refuses `no-route`
for an approved hire.
**Deny, expire, withdraw.** Deny and expiry write nothing but the trail entry.
The chief may withdraw its own decision (`broker.mjs:7205-7211`).
**Fire and change** are the same decision with `op` set. Fire revokes and
delists as today (`instance-settings.mjs:396`).
**A person starting a hire.** A human holding `org.hire` may start a hire,
fire or change in the Console. It is still a decision, with the same fields,
broker-computed lines, digest and trail, raised and resolved in one step by
that person. Flagged and credential lines still need the owner. I read ruling
8 as limiting which agent raises hires, not as stopping a person from hiring.
**Trail.** Raise, each resolution, the writer receipt and each binding are
events in the write-once tables (R2).
**What this replaces.** The direct `instance.hire` and `instance.fire` paths
(`broker.mjs:6185-6280`) and the `hireable` check (`broker.mjs:6291-6306`) go
away. The chief's `hire` tool (`packages/harness/src/tools.mjs:225-226`)
raises the decision instead of writing. Agent-levels section 7 had three
paths, one per level. Now there is one decision with a `level` field, so A4
and A5 become one slice, with the root level waiting for A2's root writer.
## 7. Plugins and modules
**Today.** Plugin kinds are a closed core list (`kinds.mjs:4-5,22-105`). The
loader reads only `packages/plugins/<kind>/<name>/index.mjs` in its own tree,
checks symlinks, exports and interface version, and pins no hash
(`loader.mjs:1-5,41-72`). A refused module keeps the kind's last good set
(`loader.mjs:7-9`). Enabling is `plugins.set` under `org.setup`
(`org-rights.mjs:74`), owner-only.
**Manifest.** Each plugin gets a `plugin.json`: name, version, kinds,
interface versions, a sha256 over its files, and what it declares: tools
(name, description, argument schema, whether it runs in the sandbox or on the
host), routed actions, credential services, and role templates.
**Install.** Sources are a GitHub URL at a commit, a zip, or a local
directory. The instance owner installs at the host with the expected hash.
The install refuses a hash mismatch, a core name, a hard-wait action or an
unknown kind. Installed plugins live read-only in an instance-level plugin
root, listed with their hashes in a lock file. That reads a path outside the
loader's own tree, which changes lead decision 143 (`loader.mjs:3-5`). Jason
decides that in PL1.
**Pin and load.** Every load checks the hash in the lock file. A mismatch
refuses the plugin and logs it. The loader's existing refusal handling keeps
core running.
**Enable.** Per organization, by the owner, through `plugins.set`. Install is
instance-wide because the code runs as the instance uid for every
organization (R10).
**A disabled or missing plugin.** Core keeps running. An agent whose role
names the plugin's tools or actions refuses at launch with
`plugin-disabled` and the plugin's name. A running agent's calls to those
tools refuse with the same code. Nothing is fired or rewritten.
**Preconfigured systems** such as a software factory or a writing workshop
are plugins that ship templates, a system type and tools. Installing one hires
nobody. The chief still raises a decision for each agent.
**Marketplace.** Out of scope. The design must not preclude one, so the
manifest keeps the source URL, version and hash, and reserves a `signature`
field. The install screen already lists every tool that runs on the host.
## 8. The minimal base
A new organization already starts with the human, a chief and an assistant
(`org-template.json:4,6`). That matches ruling 9. A6 later moves the
assistant to the root level (agent-levels section 9).
The chief reads the user's plan and raises one hire decision per agent it
needs, each citing the plan in its reason. It starts with the fewest agents
and adds one when a task needs a role nobody holds. Separate concerns go in
separate organizations or projects.
Every other shipped organization role becomes a template only: coder, pm,
reviewer, security-reviewer, merge-gate, cto, the five chiefs (executive,
financial, information, information security, marketing) and the six creative
roles (companion, continuity, fact-checker, ideas, proofreader, research).
`researcher.json` is a version 1 worker role (`roles/researcher.json:2`), which
a hire refuses (`broker.mjs:6303`), and `conductor-policy.json` is not a role.
Neither changes.
## 9. Autonomy, usability and security
Read this first. It says who decides what, and what changes as trust grows.
Ruling 13 sets the direction: from a person in the loop, to near the loop, to
out of the loop. Today most decisions sit in the loop.
**The three levels.**
- **In the loop.** A person approves before anything happens.
- **Near the loop.** An agent whose role holds the resolve right decides, and
the decision takes effect. A person is told in the Console and can reverse
it within a window the owner sets. I propose 24 hours as the default.
- **Out of the loop.** An agent whose role holds the resolve right decides.
The trail records it, and nobody is told.
For a class that routes to an arbiter today (cross-role work, merge, policy),
the arbiter resolves at every level, as PRD 1.5 REQ-DEC-2 and acceptance 314
step 5 require. The level only decides what happens after the arbiter
resolves. In the loop, the resolution waits for a person to confirm it before
it takes effect. A person who declines sends it back to the arbiter with a
reason. Near and out of the loop, it takes effect at once. No level makes a
person the resolver of these classes.
**The setting.** An organization's file gets an `autonomy` key with one level
per class below. A missing key means today's routing. Only the owner changes
it, in the Console: the verb maps to `null` in `VERB_RIGHTS`, the explicit
owner-only mark (`org-rights.mjs:28-29`), and it is not on the tester's verb
list (`broker.mjs:192`). Each change is a trail event. No agent changes it at
any level (R12). The owner can pull a class back toward the loop at any time
with no preconditions.
**Classes and levels.** "Default" marks where each class sits today.
| Class | In the loop | Near the loop | Out of the loop |
|---|---|---|---|
| Cross-role work, such as a task scope change | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default, `broker.mjs:7072-7075`) |
| Merge, `git.merge.protected` | The technical arbiter resolves; a person confirms before it lands | The technical arbiter resolves; a person is told | The technical arbiter resolves (default). Acceptance 314 step 5 has the CTO do it (`docs/acceptance/314.md:116-120`) |
| Policy, `policy.change` | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default). No shipped role holds this action |
| Plain hire, fire or role change | A person with `org.hire` approves (default, ruling 1) | An agent holding `decision.resolve.authority` resolves; the owner is told | The same agent resolves |
| Flagged grants and credential bindings | The owner approves (default) | Does not move (R8) | Does not move |
| Root-level agents | The instance owner (default) | Not an organization setting | Not an organization setting |
| Plugin install | The instance owner, at the host (default) | Not an organization setting (R10) | Not an organization setting |
| Hard waits: push, deploy, spend, mint, delete, sudo | A person, every time (R4) | Not a setting | Not a setting |
| The autonomy setting | The owner (R12) | Never | Never |
PRD approval already has its own switch: a request in `autonomous` mode sends
it to the technical arbiter, and one in `guided` mode to a person
(`packages/bus/src/request-lifecycle.mjs:308`, `broker.mjs:1057-1060`). A
person sets the mode (`broker.mjs:5918-5919`). This design leaves it as is.
**What reverse can undo.** Near the loop, reverse is a one-step act by the
owner, recorded against the decision. After the window it is an ordinary new
decision.
| Class | Reverse does | It cannot undo |
|---|---|---|
| Cross-role work | Restores the task's prior scope or state | Messages sent, records written (write-once, R2), work done in the window |
| Merge | Lands a revert commit through the normal path | The merge in history (no force push, AGENTS.md invariant 8), or anything that ran or shipped the merged code in the window |
| Policy | Restores the prior policy | Actions taken under the new policy in the window |
| Hire | Fires the agent, which revokes its credentials (`instance-settings.mjs:396-414`) | What the agent did or spent in the window |
| Fire | Rehires the same definition `rev` | The run and session state the fire ended, and the credential bindings the fire revoked. Each needs a new binding the owner approves |
| Role change | Returns the definition to the prior `rev`, and relaunches the agent if it runs the new one. A narrowing back takes effect at the next broker call (section 5) | What the agent did under the new `rev` |
| Credential binding | Not offered: it does not move | Each use of the token at the service. That is why it stays in the loop |
**Before a class moves a level.** The organization needs:
- to move any class to near the loop: a resolver instance whose role holds
that class's resolve right and is not the raiser (RR), the class's reverse
built and tested (AU), and Console notices on for the owner;
- to move any class to out of the loop: time at near the loop, and the
owner's review of its reversals in the trail;
- for merge, in addition: a reviewer role whose approve verdict a landing
needs, and the land gate's tests. Acceptance 314 has both, which is why
merge stays out of the loop by default;
- for policy, in addition: a reviewer role that sees the policy diff first;
- for plain hires, in addition: organization spend limits that cover a new
agent, and a resolver other than the chief. Out of the loop also needs
per-agent limits in every definition the resolver can approve.
Flagged grants, credential bindings, root-level agents and plugin installs do
not move in this design. Moving one needs a new ruling.
**A person changes directly.** Organizations and projects (owner). Drafts of
role copies and invented roles, which do nothing until a decision uses them
(`org.hire`). An agent's model, limits and route (`org.hire`). An agent's
personality and traits after hire (ruling 12). Enabling a plugin in an
organization (owner). Installing a plugin (instance owner, at the host).
**Only a reviewed code change.** The rails in section 3, the core list of
tools and actions, the shipped role templates, the rights table including
`org.hire`, plugin kinds, and system config, which only bootstrap writes.
**What never moves, at any level:**
- An agent cannot push to a protected branch, push to a remote, deploy, spend,
mint a credential, delete data or use sudo by itself. A person decides each
of those every time.
- An agent cannot resolve its own request or a decision about itself, and it
cannot change the autonomy setting or a rail.
- An agent cannot grant more than the chief holds, or bind a credential,
without the owner.
- Only the chief raises hires, and a hired agent cannot hire.
- An agent cannot see another organization's work. The assistant's read
across organizations is planned (A6) and not built yet.
- A plugin runs with the same power as the Mosaic service itself. Install
only code you trust.
## 10. Tooling and instructions
An agent trusted to work without a person has to know its own limits without
asking one. Three things today fall short.
**Its rails, readable at runtime.** The prompt names the agent's built-in and
bus tools (`packages/harness/src/bundle.mjs:283-284`) and the held actions
that have no bus tool (`bundle.mjs:308`). It does not say which actions the
agent takes alone, which go to which arbiter, which go to a person, or what it
may resolve. Six contracts tell the agent "the launch record you received
shows the resolved list" (`roles/coder.md:41`, `roles/cto.md:46`,
`roles/merge-gate.md:54`, `roles/pm.md:109`, `roles/reviewer.md:38`,
`roles/security-reviewer.md:27`). No session receives one. Launch records are
host-side, for principal checks (`packages/bus/src/runtime.mjs:885-901`,
`broker.mjs:2047`).
Fix, in TI: the prompt gets a generated "## Your rails" section from the
resolved definition. It lists each held action with where it goes (alone,
which arbiter instance, or a person), each resolve right with its class's
autonomy level, the network, each credential service and scope by name, and
the messaging targets. A read-only `my_rails` bus tool returns the same from
the broker, since a narrowing takes effect at the next call (section 5). The
six contracts drop the launch-record sentence and point at that section.
**Refusals that say what to do next.** The broker's `fail(code)` throws a
bare code (`broker.mjs:251-253`), and 653 lines of `broker.mjs` call it. A
reason travels only where a call builds one (`broker.mjs:243-249`, for
example `941`, `961` and `6468`). The prompt says only that "a refusal names
its reason" (`bundle.mjs:284`).
Fix, in TI: one table maps each refusal code to the rail or role rail it
enforces and the next step, for example "raise a decision for the delivery
arbiter" or "a person decides this; raise it with blocking false". The
envelope carries both. A test fails when a `fail` code has no entry.
**The tools it holds.** These are covered. The prompt names each tool
(`bundle.mjs:283-284`) and the bundle writes each tool's description and
schema to `tools.json` for the harness (`bundle.mjs:372`). What was missing
is the routing behind each tool, which the rails section adds.
## 11. Threat model
| Threat | What stops it | What is left |
|---|---|---|
| A prompt-injected chief proposes a hostile role | The broker computes the flagged and credential lines; the chief cannot hide them. Hard waits, hiring and unknown parts refuse (R4, R9, R3). A human approves against the digest. | A human can approve a hostile role. Flagged and credential lines need the owner. |
| A custom role names an unknown tool | `checkComposition` refuses it at the decision and at the writer. Launch refuses it again (R3). | None. |
| A plugin from an untrusted source | Instance owner only; hash pin; the install screen lists host-run tools. | Approved plugin code runs as the instance uid (R10). A pin does not make code safe. |
| A credential scope wider than the task | Each scope is its own line; owner-only approval; the approver may drop lines; admin and all never granted. | The service's own scope granularity. |
| An arbiter agent resolves a bad merge | Its role must hold `decision.resolve.technical` (RR). It never resolves its own raise (R5). The review verdict and the land gate come first (acceptance 314). The owner can pull merge back toward the loop. | A wrong merge is reverted, not undone. |
| An agent widens its own authority through a decision | No agent resolves its own raise or a decision about itself (R5). Flagged grants and credentials stay with the owner at every level (R8). Only the owner changes the setting (R12). | One agent raises a plain change for another. Plain means within the chief's own authority, and near the loop tells the owner. |
| A prompt-injected resolver near the loop | The owner is told and can reverse within the window. | What reverse cannot undo (section 9). |
| Approval fatigue on flagged grants | Flagged lines shown apart from plain ones; credential and flagged decisions go to the owner; no batch approval; seven-day expiry. | A busy owner who approves without reading. A later slice could limit how many flagged decisions one approval screen holds. |
| An imported template drifts from its source | The copy is frozen with its source hash. A changed template produces a diff offer, never a silent change. | An owner who keeps an old copy keeps its old behaviour. That is the ruling. |
## 12. Invariant 7 amendment
Proposed only. Jason approves it through a reviewed commit.
Replace AGENTS.md invariant 7 (`AGENTS.md:45-47`) with:
> 7. **Policy**: missions govern tasks; a task narrows, never widens. The
> rails, the core vocabulary of tools and actions, and the shipped role
> templates in `roles/` change only by reviewed commit. An organization's
> own roles are copies or compositions of known parts. One takes effect
> only through a decision, resolved where the organization's owner-set
> autonomy level sends it, by default a human with `org.hire`. The
> decision lists every grant above the chief's own as a flagged line,
> which only the owner approves. Only the chief raises hires, and a hired
> agent cannot hire.
Touched role-model lines:
- `AGENTS.md:107`: keep "policy/role expansion" as a protected act, and add
"an approved hire decision is that authority for the one agent it names".
- `AGENTS.md:209`: change to "`roles/`: shipped role templates and the
conductor policy".
## 13. Slices
Phase A after A3, in order: R1, P1, T1, A4, RR, TI, AU, PL1. A6 and A7 stay
as agent-levels section 9 has them.
Slices that change `roles/`: A4 (the chief), RR (the chief, and any role an
organization names as delivery arbiter) and TI (six contracts). Slices that
change rights policy: R1 and AU. P1 changes no file in `roles/` but defines
authority policy. All of them need Jason's approval.
**R1. The `org.hire` right.** Add `'org.hire': flag(true)` to `RIGHTS` and a
verb entry for the hire resolution in `VERB_RIGHTS`. The owner bundle picks it
up from `Object.keys(RIGHTS)` (`org-rights.mjs:22`); member, collaborator and
approver do not get it. Rights policy, so Jason's approval
(accounts design, `Grant needed` at line 1391). Tests: the owner has it; the
three templates lack it; `canGrant` lets an owner grant it to a member; an
unknown right still refuses.
**P1. Primitives validator.** `checkComposition` and the registry, beside
`role.mjs`. `HIRE_ALLOWED` stays until A4 switches over. It changes no role
file but it defines authority policy, so Jason's approval. Tests: an unknown
key, action, tool, service or scope refuses; each hard wait refuses;
`role.hire` and `role.fire` refuse; a secret in instructions or personality
refuses; a `decision.resolve.*` action comes back as a flagged line;
flagged lines match a hand-built expected set for coder against the
shipped chief.
**T1. Organization definitions and templates.** The `definitions` key,
`org:` references, import, the human edit verbs under `org.hire`, the
personality fields, the template hash and the diff read. Tests: import pins
the hash; editing a template file changes no copy; the diff read shows the
change; an `org:` reference to a missing definition refuses at launch; an
agent cannot call the edit verbs.
**A4. Hire as a decision, all levels.** The decision action, raise, Console
approval, the writer calls, credential lines, expiry, fire and change, the
Console path that raises and resolves in one step, and removal of the direct
path and `#hireable`. Every hire decision sits in the loop until AU. Org and project land first; root
waits for A2's root writer. This edits `roles/chief-of-staff.json` and `.md`
(drops `hireable`, rewords hire), the vocabulary and AGENTS.md invariant 7,
so Jason's approval. Tests: a hired agent's raise refuses `role-not-chief`; a
chief decision cannot carry its own `flagged`; a member with `org.hire`
approving a credential or flagged decision refuses; a stale digest refuses;
deny and expiry write nothing; approval writes the role, the route and the
trail, and launch refuses `credential-unbound` until binding; fire revokes;
the old `instance.hire` verb refuses; a cross-role `git.merge.protected`
decision still routes to the technical arbiter and the CTO resolves it, as
acceptance 314 step 5 expects; a hire a person starts in the Console leaves a decision record
with its flagged lines.
**RR. Role rails and resolve rights.** An arbiter must hold
`decision.resolve.<domain>` for its domain, checked when the organization
file is written and again at resolve, which extends `broker.mjs:1059` to
every decision. The vocabulary adds `decision.resolve.delivery`. A fixed
domain per action for `git.merge.protected` (technical) and `policy.change`
(delivery), so a raiser cannot pick its arbiter. The chief gets both resolve
rights, since the template names it arbiter for both
(`org-template.json:5`). The role check refuses a version 2 contract with no
`## Authority` section. Changes `roles/chief-of-staff.json` and the
vocabulary, so Jason's approval. Tests: naming an arbiter without the right
refuses when written; an arbiter whose role lost the right refuses
`not-resolver`; a merge raised with domain `delivery` still routes to the
technical arbiter; acceptance 314 step 5, the CTO resolving the merge, still
passes; a contract with no `## Authority` refuses.
**TI. Tooling and instructions.** The generated "## Your rails" prompt
section, the read-only `my_rails` tool, the refusal table and envelope
fields, and the six contract edits (section 10). Changes `roles/*.md`, so
Jason's approval. Tests: coder's prompt lists each held action with its
route; `my_rails` matches the broker after a narrowing; every `fail` code in
`broker.mjs` has a table entry; a refused cross-role call's envelope names
its rail and next step.
**AU. Autonomy setting and routing.** The `autonomy` key, the owner-only
Console verb, routing per class, the subject check that sends a decision
about its resolver to a person (R5), `decision.resolve.authority`, the
Console notice, reverse per class with its window, and the trail events.
Needs A4 and RR. Changes rights policy (a `VERB_RIGHTS` entry) and the
vocabulary, but no role file, so Jason's approval. Acceptance tests:
- with no `autonomy` key, routing is exactly today's: a merge goes to the
technical arbiter, a hire to a person;
- a member, an agent and the dev tester each refuse to change the setting;
an unknown class or level refuses;
- with merge in the loop, the technical arbiter still resolves the merge
decision, a person's resolve refuses `not-resolver`, and the merge does not
land until a person confirms; a decline returns it to the arbiter;
- with merge near the loop, the arbiter's resolve takes effect, the owner
gets a notice, and reverse within the window lands a revert commit
recorded against the decision;
- with hires near the loop, an instance holding `decision.resolve.authority`
resolves a plain hire; a hire with a flagged or credential line still goes
to the owner; the chief cannot resolve its own raise; no agent resolves a
change that names itself;
- reverse after the window refuses and names the ordinary decision path;
- each setting change is a trail event with the owner as actor;
- no setting value moves a hard wait, a flagged grant or a credential
binding away from a person.
**PL1. Plugin manifest, install and declared tools.** Manifest, pinned
install root, lock file, the registry merge, and `plugin-disabled` refusals.
Changes lead decision 143 and the loader's trust rule, so Jason's approval.
Tests: a hash mismatch refuses and core keeps running; a manifest naming a
core tool or a hard wait refuses; disabling a plugin makes its agents refuse
at launch with its name; a running agent's call to a disabled tool refuses.
@@ -0,0 +1,212 @@
# System-type toolsets at launch
Status: design only. Nothing here is built and no `roles/` or contract file is
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
project gets the toolsets of the system types that project uses."
## What exists today
- **Modules.** `installedSystemTypes()` (`packages/plugins/src/system-types.mjs`)
loads every installed `system-type` module and returns
`{id, label, description, toolsets}`. It is fail-closed: a refused module, a
missing or extra key, or a toolset name that fails `NAME` throws
`system-type-invalid`. All five shipped modules (business-management, coding,
creative-writing, marketing, montage) declare `toolsets: []`.
- **Organization enable.** The organization file's
`plugins['system-type']` holds the enabled ids. `plugins.set` writes it
through the organization writer behind the owner fence
(`organization-system-types-human-only`) and returns a transaction receipt.
The bus reads the current set with `system-types.current` on every read and
write, so it never trusts a stale snapshot.
- **Project selection.** `project.system-types.set` appends a row to
`project_system_types` (bus store, `seq`-ordered, latest wins) and accepts
only enabled ids (`system-type-not-enabled`). A read returns
`{selected, active, inactive}`, where `active = selected ∩ enabled` and
`inactive = selected − enabled`.
- **Launch.** `resolveInstance` (`packages/organization/src/resolve.mjs`)
starts from the role definition's `tools` and `network`, narrows them by
each layer's `limits.tools` and `limits.network` (system, business,
project, agent), and freezes the result with a digest. The role's `tools`
is therefore a ceiling: a launch gets those tools minus whatever a layer
limit removes, never more. The launcher (`packages/cli/src/launcher.mjs`) passes that
result to `buildBundle` (`packages/harness/src/bundle.mjs`), which writes
`policy.json` (`tools: builtinTools(resolved.limits.tools)`), `tools.json`
(typed host tools) and `manifest.json` once each (`flag: "wx"`). Typed
host tools (`typedTools`, `packages/harness/src/tools.mjs`) are not granted
by tool name: each is built only when the resolved `authority` holds the
action it needs.
- **Run tasks.** `scripts/mosaic-task.mjs` validates a mission's
`capabilities.tools` as a ceiling and a task's `capabilities.tools` as the
allowlist, both drawn from `SUPPORTED_TOOLS`. A task narrows the mission.
It never widens it.
Nothing on the launch path reads system types today, so every launch gets
its role's tools as narrowed by the layer limits. That is the coding
module's behavior, and the design keeps it as the default.
## Resolution at launch
A launch resolves its toolsets in the host-side launcher, before
`resolveInstance` freezes the digest. In order:
1. **Pick the project.** The launch's project is the one the session is
started for: the thread's project for a project thread, or none. A launch
with no single project (an organization-wide session, or a role started
with several code projects in scope) gets no system-type toolsets. Taking
the union across projects would widen one project's agent with another
project's tools, so the union is ruled out.
2. **Read the active set.** The launcher asks the bus, as the host, for the
project's selection and computes `active = selected ∩ enabled`. `enabled`
comes from `system-types.current`, the same trusted read the routes use.
Inactive selections are dropped and recorded in the manifest. They do not
refuse the launch, because the owner may disable a type at any time and
that must not strand running work.
3. **Load the modules.** `installedSystemTypes()` runs. If it throws
`system-type-invalid`, the launch refuses with that code. An active id
with no installed module refuses `system-type-missing`.
4. **Map toolset names to tools.** Each active module's `toolsets` are looked
up in the reviewed mapping file (below). The result is a set of tool names,
each a member of the role vocabulary `TOOLS`
(`packages/organization/src/vocabulary.mjs`). Typed host tools cannot be
named: they stay gated by the role's `authority` actions exactly as today,
and a system type never changes `authority`.
5. **Bound by the ceilings.** The role's `tools` stays the hard ceiling for
every effective tool, including any a system type switches on. A role may
hold some of its tools back for typed projects by listing them in
`systemTypeTools` (new, see the proposal), which must be a subset of the
role's `tools`. Then:
- `base` = (role `tools` − `systemTypeTools`), narrowed by every layer's
`limits.tools`, as `resolveInstance` does today;
- `added` = mapped tools ∩ `systemTypeTools`, narrowed by every layer's
`limits.tools` the same way;
- `effective` = `base ∪ added`;
- for a run task, `effective` is then intersected with the mission's
`capabilities.tools` and then the task's. That intersection covers
base tools too, as it does today.
Every term is a subset of role `tools` ∩ every layer limit, so a system
type can only switch on a tool the role already holds and the layers
already allow. Invariant 7 holds because each later step can only remove
entries. A role without `systemTypeTools` behaves exactly as today.
6. **Record it.** `resolveInstance` takes the added list as an input and
includes `systemTypes: {project, active, dropped, toolsets, added}` in the
resolved object, so the digest covers it. `buildBundle` writes the same
block into `manifest.json`. The run record then shows which types and
mapping produced each tool.
No step adds credentials, network reach or authority. `limits.network` and
`authority` are computed exactly as today. A toolset that needs a credential
gets it the way any role does, through the role file's `credentials` and a
reviewed commit. A system type never grants one.
## The mapping file
The mapping is repository policy, so it lives under `roles/` and changes only
through a reviewed commit, the same rule that governs `roles/*.json`
(invariant 7, AGENTS.md "Role model"). Proposed path:
`roles/toolsets/system-types.json`. It sits in a subdirectory because two
loaders read every `roles/*.json` as a role definition
(`instance-settings.mjs` `definitions()`, `harness/tests/tools.test.mjs`).
A mapping file at `roles/` top level would be read as a broken role.
Proposed format:
```json
{
"toolsetsVersion": 1,
"toolsets": {
"repository": { "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"] },
"web-research": { "tools": ["web"] }
}
}
```
The two entries are illustrations, not part of the proposal. No shipped
module names a toolset today, so the file would ship as
`{"toolsetsVersion": 1, "toolsets": {}}`.
Rules a loader enforces, fail-closed:
- Exactly the keys `toolsetsVersion` (must be `1`) and `toolsets`. Each entry
has exactly the key `tools`.
- Each toolset name matches `NAME` from `packages/plugins/src/kinds.mjs`, the
same pattern modules must use.
- `tools` is a non-empty list of distinct names, each in `TOOLS`. A typed
host tool name, or anything else, refuses `toolset-mapping-invalid` at
load.
- The file is read from the release copy, like the role files, and its
sha256 goes into `manifest.sources`.
Who may change it: a reviewed commit on the repository, landed by the lead
with Jason's authority over role policy. An organization owner cannot change
it at runtime, and neither the Console nor the bus has a write path to it.
Organization owners choose which types are enabled and projects choose among
them. The repository decides what a type's toolsets may contain.
## Fail-closed behavior
| Condition | Result |
| --- | --- |
| An installed module is refused or malformed | launch refuses `system-type-invalid` |
| An active id has no installed module | launch refuses `system-type-missing` |
| A module names a toolset the mapping lacks | launch refuses `toolset-unmapped` |
| The mapping file is missing, unreadable or invalid | launch refuses `toolset-mapping-invalid`, but only when at least one active type declares a toolset; a launch with nothing to map never reads it |
| A mapped tool is outside the role's `systemTypeTools` (and so not switched on) | the tool is dropped and recorded under `dropped`, launch continues |
| The bus cannot answer the project's selection | launch refuses `system-types-unavailable`; it does not fall back to "no types", which could hide a missing toolset the operator expects |
| A selection is inactive (disabled in the organization) | dropped and recorded, launch continues |
The two "continue" rows are narrowing, never widening. Every row that could
change what an agent may do refuses instead.
## The coding module
Coding declares `toolsets: []`. It adds nothing, and a coding project's agent
keeps its role's tools as the layer limits narrow them: for
`roles/coder.json` the ceiling is `read, write, edit, bash, grep, find, ls`
with network `api-only`, the same as today. Turning the design on changes nothing for any shipped module until a
module declares a toolset and a reviewed commit maps it.
## Proposed `roles/` change (not applied)
1. Add an optional V2 key `systemTypeTools` to the role schema
(`packages/organization/src/role.mjs`): a distinct list from `TOOLS` (same
check as `checkTools`, but empty is allowed) that must be a subset of the
role's own `tools`, or the role file refuses at load. It names the tools
the role holds back unless an active system type maps them. Absent means
`[]`: nothing is held back and no system type changes that role's tools.
Every existing role file stays valid and behaves as today, and
`checkCreative` keeps inspecting `tools`, which still bounds everything.
```diff
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
-const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
+const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable", "systemTypeTools"];
```
2. Add `roles/toolsets/system-types.json` with an empty `toolsets` object, and
a loader beside the role loader with the checks listed above.
3. Have `resolveInstance` accept `systemTypes` (the launcher's step 1-4
result) and apply step 5. Have `buildBundle` copy the block into the
manifest. `scripts/mosaic-task.mjs` already bounds tools by mission and
task. It would apply that filter to the final effective set.
No contract under `contracts/` changes: the constitution's least-privilege
rule already covers this. The design adds no grant path: the effective set
stays within role `tools` and every layer limit, and `authority`, network and
credentials are untouched. What it does add is a runtime input (the
project's active system types) that can switch a held-back tool on, so a
role author who lists a tool in `systemTypeTools` is deciding that an
organization owner's enable plus a project's selection may turn it on. Each of the
three steps is a reviewed change to role policy and needs Jason's sign-off
before it is built.
## Open points for the build
- Business-file projects (`b.projects`, used for checkouts) and bus projects
(UUIDs in `project_system_types`) are separate records today. Step 1 needs
the launch to carry the bus project id of the thread it serves. The build
should confirm how a project thread names its project at launch before
wiring step 2.
- A running session keeps the toolsets it launched with. Disabling a type
takes effect at the next launch, which matches how role changes behave.
+11
View File
@@ -1117,3 +1117,14 @@ For lifecycle tasks, the first review request includes the exact raw JSON
verification-document and verdict-body contract; unrelated reviews retain their
existing free-text contract. The typed record tools describe the existing closed
requirement schema without relaxing host validation.
## Built-in task store writes
The outside-agent human session can call `tasks.create` with `title` and a `requirement` ID
(such as `REQ-TASK-1`), plus optional task fields. `tasks.edit` accepts `task_ref` and the
fields to change, including `requirement`; archive, unarchive, and delete take `task_ref`.
Each write records `human.input`. A task read returns a `digest` that can be supplied as
`expect` to a later write; any intervening revision, including archive or unarchive, refuses
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
must be current roles. Automatic tail placement and an explicitly occupied position refuse
`task-position-full` when there is no room; no task row is written.
+41
View File
@@ -655,3 +655,44 @@ CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_t
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+405 -13
View File
@@ -50,6 +50,7 @@ export const ACTIONS = Object.freeze([
'policy.change',
'prd.approve',
'decision.resolve.technical',
'decision.resolve.delivery',
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
'library.write',
]);
@@ -78,6 +79,7 @@ const ROUTED = new Set([
'policy.change',
'prd.approve',
'decision.resolve.technical',
'decision.resolve.delivery',
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
'role.hire',
'role.fire',
@@ -88,6 +90,8 @@ const ROUTED = new Set([
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
const GATED = new Set(HARD_WAITS);
const CLOSED = "('resolved','withdrawn','expired')";
// RR (#1873): these actions never take their domain from the raising agent.
const DECISION_DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
export const RECENT = 50;
// How many replies `replies` returns, oldest first.
@@ -95,7 +99,8 @@ export const REPLIES = 50;
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
export const STRANDED = 50;
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot'];
// U4a (#1873): `archived` reads the built-in store's archived tasks; a Vikunja task is never archived.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot', 'archived'];
export const TASK_PAGE = 50;
export const TASK_PAGE_MAX = 200;
// D3 (#797): raw UTF-8 pre-bound for the approved requirement texts quoted into one coder/reviewer
@@ -184,6 +189,8 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
// collaborator or viewer, is refused every one at the USER_VERBS gate.
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// The person's task-store writes, distinct from the agent's singular task.* actions.
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -295,6 +302,105 @@ function identifier(x) {
return x;
}
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
// module. Its rows live in the organization's own bus.sqlite (store_tasks, migration 3u-3v). These
// shapes are exported for packages/tasks/src/store.mjs, the module, so the broker and the module
// share one definition; they stay in this file because its import list is the sandbox's closure.
// A task's fields, whole after every write:
// title, description text, as the Vikunja verbs take them
// due_date canonical ISO 8601 UTC with milliseconds, or null
// priority 0 (unset) to 5
// percent_done 0 to 1
// state todo, in-progress, in-review, blocked or done
// bucket, done what state implies, under the names the Console and `mosaic tasks` read
// labels positive label ids, sorted, at most 50
// assignees role instance names, sorted, at most 50
// group null or a short name: the column or list the task sits in
// position a whole number ordering tasks inside their group
// comments, comment how many comments, and the latest ({ text, by })
// verdict the review verdict a close cited
// request, op what a role's create cited; null on a person's
// requirement the requirement cited on create (or edited by a person)
export const STORE_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
// The states task.update.assigned moves between; done is task.close's (slice 1 field table).
export const ASSIGNED_STATES = Object.freeze(STORE_STATES.filter((x) => x !== 'done'));
// A built-in task's ref is its number in the organization, never reused.
const STORE_REF = /^task:([1-9][0-9]{0,14})$/;
export const storeRef = (x) => typeof x === 'string' && STORE_REF.test(x);
export const storeTaskNumber = (x) => (storeRef(x) ? Number(STORE_REF.exec(x)[1]) : null);
export const STORE_POSITION_MAX = 1_000_000_000;
// What a person's tasks.create and tasks.edit may set. Roles write through the eight task verbs,
// each held to the fields the field table gives it.
export const STORE_EDIT_FIELDS = Object.freeze(['title', 'description', 'due_date', 'priority', 'percent_done', 'state', 'labels', 'assignees', 'group', 'position', 'requirement']);
const isoMillis = (x) =>
typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x;
// One check per field: the raw value in, the stored value out, or a refusal.
export function storeFieldChecks(roles) {
const str = (x, max, min = 0) => {
if (typeof x !== 'string' || x.length < min || x.length > max || x.includes('\0')) fail('invalid-request');
return x;
};
const set = (x, item, order) => {
if (!Array.isArray(x) || x.length > 50) fail('invalid-request');
return [...new Set(x.map(item))].sort(order);
};
return {
title: (x) => (str(x, 250, 1).trim() ? x : fail('invalid-request')),
description: (x) => str(x, 65536),
due_date: (x) => (x === null || isoMillis(x) ? x : fail('invalid-request')),
priority: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : fail('invalid-request')),
percent_done: (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : fail('invalid-request')),
state: (x) => (STORE_STATES.includes(x) ? x : fail('invalid-state')),
labels: (x) => set(x, (l) => (Number.isSafeInteger(l) && l >= 1 ? l : fail('invalid-request')), (a, b) => a - b),
assignees: (x) => set(x, (r) => (typeof r === 'string' && Object.hasOwn(roles, r) ? r : fail('unknown-role'))),
group: (x) => (x === null ? null : str(x, 64, 1).trim() && !/[\u0000-\u001f\u007f]/.test(x) ? x : fail('invalid-request')),
position: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : fail('invalid-request')),
requirement: (x) => (typeof x === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(x) ? x : fail('invalid-request')),
};
}
// Checked edits on a copy of the fields. A state sets the bucket and done it implies. A move to
// another group with no position goes to the end of that group (a null position, which the write settles).
export function storeApply(fields, edits) {
const out = { ...fields, ...edits };
if (Object.hasOwn(edits, 'state')) Object.assign(out, { bucket: edits.state, done: edits.state === 'done' });
if (Object.hasOwn(edits, 'group') && !Object.hasOwn(edits, 'position') && edits.group !== fields.group) out.position = null;
return out;
}
// A new task's fields. A null position means after the last task in its group; the write settles it.
export function storeNewTask({ title, request = null, requirement = null, op = null }) {
return {
title, description: '', due_date: null, priority: 0, percent_done: 0, state: 'todo', bucket: 'todo', done: false,
labels: [], assignees: [], group: null, position: null, comments: 0, comment: null, verdict: null, request, requirement, op,
};
}
// Every field a stored task has, no more and no fewer.
export const STORE_FIELDS = Object.freeze(Object.keys(storeNewTask({ title: 'x' })));
// The fields each role verb may change: the slice 1 field table (packages/tasks/fields.json) on the
// store's fields. The table's labels and due date go with task.schedule, so the store's group and
// position do too; a comment's count goes with the latest comment; a close records its verdict; and
// a create sets what it cited. The broker refuses a role's write that changes any other field
// (field-writer), whatever the module asked for.
export const STORE_VERB_FIELDS = Object.freeze({
'task.create': Object.freeze(['title', 'description', 'due_date', 'priority', 'labels', 'group', 'position', 'request', 'requirement', 'op']),
'task.assign': Object.freeze(['assignees']),
'task.reassign': Object.freeze(['assignees']),
'task.schedule': Object.freeze(['due_date', 'labels', 'group', 'position']),
'task.priority.change': Object.freeze(['priority']),
'task.scope.change': Object.freeze(['title', 'description']),
'task.update.assigned': Object.freeze(['percent_done', 'state', 'bucket', 'comments', 'comment']),
'task.close': Object.freeze(['state', 'bucket', 'done', 'verdict']),
});
// Gaps between settled positions, so a person can place a task between two others.
export const STORE_POSITION_STEP = 1024;
// The most one task's fields take, as JSON.
const STORE_TASK_BYTES = 262144;
// Board order: ungrouped first, then group name, position and task number. Every read sorts this way.
export function storeOrder(a, b) {
const ga = a.group ?? '', gb = b.group ?? '';
if (ga !== gb) return ga < gb ? -1 : 1;
if ((a.position ?? 0) !== (b.position ?? 0)) return (a.position ?? 0) - (b.position ?? 0);
return a.task - b.task;
}
// A git commit id, SHA-1 or SHA-256.
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
@@ -544,8 +650,9 @@ export class Broker {
#accessRevoked = new Set();
#clock = 0;
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -555,6 +662,7 @@ export class Broker {
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
if (taskTracker !== null && typeof taskTracker !== 'function') throw new TypeError('taskTracker must be a function');
this.#businesses = structuredClone(businesses);
this.#store = store;
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
@@ -596,6 +704,11 @@ export class Broker {
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
this.#roleFile = roleFile;
// U4a (#1873): taskTracker(business) names the task-tracker module the bus runtime chose for the
// business at start. The store's writes refuse tracker-not-store under any other module, so a
// Vikunja organization never grows a second task list. Without it (the unit tests' brokers)
// every business uses the store.
this.#taskTracker = taskTracker ?? (() => 'store');
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
// These are host capabilities, like project/library; no filesystem module enters the client closure.
@@ -1104,6 +1217,12 @@ export class Broker {
this.#businesses[name] = next;
this.#scheduleRequests();
}
// Host-only current role resolver shared by the broker and built-in task tracker.
taskRoles(business) {
const roles = this.#business(business).roles;
if (!object(roles)) fail('invalid-business');
return structuredClone(roles);
}
// Lifecycle Task 2 (#797): the chat project a bootstrapped product lists under. Host-only, so no verb
// reaches it. State is 'free', 'associated' (this exact id, name and product) or 'conflict' (anything else
// holding the id or the name), which lets a retry insert the row once.
@@ -4632,11 +4751,16 @@ export class Broker {
// the short form in task_ref order: no description, and the assignees that are a role's tracker
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
// every task the filters match; `next` is the `after` for the next page, null on the last.
// U4a (#1873, REQ-WS-8): the store's tasks come after the Vikunja rows, in board order, in the same
// shape (task_ref, fields, digest, priority, requirement, at), with source 'store'. An archived store
// task leaves the no-argument read and the filtered read unless `archived: true` asks for archived
// tasks; `task_ref` alone still gives it, with archived true.
#tasks(business, a) {
const rows = this.#taskRows(business);
if (!Object.keys(a).length) return rows;
const rows = this.#taskRows(business), store = this.#storeRows(business);
if (!Object.keys(a).length) return [...rows, ...store.filter((r) => !r.archived)];
if ('task_ref' in a) {
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
if (Object.keys(a).length !== 1 || !(taskRef(a.task_ref) || storeRef(a.task_ref))) fail('invalid-request');
if (storeRef(a.task_ref)) return this.#storeCurrent(business, a.task_ref);
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
}
const roles = this.#business(business).roles ?? {};
@@ -4649,7 +4773,8 @@ export class Broker {
('project_key' in a && !projectKey(a.project_key)) ||
('requirement_snapshot' in a && !id(a.requirement_snapshot)) ||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
('after' in a && !taskRef(a.after))
('archived' in a && typeof a.archived !== 'boolean') ||
('after' in a && !(taskRef(a.after) || storeRef(a.after)))
)
fail('invalid-request');
const bots = new Map(
@@ -4680,10 +4805,209 @@ export class Broker {
(!('project_key' in a) || t.project_key === a.project_key) &&
(!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot),
);
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
// The store's short form adds its group, position and archived flag; its assignees are role names.
const archived = a.archived ?? false;
const filed = store
.filter((r) => r.archived === archived)
.map(({ task_ref, fields: f, priority, requirement, at, archived: gone }) => ({
task_ref,
title: f.title,
done: f.done,
bucket: f.bucket,
assignees: [...f.assignees],
labels: [...f.labels],
priority,
requirement,
changed_at: at,
group: f.group,
position: f.position,
archived: gone,
}))
.filter(
(t) =>
(!('done' in a) || t.done === a.done) &&
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
(!('label' in a) || t.labels.includes(a.label)) &&
(!('requirement' in a) || t.requirement === a.requirement) &&
!('project_key' in a) &&
!('requirement_snapshot' in a),
);
// Archived tasks are store tasks only. Pages run through the Vikunja tasks, then the store's: a
// store `after` skips every Vikunja task and the store tasks up to it in board order. A cursor whose
// task moved since continues from where it now stands; one whose task was deleted reads its last row.
const all = [...(archived ? [] : matched), ...filed];
let rest = all;
if (taskRef(a.after ?? null)) rest = all.filter((t) => !taskRef(t.task_ref) || t.task_ref > a.after);
else if ('after' in a) {
const n = storeTaskNumber(a.after);
const last = this.#store.get('SELECT fields FROM store_tasks WHERE business=? AND task=? ORDER BY seq DESC LIMIT 1', business, n);
if (!last) fail('task-not-found');
const f = JSON.parse(last.fields), at = { group: f.group, position: f.position, task: n };
rest = filed.filter((t) => storeOrder({ group: t.group, position: t.position, task: storeTaskNumber(t.task_ref) }, at) > 0);
}
const limit = a.limit ?? TASK_PAGE;
const tasks = rest.slice(0, limit);
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
return { tasks, total: all.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
// U4a (#1873, REQ-TASK-1): the built-in task store. A task is its latest store_tasks row, and it is
// archived when its latest archive or unarchive row is an archive. A deleted task's latest row is
// its delete, so it leaves every read here. Every read builds from the rows; nothing is cached.
#storeLatest(business, task = null) {
return this.#store
.all(
`SELECT t.*, (SELECT a.op FROM store_tasks a WHERE a.business=t.business AND a.task=t.task AND a.op IN ('archive','unarchive') ORDER BY a.seq DESC LIMIT 1) AS archive
FROM store_tasks t WHERE t.business=?${task === null ? '' : ' AND t.task=?'}
AND t.seq=(SELECT max(seq) FROM store_tasks WHERE business=t.business AND task=t.task) AND t.op<>'delete' ORDER BY t.task`,
...(task === null ? [business] : [business, task]),
)
.map((r) => {
const fields = JSON.parse(r.fields);
// The keys a Vikunja `tasks` row has that the Console and `mosaic tasks` read (task_ref,
// fields, digest, priority, requirement, at, seq, source), with source 'store', and the
// store's own: revision, the verb that wrote it, who (role and run, or the person), the event.
return {
seq: r.seq, at: r.at, business: r.business, task_ref: `task:${r.task}`, tracker: 'store', source: 'store',
updated: r.at, revision: r.revision, verb: r.verb, role: r.by_role, run: r.by_run, human: r.by_human, event: r.event,
digest: createHash('sha256').update(JSON.stringify([r.revision, r.fields])).digest('hex'), fields,
priority: fields.priority ?? null, requirement: fields.requirement ?? null, archived: r.archive === 'archive',
};
});
}
// The live tasks, archived ones too, in board order.
#storeRows(business) {
const at = (r) => ({ group: r.fields.group, position: r.fields.position, task: storeTaskNumber(r.task_ref) });
return this.#storeLatest(business).sort((a, b) => storeOrder(at(a), at(b)));
}
#storeOn(business) {
this.#business(business);
if (this.#taskTracker(business) !== 'store') fail('tracker-not-store');
}
#storeCurrent(business, ref) {
const n = storeTaskNumber(ref);
if (n === null) fail('invalid-request');
return this.#storeLatest(business, n)[0] ?? fail('task-not-found');
}
// A role's create cites the person's instruction that asked for it, as a Vikunja create does.
#storeRequest(business, request) {
return (
typeof request === 'string' &&
!!this.#store.get(
"SELECT 1 FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL AND json_extract(body,'$.kind')='instruction'",
business,
request,
)
);
}
// The whole task a write leaves: every field the store keeps and no other, each already in the form
// its check gives (labels and assignees sorted, no repeats), and the bucket and done its state implies.
#storeFields(business, f, previous) {
if (!object(f) || Object.keys(f).length !== STORE_FIELDS.length || !STORE_FIELDS.every((k) => Object.hasOwn(f, k))) fail('invalid-request');
const roles = { ...this.taskRoles(business), ...Object.fromEntries((previous?.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles);
for (const k of STORE_EDIT_FIELDS) {
if (k === 'requirement' && f[k] === null) continue; // historical tasks
if (JSON.stringify(check[k](f[k])) !== JSON.stringify(f[k])) fail('invalid-request');
}
const text = (x, max) => x === null || (typeof x === 'string' && x.length >= 1 && x.length <= max && !x.includes('\0'));
if (
f.bucket !== f.state ||
f.done !== (f.state === 'done') ||
!(Number.isSafeInteger(f.comments) && f.comments >= 0) ||
!(f.comment === null || (object(f.comment) && Object.keys(f.comment).length === 2 && text(f.comment.text, 10000) && f.comment.text !== null && Object.hasOwn(f.comment, 'by') && text(f.comment.by, 128) && f.comment.by !== null)) ||
!text(f.verdict, 256) ||
!text(f.request, 64) ||
!(f.requirement === null || (typeof f.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(f.requirement))) ||
!text(f.op, 256)
)
fail('invalid-request');
}
// Appends one row and the event that records it, and answers the task as it now reads (or that it
// was deleted). The caller owns the transaction and has checked who may write. A null position
// settles after the last task in the task's group.
#storeWrite(s, { current, op, verb, fields, by, kind, body }) {
const task = current
? storeTaskNumber(current.task_ref)
: (this.#store.get('SELECT max(task) AS n FROM store_tasks WHERE business=?', s.business).n ?? 0) + 1;
const ref = `task:${task}`;
const peers = this.#storeRows(s.business).filter((r) => r.task_ref !== ref && (r.fields.group ?? null) === (fields.group ?? null));
if (fields.position === null) {
const last = peers.reduce((m, r) => Math.max(m, r.fields.position ?? 0), -STORE_POSITION_STEP);
if (last + STORE_POSITION_STEP > STORE_POSITION_MAX) fail('task-position-full');
fields = { ...fields, position: last + STORE_POSITION_STEP };
} else if ((!current || fields.group !== current.fields.group || fields.position !== current.fields.position) && peers.some(r => r.fields.position === fields.position)) {
fail('task-position-full');
}
this.#storeFields(s.business, fields, current?.fields);
const json = JSON.stringify(fields);
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
this.#secretCheck(fields);
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
this.#store.run(
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
task,
current ? current.revision + 1 : 1,
op,
verb,
by.role,
by.run,
by.human,
event,
json,
);
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
}
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
// as #consumeAuthority spends it), a close's verdict check, the event and the row. `change` is the
// module's: it gets a copy of the task's fields (a new task's on a create) and of its row (null on a
// create) and returns the whole new fields. The write may change only what STORE_VERB_FIELDS gives
// its verb. An archived task takes no role write; a person unarchives it first. A create that names
// an `op` the same role already created with answers that task again, with nothing written; the same
// op with other fields, or from another role, refuses (task-create-conflict).
storeTask(cap, { verb, task_ref = null, decision = null, expect = null, verdict = null, change } = {}) {
const s = this.#session(cap);
if (!TASK_VERBS.includes(verb) || typeof change !== 'function' || (verb === 'task.create') !== (task_ref === null)) fail('invalid-request');
return this.#store.transaction(() => {
this.#agent(s);
this.#storeOn(s.business);
const current = task_ref === null ? null : this.#storeCurrent(s.business, task_ref);
if (current?.archived) fail('task-archived');
if (expect !== null && current && expect !== current.digest) fail('task-conflict');
const before = current?.fields ?? storeNewTask({ title: null }), allowed = STORE_VERB_FIELDS[verb];
const fields = change(structuredClone(before), current && structuredClone(current));
if (!object(fields)) fail('invalid-request');
for (const k of new Set([...Object.keys(before), ...Object.keys(fields)]))
if (JSON.stringify(before[k]) !== JSON.stringify(fields[k]) && !allowed.includes(k)) fail('field-writer');
if (!current && !this.#storeRequest(s.business, fields.request)) fail('request-not-found');
if (!current && typeof fields.op === 'string') {
const old = this.#store.get(
"SELECT task, by_role, fields FROM store_tasks WHERE business=? AND op='create' AND json_extract(fields,'$.op')=? ORDER BY seq LIMIT 1",
s.business,
fields.op,
);
if (old) {
const was = JSON.parse(old.fields), same = (k) => JSON.stringify(was[k]) === JSON.stringify(fields[k]);
if (old.by_role !== s.role || !STORE_FIELDS.every((k) => same(k) || (k === 'position' && fields.position === null))) fail('task-create-conflict');
this.#checkAuthority(s, verb, { decision, target: null });
const ref = `task:${old.task}`;
return this.#storeLatest(s.business, old.task)[0] ?? { task_ref: ref, deleted: true };
}
}
const authority = this.#checkAuthority(s, verb, { decision, target: task_ref });
if (authority.decision && this.#spent(s.business, authority.decision)) fail('decision-consumed');
if (verb === 'task.close') this.#closeVerdict(s, task_ref, verdict);
return this.#storeWrite(s, {
current,
op: current ? 'change' : 'create',
verb,
fields,
by: { role: s.role, run: s.run, human: null },
kind: 'action.allowed',
body: { action: verb, ...authority, target: task_ref, ...(verb === 'task.close' ? { verdict } : {}) },
});
});
}
#checkAuthority(s, action, context = {}) {
this.#processingWork();
@@ -4741,6 +5065,20 @@ export class Broker {
);
return evidence ? JSON.parse(evidence.body) : null;
}
#checkResolver(s, d) {
const b = this.#business(s.business), role = b.roles[s.role];
const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
// Pre-RR evidence has no domain. Assignments may have changed since raise,
// so require both possible rights rather than infer one from today's file.
const domains = domain === undefined ? ['technical', 'delivery'] : [domain];
for (const kind of domains) {
if (!['technical', 'delivery'].includes(kind)) fail('not-resolver');
const right = `decision.resolve.${kind}`;
if (!role?.authority?.withinRole.includes(right)) {
throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`);
}
}
}
// Whether an executor already used the decision. decision.raise records context, not permission
// consumed by an executor.
#spent(business, decision) {
@@ -5460,16 +5798,18 @@ export class Broker {
}
#refused(s, e, request, other = 'storage-refused') {
const error = e instanceof BusError ? e : new BusError(other);
// No request content or raw exception text in refusal evidence.
// No request content or raw exception text in refusal evidence. U4a: a built-in store task's
// ref files the refusal on that task's trail too.
const subject = (x) => taskRef(x) || storeRef(x);
try {
this.#store.transaction(() =>
this.#event(
s,
'action.refused',
{ code: error.code },
taskRef(request?.args?.task_ref)
subject(request?.args?.task_ref)
? request.args.task_ref
: taskRef(request?.args?.target)
: subject(request?.args?.target)
? request.args.target
: null,
),
@@ -6595,6 +6935,8 @@ export class Broker {
#dispatch(s, verb, a, spec = null, binding = null) {
const b = this.#business(s.business),
by = s.human || s.run;
// Preserve the common request gates and refuse additions until the vocabulary names them.
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
switch (verb) {
case 'role.claim': {
keys(a, []);
@@ -6740,8 +7082,8 @@ export class Broker {
if (!choices.includes(a.recommendation)) fail('invalid-recommendation');
const approvalChoice = a.approvalChoice ?? 'yes';
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
const domain = a.domain ?? 'delivery';
if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain');
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
// 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.
@@ -6823,6 +7165,7 @@ export class Broker {
decision: did,
action: a.action,
operation: 'decision.raise',
domain,
task_ref: a.task_ref ?? null,
target: a.target ?? null,
approvalChoice,
@@ -6889,6 +7232,7 @@ export class Broker {
else {
this.#agent(s);
if (s.role !== d.route_to) fail('not-resolver');
if (verb === 'decision.resolve') this.#checkResolver(s, d);
}
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
@@ -7611,6 +7955,41 @@ export class Broker {
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
// nothing; a repeat of the state the project already holds refuses.
// U4a (#1873, REQ-TASK-1): the person's own writes to the built-in task store, from the terminal
// (human-cli). Each writes a human.input event (body kind 'task') that its store_tasks row cites, so a
// person's edit comes in as an event like any other input. The person writes any field, may reopen a
// done task, and is the only one who archives, unarchives or deletes. `expect` is the digest the
// person last read; a later write refuses task-conflict.
case 'tasks.create':
case 'tasks.edit':
case 'tasks.archive':
case 'tasks.unarchive':
case 'tasks.delete': {
this.#human(s);
this.#storeOn(s.business);
const creating = verb === 'tasks.create', editing = verb === 'tasks.edit';
keys(a, creating ? STORE_EDIT_FIELDS : editing ? ['task_ref', 'expect', ...STORE_EDIT_FIELDS] : ['task_ref', 'expect'], creating ? ['title', 'requirement'] : ['task_ref']);
if (Object.hasOwn(a, 'expect') && !(typeof a.expect === 'string' && /^[0-9a-f]{64}$/.test(a.expect))) fail('invalid-request');
const current = creating ? null : this.#storeCurrent(s.business, a.task_ref);
const roles = { ...this.taskRoles(s.business), ...Object.fromEntries((current?.fields.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles), edits = {};
for (const k of STORE_EDIT_FIELDS) if (Object.hasOwn(a, k)) edits[k] = check[k](a[k]);
if (editing && !Object.keys(edits).length) fail('invalid-request');
if (current && Object.hasOwn(a, 'expect') && a.expect !== current.digest) fail('task-conflict');
if (editing && current.archived) fail('task-archived');
if (verb === 'tasks.archive' && current.archived) fail('task-already-archived');
if (verb === 'tasks.unarchive' && !current.archived) fail('task-not-archived');
const fields = creating ? storeApply(storeNewTask({ title: edits.title }), edits) : editing ? storeApply(current.fields, edits) : current.fields;
return this.#storeWrite(s, {
current,
op: creating ? 'create' : editing ? 'change' : verb.slice('tasks.'.length),
verb,
fields,
by: { role: null, run: null, human: this.#owner(s) },
kind: 'human.input',
body: { kind: 'task' },
});
}
case 'project.archive':
case 'project.unarchive': {
this.#human(s);
@@ -8391,6 +8770,19 @@ export class Broker {
);
}
}
// U4a (#1873): a store task's rows (its whole history, a delete included) and the person's
// instruction a role's create cited, with that instruction's message.
if (storeRef(subject)) {
const own = this.#store.all('SELECT * FROM store_tasks WHERE business=? AND task=? ORDER BY seq', s.business, storeTaskNumber(subject));
add('store_tasks', own);
const request = own.length ? JSON.parse(own[0].fields).request : null;
const input = request && this.#store.get("SELECT * FROM events WHERE business=? AND kind='human.input' AND id=?", s.business, request);
if (input) {
add('events', [input]);
const body = JSON.parse(input.body);
if (body.message) add('messages', this.#store.all('SELECT * FROM messages WHERE business=? AND id=?', s.business, body.message));
}
}
for (const m of [...rows.values()])
if (m.table === 'messages')
add('deliveries', this.#store.all('SELECT * FROM deliveries WHERE message=?', m.id));
+41
View File
@@ -0,0 +1,41 @@
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+51 -4
View File
@@ -63,6 +63,8 @@ export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
// U4a (#1873): it serves the businesses whose task-tracker module is Vikunja; the others get theirs
// through the plug-in loader, the built-in store by default (see trackerOf in startBroker).
// REQ-HARN-2, REQ-PLUG-2 (decision 0b81b0c5): one web service per business. Its search source is
// the "search-source" plug-in the business's organization file switches on: searxng-url (the
// business var web.searxng) or searxng-bundled. With none, web.search refuses "not configured" and
@@ -653,7 +655,21 @@ export async function startBroker({
return no(declared ? 'project-pending' : 'project-unknown');
}
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1): the task-tracker module each business uses, fixed for this
// start. The organization file's "task-tracker" plug-in names it. With none, a business the boot
// config gives a Vikunja tracker keeps Vikunja (so does every business when an in-process caller
// hands `tasks` and no `trackers`), and any other business gets the built-in store. A business that
// names another module while the boot config also gives it a Vikunja tracker refuses the boot:
// two trackers can't both hold its tasks.
const trackerOf = new Map();
for (const [id, b] of Object.entries(businesses)) {
const named = b?.plugins?.['task-tracker'];
const tracked = trackers !== null && typeof trackers === 'object' && Object.hasOwn(trackers, id);
if (named !== undefined && named !== 'vikunja' && tracked) throw new BusError('tracker-config');
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
@@ -888,11 +904,42 @@ export async function startBroker({
}
const bound = launches.map(bindLaunch);
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
if (tasks) {
adapter = await tasks({ broker, credentials, businesses, processing });
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
// U4a: one adapter per task-tracker module in use, behind one handle that routes each call by the
// caller's business. The host's `tasks` factory is the Vikunja module. Any other module, and Vikunja
// when the host gave no factory but an organization file names it, loads through the plug-in loader
// with the boot config's `trackers`. A module that refuses to load or has no factory refuses
// startup with tracker-config. A module whose factory makes no
// adapter refuses the boot (invalid-adapter), and every adapter made so far is closed. The host's
// factory is always made when given, used or not, so its own checks of `trackers` still run.
const made = new Map();
adapter = { async close() { for (const a of made.values()) await a?.close?.(); } };
const pluginLog = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`);
for (const name of new Set([...(tasks ? ['vikunja'] : []), ...trackerOf.values()])) {
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
}
// A host may omit its implicit Vikunja adapter, retaining the legacy unconfigured path.
// An explicitly selected module must always supply a factory.
if (!factory && name === 'vikunja' && !tasks && !Object.values(businesses).some(b => b?.plugins?.['task-tracker'] === 'vikunja')) continue;
if (typeof factory !== 'function') throw new BusError('tracker-config');
const one = await factory({ broker, credentials, businesses, processing });
made.set(name, one);
if (typeof one?.handle !== 'function' || !Number.isSafeInteger(one.timeout) || one.timeout < 1)
throw new BusError('invalid-adapter');
}
if (made.size) {
adapter.timeout = Math.max(...[...made.values()].map((a) => a.timeout));
adapter.handle = async (cap, verb, args) => {
const one = made.get(trackerOf.get(broker.identity(cap).business));
if (!one) throw new BusError('tracker-unconfigured');
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses);
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
@@ -921,7 +968,7 @@ export async function startBroker({
web: (business) => web.get(business) ?? null,
host: host ? { busy: (...args) => host.busy(...args),
run: (...args) => processing.work(() => host.run(...args)) } : null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
tasks: adapter?.handle ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
peerReader,
+8 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '6f6f0c0fd471b7895648a71aa9f320a769b145da792c4152ee8282006eff30c7';
export const SCHEMA_VERSION = '3u';
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8';
export const SCHEMA_VERSION = '3v';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -115,6 +115,11 @@ const migrations = [{
}, {
from: '3t', to: '3u',
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
}, {
// U4a (#1873): the task store built into each organization, the default task-tracker module.
// Only a new append-only table, its index and its guards; no existing row changes.
from: '3u', to: '3v',
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -142,6 +147,7 @@ const timedTables = [
'request_project_events',
'project_archives',
'project_system_types',
'store_tasks',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -95,6 +95,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -102,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -152,7 +153,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -164,14 +165,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+4 -4
View File
@@ -282,7 +282,7 @@ test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set
const outside = [
{ launch: { by: 'coder' } },
{ human: 'mallory' },
{ arbiters: { technical: 'coder', delivery: 'coder' } },
{ arbiters: { technical: 'chief', delivery: 'chief' } },
{ roles: {} },
{ actor: 'jason' },
{ 'launch.by': 'coder' },
@@ -616,7 +616,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -1026,7 +1026,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
+2 -2
View File
@@ -18,8 +18,8 @@ export function citationWorld(t, { definition = 'coder', library = true, guard:
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
...(library ? { projects: { books: map } } : {}),
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
const limits = () => {
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
catch { return resolveReplyLimits({ readable: false }); }
+2 -2
View File
@@ -18,8 +18,8 @@ test('C2-06 runtime reads changed and unreadable reply limits without restarting
// Runtime takes the host's broker projection, not the business validator's unresolved role entries.
// No agent action is needed here: use the same empty-authority shape as limits-runtime.test.mjs.
const business = { id: 'acme', human: 'jason', projects: checked.projects,
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir, system: systemFor(root) } });
t.after(() => runtime.close());
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3v';")
.replace('}];\nconst timedTables', `}, { from: '3u', to: '3v', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';")
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3u ready/);
assert.match(migrated.stdout, /bus schema 3v ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+3 -3
View File
@@ -48,8 +48,8 @@ function setup(t) {
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
@@ -227,7 +227,7 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
const f = setup(t);
for (const route of ['pm', 'chief']) {
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
f.call(route, 'decision.resolve', { id, choice: 'yes' });
+6 -6
View File
@@ -30,8 +30,8 @@ async function setup(t, { storeRoot = true } = {}) {
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
},
@@ -58,7 +58,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
const calls = [];
const b = new Broker({
store: s,
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
@@ -97,7 +97,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
// No lifecycle without the runtime's store.
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
});
@@ -182,8 +182,8 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
chmodSync(join(root, 'secrets', 'agents'), 0o750);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
};
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
});
@@ -24,8 +24,8 @@ async function setup(t, limit = 0) {
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'),
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
@@ -76,8 +76,8 @@ test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
+2 -2
View File
@@ -13,8 +13,8 @@ const options = [
{ key: 'no', text: 'Decline' },
];
const roles = {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send'], crossRole: [] } },
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } };
+2 -2
View File
@@ -22,8 +22,8 @@ const options = [
];
const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } };
const roles = {
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
// Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter.
coder: { definition: 'coder', ...pusher },
+2 -2
View File
@@ -13,8 +13,8 @@ const options = [
{ key: 'no', text: 'Decline' },
];
const roles = {
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = {
+7 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -85,6 +85,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -106,7 +107,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -132,7 +133,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -143,14 +144,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -48,7 +48,7 @@ function setup(t, { keygen, check, hang = false } = {}) {
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
+7 -6
View File
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -88,6 +88,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -111,7 +112,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -136,7 +137,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -147,14 +148,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
@@ -17,8 +17,8 @@ test('row 298 runtime: human scope approval writes the file and organization his
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
mkdirSync(join(root, 'data'), { mode: 0o700 });
runtime = await startBroker({ dataRoot: join(root, 'data'),
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+21 -21
View File
@@ -27,8 +27,8 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
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 } } } });
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 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);
@@ -54,19 +54,19 @@ test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbite
}
}
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/);
assert.throws(() => f.call(f.chief, '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: [] } } } });
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], 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);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
}
});
@@ -74,15 +74,15 @@ test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser,
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.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
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' });
f.call(f.chief, '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: [] } },
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
} });
const cto = f.agent('cto'), gate = f.agent('merge-gate');
@@ -106,7 +106,7 @@ test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an
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?',
const own = await f.call(f.chief, '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' });
@@ -136,7 +136,7 @@ const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind=
test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', 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');
const chief = f.chief, lead = f.agent('lead');
// No thread is held yet, so there is nothing to end.
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
const a = held(f, 'a-run'), b = held(f, 'b-run');
@@ -158,11 +158,11 @@ test('Q169: the chief of staff\'s end of the assistant stops every thread run; i
test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => {
const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } });
const chief = f.agent('chief');
const chief = f.chief;
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) {
const d = await revoke(f, chief, 'assistant', args);
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args));
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
}
// Resolved by the human, the chief's revoke citing it stops the thread run, once.
const a = held(f, 'a-run');
@@ -181,8 +181,8 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
// 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' });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
f.call(f.chief, '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' });
@@ -192,7 +192,7 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', 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/);
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
// Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the
// revoke reaches the thread claims and stops the held one.
@@ -205,8 +205,8 @@ test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbi
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(routed(f, d), { class: 'gated', route_to: 'chief' });
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
});
@@ -216,7 +216,7 @@ test('Q169: a role granted role.revoke at either class still sends a revoke of t
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' });
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'chief' });
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);
+2 -2
View File
@@ -12,7 +12,7 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
// for an open decision.
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
const id = randomUUID();
store.run(
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
@@ -21,7 +21,7 @@ export function grantDecision(store, { business = 'demo', action = 'credential.m
store.run(
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
randomUUID(), AT, business, 'chief', 'chief-run', id,
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
);
if (close)
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
+4 -4
View File
@@ -572,8 +572,8 @@ test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a
await new Promise((r) => dead.on('close', r));
writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
};
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await start();
@@ -640,8 +640,8 @@ test('M3: a host_grants record gives Credentials a store reference at start, and
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
};
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
+1 -1
View File
@@ -65,7 +65,7 @@ test('row 165 runtime: an add and a change reach the organization file with the
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const file = join(root, 'businesses/demo.json');
+5 -5
View File
@@ -26,7 +26,7 @@ const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const C = 'c'.repeat(40);
const roles = {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
@@ -221,15 +221,15 @@ test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land',
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
});
test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => {
const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } });
test('a cross-role merge that routes to the human, because the cto lander is its own arbiter, lands on the human\'s CLI answer', (t) => {
const w = setup(t, { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { ...roles, cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [MERGE] } } } });
w.push(A);
w.approve(A);
const d = w.raise();
const d = w.raise('cto');
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' });
assert.throws(() => w.resolve(d), /human-required/);
w.resolve(d, 'yes', 'human');
const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id });
const { ticket, by, via } = w.b.checkLand(w.caps.cto, { task_ref: TASK, decision: d.id });
assert.deepEqual([by, via], ['jason', 'cli']);
w.b.recordLand(ticket, w.land);
assert.equal(w.merges()[0].body.class, 'cross-role');
@@ -119,7 +119,7 @@ const TASK = 'vikunja:1/2';
test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => {
const w = setup(t, {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
@@ -43,9 +43,9 @@ async function until(fn, ms = 10000) {
}
const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
// sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent.
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
roles: { cto: { definition: 'cto', authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
...(instances ? { launch: { instances } } : {}),
});
@@ -77,7 +77,7 @@ function world(t, options) {
// chief-of-staff instance, fired by boss. The real read grant targets the library project books.
function verbDoc(url, root) {
return {
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {
boss: { definition: 'pm', authority: chiefAuthority },
// sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference.
+4 -4
View File
@@ -31,8 +31,8 @@ async function world(t, { projects, adapter, claimed = true, now = () => NOW, fi
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
mkdirSync(dataRoot);
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
const context = { business, project: 'books', dataRoot };
let mirror = null, state = null, fetches = 0;
if (business.projects.books?.kind === 'library') {
@@ -222,8 +222,8 @@ test('real socket awaits lookup results and rechecks rejected query arguments',
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
const business = { id: 'acme', human: 'jason', plugins: { harness: ['pi'] }, projects: { books: mappingEntry() },
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
+2 -2
View File
@@ -21,7 +21,7 @@ function world(t, adapter) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-sync-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-sync-agent', harness: 'pi' });
@@ -81,7 +81,7 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac
mkdirSync(dataRoot, { mode: 0o700 });
mkdirSync(join(configDir, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
writeFileSync(join(configDir, 'secrets', 'acme', library.SLOT), TOKEN + '\n', { mode: 0o600 });
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
projects: { books: { ...mappingEntry(), user: 'books-bot' }, quiet: mappingEntry() } };
const calls = [];
// A git that succeeds without fetching: the remote-tracking ref stays absent, so each pass fails.
+2 -2
View File
@@ -234,8 +234,8 @@ test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never u
const pushes = [];
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
const initial = (await bare.state()).lastFetch;
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
arbiters: { technical: 'coder', delivery: 'coder' } };
const business = { ...bare.context.business, human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
arbiters: { technical: 'chief', delivery: 'chief' } };
const runtime = await startBroker({ dataRoot: bare.context.dataRoot, businesses: { acme: business },
librarySync: { configDir, command, intervalMs: 60000 } });
try {
+4 -4
View File
@@ -16,7 +16,7 @@ function world(t, adapter = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-agent', harness: 'pi' });
@@ -68,7 +68,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
const f = repo(t), dataRoot = join(f.root, 'data'), token = join(f.root, 'fixture-token');
mkdirSync(dataRoot, { mode: 0o700 }); writeFileSync(token, 'made-up-fixture-token-123456', { mode: 0o600 });
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry(), stack: { root: f.repo } },
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
await runtime.close();
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
@@ -81,7 +81,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a socket', async t => {
const f = repo(t), dataRoot = join(f.root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const mirror = join(dataRoot, 'projects/acme/books'); mkdirSync(mirror, { recursive: true });
writeFileSync(join(mirror, 'keep'), 'made-up note');
await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }), { code: 'library-git' });
@@ -92,7 +92,7 @@ test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a s
test('C2-04 runtime: real library adapters reach the socket and lock waits leave other bus reads responsive', { timeout: 15000 }, async t => {
const f = repo(t), dataRoot = join(f.root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
t.after(() => runtime.close());
const cap = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
+2 -2
View File
@@ -26,7 +26,7 @@ const doc = (extra = {}) => ({
retired: ['old'],
...extra,
});
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 'only-there': none } };
function setup(t, { organization = null, testerOn = undefined, processing = undefined } = {}) {
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
@@ -349,7 +349,7 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
// The bus reloads after the commit, as the runtime does.
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'chief', delivery: 'chief' } });
delete next.roles.pm;
f.b.reloadBusiness('demo', next);
const writes = w.calls.length;
+2 -2
View File
@@ -13,8 +13,8 @@ import { Broker } from '../src/broker.mjs';
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const businesses = () => ({ demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
+2 -2
View File
@@ -20,8 +20,8 @@ async function setup(t, plugins, limit = 0) {
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'),
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
+3 -3
View File
@@ -8,7 +8,7 @@ import {Broker} from '../src/broker.mjs';
import {Store} from '../src/store.mjs';
test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[];
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}};
for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
// Row 175 (#1673): the tester reads limits; limits.set stays the human's.
@@ -19,7 +19,7 @@ test('limits binds actor from human proof and rejects role, reader, a tester set
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed');
});
@@ -28,7 +28,7 @@ test('uncertain organization receipts leave bus startup and reads alone but refu
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7;
const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8');
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=bus.broker.request(human,{verb:'limits'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes);
// The operator reconciles the fixture to the recorded old whole document.
writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1);
+1 -1
View File
@@ -15,7 +15,7 @@ function fixture(t, cross = false) {
arbiters: { technical: 'cto', delivery: 'pm' },
roles: {
pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []),
cto: role(),
cto: role(['decision.resolve.technical']),
coder: role(),
},
});
+2 -2
View File
@@ -46,7 +46,7 @@ test('launch admission refreshes selection and refuses a removed model and incom
writeJson(join(root,'businesses/demo.json'),doc); mkdirSync(join(root,'data'),{mode:0o700});
let present=true, reads=0;
const service={invalidate(){},read:async(_,{fresh})=>{assert.equal(fresh,true);reads++;return {status:'ready',models:present?[{module:'cliproxy',upstream:'zai',model:'fixture-id',available:true,harnesses:['pi']}]:[]};}};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const revision=bus.broker.request(human,{verb:'limits'}).revision;
const {checkedTransport}=await import('../../harness/src/bundle.mjs');
@@ -78,7 +78,7 @@ test('a different enabled fixture provider supplies legacy mapping and admission
const makeProviderLoader=()=>({load:async()=>({refused:[]}),snapshot:()=>[{name:'second-fixture',module}]});
const offered={key:'fixture-model',module:'second-fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture',available:true,reason:null,harnesses:['pi']};
const modelCatalogue={read:async()=>({status:'ready',models:[offered],notices:[]}),invalidate(){}};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=await bus.broker.request(human,{verb:'routes'});
assert.equal(view.catalogue.legacy['fixture-legacy'].module,'second-fixture');
const answer=await bus.modelAdmission({business:'demo',instance:'coder',modelKey:'fixture-legacy',harness:'pi',revision:view.revision,entry:doc.launch.models['fixture-legacy'],provider:'fixture',model:'fixture-model',account:null,route:'primary',transport});
@@ -27,7 +27,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
// The established scratch aide pattern only; repository roles/policy stay untouched.
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
+1 -1
View File
@@ -176,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
async function bootBusiness(f, binding = {}) {
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
@@ -15,7 +15,7 @@ async function hireFixture(t,{revoking=false}={}){
if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}old.credentials.close();
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
+1 -1
View File
@@ -22,7 +22,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
// Existing row331 scratch aide pattern only; repository role authority unchanged.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
+1 -1
View File
@@ -7,7 +7,7 @@ import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
const role={authority:{withinRole:['message.send'],crossRole:[]}};
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
test('proxy.init only accepts path-bound outside-agent human; refuses agent/tester/reader',async t=>{const calls=[];const f=fixture(t,async a=>{calls.push(a);return {port:a.port,binary:'/fixture'};});for(const cap of [f.tester,f.agent])assert.throws(()=>f.b.request(cap,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='human-required');assert.throws(()=>f.b.request(f.reader,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='read-only');assert.equal(calls.length,0);assert.deepEqual(await f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management',port:18432}}),{port:18432,binary:'/fixture'});assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
for(const [code,args]of [['proxy-key-invalid',{key:'a\nb'}],['proxy-config-invalid',{key:'p1-management',port:8317}],['invalid-request',{key:'p1-management',binary:'/forged'}]])test(`proxy.init ${code}`,t=>{const f=fixture(t,()=>{throw Error('must not call');});assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args}),e=>e.code===code);});
test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='not-configured');});
@@ -51,7 +51,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
let boot,writer,chief,human,busBoots=0;
if(runtime){
const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+1 -1
View File
@@ -24,7 +24,7 @@ const demo = () => ({
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'lead', delivery: 'chief' },
arbiters: { technical: 'chief', delivery: 'chief' },
roles,
definitions: { reviewer: {} },
vars: { 'bus.tester': true },
+1 -1
View File
@@ -9,7 +9,7 @@ import { createLimits, modelUsageKey } from '../src/limits.mjs';
const key='012345abcdef';
test('P5 broker preserves original plugin subject and derives internal run context from authenticated native caller',async t=>{
const root=mkdtempSync(join(tmpdir(),'p5-context-')),store=new Store(root),asked=[];t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},
limits:{mustStop:async(...args)=>{asked.push(args);return {stop:false};}},usage:{collect:async()=>({recorded:true}),view:()=>({})}});
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const cap=b.bindLaunch({business:'demo',role:'coder',run:'native-run',harness:'pi',provider:'fixture',model:'fake'});b.request(cap,{verb:'role.claim'});
+2 -2
View File
@@ -30,7 +30,7 @@ test('P5 real runtime binds stack usage to issued key, daily stops at stack coun
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
assert.equal(calls.some(p=>p.includes('usage/queue')),false);
const model=await bus.modelPreflight({business:'demo',role:'coder'}),keyId=hash(model.key);
@@ -77,7 +77,7 @@ test('R4 real runtime: a quota spent on A and moved to B with no reset still ref
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
const stored=()=>JSON.parse(readFileSync(join(config,'businesses/demo.json'),'utf8')).launch.limits.tokens.keys;
const may=async()=>(await bus.mayLaunch({business:'demo',instance:'coder',provider:'fixture',model:'fake'})).allow;
+1 -1
View File
@@ -64,7 +64,7 @@ export async function telemetryFixture(t, { foreign = false, hold = false, waitM
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
let bus=null,startPromise=null,closePromise=null;
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value)));
const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}};
const close=()=>bus?(closePromise??=bus.close()):Promise.resolve();
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as bus from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pt-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const broker = new bus.Broker({ store, businesses: {
acme: { id: 'acme', human: 'jason', roles: {
pm: { authority: { withinRole: [], crossRole: [] } },
cto: { authority: { withinRole: [], crossRole: [] } },
}, arbiters: { technical: 'cto', delivery: 'pm' } },
} });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
return { broker, human };
}
test('person task vocabulary pins exactly the five frozen store verbs', () => {
assert.deepEqual(bus.PERSON_TASK_VERBS, [
'tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete',
]);
assert.ok(Object.isFrozen(bus.PERSON_TASK_VERBS));
});
test('every person task handler is declared in the vocabulary, even if the dispatch guard regresses', () => {
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
const handlers = [...source.matchAll(/^\s*case\s+(['"])(tasks\.[^'"]+)\1\s*:/gm)].map(match => match[2]);
assert.deepEqual(handlers.sort(), [...bus.PERSON_TASK_VERBS].sort());
});
test('every listed person task verb reaches its handler, while an unlisted verb is unknown', t => {
const { broker, human } = fixture(t);
assert.ok(Array.isArray(bus.PERSON_TASK_VERBS));
for (const verb of bus.PERSON_TASK_VERBS) {
// Missing required fields reaches the handler's field validation, not unknown-verb.
assert.throws(() => broker.request(human, { verb, args: {} }), { code: 'invalid-request' }, verb);
}
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: {} }), { code: 'unknown-verb' });
});
test('unknown person task verbs retain common argument and reader refusal precedence', t => {
const { broker, human } = fixture(t);
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: [] }), { code: 'invalid-request' });
const reader = broker.bindReader({ business: 'acme' });
assert.throws(() => broker.request(reader, { verb: 'tasks.foo' }), { code: 'read-only' });
for (const verb of ['tasks.foo', 'tasks.create']) {
assert.throws(() => broker.request(reader, { verb }), { code: 'read-only' }, verb);
}
});
+3 -3
View File
@@ -37,7 +37,7 @@ test('runtime: the switch changes the organization file, the receipt carries the
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const file = join(root, 'businesses/demo.json');
@@ -72,7 +72,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
@@ -97,7 +97,7 @@ test('plugins read advertises loadable harnesses even when switched off', async
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
const human=host.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const view=host.broker.request(human,{verb:'plugins'});
assert.deepEqual(view.plugins.harness,['claude-code']);assert.ok(view.available.harness.includes('pi'));assert.ok(view.available.harness.includes('claude-code'));
+3 -1
View File
@@ -199,6 +199,8 @@ test('3p through 3s migration preserves existing request rows and the append-onl
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
const db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z';
@@ -214,7 +216,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
@@ -10,8 +10,8 @@ import { Client } from '../src/client.mjs';
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
import { readControl } from '../../processing-control/src/state.mjs';
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
async function broker(root) {
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+1 -1
View File
@@ -13,7 +13,7 @@ const state = await import('../../processing-control/src/state.mjs').catch(e =>
throw e;
});
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
} } };
function fixture(t, extra = {}) {
+6 -5
View File
@@ -17,7 +17,7 @@ import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mj
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
const AT = '2026-11-05T00:00:00.000Z';
const business = {
id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' },
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: {
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
@@ -143,7 +143,8 @@ const here = (p) => new URL(p, import.meta.url);
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '');
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither.
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
function source3s(t) {
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
@@ -178,7 +179,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
@@ -211,7 +212,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3s';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';")
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -221,7 +222,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
assert.throws(() => new Store3s(root), /schema-mismatch/);
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
const { root: old } = source3s(t);
+1 -1
View File
@@ -10,7 +10,7 @@ function fixture(t, project, secretCheck = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'project-await-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
} };
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
+1 -1
View File
@@ -64,7 +64,7 @@ test('real startBroker injects the project adapter for socket reads and bound-au
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
// Row 65: the bound run's harness is switched on.
const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } },
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: {
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
} } };
const initialized = await initProject({ business, project: 'stack', dataRoot: root });
@@ -15,7 +15,7 @@ test('role must not materialize a new catalogue key by submitting legacy entry s
const offered={key:'zai-fixture-new',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-new',label:'Fixture',available:true,reason:null,harnesses:['pi']};
let status='ready';
const modelCatalogue={invalidate(){},read:async()=>({status,models:[offered],notices:[]})};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
const allowed=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
@@ -20,7 +20,7 @@ async function fixture(t, options={}) {
if(options.fallback)Object.assign(doc.roles.coder.vars,{'fallback.harness':'pi','fallback.model':'existing-alias','fallback.thinking':'low'});
writeJson(file,doc);fs.mkdirSync(join(root,'data'),{mode:0o700});
const modelCatalogue={invalidate(){},read:async()=>({status:'ready',models:[offer],notices:[]})};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
const count=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
+2 -2
View File
@@ -18,8 +18,8 @@ function setup(t, proxyPage = async () => ({ ok: true })) {
access: () => ({ accessVersion: 1, revision: 1, users: [], grants: [
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['coder'], level: 'collaborator' },
] }),
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' },
vars: { 'bus.tester': true }, roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' },
vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
});
const caps = {
human: broker.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
+2 -2
View File
@@ -24,9 +24,9 @@ function businesses(upstream = 'https://git.example/mosaic/stack') {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
arbiters: { technical: 'chief', delivery: 'chief' },
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
roles: {
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
plain: { authority: pusher },
+2 -2
View File
@@ -211,13 +211,13 @@ test('T14 reloadBusiness refuses an unknown business, a changed human and a docu
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const role = { authority: { withinRole: [], crossRole: [] } };
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } };
const b = new Broker({ store: new Store(root), businesses: { demo } });
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
b.reloadBusiness('demo', { ...demo, roles: { ...demo.roles, 'sec-1': role } });
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
});
+6 -3
View File
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
});
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3u');
assert.equal(SCHEMA_VERSION, '3v');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
@@ -310,8 +310,11 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks.
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
@@ -328,7 +331,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
@@ -231,6 +231,8 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'advanced'", ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
@@ -251,7 +253,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
+6 -2
View File
@@ -279,6 +279,8 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
// predate it and the replayed chain recreates it, so the step's text comes out here too.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
@@ -291,7 +293,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n'); assert.equal(SCHEMA_VERSION, '3u');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
@@ -315,6 +317,8 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o);
@@ -331,7 +335,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
@@ -0,0 +1,82 @@
// RR (#1873): resolution checks current role rights, and callers cannot choose a merge/policy arbiter.
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadRole } from '../../organization/src/index.mjs';
import { REPO_ROLES } from '../../organization/tests/helpers.mjs';
import { fixture } from './spec-fixtures.mjs';
import { grantDecision } from './host-fixtures.mjs';
async function setup(t, { shared = false } = {}) {
const roles = Object.fromEntries(['cto', 'pm', 'chief-of-staff'].map(definition => {
const role = loadRole(REPO_ROLES, definition);
return [definition === 'chief-of-staff' ? 'chief' : definition, { definition, authority: structuredClone(role.authority) }];
}));
roles.coder = { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: ['task.scope.change', 'git.merge.protected', 'policy.change'] } };
const f = await fixture(t, { roles, arbiters: { technical: 'cto', delivery: shared ? 'cto' : 'pm' } });
return { ...f, roles, cto: f.agent('cto'), pm: f.agent('pm'), chief: f.chief };
}
for (const [domain, resolver] of [['technical', 'cto'], ['delivery', 'pm']]) {
test(`RR: ${domain} arbiter that loses its right refuses not-resolver`, async t => {
const f = await setup(t);
const d = f.raise({ action: 'task.scope.change', domain });
f.business.roles[resolver].authority.withinRole = ['message.send'];
f.broker.reloadBusiness('demo', f.business);
assert.throws(() => f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }), error => {
assert.equal(error.code, 'not-resolver');
assert.match(error.reason, new RegExp(`${resolver}.*${resolver}.*decision\\.resolve\\.${domain}`));
return true;
});
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', d.id).n, 0);
});
}
test('RR: a shared arbiter cannot resolve a delivery decision using only its technical right', async t => {
const f = await setup(t, { shared: true });
const d = f.raise({ action: 'task.scope.change', domain: 'delivery' });
assert.equal(d.route_to, 'cto');
assert.throws(() => f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
});
test('RR: merge raised with delivery still routes to CTO and resolves (acceptance 314 step 5)', async t => {
const f = await setup(t);
const d = await f.raise({ action: 'git.merge.protected', domain: 'delivery', project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
assert.equal(d.route_to, 'cto');
assert.throws(() => f.call(f.pm, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', d.id).op, 'resolved');
});
for (const resolver of ['chief', 'pm']) {
test(`RR: ${resolver} named delivery arbiter resolves policy.change even when raised technical`, async t => {
const f = await setup(t); f.business.arbiters.delivery = resolver;
f.broker.reloadBusiness('demo', f.business);
const d = f.raise({ action: 'policy.change', domain: 'technical' });
assert.equal(d.route_to, resolver);
f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' });
assert.equal(f.store.get('SELECT by FROM decision_events WHERE decision=?', d.id).by, `${resolver}-run`);
});
}
test('RR: the chief holds both rights when named for both domains', async t => {
const f = await setup(t); f.business.arbiters = { technical: 'chief', delivery: 'chief' };
f.broker.reloadBusiness('demo', f.business);
for (const action of ['git.merge.protected', 'policy.change']) {
const d = f.raise({ action }); assert.equal(d.route_to, 'chief');
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
}
});
// Old raise evidence did not preserve domain. Current assignments cannot prove the old domain.
test('RR: remapping arbiters cannot let a legacy decision resolve with only the other domain right', async t => {
const f = await setup(t);
const id = grantDecision(f.store, { action: 'task.scope.change', route: 'pm', domain: null, close: null });
f.business.arbiters = { technical: 'pm', delivery: 'cto' };
f.business.roles.pm.authority.withinRole = ['message.send', 'decision.resolve.technical'];
f.broker.reloadBusiness('demo', f.business);
assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' });
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0);
// Human recovery closes the old route by withdrawing it, before the raiser creates a new decision.
f.call(f.human, 'decision.withdraw', { id });
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', id).op, 'withdrawn');
});
+1 -1
View File
@@ -28,7 +28,7 @@ test('C2-10c check hydration preserves unchecked rows and one broadcast with two
const messaging = JSON.parse(readFileSync(new URL('../../../roles/companion.json', import.meta.url))).messaging;
const role = { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } };
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', vars: {},
arbiters: { technical: 'bean', delivery: 'bean' }, roles: { bean: role, chet: role }, definitions: { companion: { messaging } } } } });
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, bean: role, chet: role }, definitions: { companion: { messaging } } } } });
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args) => broker.request(human, { verb, args });
const project = call('project.create', { name: 'Room' }).id;
+4 -4
View File
@@ -110,7 +110,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the
const file = join(root, 'businesses/demo.json');
writeJson(file, doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
@@ -157,7 +157,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s
const file = join(root, 'businesses/demo.json'); writeJson(file, doc); mkdirSync(join(root, 'data'), { mode: 0o700 });
const offered={ key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture-id',available:true,reason:null,harnesses:['pi'] };
const catalogues={ read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate:()=>{} };
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
t.after(() => bus.close());
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const before=await bus.broker.request(human,{verb:'routes'});
@@ -179,7 +179,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${
let mode='absent';
const offered={key:'fixture-persisted',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture',available:true,reason:null,harnesses:['pi']};
const catalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:mode==='absent'?[]:[{...offered,harnesses:mode==='incompatible'?['claude-code']:['pi']}],notices:[]}),invalidate(){}};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const cap=writer==='organization.set'?bus.broker.bindLaunch({business:'demo',role:'chief',run:'provider-chief',harness:'pi'}):human;
if(writer==='organization.set')bus.broker.request(cap,{verb:'role.claim'});
@@ -201,7 +201,7 @@ test('legacy routes follow enabled module mappings while unchanged unavailable s
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
let state='ready';const offered={key:'zai-fixture-two',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-two',label:'fixture',available:true,reason:null,harnesses:['pi']};
const catalogue={read:async()=>({status:state,observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const request=(verb,args)=>bus.broker.request(human,{verb,args});
let view=await request('routes');assert.deepEqual(view.catalogue.legacy['legacy-one'],{module:'cliproxy',upstream:'claude',model:'fixture-one'});
const pick=(harness,model,fallback=null)=>({harness,model,thinking:'low',fallback});
+3 -3
View File
@@ -527,8 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
// 3t to 3u adds project_system_types and four guards.
assert.equal(count('table') - 1, 32);
assert.equal(count('trigger'), 123);
// 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards.
assert.equal(count('table') - 1, 33);
assert.equal(count('trigger'), 130);
assert.equal(count('view'), 5);
});
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
const after = tables(path);
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3m';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+2 -2
View File
@@ -12,8 +12,8 @@ const businesses = {
arbiters: { technical: 'cto', delivery: 'pm' },
roles: {
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
cto: { authority: { withinRole: [], crossRole: [] } },
pm: { authority: { withinRole: [], crossRole: [] } },
cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } },
pm: { authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } },
},
},
};
+4 -3
View File
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) {
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
@@ -16,6 +16,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
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']) },
chief: { definition: 'chief-of-staff', authority: authority(['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']) },
...roles,
} };
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
@@ -31,7 +32,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(cap, { verb: 'role.claim' }); return cap;
};
const coder = agent('coder'), reviewer = agent('reviewer');
const coder = agent('coder'), reviewer = agent('reviewer'), chief = agent('chief');
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options,
@@ -42,5 +43,5 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
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, agent, coder, reviewer, human, call, raise, save };
return { root, store, business, broker, repo, commit, agent, coder, reviewer, chief, human, call, raise, save };
}
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+1 -1
View File
@@ -19,7 +19,7 @@ async function setup(t, { running = ['chief', 'pm', 'cto'], thread = true } = {}
cto: { definition: 'cto', ...within }, assistant: { definition: 'assistant', ...within },
} });
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
f.cap = Object.fromEntries(running.map(role => [role, f.agent(role)]));
f.cap = Object.fromEntries(running.map(role => [role, role === 'chief' ? f.chief : f.agent(role)]));
if (thread) {
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
f.threadId = f.call(f.human, 'thread.create', { project, name: 'alpha' }).id;
+3 -3
View File
@@ -139,7 +139,7 @@ test('REQ-COS-1: the request goes to the business launcher, and only when that i
// launcher `chief`, so a check on the name passes there; these use names that differ from the definitions.
test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the pm definition can ask, an instance `pm` of the coder definition can\'t', (t) => {
const lead = setup(t, {
arbiters: { technical: 'lead', delivery: 'chief' },
arbiters: { technical: 'chief', delivery: 'chief' },
roles: {
chief: { definition: 'chief-of-staff', authority: authority.chief },
lead: { definition: 'pm', authority: authority.pm },
@@ -172,9 +172,9 @@ test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the p
test('REQ-COS-1: the launcher is judged by definition: an instance `boss` of the chief-of-staff definition receives, a `chief` of the coder definition gives no-chief-of-staff', (t) => {
const boss = setup(t, {
arbiters: { technical: 'pm', delivery: 'boss' },
arbiters: { technical: 'pm', delivery: 'chief' },
launch: { by: 'boss', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
roles: {
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
boss: { definition: 'chief-of-staff', authority: authority.chief },
pm: { definition: 'pm', authority: authority.pm },
coder: { definition: 'coder', ...none },
+5 -3
View File
@@ -67,7 +67,9 @@ test('3t store migrates offline without altering existing project rows', async t
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
assert.equal(ddl.split(step).length, 2);
db.exec(ddl.replace(step, ''));
// 3u-3v (U4a) adds store_tasks after this step; a 3t store has neither.
const later = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
db.exec(ddl.replace(step, '').replace(later, ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
@@ -77,7 +79,7 @@ test('3t store migrates offline without altering existing project rows', async t
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
});
test('runtime reads the current organization file for project selections and console discovery', async t => {
@@ -89,7 +91,7 @@ test('runtime reads the current organization file for project selections and con
doc.plugins = { 'system-type': ['coding'] };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+548
View File
@@ -0,0 +1,548 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Client } from '../src/client.mjs';
import { storeAdapter } from '../../tasks/src/store.mjs';
import { loadFields } from '../../tasks/src/fields.mjs';
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the
// default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs
// over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every
// read is the bus's `tasks` verb, the same shape whichever module holds the tasks.
const AUTHORITY = {
pm: {
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send'],
crossRole: ['task.priority.change', 'task.scope.change'],
},
coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.scope.change'] },
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'] },
};
const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }]));
const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra });
const DIGEST = /^[0-9a-f]{64}$/;
// One bus (one data root) holding the given organizations, with the store module over it.
async function org(t, ids = ['acme'], { taskTracker = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ts-'));
const businesses = Object.fromEntries(ids.map((id) => [id, business(id)]));
const store = new Store(root);
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
const adapter = await storeAdapter()({ broker, businesses });
let removed = false;
t.after(() => {
adapter.close();
if (!removed) {
store.close();
rmSync(root, { recursive: true, force: true });
}
});
const caps = new Map(), humans = new Map();
const agent = (role, id = ids[0]) => {
const key = id + '/' + role;
if (!caps.has(key)) {
const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' });
broker.request(cap, { verb: 'role.claim' });
caps.set(key, cap);
}
return caps.get(key);
};
const human = (id = ids[0]) => {
if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }));
return humans.get(id);
};
const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle);
const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args });
const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args });
const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id);
// A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it.
const approve = (role, action, target, id = ids[0]) => {
const d = broker.request(agent(role, id), {
verb: 'decision.raise',
args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false },
});
broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
return d.id;
};
const remove = () => {
removed = true;
store.close();
rmSync(root, { recursive: true, force: true });
};
return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove };
}
const codeOf = async (p) => {
try {
await p;
} catch (e) {
return e.code;
}
return null;
};
const syncCode = (fn) => {
try {
fn();
} catch (e) {
return e.code;
}
return null;
};
test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => {
assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort());
for (const [verb, list] of Object.entries(STORE_VERB_FIELDS))
for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`);
});
test('REQ-TASK-1 every role verb writes the store through the module', async (t) => {
const f = await org(t);
const request = f.instruction();
const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.match(made.digest, DIGEST);
let row = f.read({ task_ref: 'task:1' });
assert.equal(row.source, 'store');
assert.equal(row.tracker, 'store');
assert.equal(row.role, 'pm');
assert.equal(row.verb, 'task.create');
assert.equal(row.revision, 1);
assert.equal(row.digest, made.digest);
assert.equal(row.archived, false);
assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 });
// The row's event is the action.allowed the write recorded, on the task's subject.
const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event);
assert.equal(event.kind, 'action.allowed');
assert.equal(event.subject, 'task:1');
assert.equal(JSON.parse(event.body).action, 'task.create');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned');
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' });
assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']);
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role');
await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z');
assert.deepEqual(row.fields.labels, [3, 5]);
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request');
await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.role, 'coder');
assert.equal(row.fields.state, 'in-progress');
assert.equal(row.fields.bucket, 'in-progress');
assert.equal(row.fields.percent_done, 0.5);
assert.equal(row.fields.comments, 1);
assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' });
// done is task.close's, never the assignee's.
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state');
// Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once.
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required');
const d = f.approve('pm', 'task.priority.change', 'task:1');
await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d });
assert.equal(f.read({ task_ref: 'task:1' }).priority, 4);
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed');
const scope = f.approve('coder', 'task.scope.change', 'task:1');
await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope });
assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well');
const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.digest, closed.digest);
assert.equal(row.fields.done, true);
assert.equal(row.fields.state, 'done');
assert.equal(row.fields.verdict, 'review-1');
// A done task takes no role write; reopening it is the person's.
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done');
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done');
// Nine writes; no refusal wrote a row.
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9);
assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => {
const f = await org(t);
await f.create('Owned fields');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// The module holds the pm verbs to the pm definition.
assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer');
assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer');
// task.update.assigned needs the role among the assignees.
assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned');
// The broker holds every write to its verb's fields, whatever the module asks for.
const pm = f.agent('pm'), coder = f.agent('coder');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer');
// A person's session is not a role here.
assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required');
// Vikunja-only arguments refuse; nothing was written by any refusal.
for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }])
assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra));
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2);
// A refusal on a store task is filed on its trail.
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned'));
});
test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => {
const f = await org(t);
const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] });
assert.equal(made.task_ref, 'task:1');
assert.equal(made.human, 'jason');
assert.equal(made.role, null);
assert.equal(made.fields.position, 0);
assert.equal(made.fields.request, null);
const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event);
assert.equal(event.kind, 'human.input');
assert.equal(event.subject, 'task:1');
assert.equal(event.actor_role, null);
assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' });
assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role');
// expect is the digest the person last read; a later write refuses.
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict');
const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] });
assert.equal(done.fields.done, true);
assert.equal(done.fields.bucket, 'done');
// The person may reopen a done task.
assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false);
// Agents and the reader don't reach the person's verbs.
assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required');
assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null);
const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' });
f.person('tasks.archive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived');
// An archived task leaves the board read and the filtered read, unless archived: true asks.
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]);
const archived = f.read({ archived: true });
assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]);
assert.equal(archived.total, 1);
assert.equal(f.read({ task_ref: 'task:1' }).archived, true);
f.person('tasks.unarchive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived');
assert.equal(f.read({ task_ref: 'task:1' }).archived, false);
const gone = f.person('tasks.delete', { task_ref: 'task:1' });
assert.equal(gone.task_ref, 'task:1');
assert.equal(gone.deleted, true);
assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found');
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found');
assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found');
// The history stays in the organization's bus until the organization is removed.
const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks');
assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']);
assert.equal(trail[0].fields.title, 'Plan');
// Numbers are never reused.
assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3');
});
test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => {
const f = await org(t);
const request = f.instruction();
const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' };
const first = await f.call('pm', 'task.create', args);
const again = await f.call('pm', 'task.create', args);
assert.deepEqual(again, first);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1);
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict');
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found');
assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request');
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input'));
assert.ok(trail.some((r) => r.table === 'messages'));
// A stale expect refuses a role write too.
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest });
});
test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => {
const f = await org(t);
for (const title of ['A', 'B', 'C']) await f.create(title);
await f.create('D', { group: 'review' });
await f.create('E', { group: 'build' });
const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]);
assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]);
// A move to another group with no position goes to its end; a position alone reorders in place.
await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' });
await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 });
f.person('tasks.edit', { task_ref: 'task:1', position: 5000 });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]);
// A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too.
f.person('tasks.edit', { task_ref: 'task:4', group: 'build' });
f.person('tasks.edit', { task_ref: 'task:5', group: null });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request');
// The filtered short form pages in board order with the store's group, position and archived.
const one = f.read({ limit: 2 });
assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']);
assert.equal(one.total, 5);
assert.equal(one.next, 'task:1');
assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']);
const two = f.read({ limit: 2, after: one.next });
assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']);
const three = f.read({ limit: 2, after: two.next });
assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']);
assert.equal(three.next, null);
assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found');
// The other filters apply to store tasks as to Vikunja's.
await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' });
assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']);
assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5);
assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []);
});
test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => {
// Jason 11-07: each organization has its own bus and data root.
const a = await org(t, ['acme']), b = await org(t, ['beta']);
await a.create('acme task');
await b.create('beta task');
b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' });
assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]);
assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]);
// Removing an organization removes its data root, and its tasks with it.
a.remove();
assert.equal(existsSync(a.root), false);
assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']);
await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// Within one bus, too, a business reads and writes only its own rows.
const both = await org(t, ['one', 'two']);
await both.create('one task', {}, 'one');
assert.deepEqual(both.read({}, 'two'), []);
assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found');
assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1');
assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']);
});
test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => {
const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' });
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store');
assert.equal(await codeOf(f.create('x')), 'tracker-not-store');
assert.deepEqual(f.read(), []);
assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError);
});
test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => {
const f = await org(t);
await f.create('Guarded');
const event = f.store.get('SELECT event FROM store_tasks').event;
const fields = f.store.get('SELECT fields FROM store_tasks').fields;
const insert = (task, revision, op, by = ['pm', 'pm-run', null]) =>
f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
'2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields);
for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'],
["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]])
assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql);
assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/);
assert.throws(() => insert(1, 2, 'create'), /a store task is created once/);
assert.throws(() => insert(3, 1, 'create'), /with the next number/);
assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/);
assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/);
assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/);
insert(1, 2, 'archive', [null, null, 'jason']);
assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/);
insert(1, 3, 'delete', [null, null, 'jason']);
assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3);
});
// --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. ---
const here = (p) => new URL(p, import.meta.url);
const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
} finally {
db.close();
}
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value;
} finally {
db.close();
}
};
test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => {
assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once');
assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql');
const root = mkdtempSync(join(tmpdir(), 'ts-3u-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
db.exec(currentDDL.replace(stepSQL, ''));
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run();
db.close();
chmodSync(path, 0o600);
const was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3v');
assert.equal(store.get('SELECT id FROM events').id, 'preserved');
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
});
// --- The bus runtime: the store is the default module; Vikunja stays the host's. ---
test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'ts-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const businesses = {
acme: business('acme', { plugins: { harness: ['pi'] } }),
beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }),
};
const seen = [];
let closed = 0;
const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' });
const runtime = await startBroker({
dataRoot: root,
businesses,
launches: [launch('acme'), launch('beta')],
trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } },
tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }),
});
try {
const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap }));
await acme.call('role.claim');
await beta.call('role.claim');
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' });
assert.deepEqual(seen, ['task.create']);
// The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way.
const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' });
assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]);
assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store');
} finally {
await runtime.close();
}
assert.equal(closed, 1);
// A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot.
await assert.rejects(
startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }),
(e) => e.code === 'tracker-config',
);
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
});
test('review: an unknown task-tracker module must refuse boot', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'unknown-'));
t.after(() => rmSync(root, {recursive:true, force:true}));
let runtime;
try {
await assert.rejects(async () => {
runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}});
}, (e) => e.code === 'tracker-config');
} finally { await runtime?.close(); }
// Startup cleanup releases the same store so a corrected configuration can start.
const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}});
await corrected.close();
});
test('review: a stale expect must reject a delete after an intervening archive', async (t) => {
const f=await org(t);
const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'});
const archived=f.person('tasks.archive', {task_ref:old.task_ref});
assert.equal(archived.revision, old.revision+1);
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict');
const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref});
assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict');
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict');
assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current');
});
test('review: human tasks can cite the PRD-required requirement id', async (t) => {
const f=await org(t);
const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'});
assert.equal(made.fields.requirement,'REQ-TASK-1');
assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request');
for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) {
assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request');
}
const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'});
assert.equal(edited.fields.requirement, 'REQ-TASK-2');
assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input');
});
test('review: a newly hired assignee can use the store after the business reloads', async (t) => {
const f=await org(t);
const made=await f.create('For a new coder');
const next=business('acme');
next.roles.newcoder=structuredClone(next.roles.coder);
f.broker.reloadBusiness('acme',next);
f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']});
await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'});
assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress');
});
test('review: deleting an assignee does not prevent archiving the existing task', async (t) => {
const f=await org(t);
const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']});
const next=business('acme'); delete next.roles.coder; next.retired=['coder'];
f.broker.reloadBusiness('acme',next);
const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']});
assert.deepEqual(edited.fields.assignees,['coder']);
assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role');
const archived=f.person('tasks.archive',{task_ref:made.task_ref});
assert.equal(archived.archived,true);
assert.deepEqual(archived.fields.assignees,['coder']);
f.person('tasks.delete',{task_ref:made.task_ref});
const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields);
assert.deepEqual(last.assignees,['coder']);
});
test('review: saturated and adjacent positions refuse without a write', async (t) => {
const f = await org(t);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000});
const before = f.store.get('SELECT count(*) n FROM store_tasks').n;
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11});
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
});
+5 -2
View File
@@ -301,12 +301,15 @@ test('the process loads the S3 adapter from plain-data trackers', async (t) => {
const bad = await boot(t, { dataRoot: root, businesses, launches, trackers: { nope: { baseUrl: 'http://127.0.0.1:9', project: 1 } } });
assert.deepEqual(bad.reply, { ok: false, error: 'tracker-config' });
await once(bad.child, 'exit');
// An empty tracker map loads the adapter; a business without an entry refuses task verbs.
// An empty tracker map loads the adapter. U4a (#1873): a business without an entry gets the
// built-in store, not Vikunja. These fixture roles carry no definition, so the store refuses the
// caller (tracker-role) where the Vikunja adapter refused tracker-unconfigured; task-store.test.mjs
// runs the store's verbs.
const { child, reply } = await boot(t, { dataRoot: root, businesses, launches, trackers: {} });
assert.equal(reply.ok, true);
const pm = new Client({ path: reply.path, cap: reply.launches[0].cap });
await pm.call('role.claim');
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-unconfigured/);
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-role/);
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
+4 -4
View File
@@ -46,8 +46,8 @@ async function runtime(t) {
dataRoot: join(root, 'data'),
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: { coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
},
},
organization: { dir: root, rolesDir, system: systemFor(root) },
@@ -166,8 +166,8 @@ function broker(t, vars = { 'bus.tester': true }) {
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}),
roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}),
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
},
},
});
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3u ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3v ready', ''].join('\n'));
});
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3l';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -8,7 +8,7 @@ import { Broker } from '../src/broker.mjs';
test('REQ-LIMIT-4 usage protocol binds run identity, requires a delivered turn and isolates businesses', async t => {
const root = mkdtempSync(join(tmpdir(), 'usage-broker-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const businesses = { demo: { id: 'demo', human: 'jason', roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } };
const businesses = { demo: { id: 'demo', human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } } };
const recorded = [];
const broker = new Broker({ store, businesses, usage: {
collect: async (...args) => { recorded.push(args); return { recorded: true }; },
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+1 -1
View File
@@ -12,7 +12,7 @@ test('REQ-PLUG-1 runtime reaches transcript only through loader, over the real b
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'], harness: ['pi'] },
roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } };
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } };
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
// Row 65: the launch names where its harness keeps the session files.
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model', transcript: dir,
+2 -2
View File
@@ -65,10 +65,10 @@ test("merge-gate's technical raise is cross-role, goes to the cto, and only merg
assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-consumed/);
});
test("merge-gate has no message.send, and without a domain its raise goes to the delivery arbiter", (t) => {
test("merge-gate has no message.send, and without a domain its merge raise still goes to the technical arbiter", (t) => {
const { call, raise } = setup(t);
assert.throws(() => call('merge-gate', 'message.send', { to: 'cto', body: 'landed' }), /decision-required/);
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'pm');
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'cto');
// Every other action is gated for merge-gate, so an uncited raise goes to the human.
const push = raise('merge-gate', 'git.push.working', { domain: 'technical', target: TASK });
assert.deepEqual([push.class, push.route_to], ['gated', 'human']);
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
export * from "../../organization/src/accounts.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/agent-levels.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-levels.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
export * from "../../organization/src/composition.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/org-rights.mjs (rename design r2, stage B).
export * from "../../organization/src/org-rights.mjs";
+29
View File
@@ -1298,3 +1298,32 @@ shows selected, active and inactive ids. The owner replaces the selection with
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
empty `--types ''` clears it. Disabled selections stay recorded but become
inactive. Project settings changes are append-only and require human proof.
## Accounts
`mosaic accounts` administers the instance-level accounts store locally, as the
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
and takes no business selector or bus connection. It has no organization rights
verbs. Console sign-in, sessions and membership evaluation are later Phase M
slices; these commands alone do not turn accounts mode on.
```sh
mosaic accounts init --name Operator
mosaic accounts list --json
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
mosaic accounts user disable u-0123456789ab --revision 2
mosaic accounts user enable u-0123456789ab --revision 3
mosaic accounts user signout u-0123456789ab --revision 4
mosaic accounts user remove u-0123456789ab --revision 5
```
Init creates one operator account containing every configured `console.sso`
subject and refuses if accounts.json already exists. Add mints its id and defaults
to instance role `user`; `--role operator` creates another operator. Writes require
the file revision from list. Disable, enable and signout advance `authGen`, and
the last active operator cannot be disabled or removed. Removal scans ownership
while holding the registry then accounts locks, refuses if the account owns an
organization (including archived organizations) or any registry entry lacks its
owner metadata during creation, and removes its memberships in
the same write. List reports rollback rows needing repair; its JSON omits invite
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
+2 -1
View File
@@ -74,7 +74,8 @@ export function readAccounting({ dataRoot,business,start,end }) {
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
try {
db.exec('BEGIN');
if (!['3s','3t','3u'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
// U4a (#1873) 3u-3v adds only store_tasks, which accounting never reads, so 3v joins the closed list.
if (!['3s','3t','3u','3v'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
const records = turnRecords(store,business);
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
+109
View File
@@ -0,0 +1,109 @@
// Phase M2: the local, instance-uid account administration exception. No org
// rights commands and no bus transport: account init must work before orgs exist.
import { basename, join } from 'node:path';
import { configDir } from '../../organization/src/organization-config.mjs';
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ACCOUNTS_USAGE = [
'mosaic accounts init [--name NAME]',
'mosaic accounts list [--json]',
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
'mosaic accounts user disable|enable|signout|remove ID --revision N',
].join('\n');
function parse(args) {
const words = [], values = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('--')) { words.push(arg); continue; }
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
if (arg === '--json') flags.add(arg);
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
else throw new CliError(ACCOUNTS_USAGE, 4);
}
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
const revision = verb === 'init' ? 0 : Number(values['--revision']);
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
return { verb, values, flags, revision, id: words[2] };
}
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
return owner;
}
export async function accountsCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
const parsed = parse(args);
const dir = deps.configDir ?? configDir(io.env);
// This is deliberately before withRegistry as well as withAccounts.
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
const providers = deps.system.console?.sso?.providers ?? [];
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
if (parsed.verb === 'list') {
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
const repairs = accountsRepairs(doc);
// Invites are not administered here. Never expose bindings or token hashes.
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
return;
}
let added;
const mutate = doc => {
if (parsed.verb === 'init') {
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
if (parsed.verb === 'user.add') {
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
const u = doc.users.find(u => u.id === parsed.id);
if (!u) throw new CliError('refused: account not found', 2);
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
else if (parsed.verb === 'user.signout') u.authGen++;
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
};
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
let result;
if (parsed.verb === 'user.remove') {
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
const user = doc.users.find(u => u.id === parsed.id);
if (!user) throw new CliError('refused: account not found', 2);
// Even disabled accounts keep ownership. A real create reserves before
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
mutate(doc);
}), { write: false });
} else result = await withAccounts(ctx, opts, mutate);
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
} catch (e) {
if (e instanceof CliError) throw e;
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
if (e.committed) error.committed = true;
throw error;
}
}
+9 -1
View File
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError, CliExit } from "./errors.mjs";
@@ -52,6 +53,7 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
export const USAGE = `usage:
${ACCOUNTS_USAGE}
${ORG_USAGE}
${USAGE_USAGE}
${LIMITS_USAGE}
@@ -648,7 +650,12 @@ async function bus(rest, io, deps) {
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
const launchEnv = instance ? fixtureLaunchEnv(orgDoc, io.env) : io.env;
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
// U4a (#1873): agents get the task tools when the bus serves task verbs for the organization: a
// Vikunja tracker in the boot config, or the built-in store, which an organization with no
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
await launcher.listen();
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
@@ -740,6 +747,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
now: deps.now,
});
}
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
+3
View File
@@ -83,6 +83,9 @@ export function trailSummary(r) {
return `${r.op} ${r.role} by ${r.holder_run}`;
case "task_snapshots":
return `snapshot ${r.task_ref ?? ""}`.trim();
// U4a (#1873): a built-in store task's write: what it did, under which verb, by which role or person.
case "store_tasks":
return [r.op, r.verb, `r${r.revision}`, `by ${r.by_role ?? r.by_human}`, r.fields?.title ? one(r.fields.title, 100) : null].filter(Boolean).join(" ");
default:
return "";
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
try {
const p = parse(args); const words = p.pos; let result; let accepted = [];
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more