Compare commits

..
4 Commits
Author SHA1 Message Date
jason.woltje 200b30b721 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 14:44:02 -06:00
jason.woltje ff74bde424 test(webui): answer the project system-types read in the W15 integration fixture (#1873) 2026-11-10 14:41:53 -06:00
jason.woltje 85e9fcd894 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 14:41:53 -06:00
jason.woltje fec416d1df 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 14:41:53 -06:00
361 changed files with 2042 additions and 16434 deletions

No files matched your search

+5 -12
View File
@@ -42,15 +42,9 @@ green at every step. Not production software — a proven foundation.
rewritten, only pruned via `prune` with a receipt.
6. **Fail closed**: missing or invalid config/policy refuses the operation.
Never improvise around a refusal; diagnose it.
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.
7. **Policy**: missions govern tasks (least-privilege intersection — a task
narrows, never widens). Role authority is declared in `roles/` and changes
only via reviewed commits.
8. **Git**: commit only after applicable suites are green. Work on the
owner-authorized `refactor` branch; never force-push. Push remains an explicit
act. Do not merge into `next` or `main` without separate authorization.
@@ -110,8 +104,7 @@ Finish all independent work first, then ask one focused question only when:
Repository gates still apply. In particular, a successful implementation or a
broad request to “finish” does not by itself authorize push, merge, deployment,
release, production changes, policy/role expansion, or access to secrets. An approved
hire decision is that authority for the one agent it names. Perform
release, production changes, policy/role expansion, or access to secrets. Perform
such an action only when the established plan explicitly includes it. If blocked,
report the exact boundary, what is complete, the recommended resolution, and the
specific action that will resume; do not use “waiting for confirmation” as a
@@ -213,7 +206,7 @@ Full reference — usage, fields, exit codes, safety notes:
- `LAYERS.md` — implemented vs deferred layers
- `docs/SESSIONS.md` — session registry
- `adapters/README.md` — the harness adapter contract
- `roles/` — shipped role templates and the conductor policy
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
## Recovery rule
+6 -84
View File
@@ -807,52 +807,13 @@ the port and the backup's `SHA256SUMS`. Then it makes
Roles come from the target copy's `roles/`, as the live bus reads them.
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
- When the live config has `console.sso` (M3, #1873), the scratch copy keeps
its provider ids, client ids, algorithms and subjects and moves the public
origin to `rehearsal.invalid`, a name that never resolves. For a stamped
target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below;
otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/`
holding `rehearsal-placeholder-secret`; the live secret is never read.
A stamped target copy (one with `.stack-build.json`) serves only with `--sso`,
so its rehearsal needs the live instance's human-made SSO steps. Without a live
`console.sso` it refuses `sso-not-prepared`; without the live `accounts.json`
beside the config it refuses `accounts-not-initialized`. Both name the run-sheet
step. Otherwise it copies the live `accounts.json` into `config/`, writes
`data/.deployed` with the target copy's own `writeDeployment`, prints
`PASS deployment`, and starts the Console with `--sso`.
Before that, a stamped run starts a rehearsal provider and prints
`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves
HTTPS with a certificate made for this run, under `provider/` (mode 0700). For
each live provider id it serves discovery and a JWKS whose public key it made
in memory for the first algorithm it can make from that provider's list. Its
authorize and token routes answer 403 to any method and query: it issues no
code and no token, and
it reads no live secret. It logs each request to `provider/requests.log`,
deletes its TLS key file once it has read it, and exits on SIGTERM or when
the rehearsal's process is gone. `provider-start` fails when it exits early,
writes no origin within the timeout, writes an origin that is not
`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets
`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not.
The scratch Console runs every startup check against that provider. The gate
adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate,
`provider-discovery` requires a 200 discovery and JWKS request per provider and
no authorize or token request. The end stops the provider with the Console and
host and prints `provider-stopped`. The live `accounts.json` joins the end check
`live-config-unchanged`. The rehearsal adds no subject and no account.
A guard then loads the scratch config with the target's code and refuses
with a named check: `data-root-live` when the scratch data root resolves to
the live one, `sessions-live` when a `sessions.json` there names a running
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
`tracker-configured` when `bootConfig` still sets a tracker,
`credential-ref-live` for an env reference or a file outside `placeholders/`,
and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`,
an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on
`rehearsal.invalid` for an unstamped target), or a slot is not its scratch
placeholder.
`tracker-configured` when `bootConfig` still sets a tracker, and
`credential-ref-live` for an env reference or a file outside `placeholders/`.
When the backup's schema differs from the target's, the run checks that the
target refuses the store, migrates it, opens it, migrates it again as a
no-op, and that the pointer's copy refuses the migrated store.
@@ -861,8 +822,7 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
`serve --port N --business BUSINESS` from the target copy, without `--send`.
The gate needs the host's `launches disabled` line, a live `bus status` host
with this run's pid and a boot from this start, the Console's address line,
`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines
on the target copy. Then it checks
`/healthz` 200, and both command lines on the target copy. Then it checks
that the host wrote no `sessions.json` and no `launches`, stops the Console
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
the host released `writer.lock`, and compares the backup and the live config
@@ -874,21 +834,6 @@ Usage exits 4. Nothing prunes rehearsal directories.
starts no thread ticker, and leaves an earlier host's `sessions.json` and
its runs alone. No unit passes it, and `--pm` with it is a usage error.
An accounts instance runs system units (M3, #1873). Without `--units external`,
`activate`, `rollback` and `pm-on` refuse `accounts-user-unit`, before
anything stops and before `--plan` prints, when the target copy holds a
`.stack-build.json` (a stamped copy), when `accounts.json` sits beside the
config, when the config holds `console.sso`, or when `<dataRoot>/.deployed`
exists. A config that cannot be read as JSON refuses as well. The message
names the first sign found and says root renders the units with
`case2-unit.sh`. For `activate` and `rollback` it adds that the run takes
`--units external`. For `pm-on` it says the PM is a flag in root's bus unit.
So a user-unit run never turns `--sso` on any more. The `--sso`, `sso-slot`
and `--no-sso` text above describes the pointers that pre-M3 hosts left, and
the Console's own start checks the slot (`readSlot` in
`packages/webui/src/sso.mjs`). A rollback or `--no-sso` from such a pointer
runs once `console.sso` is gone and no other sign is present.
#### Root-owned units (`--units external`, row 232)
On a host whose bus and Console are root's system units, the stack user runs
@@ -949,9 +894,8 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under
```
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
[--rehearse-port N] [--next-args] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
[--timeout S]
[--rehearse-port N] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
```
Root runs it from a root-owned checkout of the reviewed commit, never from a
@@ -991,13 +935,7 @@ user's clone `<home>/src/mosaic-stack`:
(`args-differ`), and
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
(`plan-refused`, which covers `open-activation`, `migrate-required` and
`pointer-differs`). Then the render checks (#1873 M3):
`case2-unit.sh --check` renders OLD from the args file and compares the
installed units with it, so a rollback could render them, then checks
SHA's render the same way. Either refusal is `render-refused`. A re-pin
that changes the serve flags fails the second check, because an
installed unit must equal the render but for the copy id (row 195); it
takes `--next-args`, below.
`pointer-differs`).
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
@@ -1012,22 +950,6 @@ user's clone `<home>/src/mosaic-stack`:
still names the old one, and root moves the new file (the result line
names it) into place by hand.
`--next-args` changes the serve flags, as the SSO migration does
(`docs/guides/host-run-sheet.md` section 7). The run reads
`/etc/mosaic-stack/dev.args.next` as it reads the args file
(`args-unreadable`). It must equal the args file before `--` but for
`--copy`'s value (`next-args-differ`) and differ from it after `--`
(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a
first install's checks from the next args, without the installed units'.
After A3, root moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders
as a first install. A failure from there moves them back unchanged
(`units-back`), runs `daemon-reload` and starts the old units with no render.
After A5 the old args file is copied into the replaced directory, the next
args file is removed (step `next-args`), and the new args file, made from
the next args, moves into place. The result line names the replaced
directory. Nothing removes it.
A failure in A2 or a stage that exits 3 starts the old units again and gates
them (A2f). A later failure stops both units, runs `revert`, renders the old
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
+8 -86
View File
@@ -386,15 +386,10 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles
(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its
private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console
stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`,
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not
apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old
proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy
that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in
order.
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
profile and its private tester socket are unchanged.
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
@@ -423,83 +418,10 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
lead with the command's output. Removing only the args line does not help: the run then refuses
`console-unit-legacy`.
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
accounts, as the migration below sets up.
The Console stays read-only after the upgrade, until #412 (#385).
### Migrating a host to SSO accounts (M3, #1873)
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
`accounts.json` comes only from the human verb `mosaic accounts init`.
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
it refuses one that is invalid or names another instance. Check it read-only:
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
providers, their subjects and its client-secret slots, per
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
refuses if `accounts.json` exists. It also refuses `accounts-unsafe` when any folder above the config
directory is not root's or `mosaic`'s, or is group or other writable without the sticky bit (#1919).
Check first, read-only: every line printed by
`p=/srv/mosaic/.config/mosaic-dev; while [ "$p" != / ]; do stat -c '%U %a %n' "$p"; p=$(dirname "$p"); done; stat -c '%U %a %n' /`
names `root` or `mosaic`, and its mode has no group or other write bit unless it starts with 1 (sticky).
The config directory itself is `mosaic`'s with no group or other write bit. A group-writable `.config`
refuses on purpose; fix the mode, never the check.
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
of its primary group, and owner of its config directory and data root. An accounts instance never runs
under a human or development seat uid, and never under the `webui-service.sh` user units.
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
(`--proof-header`, `--proof-file`). Then run
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
it. After the stage it moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
there moves them back unchanged and starts the old copy again. After `confirm` passes,
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
rollback needs it.
To roll back past the migration, put the replaced args back as the next args and roll back with
`--next-args`:
```sh
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
```
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
with a plain `rollback`.
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
numbered in this order, for example:
```text
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
4. add --sso to the serve flags, then re-pin
```
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
rules, before it stops a unit.
## 8. Enrollment, once per host
@@ -528,7 +450,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
| Path | File | Mode | Owner | Profile | Precheck line |
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
+5 -28
View File
@@ -108,10 +108,7 @@ configured subject (`packages/cli/src/config.mjs:141`), and
`packages/webui/src/cli.mjs serve` from that copy. Its flag set is
`--port`, `--business`, `--board`, `--sso`, `--send` and the header
preview set (`webui-service.sh:98`). It has no dev-owner flag.
`scripts/case2-unit.sh` renders Case 2 units the same way. At BASE its
library profile takes the preview set and refuses `--sso`. Under the
owner ruling of 2026-11-10 a stamped library copy signs in through SSO
accounts instead (section 6, item 5).
`scripts/case2-unit.sh` renders Case 2 units the same way.
### Organizations and who may read them
@@ -1234,13 +1231,7 @@ The executable boundary:
this instance, and accounts.json loads. The Case 1 and Case 2 installers
write `.deployed`.
5. **The unit renderers have no dev flag.** `webui-service.sh` and
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). For a
stamped copy, `case2-unit.sh` renders either profile, factory or library,
only with `--sso`, `console.sso`, a loading accounts.json and a valid
`.deployed`, and refuses before any write, naming each missing step. A
stamped library unit refuses proxy-preview admission and its proof flags:
proxy-preview does not apply to a stamped copy. The dev and unstamped
paths keep the preview set. M3 adds
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). M3 adds
a test that pins it.
The instance boundary, which also covers a checkout started by mistake on a
@@ -1298,22 +1289,13 @@ business file.
### Rollback
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off,
except the library rollback past the migration below.
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
- The supported rollback is a pinned code and config rollback: install the
earlier stack copy with the same unit flags, including `--sso`, through
`stack-upgrade.sh rollback --instance NAME --to <earlier id>` for the
dedicated system units. The accounts instance must not use the
`webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in
`webui-service.sh install --copy <earlier id>`, as the unit script
already describes. Leave accounts.json, its receipts and `.deployed` in
place. BASE code ignores them, and the next roll-forward needs them.
- A library host rolled back to a copy older than M3 is the exception.
`case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so
that rollback renders the old copy from the args file the migration
replaced, through `stack-upgrade.sh rollback ... --next-args`
(`docs/guides/host-run-sheet.md` section 7). The old copy comes back with
its old flags, proxy-preview admission included, as before the migration.
accounts.json and `.deployed` stay in place.
- Before the first accounts org exists, that rollback restores today's
behavior completely. `console.sso` owners sign in as before, and FED R6
users keep their root grants. Account-only users and pending invites stop
@@ -1468,9 +1450,6 @@ Acceptance tests, against a fake OIDC provider on port 0:
- `--dev-local-owner` from a checkout refuses with accounts.json present,
with `console.sso`, and with `.deployed`.
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
- For a stamped copy, `case2-unit.sh` renders a library or factory unit
only with `--sso` and the prerequisites, names each missing step before
any write, and refuses the proxy-preview and proof flags.
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
when its shell is non-login, its primary group has no other members, and
it owns the config directory and data root. Fixtures for a human login and
@@ -1687,8 +1666,6 @@ and draft 3 applies them. No question from draft 2 remains open.
| May an invite bind to an email domain? | "agreed" | No domain binding. Section 4 binding, test in M6. |
| May members read the board and write tasks? | "Similar to 1. grantable right via RBAC. Default owner R/W, member R" | The `board.read` and `task.write` rights. `member` holds `board.read` only. Section 3, slices M1, M5 and M7. |
The library Console signs in through SSO accounts like every deployed Console; proxy-preview admission does not apply to a stamped copy.
### Choices made under the rulings
These are decided in this draft, not open. Each one is a single reviewed
@@ -1,671 +0,0 @@
# 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.
@@ -1,450 +0,0 @@
# Credential binding for agent copies
Date: 2026-11-11 UTC. Lane `org-cred-bind-design`, issue #1873.
Status: proposed, for Jason's approval before implementation. Doc only.
Baseline: `6415e81ee75a6783b889b67b985f61825f9a074e` (`origin/refactor`).
All current-code citations below refer to that commit. References to other
designs describe proposed policy, not a claim that it is implemented.
## 1. Outcome and boundaries
An owner can approve a credential binding for one root or project agent copy
inside one organization. A launch uses only that copy's own secret slot,
with verified service permissions bounded by its frozen role and the
organization's narrower approval. No agent can bind credentials for itself
or another agent. Missing, stale or uncertain authority refuses launch and
credential use. Copies in other organizations remain independent.
This proposes a writer, schema and enforcement changes for later slices. It
changes no code, right, role, contract, secret or running instance. Host and
model credentials remain on their existing grant and proxy paths. There is
no migration of named-agent credentials, no root-wide secret binding, no
shared token pool, and no new plugin service in the first build.
Owner direction requires each non-assistant agent's credentials to stay
inside its organization even when its definition comes from root
(`docs/plans/2026-11-09_unified-workspace-owner-direction.md:124-131`). A3
explicitly deferred binding and its two-org thread-copy test
(`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:52,216-217`).
## 2. Current behavior, traced before the proposal
| Step | Current behavior and evidence |
| --- | --- |
| Declare requirements | `checkCredentials` accepts each known service once and validates its scope shape. Gitea uses category levels; Vikunja uses route-group verbs; host has no scopes (`packages/organization/src/role.mjs:100-143`). `admin` and `all` are outside the grantable Gitea categories (`packages/organization/src/vocabulary.mjs:187-191`). |
| Declare a named instance | `roles.<instance>.credentials` must reference exactly the role's services, except host grants and the chief's Gitea exception when no library exists. Vikunja also requires a unique bot identity (`packages/organization/src/organization-config.mjs:50-59,124-167`). |
| Locate the slot | `parseCredentialRef` accepts exactly one of `store`, `file` or `env`. `{store:true}` derives the file from config directory, business, agent and service; it does not accept another agent's store path. Named agents can still use external file and environment references (`packages/organization/src/credentials.mjs:23-59`). This lane proposes only the store form for copies. |
| Resolve launch inputs | `resolveInstance` takes the entry from the validated organization, intersects tools/network/actions with limits, then returns its non-host credential references and tracker metadata (`packages/organization/src/resolve.mjs:34-69,81-95`). These limits do not compute a credential scope intersection. |
| Load secrets | Runtime indexes references by `business/role` and sets the store root to the agents secret directory (`packages/bus/src/runtime.mjs:333-345,378-379`). `Credentials` loads those references into its private map (`packages/bus/src/credentials.mjs:130-165`). It derives its store filename from business, agent and service (`packages/bus/src/credentials.mjs:351-355`). File reads reject symlinks, wrong ownership/mode and repository/data-root placement (`packages/bus/src/credentials.mjs:357-415`). |
| Use secrets | `Credentials.useFor` derives business and role from the session, rather than a caller-supplied target. Missing tokens refuse `credential-unavailable`; Vikunja expiry refuses `credential-expired` (`packages/bus/src/credentials.mjs:597-624`). The task adapter uses that session-bound path (`packages/tasks/src/adapter.mjs:84-88`). |
| Issue and revoke | The broker requires a human, with an explicit tester-verb exception; it checks that the agent exists and prevents delete while ordinary or thread claims remain (`packages/bus/src/broker.mjs:6212-6225`). The credential writer reads only `document.roles[agent].credentials`; issue writes a store reference, revoke drops memory/file before writing or recording the receipt (`packages/bus/src/credential-verbs.mjs:16-51`). |
| Record writes | The organization writer takes the shared registry lock, then the organization's history `write.lock`, validates the revision and document, records a prepared change and publishes the file (`packages/organization/src/organization.mjs:143-159,175-220,278-340`; `packages/organization/src/org-registry.mjs:73-101`). Its `pathsOnly` option records changed field paths and presence without values (`organization.mjs:276-278,324-327`). |
| Materialize copies | Root and project entries explicitly refuse `credentials`, `tracker`, `identity`, `publish`, `holder` and `hosts` (`packages/organization/src/agent-levels.mjs:18-41,53-59,105-115`). Copy names preserve root names or append project names. Collisions refuse both claimants; org instances shadow copies. Every required non-host service makes a copy `unbound` (`packages/organization/src/agent-copies.mjs:36-53,73-104`). An unbound copy never joins the role map and launch refuses `credential-unbound`, naming missing services (`agent-copies.mjs:223-245,279-287`; `packages/cli/src/launcher.mjs:361-375`). |
| Guard launch races | Launch checks against a fresh organization revision, then final `request.admission` compares it again after async preparation (`packages/cli/src/launcher.mjs:357-364,802-813`; `packages/bus/src/runtime.mjs:636-661`). This revision covers organization bytes, not role file bytes. A3 records that limitation explicitly (`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:215`). |
Important limit: validating a slot reference does not prove the remote
token's permissions. For non-host/non-model services, `Credentials.#format`
checks token syntax and length, not remote scopes
(`packages/bus/src/credentials.mjs:448-451`). Existing Gitea minting has a
fixed seat scope list, not a per-copy intersection
(`packages/bus/src/gitea-grant.mjs:27-30,262-269`). Vikunja startup has
specific negative permission probes, not a complete per-composition scope
proof (`packages/tasks/src/startup.mjs:30-55`). The new build must close
these gaps for copies; a JSON field that asserts scopes is insufficient.
## 3. Choice and storage
Considered three approaches:
1. Add credentials to root/project definitions. Rejected: a root definition
would carry a per-org concern and invite cross-org reuse.
2. Write synthetic copies into `roles`. Rejected: this conflicts with org
shadowing and turns a boot copy into a persistent named instance.
3. Add an org-local binding overlay and reuse the existing per-agent store.
Recommended. It keeps copy definitions secret-free, preserves shadowing,
and gives bindings the same revision fence as other org settings.
The authoritative metadata lives in the existing organization file,
`<configDir>/businesses/<org>.json`, under a proposed top-level
`copyBindings` key. It is not written into `agents.json` or `roles/`.
An absent key means no bindings. The writer stores one record per concrete
copy instance; the example below is illustrative schema, not current JSON:
```json
"copyBindings": {
"writer.site": {
"copyId": "<random 128-bit lowercase hex>",
"orgIncarnation": "<registry incarnation>",
"source": { "level": "project", "name": "writer", "project": "site" },
"definition": { "ref": "org:release-writer", "rev": 3, "sha256": "<digest>" },
"generation": 1,
"state": "bound",
"services": {
"gitea": {
"slot": "self",
"scopes": ["read:repository"],
"resources": { "projects": ["site"] },
"rotateBy": "2026-12-01",
"grantId": "<opaque service grant id>",
"grantGeneration": 1
}
},
"decision": "<authority decision id>"
}
}
```
`slot: "self"` means exactly `(org, concrete instance, service)`. It is a
selector for today's derived slot, not an arbitrary string slot name. The
record contains no path, environment name, token, token fragment or token
hash. Grant ids identify authorization records, never encode token bytes.
Secret values stay exclusively in the existing private credential store
and trusted service-call memory, never in metadata, evidence, bundles,
decision text or errors. The symbolic paths in this design describe source
architecture; runtime responses never emit resolved host paths.
`source` includes the level and original name/project, so dotted-name
collisions cannot silently attach an old binding. `copyId` is created on
first approval and retired on fire, shadowing, source removal or replacement.
`generation` increases for every binding change or revocation and never
resets while that copyId exists. An unbound tombstone remains after unbind.
A new copyId never adopts an old slot file. Name reuse requires verified
old-slot cleanup and newly verified credentials before activation.
For a root copy, source.project is null and its definition is the frozen
root definition identity. For a project copy it names the org definition
or template identity. A definition hash includes composition and contract;
the applicable instruction/personality revision is pinned too. Import
provenance (`from.sha256`) is not the customized definition's current hash.
Vikunja metadata additionally contains the existing bot identity shape
(`tracker: {bot, botId}`) at the copy record level. It must pass the same
org-wide uniqueness check as named instances, including other copies.
No service binding implicitly grants identity, publication, host enrollment
or project access. Existing identity/publish requirements remain separate.
### Writer and lock order
Extend `organizationWriter` with dedicated trusted methods
`applyCopyBindingDecision` and `revokeCopyBinding`; names are proposed API.
Only the authenticated broker/Console owner path invokes these methods.
Generic document writes and root/project writers must reject changes to
`copyBindings`, even if a submitted document validates. An actor string,
decision id or hash supplied by an agent is never authorization.
Reuse the shared registry lock `<configDir>/org-runtime.lock`, then the
organization lock `<configDir>/businesses/.history/<org>/write.lock`.
Do not introduce a second competing metadata file or lock. Reuse the
writer's revision conflict, prepared/committed receipt and paths-only
evidence machinery. Under the locks, establish fresh active org incarnation,
owner identity, expected document revision and decision digest. Perform
the final checks in the publication critical section, not only in HTTP.
Do not hold filesystem locks across remote service calls or waits for runs
to stop. Before such work, persist a non-active pending operation with a
unique id and expected generations. Reacquire the locks, re-read and compare
all identities before publishing. A competing write invalidates preparation.
Use the same operation id for idempotent recovery; a prepared or uncertain
operation cannot produce a usable binding or resurrect the prior token.
## 4. Authority, decisions and deployed ownership
Use the existing grantable `org.hire` right as the authority-change entry
right. It already exists, defaults to owner yes/member no, and maps
`hire.resolve.console` to `org.hire`
(`packages/organization/src/org-rights.mjs:6-26,67-69`). Do not add an
`org.credentials` right that would imply delegable token-grant authority.
The right is necessary but not sufficient: any decision containing a
credential line requires the current owner of the target org, even if a
member holds `org.hire` or `decide`. Map credential resolution to an
explicit owner check as well as the right check, rechecked at commit.
The owner-only credential read stays owner-only; `board.read` gets at most
the existing copy's missing-service status, never a binding record.
Bind and unbind are **authority-change decisions**, represented by the
hire model's `op: "change"`, with a binding-specific payload. The
decision names source, copyId (or new-copy proposal), org incarnation,
definition revision/hash, before/after service scopes and resources, and
expected binding generation. The broker computes credential lines and
the whole-decision digest. Approval consumes the decision once. Dropping,
narrowing or replacing a line invalidates the old digest and requires
confirmation of the new digest; widening always raises a new decision.
This reuses hire-as-a-decision instead of creating a second approval
model. It follows the roles-and-rails design's credential ruling,
owner restriction, digest, seven-day expiry, one-step human-originated
decision, and post-approval binding steps
(`docs/plans/2026-11-10_roles-and-rails-design.md:35,232-310`). The chief
may propose a binding only through that decision path, never by naming a
slot, supplying a token or calling the writer. Other agents cannot raise
this authority-change subtype. No agent resolves it, including the chief,
an arbiter, its subject, or a different copy of the same definition.
This is an authority change under system rails R1, R5, R7 and R8 and the
role's credential rail. Default autonomy is **in the loop**. As required by
the existing design, credential bindings do not move to near or out of the
loop at all without a new owner ruling
(`docs/plans/2026-11-10_roles-and-rails-design.md:90-119,416-425,445-463`).
Unbind uses the same human decision record, including a one-step
owner-originated decision. Emergency **revoke** remains an owner-only
immediate verb: it only removes use, must not wait for a chief proposal,
and records the authority reduction. Revoke never issues a token or changes
an unbound binding to bound.
A root-level definition change still needs the instance owner. Binding one
root copy inside org O needs O's owner and grants nothing in another org.
Being an instance operator or root-business owner alone does not make a
person O's owner. Accounts design separates these principals explicitly
(`docs/plans/2026-11-10_accounts-design.md:314-349`). No account-only session
falls back to a human/root transport. All Console mutations retain Origin,
session, fresh ownership and confirmation fences.
The host remains the trust boundary. Host root and the instance uid can
already change the instance and its secrets; this proposal does not claim
protection from either, or from a sandbox escape to that uid. The deployed
instance uid must remain separate from human/development seats
(`docs/plans/2026-11-10_accounts-design.md:887-936`).
## 5. Permission calculation and proof
For every service V, compute:
`effective(V) = role-declared(V) intersect org-approved-binding(V)`.
The binding is the org's per-copy narrowing. Omitted services are unbound,
not permission to use another source. Unknown services/scopes refuse; they
are never ignored. The requested scopes must already be a subset of the
role. Do not silently trim a broader request and report that it succeeded.
Resources also intersect the org's current admitted project set and the
copy's union. A project copy may access its project only. A root copy may
access only the approved projects that still admit it. An empty intersection
allows no service operations and leaves a required service unbound.
Scope comparison is semantic: Gitea read is narrower than write in the same
category, categories are independent, and neither admin nor all is allowed.
Vikunja compares each route-group/verb pair. Missing categories/groups mean
no permission; there is no wildcard. All adapters must apply their service's
registered semantics, never compare scope strings lexicographically.
### At proposal, approval and bind
1. Resolve the concrete copy from the target org's boot snapshot and current
project union. Refuse missing, shadowed, colliding, excluded or fired
copies. Never create `roles[copyName]` to make the operation succeed.
2. Validate the role through the core composition validator and current
enabled registry. Read T1's definition revision/hash, or the frozen root
role identity. Verify org active state and incarnation. Compute all
credential lines from these inputs, not from chief-supplied summaries.
3. Check every requested service/scope/resource against that definition and
org. Verify the resolver is the fresh org owner. The owner cannot override
a system rail or widen a binding past the approved role; changing the role
requires its own approved change first.
4. Obtain a dedicated service credential for this exact org/copy/service.
Trusted service adapters must establish both token scopes and remote
identity resource grants. Prove the allowed projects/repos at the service,
not just a local `resources` list. A root-wide or cross-org bot account is
unacceptable even if its token is stored in a per-org directory.
5. Accept a token only after trusted live introspection verifies its scopes,
service account, permitted resources and expiry against this operation,
or after a reviewed managed mint path produces and verifies that exact
bounded grant. An owner-entered assertion, copied grant id, token format,
or a few successful/negative probes are not proof of complete permissions.
If the service/version cannot prove the bound, refuse
`credential-scope-unverified`. Ship that refusal rather than an unsafe
compatibility mode. This is a required service-adapter build item.
6. Stage the secret privately in the existing store with the binding inactive.
Recheck decision digest, definition hash, org incarnation, document
revision and slot generation after remote work. Only then commit the
metadata/receipt. A grant record binds the opaque grant id, copyId,
incarnation and generation to the issued slot. It stores no secret or
token-derived digest. Ambiguous crash recovery keeps use closed and
requires a new verified issue if exact provenance cannot be established.
Gitea's existing fixed `SEAT_SCOPES` must not be reused for a read-only role.
Vikunja needs a reviewed verifier for the complete route-group scope set
and bot/project access. Until each adapter exists, that service's copy
binding refuses. Do not claim the existing named-agent path provides this
proof. Extending remote minting beyond its current approved envelope is a
separate protected change, identified in slice CB2 below.
### At launch, restart and every use
The first build uses the existing restart model for **new references**.
Approval may record a binding, but it is not usable until the org bus
restarts and verifies it. Surface `restart-required`, not a successful
launch. Today reference/bot changes already require restart
(`packages/bus/src/runtime.mjs:547-583`). Do not add live reference reload
in the first slice. Revocations take effect immediately and never wait
for this restart.
On boot and every launch, re-resolve the effective role and binding,
validate source/copyId/incarnation/generation, and obtain fresh trusted
service scope/resource proof for the actual slot contents. If verification
is unavailable, launch refuses. All required services must be bound and
available. Bindings may be approved incrementally, but partial success
cannot launch. A stale file alone never counts as a binding.
Preserve A3's organization revision fence. Add a separate launch authority
digest over frozen definition identity, copyId, org incarnation, binding
generation and verified grant generation. Do not redefine the writer's
document revision. Carry both into final admission and `bindLaunch`, and
bind the admitted generations to the session. A pending revocation blocks
admission, including the interval between final admission and spawn/bind.
If a child was spawned before revocation won, deny its capability and stop
it; it must perform no credential call. Do not release a launch fence on an
uncertain outcome until that child is confirmed stopped.
At every credential-bearing operation, check session org/instance, active
incarnation, effective scopes/resources, binding/grant generation and slot
liveness before any external request. Derive the slot from the session,
never from request arguments or thread text. Raw tokens and credential file
paths do not enter sandboxes. The trusted service adapter checks each
method/resource as well as token permissions. Constrain outbound destinations
to the approved service origin and reject redirects carrying authorization
to another origin. Operation completion rechecks the generation before
publishing its result. Unknown or stale state refuses closed.
## 6. Lifecycle and running copies
All transitions below close credential admission first, invalidate affected
session generations, then stop **all** affected ordinary and thread runs.
Serialize close versus new admission so a concurrent launch cannot escape
the stop inventory. Abort cancellable service calls and wait for all held
uses to settle before slot deletion or replacement. A completed external
write cannot be undone; report that limit, never call local revoke a remote
rollback. Existing non-host `Credentials.use` does not provide the host-key
abort behavior, so generalizing the use fence is required, not assumed
(`packages/bus/src/credentials.mjs:556-569,618-653`).
| Event | Durable outcome and effect on a running copy | Next launch/use |
| --- | --- | --- |
| Unbind service | Owner-approved change bumps generation, records unbound tombstone, closes use, stops every run of that copy, revokes/removes its slot. Never retain a reusable token behind an inactive binding. | `credential-unbound`: `credentials.gitea needs an owner-approved binding.` |
| Emergency revoke | Owner verb immediately closes use, bumps generation, stops all copy runs and performs local revoke. A managed remote grant is also revoked through its approved adapter. Keep tombstone; reissue needs a fresh decision and verification. | `credential-revoked`: `credentials.gitea was revoked; the owner must approve a new binding.` |
| Slot removed or altered outside the writer | A slot liveness/identity check at use and launch refuses even if memory still holds the token. Mark binding unavailable, invalidate session use and stop its runs. Never fall back to cached memory, environment or another slot. Replacement is a newly verified issue. | `credential-unavailable`: `credentials.gitea is unavailable; the owner must verify the slot.` |
| Expiry or upstream revocation | Local expiry checks and service authentication failure close the binding, invalidate use and stop runs. Rotation is a new verified slot generation and owner-approved change; no in-place replacement behind active uses. | `credential-expired`: `credentials.vikunja has expired; the owner must approve a replacement.` Upstream revoke uses `credential-revoked`. |
| Role/definition changed | Any role identity/hash/revision mismatch closes use and stops affected copies. Do not inherit old credentials even for equal service names. A narrower role requires new bounded service proof; a wider role requires approval first and fresh binding. T1 blocks edits of an in-use definition until the change-decision path exists. | `credential-role-changed`: `credentials.gitea no longer matches the approved definition; the owner must review the binding.` |
| Copy fired, source removed, excluded or shadowed | Close use, retire copyId, stop every run, revoke slots and delist the copy. Preserve run/trail evidence. A newly shadowing named instance must not load the old copy's file at the identical slot name. Require cleanup before it can use that name. | `copy-unavailable`: `copy is no longer active in this organization.` |
| Copy-name collision | Refuse attaching either binding. If introduced while running, close affected use and stop both claimants. No first-wins fallback. | `copy-refused`: `copy identity is ambiguous; the owner must resolve the definition collision.` |
| Organization archived | Close the org's admission, invalidate its running sessions and stop every org run before archive completes. Revoke all copy service slots and mark bindings unbound. Restore preserves definitions but requires fresh binding decisions. Never affect another org's runs or slots. | `org-archived`: `organization is archived.` After restore, `credential-unbound`. |
| Organization deleted | Close all admission/use and stop every org run, revoke managed remote grants and local slots before the existing quarantine flow. Preserve evidence/tombstones in deletion evidence. A new org with the same id has a new incarnation and no inherited bindings. | `org-unavailable`: `organization is not active.` Recreated org starts unbound. |
| Revoke/stop/cleanup uncertain | Persist a non-usable pending-revocation state and keep the affected copy or org fenced. Do not publish success or restore memory on write failure. Retry with the same operation id. Remote revocation uncertainty remains visible to the owner. | `credential-revoke-failed`: `credentials.gitea revocation is incomplete; the owner must retry cleanup.` |
These archive/delete effects are proposed extensions. Current archive stops
the bus and marks the registry archived; deletion quarantines org runtime,
document/history and org secret trees, while its receipt says remote-side
revocation is out of scope (`packages/organization/src/org-service.mjs:239-267,332,343-347`).
The new build must explicitly test and add copy-run and copy-grant cleanup;
it cannot infer it from a stopped bus.
Refusal codes in this section are proposed public contracts where new.
Use `human-required` with `Only the organization owner can approve credential
binding.` for an agent/member mutation, `credential-not-yours` with
`credentials.gitea must use this copy's own organization slot.` for a wrong
slot, `credential-scope-exceeded` with `credentials.gitea.scopes exceeds the
approved role.` for excess scopes, and `credential-scope-unverified` with
`credentials.gitea permissions could not be verified.` for missing proof.
`credential-binding-changed` says `credentials.gitea changed; read and approve
again.` for stale generation; `organization-changed` retains the existing
document-race code. Validation errors name field positions such as
`copyBindings entry 1 services field`, not untrusted submitted keys.
All new boundaries allow only registered service/field labels and fixed
remediation text. Never interpolate a submitted value, resolved path, raw
service error, URL, bot name, secret, token hash or token fragment into an
error, receipt or trail. New metadata serializers use an allowlist and
paths-only receipts. Scan human text with the existing secret scanner and
fixed field-size limits; arbitrary unknown keys refuse with a fixed field
label. Metadata holds only validated ids, known scope names and bounded
dates/integers. The scope is every new copy-binding surface, consistent
with the brief's #1916/#1920 requirement, without changing unrelated legacy
diagnostics. Neither reviewer nor implementer needs live secrets to test it.
## 7. Two organizations and identity reuse
Example acceptance fixture: org A and org B both materialize root `writer`
from the same creative template. A's binding selects `(A, writer, gitea)`;
B's selects `(B, writer, gitea)`. Each uses a dedicated remote bot/token whose
resources exclude the other org. B cannot select A's slot or reuse A's grant
record. A path restriction alone would not prevent a token with both orgs'
remote grants, so service proof must check those grants too.
Enforce isolation at all of these independent boundaries:
1. Console resolves the target org from the authorized route and fresh
ownership, not a body-supplied org or an instance-wide owner flag.
2. The writer derives its org and slot from that context and binds the
metadata to the registry incarnation. A copied record from A has the
wrong incarnation in B and refuses before opening any slot.
3. Copy source tuple, copyId and definition digest must match B's materialized
copy. A root definition carries no binding to propagate.
4. Credential loader derives the slot, enforces private regular-file rules,
rejects alias/symlink/hardlink substitution for copy slots and verifies
remote grant ownership. Reject nlink other than one for these slots.
5. Every use derives org/instance from the authenticated session and checks
the active binding generation. A thread, room or caller-supplied instance
cannot choose the key. Session and grant records cannot cross buses.
6. Archive/delete touches only that incarnation's resources. A failed cleanup
never makes credentials valid for a later org or copy with the same name.
Registry incarnation is a dependency of accounts design
(`docs/plans/2026-11-10_accounts-design.md:501-514`). The binding build must
use that stable value; if the target baseline lacks it, land an explicitly
approved incarnation slice first. Do not substitute timestamps, a pathname
or an org id alone. The dedicated store layout remains unchanged.
## 8. T1 dependency and implementation slices
T1 is in flight. Its brief is
`/mnt/storage/scratch/mosaic-lead/uidev/org-release/T1-BRIEF.md`. This design
depends on its org-local definitions, `org:` resolver, revision/hash read,
composition validation and in-use definition guard, as specified in
roles-and-rails sections 4 and 5
(`docs/plans/2026-11-10_roles-and-rails-design.md:157-170,186-228`). Do not
patch T1's files in this lane. Project copies use those org definitions;
root copies keep a frozen root identity. Supporting root-local customized
definitions waits for that resolver to exist, rather than inventing another
definition format here. A4 supplies the authority-change decision engine;
this build extends it, rather than shipping a temporary direct bind verb.
Every slice below is a later proposal requiring Jason's approval before
work. Each ends with focused tests plus affected organization, business,
bus, CLI and Console suites. No slice edits `roles/` or `contracts/`.
| Slice and dependencies | Build and acceptance evidence | Protected changes needing approval |
| --- | --- | --- |
| CB0: identity foundation, after T1/accounts | Reuse incarnation or add its approved missing support. Define copyId, source/hash pin and binding generation semantics. Pure resolver tests cover identical root copies across two orgs, project dotted-name collisions, org shadowing, removed/recreated org and source-name reuse. No secret read and no launch enabled. | Org schema; registry schema only if incarnation is absent. No new rights or role files. |
| CB1: metadata and decisions, after CB0/A4 | Add `copyBindings`, guarded organization writer, owner-only credential subtype under `org.hire`, digest/expiry/single-consumption and paths-only receipts. Tests: owner binds/unbinds; member with org.hire/decide, chief, arbiter, subject agent, tester and reader all fail to resolve/write; agent may not call raw writer; only chief agent can propose; stale revision/digest/generation, expired approval, replay, wrong incarnation and generic write injection have no activation. Pending/partial binding never launches. | Organization and decision payload schema; `VERB_RIGHTS`/owner dispatch changes even though no new grantable right. Approval explicitly covers the reuse of org.hire with the owner restriction. |
| CB2: verified service slots, after CB1 | Reuse self-only store and dedicated bot identity; add complete Gitea/Vikunja verification or exact bounded mint adapters. Tests use fake services for narrower/equal/broader scopes, read/write hierarchy, extra route verbs, unrelated repos/projects, reused foreign bot/grant, opaque/unverifiable token, wrong date, symlinks/hardlinks, stale file and token replacement. No request, path or token enters metadata/evidence; lost replies and crash points remain inactive. A live scratch-service acceptance must prove each service/version's permission verifier before that service enables copy launch. | Credential handling/security boundary; grant metadata schema and any remote mint/revoke envelope changes. No automatic reuse of existing fixed Gitea seat scopes. |
| CB3: admission and use, after CB2 | Materialize only verified bound copies; new references visibly need restart. Check document revision plus separate authority digest at prepare, final admission and bindLaunch. Session-bound adapter checks every operation and slot liveness. Race tests mutate role hash, org bindings, token contents or admitted projects during async prepare and between admission/spawn/bind/use. Assert no admitted credential call, including an empty project manifest. Concurrent revoke denies new work; no cached-token fallback after unlink. | Launcher/broker credential enforcement boundary; session/launch authority fields and any store schema needed to persist fences. No live reference-reload policy. |
| CB4: lifecycle and recovery, after CB3 | Test unbind, emergency revoke, slot removal, expiry, role change, fire, collision, shadowing, archive/restore, delete/recreate. Each closes admissions, invalidates ordinary and every thread run, aborts or drains uses, and denies later calls. Inject failures before/after metadata publication, secret staging, remote revoke, stop acknowledgement and receipt write; restart never resurrects authority. Include named-instance shadowing over a former copy's slot. Other org continues. | Credential/revocation and org lifecycle boundary; durable pending-operation schema. Existing deletion quarantine must not silently claim remote revoke. |
| CB5: two-org thread acceptance and operator flow, after CB4 | Finish A3's deferred test with a shipped Gitea-requiring creative thread role in both orgs. Approve independent verified bindings; boot real buses and use fake harness/service, create one thread/session per org, launch both, write/read records, reopen each. Assert distinct session/run/trail/workspace and slot identities, no A record visible in B, no B request uses A's token, and B survives A revoke/archive/delete. Reject raw A binding/grant replay in B. Operator flow shows computed scope lines, confirmation, missing service, restart and remediation states, with sentinel redaction tests on every response/event/log. No screenshot gate for this doc lane. | Console mutation/decision flow; no further right or role changes. Any discovered schema delta returns to approval before build. |
Existing regressions to retain include organization `credentials.test.mjs`,
`role.test.mjs`, `agent-copies.test.mjs`, `instance-credential-transitions.test.mjs`,
bus `credential-store.test.mjs`, `credential-verbs.test.mjs`,
`agent-copies.test.mjs`, and CLI `thread-launch.test.mjs` and
`launcher-final-race-probe.test.mjs`. Add meaningful new focused tests for
the guarantees above; merely checking the proposed JSON shape is inadequate.
Acceptance samples for this document are the seven brief requirements:
storage/writer/locks in section 3; right/rail/decision in section 4;
role ceiling and verification in section 5; lifecycle/code/text in section 6;
two-org enforcement in section 7; tests/slices/protected changes in section 8;
and at most three owner questions below. No visual samples apply.
## 9. Questions for Jason, with recommendations
1. Approve `org.hire` as the entry right while keeping every credential
binding/unbinding approval owner-only? Recommend yes: it matches the
existing hire decision model and does not grant members token authority.
2. Approve the overlay schema and CB0-CB5 security changes, including complete
service scope/resource proof as a prerequisite and restart for new
references? Recommend yes. A service without a verifier stays refused;
implement Gitea first if its verifier is ready before Vikunja's.
3. On archive/restore, discard copy credentials and require fresh bindings?
Recommend yes. It adds setup on restore, but avoids keeping dormant
usable grants and gives archive one clear revocation meaning.
These are recommendations, not approvals. The Lead takes this design to
Jason. No implementation or rollout is authorized by this document.
-7
View File
@@ -189,15 +189,8 @@ those messages stranded: no later holder receives or reads them, and `agents`
lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets
an operator inspect content and delivery evidence after an uncertain result.
Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add
`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never
quotes caller input or file bytes. The runner and typed tools carry the guidance
to the agent. `src/rails.mjs` in the organization package supplies the shared
prompt/live projection; `decisionAutonomy` is the extension point for AU.
| Verb | Arguments and result |
|---|---|
| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. |
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
| `role.release` | No args; only its current holder releases. |
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
+94 -312
View File
@@ -1,5 +1,3 @@
import { ownRails } from '../../organization/src/rails.mjs';
import { refusalGuidance } from './refusals.mjs';
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
@@ -9,7 +7,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -43,7 +41,6 @@ export const ACTIONS = Object.freeze([
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
'role.hire',
'role.fire',
'role.change',
'credential.mint',
'spend',
'deploy',
@@ -53,7 +50,6 @@ 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',
]);
@@ -82,11 +78,9 @@ 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',
'role.change',
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write.
'library.write',
]);
@@ -94,8 +88,6 @@ 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.
@@ -174,7 +166,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -195,15 +187,6 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
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']);
// #1873 T1: the human's definition writes, each with its [allowed, required] argument keys. Kept here,
// not imported, as the sandbox client loads this file; runtime.mjs maps each verb to the verb module
// (organization/src/definition-verbs.mjs names the same four, and a test holds them equal).
export const DEFINITION_BUS_KEYS = Object.freeze({
'definition.import': [['revision', 'template', 'as'], ['revision', 'template']],
'definition.invent': [['revision', 'name', 'definition'], ['revision', 'name', 'definition']],
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
});
// 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']);
@@ -228,6 +211,14 @@ const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
// Row 194 S6 (D2.2): the stop outcomes a fire's lifecycle.done reports, each written as its own event.
const FIRE_STOPS = ['killed', 'alive'];
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
// required. Each carries the reason the log records.
const HIRE_FIRE_ARGS = Object.freeze({
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
'role.fire': [['name', 'reason'], ['name', 'reason']],
});
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
// What instance.request and the host ops answer about a request.
const lifecycleView = (r) => ({
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
@@ -250,11 +241,10 @@ const canonical = (x) =>
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
export class BusError extends Error {
// Guidance is static policy text; never quote arguments or file bytes here.
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
constructor(code, reason) {
super(code);
this.code = code;
Object.assign(this, refusalGuidance(code));
if (reason !== undefined) this.reason = reason;
}
}
@@ -536,7 +526,7 @@ function checkBusiness(name, b) {
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => (!ACTIONS.includes(v) && !pluginName(v)) || GATED.has(v)))
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
@@ -596,8 +586,6 @@ export class Broker {
#builtin;
#builtinBinding;
#organization;
#hires;
#pluginService;
#credentialVerbs;
#proxyInit;
#proxyPage;
@@ -660,7 +648,7 @@ export class Broker {
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = 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 }) {
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');
@@ -683,8 +671,6 @@ export class Broker {
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#hires = hires;
this.#pluginService = pluginService;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#proxyInit = proxyInit;
@@ -1227,11 +1213,6 @@ export class Broker {
this.#businesses[name] = next;
this.#scheduleRequests();
}
// #1873 A3: host-only, for the launch boundary's admission: the code that refuses `instance` in code
// project `repo` under the business's current document (the one the last reload swapped in), or null.
projectBarred(business, instance, repo) {
return this.#projectRefused(this.#business(business), instance, repo)?.code ?? null;
}
// Host-only current role resolver shared by the broker and built-in task tracker.
taskRoles(business) {
const roles = this.#business(business).roles;
@@ -1546,45 +1527,11 @@ export class Broker {
this.#processingWork();
const b = this.#business(s.business);
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
this.#copyRefused(b, instance);
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
if (this.#threaded(s.business, instance)) fail(thread);
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
if (this.#firing(s.business, instance)) fail('instance-firing');
}
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
// names the agent and the missing services or the reason, never a value (organization
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
#copyRefused(b, instance) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
if(copy?.level==='root') {
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
if(refusal)fail(refusal);
}
if (!copy || copy.state === 'instance') return;
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
}
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
// (organization agent-copies.mjs projectRefusal, inlined for the same reason). A project copy works
// only in its own project; in a project whose union refuses no agent works; an instance the
// project excludes doesn't work there. The reason names the instance, the project and why.
#projectRefused(b, instance, repo) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (copy && copy.project !== null && copy.project !== repo)
return new BusError('project-agent-elsewhere', `${instance} works only in project ${copy.project}`);
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
if (!union) return null;
if (!union.ok) return new BusError('project-refused', `${instance} can't work in project ${repo}: project ${repo} refuses its agents: ${union.reason}`);
if (!union.agents.includes(instance)) return new BusError('project-refused', `${instance} can't work in project ${repo}: the project excludes it`);
return null;
}
// #1873 A3: the union refusal of code project `repo`, as a BusError naming the project and why, or null.
#unionRefused(b, repo) {
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
return union && !union.ok ? new BusError('project-refused', `project ${repo} refuses its agents: ${union.reason}`) : null;
}
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
#firing(business, instance) {
return !!this.#store.get(
@@ -1849,7 +1796,6 @@ export class Broker {
if (s.tester) fail('tester-refused');
if (s.reader) fail('read-only');
if (s.human) fail('agent-required');
this.#copyRefused(this.#business(s.business),s.role);
const h = this.#own(s);
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
@@ -1899,15 +1845,7 @@ export class Broker {
this.#revokeLost('access-expiry', true, doc);
rows = this.#threadRows('t.business=?', business);
}
// #1873 A3: a thread whose instance no longer works in its project's code project (the project's
// union refuses, or it excludes the instance) says so, so the launcher refuses its launch.
const b = this.#business(business);
const refusedIn = (instance, project) => {
if (!b.unions && !b.copies) return null;
const e = this.#projectRefused(b, instance, this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, business)?.repo ?? null);
return e ? { projectRefused: { code: e.code, reason: e.reason } } : null;
};
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}), ...refusedIn(r.instance, project) }));
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
}
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
// from a threadsState snapshot, and a message the run took after it would be delivered to a
@@ -2164,13 +2102,6 @@ export class Broker {
}
bindLaunch(record) {
const { key, session, old, body } = this.#launchProof(record);
const launchRole=this.#business(record.business).roles[record.role];
const pluginCapability=[...(launchRole.tools ?? []),...(launchRole.authority?.withinRole ?? []),...(launchRole.authority?.crossRole ?? []),...Object.keys(launchRole.credentials ?? {})].find(pluginName);
if(pluginCapability) {
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(pluginCapability));
try { this.#pluginService.check(record.business,record.role); }
catch(e) { throw new BusError(e.code === 'plugin-disabled' ? e.code : 'invalid-request', e.code === 'plugin-disabled' ? e.plugin : undefined); }
}
if (!old) this.#processingWork();
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
@@ -2285,14 +2216,6 @@ export class Broker {
run: null,
});
}
// Trusted account transport supplies freshly resolved membership, never request arguments.
// This capability reaches only the A4 verbs; it cannot pass existing owner Console guards.
bindHireConsole({business, actor, subject, rights, owner = false, instanceOwner = false}) {
this.#business(business);
identifier(actor);
if (!object(subject) || !['provider','iss','sub'].every(k=>typeof subject[k]==='string' && subject[k]) || !Array.isArray(rights)) fail('human-required');
return this.#cap({business,human:actor,role:null,run:null,via:'console',console:Object.freeze({...subject}),hireMembership:Object.freeze({actor,rights:[...rights],agent:false,owner,instanceOwner,subject:{...subject}})});
}
bindReader({ business }) {
this.#business(business);
return this.#cap({ business, reader: true, role: null, run: null, human: false });
@@ -2450,7 +2373,6 @@ export class Broker {
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
new Set(options.map(o => o.key)).size !== options.length) return null;
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...raise.hire} : null;
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
@@ -3366,8 +3288,7 @@ export class Broker {
const result = await write('instance.fire');
step = 'C';
this.#store.transaction(() => {
if(sc.human)this.#event(sc,'action.allowed',{action:'role.fire',target:args.name,reason:args.reason,transaction:result.transaction??null},args.name);
else this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(sc, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null, job, grants: out.grants, grantsLeft: out.grantsLeft }, name);
});
} catch (e) {
@@ -4698,14 +4619,7 @@ export class Broker {
fail('role-end-refused');
}
#classification(s, action, target = null) {
if (!ACTIONS.includes(action)) {
const name=pluginName(action);
const held=this.#business(s.business).roles[s.role]?.authority;
if(!name || ![...(held?.withinRole ?? []),...(held?.crossRole ?? [])].includes(action))fail('unknown-action');
if(!this.#pluginService)throw new BusError('plugin-disabled',name);
let checked;try {checked=this.#pluginService.check(s.business,s.role);}catch{throw new BusError('plugin-disabled',name);}
if(!checked.view.registry.actions.includes(action))throw new BusError('plugin-disabled',name);
}
if (!ACTIONS.includes(action)) fail('unknown-action');
if (
this.#hardWait(s, action, target) ||
(action === 'role.launch' && this.#business(s.business).launch?.by !== s.role)
@@ -5147,20 +5061,6 @@ 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) {
@@ -5952,15 +5852,6 @@ export class Broker {
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
}
this.#secretCheck(args);
if (request.verb === 'plugin.tool') {
if(s.human || s.reader || s.tester || s.user || s.console)fail('agent-required');
keys(args,['tool','arguments'],['tool','arguments']);
this.#store.transaction(()=>this.#agent(s));
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(args.tool) ?? undefined);
return this.#pluginService.call(s.business,s.role,args.tool,args.arguments).then(result=>{
this.#secretCheck(result);return result;
}).catch(e=>{throw this.#refused(s,new BusError(['plugin-disabled','invalid-request','not-authorized'].includes(e.code)?e.code:'adapter-failed',e.code==='plugin-disabled'?e.plugin:undefined),{verb:'plugin.tool'});});
}
if (request.verb === 'processing.halt' || request.verb === 'processing.resume')
return this.#requestProcessing(s, request.verb, structuredClone(args));
if (request.verb === 'processing.status') {
@@ -6096,25 +5987,14 @@ export class Broker {
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
if(request.verb==='hire.templates.console') {
keys(args,[]);const caller=this.#hireCaller(s);
if(!caller.rights.includes('org.hire'))fail('right-required');
if(!this.#hires?.templates)fail('not-configured');
return this.#hires.templates();
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
{
const result = this.#chiefLifecycle(s, request.verb, structuredClone(args));
return result?.then ? result.catch(error => { throw this.#refused(s, error, request); }) : result;
}
if (request.verb === 'instance.hire' || request.verb === 'instance.fire') fail('decision-required');
if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' ||
(request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) {
const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args;
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
}
if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (['hire.preview.console','hire.resolve.console'].includes(request.verb))
return this.#hireConsole(s,request.verb,args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -6169,13 +6049,6 @@ export class Broker {
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
}
// #1873 T1: the definition writes need org.hire. Until row 193, as #mayHuman, the business's
// one human (the owner) passes; the tester is refused above and an agent here.
else if (Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
keys(args, ...DEFINITION_BUS_KEYS[request.verb]);
this.#human(s);
if (s.human !== this.#business(s.business).human) fail("human-required");
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -6216,12 +6089,6 @@ export class Broker {
entry.running = this.#running(s.business, name);
entry.alive = this.#aliveAfterFire(s.business, name);
}
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
// instances above carry no copy; a copy carries its state, missing service names or reason.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
}]));
return result;
}
return this.#store.transaction(() => {
@@ -6311,121 +6178,47 @@ export class Broker {
}
return result;
}
#hireCaller(s) {
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
if(s.hireMembership) return s.hireMembership;
if(s.human!==this.#business(s.business).human)fail('human-required');
return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console};
}
async #raiseHire(s, input, immediate) {
const b=this.#business(s.business);
// Change is a replacement composition and uses the existing role.hire grant.
const chiefAllowed=()=>{
this.#agent(s);
if(s.role!==this.#business(s.business).launch?.by)fail('role-not-chief');
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
// transaction, so the grant is checked before it and the use logged only after it, as
// organization.set's grant is.
#chiefLifecycle(s, verb, a) {
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
this.#agent(s);
const [allowed, required] = HIRE_FIRE_ARGS[action];
keys(a, allowed, required);
this.#hireFireArgs(action, a);
const args = a;
const b = this.#business(s.business);
if (b.launch?.by !== s.role) fail('role-not-chief');
if (!this.#organization) fail('not-configured');
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
// (decision-required), and these verbs take none.
const authority = { target: args.name };
this.#checkAuthority(s, action, authority);
// D2.4: a kept name is neither hired nor fired.
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
const actor = b.roles[s.role].definition + '.' + s.role;
const context = { instance: s.role, run: s.run, reason: args.reason };
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
const { reason: _, ...written } = args;
const write = (op) => {
try {
const result = this.#organization(s.business, op, written, actor, context);
return result?.then ? result.catch(error => this.#organizationFail(error, verb)) : result;
} catch (e) {
this.#organizationFail(e, verb);
}
};
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
else chiefAllowed();
if(!this.#hires)fail('not-configured');
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt});
// Recheck the chief after asynchronous preparation; a released run cannot raise.
if(!immediate)chiefAllowed();
else {
const caller=this.#hireCaller(s);
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
const finish = result => {
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
};
return result?.then ? result.then(finish) : finish(result);
}
const action=`role.${p.op}`;
this.#store.transaction(()=>{
const raiser=immediate?'human':s.role;
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,'human',`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p},did);
});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
return {...this.#decisionView(this.#decision(s,did)),hire:p};
}
async #hireConsole(s,verb,a) {
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
identifier(a.id);
const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id);
let p=raise?.hire;
if(!['role.hire','role.fire','role.change'].includes(d.action) || !p)fail('decision-not-eligible');
const closed=this.#closed(d.id);
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(!caller.rights.includes('org.hire'))fail('right-required');
if(a.adjust!==undefined) {
if(!this.#hires?.preview)fail('not-configured');
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
}
if((p.flagged.length || p.credentialLines.length) && !caller.owner || p.level==='root' && !caller.instanceOwner)fail('hire-owner-required');
if(verb==='hire.preview.console')return {id:d.id,business:s.business,question:d.question,kind:'hire',options:JSON.parse(d.options),...p};
if(a.digest!==p.digest)fail('decision-stale');
if(a.choice==='yes') { if(!this.#hires)fail('not-configured'); await this.#hires.check(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}); }
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
// A durable close/consume precedes the external file effect. A crash never replays an approval.
this.#store.transaction(()=>{
if(this.#closed(d.id))fail('decision-closed');
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',caller.actor,a.choice,'console');
this.#event(s,'human.input',{kind:'answer',decision:d.id,op:'resolved',choice:a.choice,via:'console',subject:s.console},d.id);
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
});
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
if(!this.#hires)fail('not-configured');
try {
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust});
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
} catch(e) {
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:e.code??'outcome-unknown'},d.id));
throw e;
}
}
#expireHire(s,d,p,insideTransaction=false) {
if(!p || Date.parse(p.expiresAt)>Math.max(Date.now(),this.#clock) || !Number.isFinite(Date.parse(p.expiresAt)))return false;
const close=()=>{
if(this.#closed(d.id))return;
this.#store.run('INSERT INTO decision_events(decision,at,op,by,via) VALUES(?,?,?,?,?)',d.id,this.#now(),'withdrawn','expiry','broker');
this.#event({business:s.business,role:null,run:null},'action.allowed',{operation:'hire.expired',decision:d.id},d.id);
};
if(insideTransaction)close();else this.#store.transaction(close);
return true;
}
#expireHires(s) {
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
}
async #stopCopyApproved(s,p) {
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
const b=this.#business(s.business);
if(!b.roles[instance])return;
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
const set=this.#store.transaction(()=>{
const held=this.#threaded(s.business,instance)?this.#threadHolders(s.business,instance):[this.#holder(s.business,instance)].filter(h=>h?.op==='claim');
for(const h of held)this.#claimEnd(s,h,'revoke',p.id);
const runs=[...this.#runs.values()].filter(r=>r.business===s.business&&r.role===instance).map(r=>r.run);
const request=runs.length?randomUUID():null;
if(request)this.#event(s,'action.allowed',{action:'lifecycle.end',request,via:'human',by:s.human,runs,reason:'Approved fire decision',kill:true},instance);
this.#event(s,'action.allowed',{action:'fire.stopped',request,decision:p.id},instance);
return this.#grantSet(s.business,instance);
});
if(set.length)await this.#stepG(s.business,instance,set,{job:p.id,reason:'Approved fire decision',by:s.human,flag:{fire:true}});
}
// The existing row-281 fire executor, now entered only after a consumed human decision.
#fireApproved(s,p) {
const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire';
const b=this.#business(s.business),authority={target:p.name};
const write=op=>{
try {const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human,{decision:p.id,digest:p.digest});
return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result;
} catch(e) {this.#organizationFail(e,verb);}
};
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
if (b.launch?.by === args.name) fail('organization-instance-referenced');
@@ -6467,7 +6260,7 @@ export class Broker {
// A non-empty set: step G, B and C run as a job, and the verb answers now with no transaction.
if (stopped.job) {
const { job, set, ...rest } = stopped;
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human };
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: null };
this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set));
return { ...rest, job, revoking: set.map((g) => g.label), done: false };
}
@@ -6476,13 +6269,41 @@ export class Broker {
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
const finish = result => {
this.#store.transaction(() => {
this.#event(s,'action.allowed',{action:'role.fire',operation:'hire.apply',decision:p.id,transaction:result.transaction??null},args.name);
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
};
return result?.then ? result.then(finish) : finish(result);
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
identifier(args.name);
// FED R3 r4 (sage-2557): `user` is the granted user's principal, as checkBusiness refuses it.
if (args.name === 'human' || args.name === 'tester' || args.name === 'user') fail('invalid-request');
string(args.reason, 500);
if (action === 'role.fire') return;
identifier(args.definition);
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
}
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
#hireable(s, definition) {
if (!this.#roleFile) fail('not-configured');
const b = this.#business(s.business);
const load = (name) => {
try {
return this.#roleFile(name);
} catch (e) {
if (e.name === 'BusinessError') fail('definition-not-hireable');
throw e;
}
};
const def = load(definition);
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
@@ -6498,8 +6319,6 @@ export class Broker {
if ((verb === "instance.hire" || verb === "instance.fire") && e.code === "definition-not-hireable") fail(e.code);
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
if (e.code === "instance-kept") fail(e.code);
// #1873 T1: the definition writer's refusals pass by code; a composition refusal stays invalid-request.
if (Object.hasOwn(DEFINITION_BUS_KEYS, verb) && /^definition-[a-z-]+$/.test(e.code ?? "")) fail(e.code);
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
@@ -7101,13 +6920,6 @@ export class Broker {
// 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 'my_rails': {
keys(a, []);
this.#agent(s);
const entry = b.roles[s.role], definition = b.definitions?.[entry.definition];
return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network,
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by });
}
case 'role.claim': {
keys(a, []);
if (s.human) fail('agent-required');
@@ -7252,8 +7064,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');
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
const domain = a.domain ?? 'delivery';
if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain');
// 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.
@@ -7335,7 +7147,6 @@ export class Broker {
decision: did,
action: a.action,
operation: 'decision.raise',
domain,
task_ref: a.task_ref ?? null,
target: a.target ?? null,
approvalChoice,
@@ -7383,7 +7194,6 @@ export class Broker {
identifier(a.id);
if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id);
if (['role.hire','role.fire','role.change'].includes(d.action) && ['decision.resolve','decision.resolve.console'].includes(verb)) fail('hire-resolution-required');
if (this.#closed(d.id)) fail('decision-closed');
// Recheck eligibility, context digest and membership in the current options in this
// transaction, before any resolution, audit, notification or grant execution.
@@ -7403,7 +7213,6 @@ 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))
@@ -7843,7 +7652,6 @@ export class Broker {
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(s);
if (verb === 'inbox')this.#expireHires(s);
if (verb === 'inbox')
return this.#store
.all(
@@ -7889,7 +7697,6 @@ export class Broker {
case 'decisions': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['list'], ['list']);
this.#expireHires(s);
if (a.list === 'roles')
return this.#store
.all(
@@ -8196,17 +8003,7 @@ export class Broker {
// #797 MVP3: an archived project takes no new room threads and no new requests; reading it
// (room.read) and every existing record stay open.
if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived');
// #1873 A3: the room's members are the thread instances that work in its code project. A project
// whose union refuses takes no new room thread or request, naming the project and why; its
// existing threads stay readable.
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, s.business)?.repo ?? null;
const unionRefused = this.#unionRefused(b, repo);
if ((opening || sending) && unionRefused) throw unionRefused;
const members = Object.keys(b.roles).filter((instance) => {
if (!this.#threaded(s.business, instance)) return false;
const refused = this.#projectRefused(b, instance, repo);
return !refused || (unionRefused !== null && refused.code === 'project-refused');
}).sort();
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
if (!members.length) fail('thread-instance-missing');
const owner = this.#owner(s);
// Preflight all collisions/missing threads before open creates or send writes anything.
@@ -8294,13 +8091,7 @@ export class Broker {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
// #1873 A3: a root or project copy takes a thread only when the caller names it, so leaving the
// instance out still chooses among the organization file's own thread instances.
const copies = b.copies ?? {};
// #1873 A3: the code project the chat project maps to decides which agents work in it.
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo ?? null;
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) &&
(a.instance !== undefined || (!Object.hasOwn(copies, r) && !this.#projectRefused(b, r, repo))));
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
const instance = a.instance === undefined
? (instances.length === 1 ? instances[0] : null)
: identifier(a.instance);
@@ -8314,18 +8105,9 @@ export class Broker {
fail('project-not-found');
// #797 MVP3: an archived project refuses new threads (and the room's requests, above).
if (this.#archived(s.business, identifier(a.project))) fail('project-archived');
// #1873 A3: a named copy the boot left unbound or refused says why, and a project whose union
// refuses takes no thread, naming the project and why.
if (a.instance !== undefined && instance) this.#copyRefused(b, instance);
const unionRefused = this.#unionRefused(b, repo);
if (unionRefused) throw unionRefused;
// The human selects an instance; omission is compatible only with one candidate.
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
// #1873 A3: a project agent works only in its own project, and an agent the project excludes
// doesn't work in it.
const elsewhere = this.#projectRefused(b, instance, repo);
if (elsewhere) throw elsewhere;
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
+2 -2
View File
@@ -67,7 +67,7 @@ class Failure extends Error {
// lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows
// the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a
// daily key quota is set and a check is switched on. Without keyLineages they refuse.
export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null,
export function createLimits({ store, read, log, makeLoader = createLoader, keyForSubject = () => null,
keyLineages = () => { throw new Failure('proxy-key-refused'); } }) {
if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log');
const loaders = new Map();
@@ -103,7 +103,7 @@ export function createLimits({ store, read, pluginDataRoot = null, log, makeLoad
return validLineages(value) ? value : null;
}
const loaderFor = business => {
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
if (!loaders.has(business)) loaders.set(business, makeLoader({ log }));
return loaders.get(business);
};
+2 -2
View File
@@ -4,7 +4,7 @@ import { createLoader } from '../../plugins/src/loader.mjs';
const validId = s => typeof s === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(s);
const validModel = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
const disabled = () => ({ status: 'disabled', observedAt: null, models: [], notices: [] });
export function createModelCatalogue({ client, settings, pluginDataRoot = null, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
export function createModelCatalogue({ client, settings, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
const cache = new Map(), flights = new Map(); let generation = 0, writing = 0;
function invalidate() { generation++; cache.clear(); }
function begin() { writing++; invalidate(); }
@@ -19,7 +19,7 @@ export function createModelCatalogue({ client, settings, pluginDataRoot = null,
try {
if (off(id)) return disabled();
const names = [...settings(id)['model-provider']];
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
const loader = makeLoader({ log: () => {} });
const result = await loader.load({ 'model-provider': names });
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
const c = client(); if (!c) throw Error();
+13 -12
View File
@@ -198,12 +198,12 @@ export class ModelKeys {
// Host-only callbacks are the existing synchronous organization path.
hire(instance, check, publish) {
return this.#exclusive(async () => {
await check(); // Fresh validation and captured revision AFTER controller queue.
check(); // Fresh validation and captured revision AFTER controller queue.
const existed = this.hasRecord(instance);
try {
await this.#issue(instance);
await check(); // Recheck captured revision AFTER HTTP, before publication.
return await publish();
check(); // Recheck captured revision AFTER HTTP, before publication.
return publish();
} catch (error) {
// A newly issued generation has not escaped to queued admission yet.
if (!existed && this.hasRecord(instance)) {
@@ -347,7 +347,7 @@ export function modelOrganization({ modelKeys, writer }) {
};
const wrap = invoke => (business, verb, args, actor, context) => {
const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
if (!isRevoke) return invoke(business, verb, args, actor, context);
if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
return (async () => {
if (pending.has(business)) fail('organization-busy');
const agent = args.name ?? args.instance, instance = business + '/' + agent;
@@ -355,11 +355,19 @@ export function modelOrganization({ modelKeys, writer }) {
if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
const { revision } = writer.read(business);
if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
const checkHire = () => {
if (writer.read(business).revision !== revision) fail('revision-conflict');
const checked = invoke(business, 'instance.hireCheck', args, actor, context);
if (checked.revision !== revision) fail('revision-conflict');
return checked;
};
if (!isRevoke) checkHire(); // No registration or proxy I/O before shared checks.
const prepare = { instance, revision, remoteRevoked: false };
pending.set(business, prepare);
const existed = modelKeys.hasRecord(instance);
try {
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
else return await modelKeys.hire(instance, checkHire, () => invoke(business, verb, args, actor, context));
return invoke(business, verb, args, actor, context);
} catch (error) {
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
@@ -367,12 +375,5 @@ export function modelOrganization({ modelKeys, writer }) {
} finally { pending.delete(business); }
})();
};
const hire = async (business, agent, check, publish) => {
if (pending.has(business)) fail('organization-busy');
const prepare = { instance: `${business}/${agent}`, revision: writer.read(business).revision, remoteRevoked: false };
pending.set(business, prepare);
try { return await modelKeys.hire(prepare.instance, check, publish); }
finally { pending.delete(business); }
};
return { writer: adapter, revoke, wrap, hire };
return { writer: adapter, revoke, wrap };
}
@@ -16,10 +16,8 @@ export function busOrganization(business, resolved) {
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].network = r.limits.network;
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].tools = [...(r.limits.tools ?? [])];
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
+1 -1
View File
@@ -88,7 +88,7 @@ function launchOp(m) {
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
if (m.op === 'request.chief') return runtime.requestChief(m.business);
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
+2 -2
View File
@@ -8,8 +8,8 @@ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
const ID = /^[a-f0-9]{12}$/;
const plain = n => n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : String(n);
// makeLoader is the plug-in loader's factory; a test wraps it to see what a module is handed.
export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log, dataRoot:pluginDataRoot });
export function createProxyUsage({ store, read, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log });
let due = 0, previous = '', pending = null, closed = false;
const states = new Map();
// #778: per enabled business, the batches taken off the proxy queue and then not written, since
-247
View File
@@ -1,247 +0,0 @@
// Static refusal table: each listed code maps to one rail and next step. These strings
// never interpolate caller arguments, credential references, policy values or file bytes.
const entries = [];
const group = (codes, rail, nextStep) => {
const guidance = Object.freeze({ rail, nextStep });
for (const code of codes.split(' ')) {
if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance');
entries.push([code, guidance]);
}
};
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
group(
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
"R7: caller access and scoped session boundary",
"Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity."
);
group(
"adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed",
"R3: fail closed on an unsatisfied operation precondition",
"Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect."
);
group(
"addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb",
"R3: validated vocabulary and request shape",
"Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal."
);
group(
"agent-required",
"Role/session: agent identity required",
"Use this tool only from an authorized managed agent session."
);
group(
"agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked",
"Role/session: current holder and lifecycle fence",
"Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity."
);
group(
"apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported",
"R1/R3: owner-managed model and provider configuration",
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
);
group(
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
"Role messaging: permitted sender, recipient and reply context",
"Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable."
);
group(
"command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed",
"R1/R8: scoped and revocable access",
"Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants."
);
group(
"outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked",
"R2/R3: current revision and verified effect",
"Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt."
);
group(
"confirm-required",
"R2/R3: explicit confirmation of the current operation",
"Read the current status and obtain the required confirmation through the supported human path."
);
group(
"decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused",
"Role decisions: current, matching, approved and single-use decision",
"Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused."
);
group(
"decision-not-allowed",
"Role authority: no free-text decision channel",
"Use the role's permitted message path to report the need to a person; do not use another decision channel."
);
group(
"decision-required",
"Role authority and R4: approval before acting",
"Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task."
);
group(
"decision-route-refused",
"Role messaging: decision close route",
"Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path."
);
group(
"definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused",
"Role configuration and protected instance controls",
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
);
group(
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
"Role task ownership, allowed fields and current task state",
"Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path."
);
group(
"task-required",
"Role decisions: a blocking decision belongs to a task",
"Supply the task_ref for this work, or raise with blocking false when there is no task."
);
group(
"fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown",
"Role access: declared service, host or remote and verified outcome",
"Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying."
);
group(
"human-required",
"R4/R8: person-only operation",
"A person must perform or approve this operation through its supported human path; an agent must not retry as a person."
);
group(
"land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref",
"Role review and landing: independent verdict on the current work",
"Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying."
);
group(
"library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted",
"Role tools: granted and configured web/library access",
"Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration."
);
group(
"message-not-found no-subject not-assistant operation-not-found operation-not-open",
"R7/role scope: visible object and allowed operation",
"Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable."
);
group(
"not-authorized",
"Role authority: held action or approved decision",
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
);
group(
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured",
"R3/role scope: configured project and service state",
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
);
group(
"not-resolver",
"Role resolve right and decision route",
"Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver."
);
group(
"prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec",
"Role workflow: current admitted request and approved requirements",
"Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing."
);
group(
"processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable",
"R2/R3: available audited storage and processing state",
"Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired."
);
group(
"read-only",
"Role/session: read-only capability",
"Use the permitted reads. Ask the owner or an authorized writer to perform the change."
);
group(
"reserved-event",
"R2: trusted append-only evidence",
"Use the typed operation that records its own evidence; never emit a reserved event directly."
);
group(
"role-not-chief",
"R9: only the configured chief manages hires",
"Send the staffing need to the configured chief through an allowed message path."
);
group(
"unauthenticated",
"R7: authenticated caller identity",
"Ask the launcher to restore the caller's own authenticated session; never borrow another capability."
);
group(
"request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full",
"R3/role tools: bounded protocol and available capacity",
"Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen."
);
// Finite error vocabularies forwarded by the project's, library's and request's
// typed adapters are part of the same table, not an unhelpful unknown-code path.
group(
"project-busy library-sync-busy",
"Role storage: exclusive write or sync already in progress",
"Wait for the current operation to finish, read its status, then retry once the lock is clear."
);
group(
"stale-base library-accept-stale",
"R2/R3: current file or served revision",
"Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly."
);
group(
"citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid",
"Role replies: validated citations, provenance and scope",
"Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance."
);
group(
"invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused",
"R3: typed request plan, delivery and evidence schema",
"Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence."
);
group(
"prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid",
"Role PRD workflow: current candidate, authors and admission",
"Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship."
);
group(
"request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused",
"Role request workflow: current phase, question and selected attachment",
"Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase."
);
group(
"request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision",
"R2/R3: current request revision and bound operation",
"Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision."
);
group(
"request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid",
"R1/R3: owner-configured service, mapping and accepted library state",
"Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect."
);
group(
"library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup",
"R2/R3: verified repository effect and recovery",
"Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly."
);
group(
"project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area",
"Role project scope: declared repository, clean state and allowed paths",
"Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks."
);
group(
"project-record-unchanged",
"R2: no new record effect for unchanged content",
"Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write."
);
group(
"library-write-story-path library-revert-not-agent",
"Role library writes: supporting paths and permitted agent-owned commits",
"Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope."
);
group(
"query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown",
"Role library reads: known universe, unambiguous selection and bounded result",
"Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying."
);
export const REFUSALS = Object.freeze(Object.fromEntries(entries));
const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." });
export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN;
+32 -87
View File
@@ -1,7 +1,3 @@
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationPlugins } from '../../plugins/src/organization.mjs';
import { pluginService } from '../../plugins/src/tools.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
@@ -37,11 +33,9 @@ import { createProxyLock } from './proxy-lock.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
@@ -63,7 +57,7 @@ import { discordProcessingHooks } from '../../discord/src/processing.mjs';
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.fire']);
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
// FED R4 (#1801, design 3.7): the revocation timer's period.
export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
@@ -77,10 +71,10 @@ export const ACCESS_SWEEP_MS = 30_000;
// web.fetch still works. A plug-in that refuses to load is logged and leaves search off; it never
// stops the broker. Under harness-builtin the harness serves web itself, so the broker gets no
// service and refuses the web verbs.
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) {
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps } = {}) {
const services = new Map();
for (const [name, b] of Object.entries(businesses)) {
const loader = createLoader({ log, dataRoot });
const loader = createLoader({ log });
await loader.load(b.plugins ?? {});
const [source] = loader.modules('search-source');
// "harness-builtin" is served by the harness itself, not the broker: no broker web verbs.
@@ -96,7 +90,7 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
// with a re-pin, and a re-pin restarts the bus.
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
const levels = new Map();
let names = [];
try {
@@ -104,12 +98,8 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
} catch (e) {
log({ event: 'harness-levels-unread', message: e.message });
}
if (dataRoot !== null) {
try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; }
catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); }
}
for (const name of names) {
const loader = createLoader({ log, dataRoot });
const loader = createLoader({ log });
if ((await loader.load({ harness: [name] })).refused.length) continue;
try {
const list = loader.modules('harness')[0].levels();
@@ -294,14 +284,7 @@ export async function startBroker({
builtinBinding = null,
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
// reload of the organization file below re-applies this snapshot, never the root file.
copies = null,
}) {
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
throw new BusError('invalid-host-request');
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot, snapshot: copies?.org === id ? copies : null });
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
throw new BusError('invalid-host-request');
@@ -416,20 +399,20 @@ export async function startBroker({
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot ?? dataRoot,exists:(kind,name)=>hasModule(kind,name,instanceRoot ?? dataRoot)}):null;
const levels=organization?await harnessLevels({dataRoot:instanceRoot ?? dataRoot}):()=>null;
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
const levels=organization?await harnessLevels():()=>null;
// Row 333 R4 (#1831): the writer's key lineage lookup, ids only, from this bus's own credential
// records. A daily key quota moved within one agent's lineage keeps its since and its count.
const keyLineage=(business,keyId)=>modelKeyLineage(credentials.status(),business,keyId);
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels,keyLineage}):null;
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
// Resolve legacy table entries with the enabled modules' declarations. A
// missing or ambiguous mapping never becomes an available route.
async function catalogueFor(id, options = {}) {
const snapshot = settings.writer.read(id);
const current = settings.current(id);
const names = current.plugins?.['model-provider'] ?? [];
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
const loader = makeProviderLoader({log:()=>{}});
const loaded = await loader.load({'model-provider': names});
const catalogue = await modelCatalogues.read(id, options);
if (loaded.refused.length || settings.writer.read(id).revision !== snapshot.revision) return {...catalogue, status:'unavailable', legacy:{}};
@@ -489,16 +472,16 @@ export async function startBroker({
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
}
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null;
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null;
const usageLog = entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`);
const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null;
// Row 333 R3 (#1831): the usage view marks key quotas that match no hired agent; limits answers.
let orphanedKeys = () => null;
const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun,
const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun,
proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) });
proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => {
proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
if (!modelKeys) return null;
await modelKeys.verifyIdentity();
return modelProxyClient;
@@ -508,7 +491,7 @@ export async function startBroker({
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
// each ask, so a limit, a reset or a switched-on check takes effect without a restart.
const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`);
const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
keyForSubject: (business, subject) => modelUsageKey(credentials.status(), modelRuns, business, subject),
keyLineages: business => modelKeyLineages(credentials.status(), business) });
orphanedKeys = business => limits.orphanedKeys(business);
@@ -581,7 +564,7 @@ export async function startBroker({
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
function reload(id) {
try {
const b = loadOrg(id);
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = withoutPending(id, busOrganization(b, resolved));
@@ -601,7 +584,7 @@ export async function startBroker({
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
// the product pending. Both readiness records must exist before even the broker's reload begins.
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : null;
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business');
@@ -612,7 +595,7 @@ export async function startBroker({
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
if (revision !== found.after) refuse('bootstrap-revision-stale');
const b = loadOrg(id);
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const full = busOrganization(b, resolved);
@@ -639,11 +622,7 @@ export async function startBroker({
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
// broker's reload now follows both readiness records (#802).
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
function admission({ business: id, project, projects, thread, session }) {
if (projects !== undefined) {
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
@@ -652,21 +631,9 @@ export async function startBroker({
if (!row) return no('session-not-found');
if (row.archived) return no('project-archived');
}
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
if (instance !== undefined) {
let now;
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
if (now === undefined) return no('organization-unreadable');
if (now !== launched) return no('organization-changed');
}
for (const p of projects) {
const result = admission({ business: id, project: p });
if (!result.ready) return result;
if (instance !== undefined) {
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
const barred = broker.projectBarred(id, instance, p);
if (barred) return no(barred);
}
}
return { ready: true, revision: null, code: null };
}
@@ -702,7 +669,6 @@ export async function startBroker({
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
pluginService: pluginService({dataRoot:instanceRoot ?? dataRoot,read:id=>organization ? loadOrg(id) : businesses[id]}),
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,
@@ -771,7 +737,7 @@ export async function startBroker({
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }),
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
@@ -787,7 +753,7 @@ export async function startBroker({
// plugins.set reloads the business's limit checks in the background.
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
const current = loadOrg(id);
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busOrganization(current, resolved));
@@ -802,7 +768,7 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.installed') return installedSystemTypes();
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
@@ -820,6 +786,8 @@ export async function startBroker({
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
// Internal read-only path: no reload, journal, credential or publication.
if(verb==='instance.hireCheck')return instances.checkHire(id,args);
let result;
if (['limits.set', 'instances.set'].includes(verb) && settings.current(id).plugins?.['model-provider']?.length) {
return catalogueFor(id, { fresh: true }).then(catalogue => {
@@ -841,21 +809,16 @@ export async function startBroker({
});
result=settings.setRoutes(id,args,actor);
}
else if(verb==='plugins.set') {
if(actor==='tester' && Object.entries(args.plugins ?? {}).some(([kind,value])=>(Array.isArray(value)?value:[value]).some(name=>name!==null && !hasModule(kind,name)))) throw new BusError('human-required');
result=plugins.set(id,args,actor);modelCatalogues?.invalidate();
}
else if(verb==='plugins.set'){result=plugins.set(id,args,actor);modelCatalogues?.invalidate();}
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 298: the human's grant scope, through the instance writer.
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name,revision:args.revision},{actor,context});
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
@@ -866,24 +829,6 @@ export async function startBroker({
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}):null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
hires: organization ? (()=>{
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
return {...service,apply:async(id,decision,caller)=>{
const proposal=service.read(decision);
const publish=async()=>{
let approved=caller;
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
await modelKeys.prepareRevoke(instance);
approved={...caller,beforeWrite:()=>modelKeys.completeRevoke(instance)};
}
const result=await service.apply(id,decision,approved);reload(id);return result;
};
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
return publish();
}};
})() : null,
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
@@ -900,7 +845,7 @@ export async function startBroker({
return settings;
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
@@ -973,7 +918,7 @@ export async function startBroker({
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({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog });
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 ?? {} });
@@ -995,7 +940,7 @@ export async function startBroker({
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot});
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);
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
@@ -1248,13 +1193,13 @@ export async function startBroker({
if (vars[prefix + 'model'] !== modelKey || vars[prefix + 'harness'] !== harness) return refused('model-provider-unavailable');
const entry = current.launch?.models?.[modelKey];
if (!entry || canonicalJson(entry) !== canonicalJson(expected ?? null) || entry.provider !== provider || entry.model !== model) return refused('model-provider-unavailable');
const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
const harnessLoader = createLoader({log:()=>{}});
if ((await harnessLoader.load({harness:[harness]})).refused.length) return refused('model-provider-unavailable');
const countsAccount = harnessLoader.modules('harness')[0].countsAccount();
if (typeof countsAccount !== 'boolean' || account !== (countsAccount ? current.launch?.account?.name ?? null : null)) return refused('model-provider-unavailable');
const names = current.plugins?.['model-provider'];
if (!Array.isArray(names) || !names.length || (entry.module && !names.includes(entry.module))) return refused('model-provider-unavailable');
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
const loader = makeProviderLoader({log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
if (loaded.refused.length) return refused('model-provider-unavailable');
const selected = selectModelProvider(loader.snapshot('model-provider'), entry);
if (!selected) return refused('model-provider-unavailable');
@@ -1271,7 +1216,7 @@ export async function startBroker({
return refused(['model-provider-unavailable','model-catalogue-unavailable','model-harness-incompatible'].includes(answer?.code) ? answer.code : 'model-provider-unavailable');
},
modelPreflight: async ({ business, role }) => {
const current = organization ? loadOrg(business) : businesses[business];
const current = settings ? settings.current(business) : businesses[business];
if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
},
+9 -10
View File
@@ -9,12 +9,11 @@ const LIMIT = 65536;
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
// Preserve every code and scoped reason, adding only static rail/next-step guidance.
const refusal = (e, other) => {
const error = e instanceof BusError ? e : new BusError(other);
return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep,
...(typeof error.reason === 'string' ? { reason: error.reason } : {}) };
};
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -49,16 +48,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
socket.end(JSON.stringify(result) + '\n');
};
socket.setTimeout(timeout, () => {
finish(refusal(new BusError('request-timeout')));
finish({ ok: false, error: 'request-timeout' });
socket.destroySoon();
});
socket.on('data', async (chunk) => {
if (done) return;
if (processing) return finish(refusal(new BusError('invalid-envelope')));
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
const total = input.length + chunk.length;
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
finish(refusal(new BusError('request-too-large')));
finish({ ok: false, error: 'request-too-large' });
return;
}
input = Buffer.concat([input, chunk]);
@@ -188,7 +187,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
}
});
socket.on('end', () => {
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
});
});
await new Promise((resolve, reject) => {
+2 -2
View File
@@ -53,10 +53,10 @@ export function turnRecords(store, business) {
};
});
}
export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
const loaders = new Map();
for (const [id, b] of Object.entries(businesses)) {
const loader = createLoader({ log, dataRoot:pluginDataRoot });
const loader = createLoader({ log });
await loader.load(b.plugins ?? {});
loaders.set(id, loader);
}
-122
View File
@@ -1,122 +0,0 @@
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
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 { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { BusError, Broker } from '../src/broker.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
const demo = () => ({
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
assistant: { definition: 'assistant', ...none },
gate: { definition: 'merge-gate', ...none },
'companion.stack': { definition: 'companion', ...none },
buddy: { definition: 'companion', ...none },
},
copies: {
gate: copy('gate', 'merge-gate', null, 'instance'),
buddy: copy('buddy', 'companion', null, 'instance'),
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
},
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
unions: {
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
},
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const b = new Broker({ store, businesses: { demo: demo() } });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args = {}) => b.request(human, { verb, args });
const refusal = (fn) => {
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
assert.fail('no refusal');
};
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
return { b, store, call, refusal, trail };
}
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
const f = setup(t);
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
assert.equal(refused.code, 'copy-refused');
assert.match(refused.reason, /^wide can't launch in demo: /);
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
assert.equal(project.code, 'project-refused');
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
// Regenerate takes the same check.
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
// A name that is no copy and no instance is still unknown.
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
// The trail holds each refusal's code, never its reason.
const trail = f.trail();
assert.match(trail, /credential-unbound/);
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
});
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
const f = setup(t);
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
const loose = f.call('project.create', { name: 'loose' }).id;
// Unnamed, the organization file's own thread instance is still the one choice.
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
assert.match(own.id, /\S/);
for (const project of [web, loose])
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
// A named copy the boot refused says why, before the thread instance check.
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
});
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
const f = setup(t);
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
// Another project's agent never joins, and the project's own exclusion holds.
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
assert.deepEqual(members(web), ['assistant']);
assert.deepEqual(members(loose), ['assistant', 'buddy']);
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
for (const fn of [
() => f.call('thread.create', { project: docs, name: 'plain' }),
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
() => f.call('room.open', { project: docs }),
]) {
const e = f.refusal(fn);
assert.equal(e.code, 'project-refused');
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
}
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
});
+1 -3
View File
@@ -37,9 +37,7 @@ const baselines = {
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
// to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+371 -7
View File
@@ -8,6 +8,7 @@ import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../organization/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
@@ -281,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: 'chief', delivery: 'chief' } },
{ arbiters: { technical: 'coder', delivery: 'coder' } },
{ roles: {} },
{ actor: 'jason' },
{ 'launch.by': 'coder' },
@@ -710,17 +711,380 @@ test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('A4 the retired lifecycle verbs always require a decision and never call the writer',t=>{
const {call,organization}=setup(t,KEPT);
for(const who of ['chief','coder','human'])for(const verb of ['instance.hire','instance.fire'])
assert.throws(()=>call(who,verb,{name:'coder',definition:'coder',reason:'help'}),{code:'decision-required'});
assert.equal(organization.length,0);
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
// each becomes its own event against the instance. Only a fire's end carries them.
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
const { b, call, allowed } = hireSetup(t);
call('chief', 'instance.fire', FIRE);
const [end] = allowed('lifecycle.end');
const request = end.body.request;
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
// sage-2289: only an alive stop carries a reason, and only unknown.
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
assert.equal(closed.state, 'closed');
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
// A terminate's end sends SIGTERM only, so its done has no stops.
const term = call('human', 'instance.end', { instance: 'pm' }).request;
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
assert.equal(allowed('lifecycle.end.killed').length, 1);
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.during = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = ([, verb]) => verb === 'instance.fire' && store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
// step A revokes, ends or marks anything, so the instance isn't left firing.
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
writer.during = null;
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
assert.equal(lastClaim('coder').op, 'claim');
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
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');
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(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busOrganization(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = setup(t,LISTED);
const { b, call, store } = hireSetup(t);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
+2 -3
View File
@@ -4,7 +4,6 @@ import { connect } from 'node:net';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
@@ -139,7 +138,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
});
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => {
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
@@ -149,7 +148,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal retains
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
});
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
});
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
+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: '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: [] } } } };
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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 });
+4 -4
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', 'decision.resolve.delivery'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
@@ -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: 'policy.change', route, close: null });
const id = grantDecision(f.store, { action: 'prd.approve', 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: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: {
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: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { 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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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: '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 } } } },
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 } } } },
};
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: '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' } } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: 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: '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 broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: 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: ['decision.resolve.delivery', 'message.send'], crossRole: [] } },
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
cto: { authority: { withinRole: ['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', 'decision.resolve.technical'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], 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: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = {
@@ -1,74 +0,0 @@
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
// and the writer runs the credential store's held-secret check on what it would write.
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fixture-held-token-t1-5c0e9a';
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
});
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const bus = await startBroker({ dataRoot: data, 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: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const revision = () => b.request(human, { verb: 'limits' }).revision;
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
// An agent, a reader and the tester never reach the writer.
const before = readFileSync(file);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
// Unknown and missing arguments refuse; an actor can't be forged through them.
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
assert.deepEqual(readFileSync(file), before);
// The human's invent commits, as the human, with a receipt that carries no role text.
const receipt = b.request(human, invent('scout'));
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
assert.equal(last.actor, 'jason');
assert.equal(last.state, 'committed');
// A writer refusal passes by its code.
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
// The bus refuses a request that carries the held token, and the writer refuses a template whose
// contract carries it (the import request itself holds no token). Neither writes.
const written = readFileSync(file);
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
assert.deepEqual(readFileSync(file), written);
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
});
+33 -35
View File
@@ -6,7 +6,6 @@
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
// test needs outside the human's yes go in by SQL as fixtures.
import test from 'node:test';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
@@ -157,7 +156,7 @@ async function setup(t) {
return { revision: REV };
};
const make = () =>
new Broker({hires:hireServiceFixture,
new Broker({
store,
businesses: { demo: structuredClone(doc) },
secretCheck: (value) => credentials.assertClean(value),
@@ -185,7 +184,7 @@ async function setup(t) {
store
.all("SELECT actor_role, subject, body FROM events WHERE business='demo' AND kind='action.allowed' ORDER BY seq")
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
.filter((e) => e.body.action === action && e.body.operation !== 'decision.raise');
.filter((e) => e.body.action === action);
// The bus's step G messages to the human: no decision, so no grant notice.
const inbox = () =>
store.all("SELECT body FROM messages WHERE business='demo' AND from_role='_bus' AND to_role='human' AND decision IS NULL ORDER BY rowid").map((r) => r.body);
@@ -233,8 +232,7 @@ async function setup(t) {
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
},
});
const hireConsole=fixtureConsole(broker);
const fire = name => approveFixtureFire(broker,hireConsole,name);
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'the work is done' });
// A delete as the launcher's pass runs it: the human's request, the take, then lifecycleDelete. A
// refused delete closes the request with its code, as the launcher does.
const remove = (instance) => {
@@ -306,7 +304,7 @@ function allRevoked(f, hosts = ['web1', 'web2']) {
test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers with a job, then revokes all three', async (t) => {
const f = await setup(t);
await threeGrants(f);
const out = await f.fire('coder');
const out = f.fire('coder');
assert.equal(out.done, false);
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1', 'host:web2', 'gitea']);
@@ -315,7 +313,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.equal(more.length, 0);
assert.equal(revoking.body.job, out.job);
assert.deepEqual(revoking.body.grants, out.revoking);
assert.equal(revoking.actor_role, null);
assert.equal(revoking.actor_role, 'chief');
assert.equal(revoking.subject, 'coder');
await f.broker.grantsIdle();
// Each host as hosts.revoke records it, with fire: true, line first so the close says removed.
@@ -342,7 +340,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
assert.deepEqual(done.body.grantsLeft, []);
assert.match(done.body.transaction, /^t-/);
assert.equal(done.actor_role, null);
assert.equal(done.actor_role, 'chief');
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
assert.ok(!f.doc.launch.instances.includes('coder'));
// No step G message for a job that completed.
@@ -482,7 +480,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
const f = await setup(t);
await threeGrants(f);
f.failing.add('web2');
const out = await f.fire('coder');
const out = f.fire('coder');
await f.broker.grantsIdle();
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(more.length, 0);
@@ -512,7 +510,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
assert.equal(f.state('coder', 'gitea'), 'valid');
// B back: the repeat revokes only B and Gitea.
f.failing.delete('web2');
const repeat = await f.fire('coder');
const repeat = f.fire('coder');
assert.deepEqual(repeat.revoking, ['host:web2', 'gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -531,7 +529,7 @@ test('row 281 T4b (Q1): a line a manual hosts.revoke left on C is removed by the
const tag3 = f.tag('coder', 'web3');
assert.equal(manual.hostLineLeft, tag3);
f.failing.delete('web3');
const out = await f.fire('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.lines.web3.has(tag3), false);
@@ -554,7 +552,7 @@ test('row 281 T4b (Q1): with C unreachable, the fire completes and names the lin
f.failing.add('web3');
await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
const tag3 = f.tag('coder', 'web3');
const out = await f.fire('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -570,7 +568,7 @@ test("row 281 T5: Gitea's admin token can't be checked: the job refuses with the
const f = await setup(t);
await f.approveGitea('coder');
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/version', { status: 500, json: {} });
const out = await f.fire('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
off();
@@ -599,7 +597,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
const helperDeletes = () => f.gitea.requests.filter(tokenDelete('demo-helper')).length;
const before = helperDeletes();
const offCoder = f.gitea.rule(tokenDelete('demo-coder'), { status: 500, json: {} });
const out = await f.fire('coder');
const out = f.fire('coder');
await f.broker.grantsIdle();
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(refused.grant, 'gitea');
@@ -610,7 +608,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
assert.equal(f.allowed('fire.done').length, 0);
// Gitea back for coder only. The helper's DELETE still fails, so a business-wide sweep would show.
offCoder();
const repeat = await f.fire('coder');
const repeat = f.fire('coder');
assert.deepEqual(repeat.revoking, ['gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -629,7 +627,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
test('row 281 T6 (sage-2574): a host decision approved before the fire and run after it ends revoked, with nothing applied', async (t) => {
const f = await setup(t);
const id = f.fixtureHost('coder', 'web1');
const out = await f.fire('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.host === 'web1');
@@ -646,7 +644,7 @@ test('row 281 T6 (sage-2574): a host decision approved before the fire and run a
test('row 281 T6b (sage-2574): a Gitea decision approved before the fire and run after it ends revoked before any Gitea write', async (t) => {
const f = await setup(t);
const id = f.fixtureGitea('coder');
const out = await f.fire('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.method === 'gitea');
@@ -691,7 +689,7 @@ test('row 281 T6c: a host executor in flight aborts and ends revoked; a Gitea ex
const gd = await f.raiseGitea('coder');
f.call('human', 'decision.resolve', { id: gd.id, choice: 'yes' });
await until(() => reached);
const out = await f.fire('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await until(() => f.ended(host.id));
assert.equal(f.ended(host.id).code, 'revoked');
@@ -716,7 +714,7 @@ test('row 281 T6d (B1): undecided host and Gitea decisions are in the set, and a
const f = await setup(t);
const host = await f.raiseHost('coder', 'web1');
const gd = await f.raiseGitea('coder');
const out = await f.fire('coder');
const out = f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.done').length, 1);
@@ -738,14 +736,14 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
await f.approveHost('coder', 'web1');
// Firing with no job: step B refused, so fire.stopped stands with no fire.done.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
await f.fire('coder');
f.fire('coder');
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.failed').length, 1);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// After fire.done: delisted.
f.org.fireError = null;
assert.match((await f.fire('coder')).transaction, /^t-/);
assert.match(f.fire('coder').transaction, /^t-/);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// A rehire lists the name again.
@@ -763,7 +761,7 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
test('row 281 T7: an instance with no grant fires as today, synchronously, with the same events', async (t) => {
const f = await setup(t);
const out = await f.fire('helper');
const out = f.fire('helper');
assert.deepEqual(Object.keys(out).sort(), ['cancelled', 'request', 'revision', 'runs', 'taken', 'transaction']);
assert.match(out.transaction, /^t-/);
// Recorded before the verb answered: no job.
@@ -783,9 +781,9 @@ test('row 281 T9 (B3): one step G per agent; the manual verbs refuse while it ru
await f.approveHost('coder', 'web1');
await f.approveGitea('coder');
f.held.add('web1:remove');
await f.fire('coder');
f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
await assert.rejects(f.fire('coder'),{code:'fire-in-progress'});
assert.equal(code(() => f.fire('coder')), 'fire-in-progress');
assert.equal(f.remove('coder').code, 'grants-revoking');
assert.equal(f.allowed('grants.revoking').length, 0);
assert.equal(f.allowed('fire.revoking').length, 1);
@@ -822,7 +820,7 @@ test('row 281 T10 (B2): the fire and the delete answer while their jobs are held
// The verbs answer before the event loop turns, so before any part of a job has run.
let turned = false;
setImmediate(() => (turned = true));
const fired = await f.fire('coder');
const fired = f.fire('coder');
const deleted = f.remove('ops');
assert.equal(turned, false);
assert.equal(fired.done, false);
@@ -846,7 +844,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.held.add('web1:remove');
const out = await f.fire('coder');
const out = f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
f.broker.endLaunch({ business: 'demo', run: 'chief-run', reason: 'test' });
f.sw.state = { version: 1, halted: true, generation: GENERATION };
@@ -857,7 +855,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
assert.match(done.body.transaction, /^t-/);
const [used, ...more] = f.allowed('role.fire');
assert.equal(more.length, 0);
assert.equal(used.actor_role, null);
assert.equal(used.actor_role, 'chief');
assert.equal(used.body.transaction, done.body.transaction);
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.allowed('fire.failed').length, 0);
@@ -869,12 +867,12 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
await f.approveHost('coder', 'web1');
// N7: the writer's own credential-revoke-failed, which step G's key revokes can't clear.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
const out = await f.fire('coder');
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed, ...more] = f.allowed('fire.failed');
assert.equal(more.length, 0);
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'credential-revoke-failed']);
assert.equal(failed.actor_role, null);
assert.equal(failed.actor_role, 'chief');
const [message] = f.inbox();
for (const s of [out.job, 'host:web1', 'step B', 'credential-revoke-failed']) assert.ok(message.includes(s), s);
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -884,7 +882,7 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
// The grant was revoked before step B.
assert.equal(f.hostRow('coder', 'web1'), undefined);
f.org.fireError = null;
const repeat = await f.fire('coder');
const repeat = f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -902,7 +900,7 @@ test('row 281 T13 (N4): a restart records each held job interrupted once, with o
assert.equal(f.allowed('grants.revoked.done').length, 1);
f.held.add('web1:remove');
f.held.add('web2:remove');
const fired = await f.fire('coder');
const fired = f.fire('coder');
assert.equal(f.remove('ops').code, 'grants-revoking');
const deleteJob = f.allowed('grants.revoking').at(-1).body.job;
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`) && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
@@ -932,7 +930,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
const d = await f.raiseHost('coder', 'web1');
// N1: hosts.remove doesn't check open decisions, so the decision outlives its host.
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
const out = await f.fire('coder');
const out = f.fire('coder');
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
@@ -948,7 +946,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
await f.broker.grantsIdle();
assert.equal(f.ended(d.id), undefined);
const repeat = await f.fire('coder');
const repeat = f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -963,7 +961,7 @@ test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
const out = await f.fire('coder');
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
@@ -989,7 +987,7 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
const out = await f.fire('coder');
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
+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', 'decision.resolve.delivery'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
@@ -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: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority } } } });
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority } } } });
for (const action of WAITS) {
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action);
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action);
@@ -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.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
});
test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => {
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: '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: [] } } } });
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' },
roles: { cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } } } });
f.agent('cto');
for (const action of LEFT) {
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: 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);
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);
}
});
@@ -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: 'chief' });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/);
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
});
test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => {
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: '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: [] } },
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, roles: {
cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } },
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
} });
const cto = f.agent('cto'), gate = f.agent('merge-gate');
@@ -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.chief, 'decision.raise', { action: 'policy.change', question: 'Change?',
const own = await f.call(f.reviewer, 'decision.raise', { action: 'policy.change', question: 'Change?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current });
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
@@ -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.chief, lead = f.agent('lead');
const chief = f.agent('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.chief;
const chief = f.agent('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.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
assert.throws(() => f.call(f.reviewer, '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: 'chief' });
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } };
const later = new Broker({ store: f.store, businesses: { demo: now } });
const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' });
@@ -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.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
// 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: 'chief' });
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
});
@@ -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: 'chief' });
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'reviewer' });
const own = await revoke(f, boss, 'assistant', { choice: 'yes' });
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0);
-207
View File
@@ -1,207 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
test('A4 direct instance.hire and instance.fire refuse and cannot write', t => {
const root = mkdtempSync(join(tmpdir(), 'a4-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
let writes = 0;
const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['role.hire','role.fire'], crossRole: [] } } }, launch: { by: 'chief', instances: [] } } }, organization: () => { writes++; } });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire']) assert.throws(() => b.request(chief, { verb, args: { name: 'scout', definition: 'coder', reason: 'Need help' } }), { code: 'decision-required' });
assert.equal(writes, 0);
});
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { readFileSync, mkdirSync } from 'node:fs';
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report sk-test-NOT-A-SECRET-1234.' };
const SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
function fixture(t) {
const root=mkdtempSync(join(tmpdir(),'a4-bus-')), rolesDir=rolesCopy(root);
const file=writeJson(join(root,'businesses/demo.json'),{...businessDoc(root,'demo'),plugins:{harness:['pi']}});
const storeRoot=join(root,'data');mkdirSync(storeRoot,{mode:0o700});
const writer=organizationWriter({dir:root,rolesDir}),store=new Store(storeRoot);
const service=hireDecisions({writer,rolesDir,dataRoot:storeRoot,configDir:root,assertClean:()=>{}});
const loaded=()=>{const doc=loadBusiness('demo',{dir:root,rolesDir});return {...doc,roles:Object.fromEntries(Object.entries(doc.roles).map(([n,e])=>[n,{...doc.definitions[e.definition],...e}]))};};
let writes=0;
const b=new Broker({store,businesses:{demo:loaded()},hires:{...service,apply:async(...args)=>{writes++;const r=await service.apply(...args);b.reloadBusiness('demo',loaded());return r;}}});
const owner=b.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
const member=rights=>b.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights});
const caps={owner,chief:b.bindLaunch({business:'demo',role:'chief',run:'chief-run',harness:'pi'}),coder:b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi'})};
b.request(caps.chief,{verb:'role.claim'});b.request(caps.coder,{verb:'role.claim'});
const call=(cap,verb,args={})=>b.request(cap,{verb,args});
const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra});
const raise=extra=>call(caps.chief,'hire.raise',request(extra));
t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
const reloadChief=authority=>{const doc=loaded();doc.roles.chief.authority=authority;b.reloadBusiness('demo',doc);};
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,reloadChief,writes:()=>writes};
}
test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{
const {call,caps,request,store}=fixture(t);
await assert.rejects(call(caps.coder,'hire.raise',request()),{code:'role-not-chief'});
await assert.rejects(call(caps.chief,'hire.raise',request({flagged:[]})),{code:'invalid-request'});
assert.equal(store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 approve is digest bound and consumed once; generic decide never resolves hire, fire or change',async t=>{
const {raise,call,owner,writes,store,file,b,caps}=fixture(t);
const d=await raise();const p=await call(owner,'hire.preview.console',{id:d.id});
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:'0'.repeat(64)}),{code:'decision-stale'});
for(const action of ['role.hire','role.fire','role.change']) {
if(action!==d.action)store.run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',action,'2026-01-01T00:00:00.000Z','demo','chief','chief-run','gated',action,'human','Question?',JSON.stringify([{key:'yes',text:'Yes'},{key:'no',text:'No'}]),'no',0);
assert.throws(()=>call(owner,'decision.resolve.console',{id:action===d.action?d.id:action,choice:'yes',digest:p.digest}),{code:'hire-resolution-required'});
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>call(human,'decision.resolve',{id:action===d.action?d.id:action,choice:'yes'}),{code:'hire-resolution-required'});
}
const before=readFileSync(file);
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest});
assert.equal(writes(),1);assert.notDeepEqual(readFileSync(file),before);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-closed'});
assert.equal(writes(),1);
for(const row of [...store.all('SELECT * FROM decisions'),...store.all('SELECT * FROM events')])assert.ok(!JSON.stringify(row).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 denied and expired decisions leave organization bytes untouched',async t=>{
const {raise,call,owner,file,writes}=fixture(t);
const before=readFileSync(file),d=await raise();
await call(owner,'hire.resolve.console',{id:d.id,choice:'no',digest:d.hire.digest});
const e=await raise(); const original=Date.now;
try{Date.now=()=>original()+8*86400000;await assert.rejects(call(owner,'hire.resolve.console',{id:e.id,choice:'yes',digest:e.hire.digest}),{code:'decision-expired'});}finally{Date.now=original;}
assert.deepEqual(readFileSync(file),before);assert.equal(writes(),0);
});
test('A4 members with org.hire refuse flagged or credential grants; decide alone refuses all hires',async t=>{
const {raise,call,member}=fixture(t);
const plain=await raise();
await assert.rejects(call(member(['decide']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest}),{code:'right-required'});
const flagged=await raise({role:{inline:{...INLINE,tools:['read','bash']}}});
const credential=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
for(const d of [flagged,credential])await assert.rejects(call(member(['org.hire']),'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'hire-owner-required'});
await call(member(['org.hire']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest});
});
test('A4 human Console start raises and resolves one recorded decision with computed lines',async t=>{
const {call,owner,request,store,writes}=fixture(t);
const r=await call(owner,'hire.start.console',request({role:{inline:{...INLINE,tools:['read','bash']}}}));
assert.equal(r.op,'resolved');assert.equal(writes(),1);
const event=store.all("SELECT body FROM events WHERE json_extract(body,'$.operation')='decision.raise'").map(e=>JSON.parse(e.body))[0];
assert.deepEqual(event.hire.flagged,[{field:'tools',value:'bash',reasons:['above-chief']}]);
assert.equal(store.get('SELECT raised_by_role FROM decisions WHERE id=?',r.id).raised_by_role,'human');
});
test('A4 a stale organization digest never reaches the writer',async t=>{
const {raise,call,owner,writer,file,writes}=fixture(t);
const d=await raise(),old=writer.read('demo');
writeJson(file,{...old.document,vars:{...old.document.vars,'tracker.pollSeconds':61}});
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'decision-stale'});
assert.equal(writes(),0);
});
test('A4 approved credential role refuses launch until a binding is present',async t=>{
const {raise,call,owner,b,caps,writer,file,root,rolesDir}=fixture(t);
const d=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest});
assert.throws(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}),{code:'credential-unbound'});
// Binding implementation is outside A4. The existing file shape supplies its metadata here.
const doc=writer.read('demo').document;
doc.roles.scout.credentials={gitea:{store:true,rotateBy:'2099-01-01'}};
writeJson(file,doc);
const loaded=loadBusiness('demo',{dir:root,rolesDir});
b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
assert.doesNotThrow(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}));
});
test('A4 refusals and records never carry invalid role text',async t=>{
const {raise,store}=fixture(t);
await assert.rejects(raise({role:{inline:{...INLINE,authority:{withinRole:['sk-test-NOT-A-SECRET-1234'],crossRole:[]}}}}),e=>{
assert.ok(!JSON.stringify(e).includes('sk-test-NOT-A-SECRET-1234'));return true;
});
assert.ok(!JSON.stringify(store.all('SELECT * FROM events')).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 a human may mark a hire seen, and the chief may withdraw its own proposal',async t=>{
const {raise,call,b,caps,writes}=fixture(t),d=await raise();
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.equal(call(human,'decision.seen',{id:d.id}).op,'seen');
assert.equal(call(caps.chief,'decision.withdraw',{id:d.id}).op,'withdrawn');
assert.equal(writes(),0);
});
test('A4 Console previews and consumes only the exact narrowed grants and route',async t=>{
const {raise,call,owner,writer,store}=fixture(t);
const d=await raise({role:{inline:{...INLINE,tools:['read','bash'],credentials:[{service:'gitea',scopes:['read:repository']}]}}});
const adjust={role:{tools:['read']},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
const p=await call(owner,'hire.preview.console',{id:d.id,adjust});
assert.notEqual(p.digest,d.hire.digest);assert.deepEqual(p.flagged,[]);assert.deepEqual(p.credentialLines,[]);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-stale'});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest,adjust});
const doc=writer.read('demo').document,entry=doc.definitions[doc.roles.scout.definition.slice(4)];
assert.deepEqual(entry.role.tools,['read']);assert.deepEqual(entry.role.credentials,[]);
const consume=JSON.parse(store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='hire.consume'").body);
assert.equal(consume.hire.digest,p.digest);assert.deepEqual(consume.hire.credentialLines,[]);
});
test('A4 Console lists template hashes without role text, under org.hire',async t=>{
const {call,owner,member}=fixture(t);
const list=await call(owner,'hire.templates.console');
assert.ok(list.templates.some(x=>x.name==='coder'&&/^[a-f0-9]{64}$/.test(x.sha256)));
assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256'));
assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'});
});
test('A4 chief proposals require current role.hire for hire/change and role.fire for fire',async t=>{
const f=fixture(t);
f.reloadChief({withinRole:['message.send'],crossRole:[]});
for(const op of ['hire','change','fire'])for(const verb of ['hire.raise','decision.raise']) {
const hire=f.request({op});
await assert.rejects(f.call(f.caps.chief,verb,verb==='decision.raise'?{action:`role.${op}`,hire}:hire),{code:'not-authorized'});
}
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
f.reloadChief({withinRole:['role.hire'],crossRole:[]});
assert.ok((await f.raise()).id);
assert.ok((await f.raise({op:'change',name:'coder'})).id);
const fire={op:'fire',level:'org',name:'coder',reason:'Finished'};
await assert.rejects(f.call(f.caps.chief,'hire.raise',fire),{code:'not-authorized'});
f.reloadChief({withinRole:['role.fire'],crossRole:[]});
assert.ok((await f.call(f.caps.chief,'hire.raise',fire)).id);
await assert.rejects(f.raise(),{code:'not-authorized'});
});
test('A4 chief authority is checked again after asynchronous proposal preparation',async t=>{
const f=fixture(t),pending=f.raise();
f.reloadChief({withinRole:['message.send'],crossRole:[]});
await assert.rejects(pending,{code:'not-authorized'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 a member start needing owner approval refuses before saving a decision',async t=>{
const f=fixture(t),cap=f.member(['org.hire']),before=readFileSync(f.file);
for(const inline of [{...INLINE,tools:['read','bash']},{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}])
await assert.rejects(f.call(cap,'hire.start.console',f.request({role:{inline}})),{code:'hire-owner-required'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
assert.deepEqual(readFileSync(f.file),before);
assert.equal((await f.call(cap,'hire.start.console',f.request())).op,'resolved');
});
test('A4 inbox and decision reads expire unopened hires once without changing configuration',async t=>{
const f=fixture(t),d=await f.raise({role:{inline:{...INLINE,tools:['read','bash']}}}),before=readFileSync(f.file);
const human=f.b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const original=Date.now;
try {
Date.now=()=>original()+8*86400000;
await assert.rejects(f.call(f.member(['org.hire']),'hire.preview.console',{id:d.id}),{code:'decision-expired'});
assert.deepEqual(f.call(human,'inbox'),[]);
const recent=f.call(human,'decisions',{list:'recent'});
assert.equal(recent[0].closed.by,'expiry');
const another=await f.raise();
Date.now=()=>original()+16*86400000;
assert.deepEqual(f.call(human,'inbox'),[]);
f.call(human,'decisions',{list:'recent'});
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND by='expiry'",another.id).n,1);
} finally {Date.now=original;}
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
});
-14
View File
@@ -1,14 +0,0 @@
// Trusted proposal service stand-in for executor-only tests. Authorization and real writers are
// covered by hire-decisions.test.mjs; these tests isolate the existing grant/fire executors.
export const hireServiceFixture = {
prepare: (_business,args,{id,expiresAt}) => ({id,op:args.op,level:args.level,name:args.name,revision:'b'.repeat(64),flagged:[],credentialLines:[],digest:'a'.repeat(64),expiresAt}),
check: () => {},
};
export const fixtureConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export const approveFixtureFire = async (broker,cap,name) => {const {id,op,choice,...result}=await broker.request(cap,{verb:'hire.start.console',args:{op:'fire',level:'org',name,reason:'The work is done'}});if(result.done===false)delete result.transaction;return result;};
export const ownerConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export async function approveTemplateHire(broker,rolesDir,{business='demo',name,definition,reason='Fixture hire',model='claude-opus-5-5',...extra}) {
const {readTemplate}=await import('../../organization/src/definitions.mjs');
const source=readTemplate(rolesDir,definition);
return broker.request(ownerConsole(broker,business),{verb:'hire.start.console',args:{op:'hire',level:'org',name,role:{template:definition,sha256:source.sha256},route:{harness:'pi',model},reason,...extra}});
}
+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', domain = 'delivery', grant, close = HUMAN_YES }) {
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', 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', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
JSON.stringify({ decision: id, action, operation: 'decision.raise', 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: '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: {} } },
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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: '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: {} } },
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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: '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 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 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', 'decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
pm: { authority: { withinRole: ['message.send'], 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 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] } } } });
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' } });
w.push(A);
w.approve(A);
const d = w.raise('cto');
const d = w.raise();
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.b.checkLand(w.caps.cto, { task_ref: TASK, decision: d.id });
const { ticket, by, via } = w.check({ 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', 'decision.resolve.technical'], crossRole: [] } },
pm: { authority: { withinRole: ['message.send'], 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] } },
@@ -6,7 +6,6 @@
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
// decisions. Tokens are made up or the fake's, and only ever compared.
import test from 'node:test';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -44,9 +43,9 @@ async function until(fn, ms = 10000) {
}
const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({
id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
// sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent.
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 } },
roles: { ...(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 } } : {}),
});
@@ -69,7 +68,7 @@ function world(t, options) {
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = makeBusiness({ ...options, root });
const held = new Set();
const broker = new Broker({hires:hireServiceFixture, store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
}
@@ -78,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: 'chief', delivery: 'chief' },
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
roles: {
boss: { definition: 'pm', authority: chiefAuthority },
// sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference.
@@ -139,7 +138,7 @@ async function verbWorld(t, opts = {}) {
}
return { revision: REV };
};
broker = new Broker({hires:hireServiceFixture,
broker = new Broker({
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
});
@@ -175,7 +174,7 @@ async function verbWorld(t, opts = {}) {
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
};
const fire = async () => {
await approveFixtureFire(broker,fixtureConsole(broker,'acme'),'chief');
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
await until(() => !doc.launch.instances.includes('chief'));
await broker.grantsIdle();
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
@@ -197,7 +196,7 @@ test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s li
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
grant(w.store, 'chief', { user: 'chief-bot' });
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
assert.deepEqual(new Broker({hires:hireServiceFixture, store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
'a bus with no grant store refuses rather than guess');
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
+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: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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: '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' } }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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.
+3 -4
View File
@@ -1,5 +1,4 @@
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
import { refusalGuidance } from '../src/refusals.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
@@ -181,7 +180,7 @@ test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope
socket.on('error', reject); socket.on('connect', () => socket.write(request));
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
});
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
assert.equal(f.seen.length, 0);
});
@@ -235,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: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
arbiters: { technical: 'chief', delivery: 'chief' } };
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
arbiters: { technical: 'coder', delivery: 'coder' } };
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: '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() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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: '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' } } } } };
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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 });
+8 -10
View File
@@ -1,4 +1,3 @@
import { fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
// Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials
// after the writer's checks and before the write. Fire revokes each service and keeps the store
// directory. Delete calls remove (sage-1593). A failed revoke stops the verb with
@@ -55,8 +54,7 @@ async function setup(t, { drop = [], seed = null } = {}) {
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const hireConsole=fixtureConsole(bus.broker);
const fire = name => approveFixtureFire(bus.broker,hireConsole,name);
const fire = (name) => bus.broker.request(chief, { verb: 'instance.fire', args: { name, reason: 'the work is done' } });
const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent);
const plant = (agent, service) => {
mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 });
@@ -87,12 +85,12 @@ test('T20 fire: a revoke that fails stops the fire with the file unchanged; one
// The revoke can't unlink the token, so it fails before the write: the file still lists coder.
const before = f.bytes();
chmodSync(f.agentDir('coder'), 0o500);
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
chmodSync(f.agentDir('coder'), 0o700);
assert.equal(f.bytes(), before);
assert.ok(JSON.parse(before).launch.instances.includes('coder'));
const fired = await f.fire('coder');
const fired = f.fire('coder');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] });
assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder'));
@@ -111,7 +109,7 @@ test('T20 fire: an instance already delisted still has its tokens revoked and th
doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2');
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const before = f.bytes();
const fired = await f.fire('rev-2');
const fired = f.fire('rev-2');
assert.equal(fired.delisted, false);
assert.equal(fired.transaction, null);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
@@ -124,10 +122,10 @@ test('T20 fire: with no entry in the running bus, a token on disk refuses and an
const f = await setup(t, { drop: ['rev-2'] });
f.plant('rev-2', 'gitea');
const before = f.bytes();
await assert.rejects(f.fire('rev-2'),{code:'credential-revoke-failed'});
assert.equal(f.code(() => f.fire('rev-2')), 'credential-revoke-failed');
assert.equal(f.bytes(), before);
rmSync(join(f.agentDir('rev-2'), 'gitea'));
const fired = await f.fire('rev-2');
const fired = f.fire('rev-2');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
});
@@ -200,11 +198,11 @@ test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses st
const f = await setup(t);
f.plant('coder', 'host.web1');
const before = f.bytes();
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
assert.equal(f.bytes(), before);
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
rmSync(join(f.agentDir('coder'), 'host.web1'));
assert.equal((await f.fire('coder')).delisted, true);
assert.equal(f.fire('coder').delisted, true);
});
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
+2 -2
View File
@@ -26,7 +26,7 @@ const doc = (extra = {}) => ({
retired: ['old'],
...extra,
});
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 } };
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { '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: 'chief', delivery: 'chief' } });
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', 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: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: {
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: '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' } } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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:'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 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 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:'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 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 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:'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 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 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(['decision.resolve.technical']),
cto: role(),
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:'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 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 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:'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 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 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});
@@ -1,5 +1,3 @@
import {readTemplate} from '../../organization/src/definitions.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// #655 isolated STATIC fixture: actual managed fromSession caller + runtime/writer/controller.
import fs from 'node:fs';
import http from 'node:http';
@@ -27,10 +25,10 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
// Available before any acquired resource/setup can fail; no later f/launcher TDZ.
const launchLog=()=>{const file=launchLogFile(join(root,'data'),'acme');return fs.existsSync(file)?fs.readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[];};
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'];if(!listed)delete doc.roles.aide;return doc;});
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\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');
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:[]});}
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);
const state={keys:[sentinel],calls:[],refuseProbe,counts:{GET:0,PUT:0,PROBE:0},puts:[],failRemoval:false};
@@ -112,7 +110,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
const file=join(configDir,'businesses','acme.json');
const hold=(method='GET',ordinal=state.counts[method]+1)=>{let release;const promise=new Promise(r=>{release=r;});const gate={method,ordinal,seen:false,used:false,cancelled:false,promise,release};gates.push(gate);return {ordinal,seen:()=>gate.seen,entered:()=>waitFor(()=>gate.seen,'owned HTTP gate not reached',5000),release,cancel:()=>{gate.cancelled=true;release();}};};
const outcome=promise=>{const box={settled:false};box.promise=track(Promise.resolve(promise).then(value=>{box.settled=true;box.ok=true;box.value=value;return box;},error=>{box.settled=true;box.ok=false;box.code=error.code??'unexpected-error';return box;}));return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=(async()=>{const definition=extra.definition??'aide';const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(f.roles,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture ownership',...(extra.vars?{vars:extra.vars}:{})}});return bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.resolve.console',args:{id:d.id,choice:'yes',digest:d.hire.digest}});})();called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=bus.broker.request(cap,{verb:'instance.hire',args:{name,definition:'aide',reason:'fixture ownership',...extra}});called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const record=(name='aide')=>bus.credentials.modelRecord('acme/'+name);
const snapshot=()=>{const journal=join(configDir,'businesses','.history','acme','changes.jsonl');return {membership:id(fs.readFileSync(file)),journal:fs.existsSync(journal)?id(fs.readFileSync(journal)):null,credentials:id(JSON.stringify(bus.credentials.status())),model:record()?id(JSON.stringify(record())):null,remote:id(JSON.stringify(state.keys)),calls:state.calls.length};};
return {root,f,state,trace,proxyReceipts,logs,launcher,bus,chief,record,hold,outcome,hire,snapshot,file,launchLog,expectedKeyId:()=>expectedKeyId,valueFree,
@@ -125,7 +123,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
assertModelAbsent:name=>{assert.equal(bus.credentials.modelRecord('acme/'+name)===null,true);assert.equal(fs.existsSync(join(configDir,'secrets','agents','acme',name,'model')),false);},
rotateBound:async()=>{const before=record();bus.credentials.writeModel('acme/aide',{...before,rotateBy:new Date(Date.now()-86400_000).toISOString().slice(0,10)});await waitFor(()=>record()?.retiring.some(r=>r.keyId===before.keyId),'original runtime sweep rotates bound generation');return before.keyId;},
explicitEnd:()=>bus.broker.request(chief,{verb:'role.revoke',args:{role:'aide',reason:'fixture explicit end'}}),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture explicit fire'}}))),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture explicit fire'}}))),
runDirectories:()=>fs.existsSync(runsDir(f.dataRoot,'acme'))?fs.readdirSync(runsDir(f.dataRoot,'acme')).map(name=>join(runsDir(f.dataRoot,'acme'),name)):[],
sessions:()=>readSessions(f.dataRoot),send:body=>bus.broker.request(human,{verb:'message.send',args:{to:'aide',body,class:'REQUEST'}}),
reply:sent=>waitFor(()=>bus.broker.request(human,{verb:'message.receive'}).find(m=>m.in_reply_to===sent.id),'real managed reply observed'),
@@ -38,8 +38,9 @@ test('655 actual launch own preflight generation and bound consumer survive conc
for(const [label,options,args,code] of [
['retired name',{retired:true},{name:'alpha'},'organization-instance-retired'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'invalid-request'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'invalid-request'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
['fired mismatched legal definition',{listed:false},{definition:'helper'},'organization-instance-exists'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
])test('655 shared validation '+label+' has no credential effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot(),{name='aide',...extra}=args;await refused(f.hire(name,extra),code);
assert.deepEqual(f.snapshot(),before,'shared hire refusal precedes credential registration or proxy I/O');f.valueFree();
@@ -48,7 +49,7 @@ for(const [label,options,args,code] of [
for(const [label,options,extra,code] of [
['kept target',{keep:true},{},'instance-kept'],
['noHire definition',{noHire:true},{},'definition-not-hireable'],
['duplicate with another definition',{}, {definition:'security-reviewer'},'organization-instance-exists'],
['ineligible definition',{}, {definition:'security-reviewer'},'definition-not-hireable'],
['invalid vars',{}, {vars:{harness:'pi'}},'invalid-request'],
])test('655 existing Broker '+label+' refusal remains before model effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot();await refused(f.hire('aide',extra),code);assert.deepEqual(f.snapshot(),before);f.valueFree();
@@ -58,9 +59,9 @@ test('655 existing nonchief actor refusal remains before shared checks and model
const f=await ownershipFixture(t),cap=f.wrongActor(),before=f.snapshot();await refused(f.hire('aide',{},cap),'role-not-chief');assert.deepEqual(f.snapshot(),before);f.valueFree();
});
test('655 valid new hire publishes before queued acquisition',{timeout:30000},async t=>{
test('655 valid fired matching definition rehire publishes before queued acquisition',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();assert.equal(f.listed('aide'),false);
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.code,'unknown-role','no admission before membership publication');assert.equal(f.listed('aide'),true);const admitted=await done(f.preflight());assert.equal(admitted.ok,true);assert.equal(admitted.value.key===f.record().key,true);assert.equal(await f.healthy(admitted.value.key),200);f.valueFree();
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.ok,true);assert.equal(f.listed('aide'),true);assert.equal(p.value.key===f.record().key,true);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
});
test('655 preexisting admitted generation and retiring metadata survive duplicate failure',{skip:unavailable,timeout:30000},async t=>{
@@ -73,22 +74,22 @@ test('655 captured revision is checked after controller queue before generation
const f=await ownershipFixture(t),gate=f.hold('GET'),p=f.preflight('chief');await gate.entered();const h=f.hire('alpha');
// A following same-business operation proves H established its pending slot,
// rather than an arbitrary sleep before the external owner revision change.
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'decision-stale');
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'revision-conflict');
assert.equal(f.state.counts.PUT,1,'queued stale hire does not issue a generation');f.assertModelAbsent('alpha');assert.equal(f.listed('alpha'),false);f.valueFree();
});
test('655 genuine new generation revision failure cleans before queued acquisition receives a key',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();
const failedGeneration=f.state.puts[0].find(k=>!k.includes('-sentinel-'));assert.ok(failedGeneration,'real first issuance payload observed');
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'decision-stale');await done(p);
assert.equal(p.code,'unknown-role','an unpublished agent cannot acquire a model key');
assert.equal(f.state.keys.includes(failedGeneration),false,'failed generation was removed');f.assertModelAbsent('aide');assert.equal(f.listed('aide'),false);f.valueFree();
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'revision-conflict');await done(p);
const valid=p.ok&&p.value.key!==failedGeneration&&f.record()?.key===p.value.key&&f.state.keys.includes(p.value.key)&&!f.state.keys.includes(failedGeneration);
assert.equal(valid,true,'queued acquisition receives a valid post-cleanup generation');assert.equal(f.listed('aide'),false);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
});
test('655 failed cleanup retains revoking intent and refuses queued admission',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();f.bumpRevision();f.state.failRemoval=true;put.release();
await refused(h,'decision-stale');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='unknown-role',true,'unpublished membership refuses admission while model record retains revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
await refused(h,'revision-conflict');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='proxy-key-revoking',true,'queued admission refuses retained revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
});
test('655 no-launch remains before own preflight and grants no consumer or stop authority',{timeout:30000},async t=>{
+10 -28
View File
@@ -1,6 +1,3 @@
import {materializeCopies,withCopies} from '../../organization/src/agent-copies.mjs';
import {loadRootAgents} from '../../organization/src/agent-levels.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// P2: real private Credentials, fake loopback management/model server. No live config.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -176,34 +173,31 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
stopped = true; await overdue.sweep(); assert.equal(f.state.keys.includes(first.key), false);
});
async function bootBusiness(f, {copyLevel = null,...binding} = {}) {
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\n## Authority\n');
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'] });
doc.plugins = { ...doc.plugins, harness: ['pi'] };
doc.roles['sec-1'] = { definition: 'security-reviewer' }; doc.launch.instances.push('sec-1');
const copyEntry={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5'}};
if(copyLevel==='project')doc.projects.stack.agents={aide:copyEntry};
if(copyLevel==='root')writeJson(join(f.configDir,'agents.json'),{agentsVersion:1,agents:{aide:copyEntry}});
const copies=copyLevel?materializeCopies({orgId:'demo',doc,rolesDir,root:loadRootAgents(f.configDir,{rolesDir})}):null;
const file = writeJson(join(f.configDir, 'businesses', 'demo.json'), doc);
fs.writeFileSync(join(f.configDir, 'secrets', 'proxy-management.key'), 'fx331-manage', { mode: 0o600 });
const business = copies?withCopies(doc,file,{rolesDir,snapshot:copies}):loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
const business = loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
// Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
new Registry(join(f.root, 'data'));
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busOrganization(business, resolved) },
copies, organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
f.closers.push(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const fire = () => bus.broker.request(ownerConsole(bus.broker), { verb: 'hire.start.console', args: { op:'fire',level:'org',name: 'sec-1', reason: 'fixture fire' } });
const fire = () => bus.broker.request(chief, { verb: 'instance.fire', args: { name: 'sec-1', reason: 'fixture fire' } });
const listed = () => JSON.parse(fs.readFileSync(file, 'utf8')).launch.instances.includes('sec-1');
return { bus, chief, fire, listed, file, rolesDir, system };
}
@@ -239,7 +233,7 @@ test('proxy-revoke-revision-conflict: racing organization write keeps record, ne
});
test('bus hire issues and confirms implicit model key before publishing new membership', async t => {
const f = await fixture(t); const b = await bootBusiness(f); const calls = f.state.calls.length;
const result = await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
const result = await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
assert.equal(typeof result.transaction, 'string');
assert.equal(JSON.parse(fs.readFileSync(b.file, 'utf8')).launch.instances.includes('sec-new'), true);
assert.equal(b.bus.credentials.modelRecord('demo/sec-new')?.state, 'valid');
@@ -248,7 +242,7 @@ test('bus hire issues and confirms implicit model key before publishing new memb
test('proxy-key-issue-failed: bus hire failure publishes neither membership nor model file', async t => {
const f = await fixture(t); const b = await bootBusiness(f); f.state.failPut = true;
const before = fs.readFileSync(b.file, 'utf8');
await assert.rejects(approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'}), failCode('proxy-key-issue-failed'));
await assert.rejects(b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } }), failCode('proxy-key-issue-failed'));
assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.bus.credentials.modelRecord('demo/sec-new') === null, true);
});
test('proxy-open: removing sentinel after boot refuses the next launch before models probe', async t => {
@@ -258,7 +252,7 @@ test('proxy-open: removing sentinel after boot refuses the next launch before mo
});
test('newly hired role can use runtime preflight without a broker restart', async t => {
const f = await fixture(t); const b = await bootBusiness(f);
await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
const proxy = await b.bus.modelPreflight({ business: 'demo', role: 'sec-new' });
assert.equal(proxy.baseUrl, f.baseUrl); assert.equal(f.state.keys.includes(proxy.key), true);
});
@@ -334,15 +328,3 @@ test('beta bus registers while alpha bus runs and alpha key stays accepted', asy
assert.ok(f.state.keys.some(value => value.startsWith(`mso-${hash(f.configDir)}-beta-sentinel-`)));
assert.equal((await alpha.bus.modelPreflight({ business: 'demo', role: 'sec-1' })).key, key);
});
for(const level of ['root','project'])test(`A4 ${level} fire removes its implicit model key before publication`,async t=>{
const f=await fixture(t),b=await bootBusiness(f,{copyLevel:level});
const instance=level==='root'?'aide':'aide.stack';
const issued=await b.bus.modelPreflight({business:'demo',role:instance});
assert.ok(issued.key);
await b.bus.broker.request(ownerConsole(b.bus.broker),{verb:'hire.start.console',args:{op:'fire',level,...(level==='project'?{project:'stack'}:{}),name:'aide',reason:'Copy work finished'}});
assert.equal(f.state.keys.includes(issued.key),false,'remote generation is revoked');
assert.equal(b.bus.credentials.modelRecord('demo/'+instance),null,'private generation is removed');
const human=b.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>b.bus.broker.request(human,{verb:'instance.launch',args:{instance}}));
});
@@ -1,4 +1,3 @@
import { approveTemplateHire } from './hire-fixtures.mjs';
// #651 actual runtime + organization.hire publication with orphan private records.
// In-process trusted Broker, real SQLite/organization writer/HTTP; not process IPC.
import test from 'node:test';
@@ -16,8 +15,8 @@ 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\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');doc.plugins={...doc.plugins,harness:['pi']};
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']});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});
const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
@@ -28,7 +27,7 @@ async function hireFixture(t,{revoking=false}={}){
assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
const listed=()=>JSON.parse(fs.readFileSync(file,'utf8')).launch.instances.includes('orphan'),observed=[];f.state.observePut=()=>observed.push(listed());f.state.calls=[];
const hire=()=>approveTemplateHire(bus.broker,rolesDir,{name:'orphan',definition:'aide'});
const hire=()=>bus.broker.request(cap,{verb:'instance.hire',args:{name:'orphan',definition:'aide',reason:'fixture orphan hire'}});
return {...f,bus,issued,file,log,listed,observed,hire};
}
test('L3b runtime hire publishes orphan only after fresh remote confirmation',async t=>{
+2 -4
View File
@@ -1,5 +1,3 @@
import { readTemplate } from '../../organization/src/definitions.mjs';
import { ownerConsole } from './hire-fixtures.mjs';
// #654 isolated real runtime/writer/controller with owned HTTP request gates.
import fs from 'node:fs';
import http from 'node:http';
@@ -24,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\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:[]});
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']});
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);
@@ -46,7 +44,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const holdNext=(method='GET')=>{assert.equal(state.nextGate,null,'only one owned HTTP hold armed');const g={method,entered:deferred(),release:deferred()};gates.push(g);state.nextGate=g;return {entered:()=>bounded(g.entered.promise,'owned lifecycle HTTP boundary not observed'),release:()=>g.release.resolve()};};
const listed=(business,name)=>JSON.parse(fs.readFileSync(files[business],'utf8')).launch.instances.includes(name);
const snapshot=business=>{const journal=join(configDir,'businesses','.history',business,'changes.jsonl');return {membership:hash(fs.readFileSync(files[business])),publicationJournal:fs.existsSync(journal)?hash(fs.readFileSync(journal)):null,modelStatus:hash(JSON.stringify(bus.credentials.status())),remote:hash(JSON.stringify(state.keys)),calls:state.calls.length};};
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>(async()=>{const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(rolesDir,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture pending hire'}});return bus.broker.request(ownerConsole(bus.broker,business),{verb:'hire.resolve.console',args:{id:d.id,digest:d.hire.digest,choice:'yes'}});})()));
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>bus.broker.request(cap,{verb:'instance.hire',args:{name,definition,reason:'fixture pending hire'}})));
const busy=async promise=>{const result=await bounded(promise.then(()=>({code:'unexpected-success'}),e=>({code:e.code??'unexpected-error'})),'same-business refusal not observed');assert.equal(result.code,'organization-busy','second same-business lifecycle call refuses exact pending code');};
return {root,configDir,dataRoot,rolesDir,bus,state,receipts,chief,holdNext,listed,snapshot,hire,busy,valueFree,
healthy:async(business,name)=>{const value=await bus.modelPreflight({business,role:name});const record=bus.credentials.modelRecord(business+'/'+name);assert.equal(value.key===record.key,true);assert.equal(state.keys.includes(record.key),true,'published member retains confirmed model credential');},
+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:'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'})};}
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'})};}
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');});
@@ -1,4 +1,3 @@
import {ownerConsole} from './hire-fixtures.mjs';
// #656 STATIC isolated fixtures. No real config/key/proxy; no production seam.
import fs from 'node:fs';
import http from 'node:http';
@@ -52,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\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);
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);
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);};
@@ -75,7 +74,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
syncBuiltinESMExports();restoreFs=()=>{Object.assign(fs,original);syncBuiltinESMExports();};
const record=()=>c.modelRecord(instance),exists=path=>{try{original.lstatSync(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}};
const seed=({matchingTmp=true,retiring=0,valid=false}={})=>{const current=key(),old=Array.from({length:retiring},()=>({key:key(),issuedAt:'2026-10-23T00:00:00.000Z',retireAt:'2099-01-01T00:00:00.000Z'})).map(r=>({...r,keyId:id(r.key)}));const value={key:current,keyId:id(current),issuedAt:'2026-10-24T00:00:00.000Z',rotateBy:'2099-01-01',state:valid?'valid':'revoking',...(valid?{}:{revokingAt:'2026-10-25T00:00:00.000Z'}),retiring:old};state.keys.push(current,...old.map(r=>r.key));c.writeModel(instance,value);if(matchingTmp){fs.copyFileSync(file,temporary);fs.chmodSync(temporary,0o600);}removedTmp=false;return value;};
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(ownerConsole(bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const writeSessions=entries=>writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:entries});
const ownRun=async({wrongTicks=false,wrongKey=false,publish=true}={})=>{
const spawnOwn=async()=>{const handle=spawn('/usr/bin/sleep',['60'],{stdio:'ignore'}),closed=new Promise(resolve=>handle.once('close',resolve));const owned={handle,closed};children.push(owned);await once(handle,'spawn');const ticks=startTimeOf(handle.pid);assert.equal(typeof ticks,'string');return {...owned,ticks,exit:async()=>{if(handle.exitCode===null&&handle.signalCode===null)handle.kill('SIGTERM');await bounded(closed,'owned stand-in exit timeout');}};};
+1 -1
View File
@@ -24,7 +24,7 @@ const demo = () => ({
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'chief', delivery: 'chief' },
arbiters: { technical: 'lead', 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:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{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:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
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:[]}}}}},
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:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
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:[]}}}}},
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:'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 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:[]}}}}},
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();
+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: '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 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 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: '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 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 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:'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 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 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'));
@@ -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: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { 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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
} } };
function fixture(t, extra = {}) {
+1 -1
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: 'chief', delivery: 'chief' },
id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' },
roles: {
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
+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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
} };
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
+3 -4
View File
@@ -5,7 +5,6 @@ import { join } from 'node:path';
import { fixture, git } from './spec-fixtures.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
// Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor.
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
@@ -110,11 +109,11 @@ test('a request that names the save verb first and another verb last is refused
const f = await socketFixture(t);
const pad = 'x'.repeat(100000);
const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`;
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large' });
const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n');
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large' });
const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n');
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
assert.deepEqual(over, { ok: false, error: 'request-too-large' });
});
test('two saves racing for one repository make one commit', async t => {
+2 -4
View File
@@ -13,8 +13,6 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
"import { ownRails } from '../../organization/src/rails.mjs';",
"import { refusalGuidance } from './refusals.mjs';",
"import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';",
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
@@ -41,7 +39,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
@@ -66,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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: {
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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:'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 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 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:'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 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 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: '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: [] } } } } },
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' },
vars: { 'bus.tester': true }, roles: { 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: 'chief', delivery: 'chief' },
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
roles: {
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
plain: { authority: pusher },
-147
View File
@@ -1,147 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { connect } from 'node:net';
import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs';
import { Broker, BusError } from '../src/broker.mjs';
import { REFUSALS, refusalGuidance } from '../src/refusals.mjs';
import { Store } from '../src/store.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
import { railsText } from '../../organization/src/rails.mjs';
import { GUARD_CODES } from '../../library/src/reply-protocol.mjs';
const SECRET = 'sk-test-NOT-A-SECRET-1234';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'rails-'));
const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES });
const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })]));
writeFileSync(business.roles.coder.credentials.gitea.file, SECRET);
const resolved = resolve(business), store = new Store(root);
const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } });
const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' });
broker.request(cap, { verb: 'role.claim' });
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
return { root, business, resolved, resolve, broker, store, cap };
}
test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => {
const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args });
const before = f.store.get('SELECT count(*) n FROM events').n;
assert.deepEqual(call({}), f.resolved.coder.rails);
const result = call({}); result.actions.length = 0;
assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy');
const business = structuredClone(f.business);
business.roles.coder.vars['limits.authority'] = ['message.send'];
const narrowed = f.resolve(business);
f.broker.reloadBusiness('acme', busOrganization(business, narrowed));
assert.deepEqual(call({}), narrowed.coder.rails);
assert.equal(f.store.get('SELECT count(*) n FROM events').n, before);
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
});
test('my_rails cannot select another identity or expose credential refs and file/call values', t => {
const f = fixture(t);
for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) {
assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' });
}
const rails = f.broker.request(f.cap, { verb: 'my_rails' });
const text = JSON.stringify(rails) + railsText(rails);
for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value);
const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' });
});
function raw(path, request) {
return new Promise((resolve, reject) => {
const socket = connect(path); let out = '';
socket.on('error', reject);
socket.on('connect', () => socket.end(JSON.stringify(request) + '\n'));
socket.on('data', b => out += b);
socket.on('end', () => resolve(JSON.parse(out)));
});
}
test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => {
const f = fixture(t), path = join(f.root, 'bus.sock');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } };
const refused = await raw(path, request);
assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found');
assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']);
assert.ok(!JSON.stringify(refused).includes(SECRET));
await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => {
assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true;
});
const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery',
question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } });
const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } });
assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/);
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0);
// Exercise an actual cross-role action without its required decision, not just resolve.
const live = busOrganization(f.business, f.resolved);
live.roles.coder.authority.crossRole.push('role.end');
f.broker.reloadBusiness('acme', live);
const decisions = f.store.get('SELECT count(*) n FROM decisions').n;
const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } });
assert.equal(required.ok, false); assert.equal(required.error, 'decision-required');
assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']);
assert.ok(!JSON.stringify(required).includes(SECRET));
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions);
});
test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => {
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
// Balance each call (including ternaries), so a following statement cannot masquerade
// as a fail code. Every literal branch, with either quote style, must be in the table.
for (const match of source.matchAll(/\bfail\s*\(/g)) {
let depth = 1, i = match.index + match[0].length;
while (depth && i < source.length) {
const char = source[i++];
if (char === "'" || char === '"') {
const start = i - 1;
let value = '';
while (i < source.length && source[i] !== char) {
if (source[i] === "\\") { i += 2; continue; }
value += source[i++];
}
i++;
if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value);
} else if (char === '(') depth++;
else if (char === ')') depth--;
}
assert.equal(depth, 0, 'balanced fail call');
}
// Finite delegated fail(code) sources must be covered as well as literal branches.
for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork',
'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) {
assert.ok(Object.hasOwn(REFUSALS, code), code);
}
for (const [code, guidance] of Object.entries(REFUSALS)) {
assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code);
assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance);
assert.ok(!JSON.stringify(guidance).includes(SECRET));
}
assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code'));
});
test('finite forwarded project, library and request errors have static next steps', () => {
const modules = [
...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)),
...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs'))
.map(name => new URL(`../../library/src/${name}`, import.meta.url)),
...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)),
];
for (const file of modules) {
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) {
assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`);
}
}
assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/);
assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/);
assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/);
});
+8 -7
View File
@@ -1,4 +1,3 @@
import {approveTemplateHire,ownerConsole} from './hire-fixtures.mjs';
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
// restart would load it, so a created instance binds with no restart. A definition change on a
// running instance refuses, and a file whose credential references changed isn't reloaded.
@@ -150,6 +149,8 @@ test('T19 a credentialed create answers restart true; issue works only after a r
// A, from the file, so the kept instance keeps its claim and isn't left firing.
test('T14 after a refused reload veto.set, instance.hire and instance.fire answer restart true, and a fire of a name kept since refuses before step A', async (t) => {
const f = await setup(t);
const roleFile = join(f.rolesDir, 'chief-of-staff.json');
writeFileSync(roleFile, JSON.stringify({ ...JSON.parse(readFileSync(roleFile, 'utf8')), hireable: ['chief-information-security-officer'] }, null, 2));
mkdirSync(join(f.root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
const chief = bind(f.bus, 'chief');
f.bus.broker.request(chief, { verb: 'role.claim' });
@@ -161,21 +162,21 @@ test('T14 after a refused reload veto.set, instance.hire and instance.fire answe
const added = f.ask('instances.set', { revision: f.revision(), instance: 'rev-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-demo-rev-2', botId: 40 } });
assert.equal(added.restart, true);
const hired = await approveTemplateHire(f.bus.broker,f.rolesDir,{name:'sec-2',definition:'chief-information-security-officer'});
assert.ok(hired.transaction);
const hired = asChief('instance.hire', { name: 'sec-2', definition: 'chief-information-security-officer', reason: 'a security lead' });
assert.equal(hired.restart, true);
const kept = f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'reviewer' });
assert.equal(kept.restart, true);
assert.equal(f.reloads.length, 0, 'the running copy still has reviewer unkept');
const before = readFileSync(f.file, 'utf8');
await assert.rejects(f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}}),{code:'instance-kept'});
assert.equal(code(() => asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' })), 'instance-kept');
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.deepEqual(f.ask('instances').instances.reviewer.running, run, 'step A never ran: the claim is held');
assert.notEqual(code(() => f.ask('instance.launch', { instance: 'reviewer' })), 'instance-firing');
const unkept = f.ask('veto.set', { revision: f.revision(), op: 'unkeep', name: 'reviewer' });
assert.equal(unkept.restart, true);
const fired = await f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}});
const fired = asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' });
assert.equal(fired.restart, true);
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).launch.instances.includes('reviewer'), false);
});
@@ -210,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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } };
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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: { ...demo.roles, 'sec-1': role } });
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
});
+1 -1
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
@@ -1,82 +0,0 @@
// 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: '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 } } } } });
arbiters: { technical: 'bean', delivery: 'bean' }, roles: { 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: '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 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(() => 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:'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 });
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 });
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:'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 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 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:'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 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 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});
+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: ['decision.resolve.technical'], crossRole: [] } },
pm: { authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } },
cto: { authority: { withinRole: [], crossRole: [] } },
pm: { authority: { withinRole: [], crossRole: [] } },
},
},
};
+3 -4
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: 'chief', delivery: 'chief' }, access = null } = {}) {
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, 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,7 +16,6 @@ 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 });
@@ -32,7 +31,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'), chief = agent('chief');
const coder = agent('coder'), reviewer = agent('reviewer');
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,
@@ -43,5 +42,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, chief, human, call, raise, save };
return { root, store, business, broker, repo, commit, agent, coder, reviewer, human, call, raise, save };
}
+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, role === 'chief' ? f.chief : f.agent(role)]));
f.cap = Object.fromEntries(running.map(role => [role, 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: 'chief', delivery: 'chief' },
arbiters: { technical: 'lead', 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: 'chief' },
arbiters: { technical: 'pm', delivery: 'boss' },
launch: { by: 'boss', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
roles: {
boss: { definition: 'chief-of-staff', authority: authority.chief },
pm: { definition: 'pm', authority: authority.pm },
coder: { definition: 'coder', ...none },
+1 -1
View File
@@ -91,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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { 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 });
+4 -4
View File
@@ -46,8 +46,8 @@ async function runtime(t) {
dataRoot: join(root, 'data'),
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: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: { 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: '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 } },
id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}),
roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
},
},
});
+2 -3
View File
@@ -10,7 +10,6 @@ import { Store } from '../src/store.mjs';
import { Broker, TASK_VERBS } from '../src/broker.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { startBroker } from '../src/runtime.mjs';
import { readProcess, verifyHuman } from '../src/human.mjs';
import { verifyTester, testerNonce } from '../src/tester.mjs';
@@ -388,9 +387,9 @@ test('an envelope carries exactly one of cap, human and tester; no tester authen
const cap = b.bindReader({ business: 'demo' });
const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }])
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope', ...refusalGuidance('invalid-envelope') }, Object.keys(r).join('+'));
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+'));
assert.equal(calls, 0);
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required', ...refusalGuidance('tester-required') });
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' });
assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] });
assert.equal(calls, 1);
// The tester's capability lasts one request, as the human's does.
+1 -5
View File
@@ -8,7 +8,6 @@ import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { views } from '../src/views.mjs';
const businesses = {
demo: {
@@ -73,10 +72,7 @@ test('malformed, oversized and identity-forging envelopes refuse without echoing
const out = await raw(input);
assert.equal(JSON.parse(out).ok, false);
assert.ok(!out.includes('not-json'));
const refusal = JSON.parse(out);
assert.ok(['invalid-envelope', 'request-too-large'].includes(refusal.error));
assert.deepEqual(refusal, { ok: false, error: refusal.error, ...refusalGuidance(refusal.error) });
assert.ok(out.length < 1024); // A bounded static description, independent of input size.
assert.ok(out.length < 150);
}
});
test('client preserves UTF-8 when a response divides a multibyte character', async (t) => {
+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: {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 businesses = { demo: { id: 'demo', human: 'jason', roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } };
const recorded = [];
const broker = new Broker({ store, businesses, usage: {
collect: async (...args) => { recorded.push(args); return { recorded: true }; },
+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: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } };
roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } };
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,
+1 -1
View File
@@ -385,7 +385,7 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
b.request(caps.chief, { verb: 'role.claim' });
const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' });
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'decision-required', name);
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'invalid-request', name);
// A name that isn't reserved passes the name check and refuses later, for want of role files.
assert.notEqual(hire('helper'), 'invalid-request');
});
+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 merge raise still goes to the technical arbiter", (t) => {
test("merge-gate has no message.send, and without a domain its raise goes to the delivery 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, 'cto');
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'pm');
// 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
@@ -1,2 +0,0 @@
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
export * from "../../organization/src/accounts.mjs";
-2
View File
@@ -1,2 +0,0 @@
// Old path of packages/organization/src/agent-copies.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-copies.mjs";
@@ -1,2 +0,0 @@
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-levels-write.mjs";
-2
View File
@@ -1,2 +0,0 @@
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
export * from "../../organization/src/composition.mjs";
@@ -1,2 +0,0 @@
// Old path of packages/organization/src/config-dir-trust.mjs (rename design r2, stage B).
export * from "../../organization/src/config-dir-trust.mjs";
@@ -1,2 +0,0 @@
// Old path of packages/organization/src/definition-verbs.mjs (rename design r2, stage B).
export * from "../../organization/src/definition-verbs.mjs";
Loaded 100 of 361 files, more files were not shown because too many files have changed in this diff. Show more