Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e588fb32cb | ||
|
|
e3577e9d31 | ||
|
|
99ce579e3c | ||
|
|
b44b14c7df | ||
|
|
85858938fc | ||
|
|
6415e81ee7 | ||
|
|
15225c5e1c | ||
|
|
cb9580f6fb | ||
|
|
c0e08f81ea | ||
|
|
f7813339b5 | ||
|
|
3e82bc16c3 | ||
|
|
57052c53dd | ||
|
|
930182b54a | ||
|
|
d0cfebe286 | ||
|
|
9acc378555 | ||
|
|
db822c5b9d | ||
|
|
554bc5116d | ||
|
|
6db8fb2dce | ||
|
|
85122dd2bc | ||
|
|
82dcba2d45 | ||
|
|
29f7c3af85 | ||
|
|
17a48217f6 | ||
|
|
9d00f9ad54 | ||
|
|
a3e82efe23 | ||
|
|
c8ff8ea128 | ||
|
|
ddf4f895cd | ||
|
|
7035ce61b3 | ||
|
|
54bf239e32 | ||
|
|
a4eed1e219 | ||
|
|
1e1bbaa6dd | ||
|
|
78bda9d6a4 | ||
|
|
6b39af7888 | ||
|
|
96c900d210 | ||
|
|
307fe928ac | ||
|
|
e9a079fcc4 | ||
|
|
7de9cfccc3 | ||
|
|
c3c192677d | ||
|
|
95ee19cc71 | ||
|
|
07dba7f2f5 | ||
|
|
ceee80d5b7 | ||
|
|
fa9bedb09a | ||
|
|
67d6c9ed84 | ||
|
|
c8dbd3f6ec | ||
|
|
8f7de8c4d3 | ||
|
|
6f0c72a02f | ||
|
|
257a156845 | ||
|
|
b6e52e1b52 | ||
|
|
3bcdee0926 | ||
|
|
9f0fcf8690 | ||
|
|
c7b588289f | ||
|
|
aa9f936690 | ||
|
|
49a8d2983e | ||
|
|
d630f815c2 | ||
|
|
14f1b3b2b1 | ||
|
|
f98f278fbb | ||
|
|
c6e341e92e | ||
|
|
50be333552 | ||
|
|
3bb0f70acc | ||
|
|
52a82a4e56 | ||
|
|
5f3706e807 | ||
|
|
5f57008aed | ||
|
|
bc3e0a9f49 | ||
|
|
a71da41146 | ||
|
|
4c74e7ea49 | ||
|
|
ac3a863bec | ||
|
|
af7d330101 | ||
|
|
db75994f82 | ||
|
|
ac977d833b | ||
|
|
aa087e7380 | ||
|
|
68fd66903b | ||
|
|
a7b2199390 | ||
|
|
4c495647cd | ||
|
|
f5cc712a59 | ||
|
|
821fbe8e9b | ||
|
|
d247b4eb61 | ||
|
|
e358886826 | ||
|
|
01b79e1db4 | ||
|
|
0a75cc82f3 | ||
|
|
c66cd58846 | ||
|
|
69af2fe94b | ||
|
|
4106bf7344 | ||
|
|
8630b8dfc0 | ||
|
|
5eb6dda049 | ||
|
|
8710cc0e1e | ||
|
|
364f1ae7a6 | ||
|
|
9b7bf11afb | ||
|
|
feb98d5529 | ||
|
|
221c1e9d35 | ||
|
|
544918a715 |
No files matched your search
@@ -6345,3 +6345,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
|
||||
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
|
||||
|
||||
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
|
||||
|
||||
2026-11-10 UTC, unified workspace U1 and SYS1 (#1873; PRs #1889 d35447005 and #1890 2c6556ab1). U1, before: each org page read only its own bus and nothing read every org at once. After: /api/workspace returns projects, sessions, inbox, tasks and metrics from every org the owner holds, each row tagged with its org and each org with a state (ok, starting, down, timeout, invalid); `mosaic console restart` and `mosaic org reload <org>` send the F3 routes. Sol review CHANGES 0/2 on 2109458b3: malformed data from one org turned the whole read into a 503, and the per-org deadline left sockets and transport children running. Terra fix 0672d5fe8 validates each org's result inside its boundary and carries an absolute deadline and AbortSignal through the status check and transport; the new workspace-failures test is 0/10 on the parent and 10/0 on the fix. The lead re-pinned the webui bus.mjs hash in bus channel-proof (the deliberate signal/deadline change; nonces, argv and limits unchanged) and amended as d35447005. SYS1, before: no system-type kind. After: a system-type plug-in kind with five modules (business-management, coding, creative-writing, marketing, montage), an org enable list under plugins.system-type, owner-only project selection with an append-only project_system_types table (migration 3t-3u) and CLI verbs; the modules grant no tools yet (REQ-SYS-2 deferred). Sol review CHANGES 1/2 on a9629f332: the restore guard did not count project_system_types, the CLI channel did not admit the two project verbs, and the #811 migration-reader pin still expected 18 steps. Lead fix 2c6556ab1 (rebased on d35447005) adds the table to COUNTED and LISTED with refusal and in-run recount tests, admits both verbs with a run()-level channel test (red on the unfixed channel), and moves the pin to 19 steps ending 3t-3u; fix files 323/0 and rebase overlap files 63/0 (Node 26.12.1, one file at a time, env -i). Full gate on a9629f332 4040/5: the two stack-release pins above, and tester-reads 3/3, which also fails on 1616d1dd6 (project.mjs:95 refuses an org --tester init; follow-up). Correction to the uiorg deploy at 00:50Z: the deploy script switched the tree without the offline schema migration, so all five buses refused to start (normal startup refuses an older schema by design). The lead ran migrate-schema.mjs for the five org stores (3t to 3u, all ready), restarted the buses (201 each) and added the migration to the deploy script with stop-on-refusal. test3 refused writer-locked and stays down, as before. Reports: /mnt/storage/scratch/mosaic-lead/reports/{u1-review,u1-fix,org-sys1-build,sys1-review}.md.
|
||||
@@ -2123,3 +2123,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
|
||||
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
|
||||
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
|
||||
2026-11-10 UTC | Mos (lead, Claude Opus 5.5) | U1 and SYS1 landings (#1873) | PR #1889 merged d35447005, PR #1890 merged 2c6556ab1; both Sol reviews CHANGES, fixed (U1 by Terra, SYS1 by the lead); uiorg at 2c6556ab1 after the missed 3t-3u store migration was run; deploy script now migrates.
|
||||
+84
-6
@@ -807,13 +807,52 @@ 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, and
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`.
|
||||
`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.
|
||||
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.
|
||||
@@ -822,7 +861,8 @@ 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 both command lines on the target copy. Then it checks
|
||||
`/healthz` 200 (and `sso-ready` for a stamped target), 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
|
||||
@@ -834,6 +874,21 @@ 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
|
||||
@@ -894,8 +949,9 @@ 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] [--timeout S]
|
||||
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
[--rehearse-port N] [--next-args] [--timeout S]
|
||||
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
|
||||
[--timeout S]
|
||||
```
|
||||
|
||||
Root runs it from a root-owned checkout of the reviewed commit, never from a
|
||||
@@ -935,7 +991,13 @@ 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`).
|
||||
`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.
|
||||
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`;
|
||||
@@ -950,6 +1012,22 @@ 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
|
||||
|
||||
@@ -386,10 +386,15 @@ 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.
|
||||
|
||||
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 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.
|
||||
|
||||
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
|
||||
@@ -418,10 +423,83 @@ 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 Console stays read-only after the upgrade, until #412 (#385).
|
||||
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
|
||||
accounts, as the migration below sets up.
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
|
||||
rules, before it stops a unit.
|
||||
### 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.
|
||||
|
||||
## 8. Enrollment, once per host
|
||||
|
||||
@@ -450,7 +528,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/` | 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/` | 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/<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. |
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
||||
# Agent levels and the cross-organization assistant (Phase A design, #1873)
|
||||
|
||||
Status: design for lane org-a1, 2026-11-10. Slice A1 (read-only store, union
|
||||
resolution, Agents grid) is built on branch `org-a1`. Everything that gives an
|
||||
agent a new capability is in the roles proposal and the later slices listed in
|
||||
section 9; none of it is applied.
|
||||
|
||||
Direction: PRD 1.20 draft 2 (REQ-ASSIST-1, REQ-USER-1 amendment, REQ-AGENT-1
|
||||
to REQ-AGENT-3, REQ-COS-3, REQ-DEC-2, REQ-ROLE-3), owner direction rulings 4
|
||||
and 11, the U0 approval and the addendum in
|
||||
`docs/plans/2026-11-09_unified-workspace-owner-direction.md`, and the role model
|
||||
in `roles/` and `docs/plans/2026-09-02_atomic-mosaic-foundation.md`.
|
||||
|
||||
Owner words: "Agents are configured at three levels: the user's root, the
|
||||
organization and the project. A project's agents are the union of all three.
|
||||
The user's main interaction agent (the assistant) is the one exception to
|
||||
isolation. [...] Every other agent is isolated to the organization it works
|
||||
in." And: "the COS should be able to hire and fire agents to fulfill the
|
||||
user's needs."
|
||||
|
||||
## 1. Terms
|
||||
|
||||
- **Definition**: a named agent entry at one level. It names a role
|
||||
definition in `roles/` (`definition`) and may carry vars. It never names a
|
||||
credential, a holder, a tracker bot, an identity or a publish grant at the
|
||||
root or project level.
|
||||
- **Role definition**: `roles/<name>.json`, versioned and changed only by
|
||||
reviewed commits (invariant 7). A level chooses among role definitions and
|
||||
never creates one, so no level can grant an authority that `roles/` does
|
||||
not already hold.
|
||||
- **Copy**: a root definition as it runs inside one organization. Each
|
||||
organization runs its own copy, and each copy has that organization's
|
||||
memory, files and credentials.
|
||||
- **Union**: the agents available in one project of one organization.
|
||||
|
||||
## 2. Where each level lives
|
||||
|
||||
| Level | File | Writer today | Owner |
|
||||
|---|---|---|---|
|
||||
| root | `<configDir>/agents.json`, next to the instance's system config | the user, by hand (no writer in A1) | the instance's OS user |
|
||||
| org | `roles` in `<configDir>/businesses/<org>.json` (unchanged) | existing org writers | the organization |
|
||||
| project | `projects.<project>.agents` in the same organization file | the owner's `updateOrg` only; no agent writer can reach it | the organization |
|
||||
|
||||
### Root: `<configDir>/agents.json`
|
||||
|
||||
`configDir` is the directory of the instance's system config
|
||||
(`~/.config/mosaic-<instance>/`). The file sits outside the data root, so it is
|
||||
outside every organization root (`<dataRoot>/org-runtime/<org>`) and every
|
||||
bus, and no bus or sandbox mount reaches it. It is per instance, as the system
|
||||
config is (invariant 2). When accounts arrive (Phase M), it moves to a
|
||||
per-principal path under the same directory; the format stays.
|
||||
|
||||
```json
|
||||
{
|
||||
"agentsVersion": 1,
|
||||
"agents": {
|
||||
"assistant": { "definition": "assistant", "vars": { "model": "opus-5.5" } },
|
||||
"reviewer": { "definition": "reviewer", "excludeOrgs": ["home"] },
|
||||
"researcher": { "definition": "researcher" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Keys: `definition` (required), `vars` (agent layer, `checkVars(..., "agent")`),
|
||||
`excludeOrgs` (distinct organization ids; "reviewer turned off for Home" in
|
||||
sample 08). Any other key refuses.
|
||||
- The file must be a regular file opened without following a link, owned by
|
||||
the process uid and not writable by group or other, the same checks as the
|
||||
organization file. A missing file means no root agents. Anything else that
|
||||
is wrong refuses the whole root level (section 6).
|
||||
- Refused at the root: a role definition that is version 1 or missing, the
|
||||
`chief-of-staff` definition (each organization's chief is its own,
|
||||
REQ-COS-3), an `assistant` definition under any name but `assistant`, and
|
||||
`credentials`, `holder`, `tracker`, `identity`, `publish`, `hosts`.
|
||||
|
||||
### Organization: `roles`
|
||||
|
||||
Unchanged. Every organization instance stays as it is, validated by
|
||||
`validateBusinessDocument`, with its credential map, tracker bot and identity.
|
||||
|
||||
### Project: `projects.<project>.agents`
|
||||
|
||||
The project level is in the organization file, not in the project repository.
|
||||
The repository's `.mosaic/project.json` can be written by any agent that can
|
||||
push to the project, so a definition there would let an agent create agents.
|
||||
The organization file is reachable only by the trusted writer, and the
|
||||
existing projects fence (`organization-projects-protected` in
|
||||
`organization.mjs`) already refuses every general write that changes
|
||||
`projects`. No agent writer can add, change or remove a project agent. The
|
||||
owner's `updateOrg` can, and the Console's organization editor holds the key
|
||||
on the server (`SERVER_PROJECT_KEYS`), so the browser draft cannot change it in
|
||||
A1.
|
||||
|
||||
```json
|
||||
"projects": {
|
||||
"spring-launch": {
|
||||
"root": "...",
|
||||
"agents": {
|
||||
"social-scheduler": { "definition": "pm", "vars": { "model": "haiku-5.5" } },
|
||||
"coder": { "vars": { "limits.network": "none" } },
|
||||
"reviewer": { "exclude": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each entry has exactly one form:
|
||||
|
||||
- **define**: `{ "definition", "vars"? }`. Agent-layer vars. Not
|
||||
`chief-of-staff`, not `assistant`.
|
||||
- **narrow**: `{ "vars" }` only. Every key must be an intersect key
|
||||
(`limits.tools`, `limits.network`, `limits.authority`), checked at the
|
||||
project layer. A project narrows an inherited agent and never widens it.
|
||||
- **exclude**: `{ "exclude": true }`. The inherited agent is not available in
|
||||
this project.
|
||||
|
||||
`validateBusinessDocument` checks each entry's shape and role definition for
|
||||
every reader (bus, Console, CLI). It does not copy `agents` into the loaded
|
||||
business object, so a bus built from the file sees exactly what it saw before.
|
||||
A project that is deleted takes its agents with it, and so does a deleted
|
||||
organization.
|
||||
|
||||
Library projects (`kind: "library"`) keep their own mapping check and take no
|
||||
`agents` key.
|
||||
|
||||
This is an additive, optional key in the organization file. It is not a bus
|
||||
store (SQLite) schema change: no migration, no new table, no bus verb.
|
||||
|
||||
## 3. Union resolution
|
||||
|
||||
For organization O and project P:
|
||||
|
||||
1. Start with the root agents, minus any whose `excludeOrgs` names O.
|
||||
2. Lay the organization's `roles` over them. An org instance with the same
|
||||
name as a root agent replaces it entirely; the root entry is recorded as an
|
||||
override of that agent.
|
||||
3. Lay P's entries over the result:
|
||||
- **define** replaces any inherited agent of that name; the inherited
|
||||
entry is recorded as an override.
|
||||
- **narrow** merges vars through `mergeVars`, inherited layer first and the
|
||||
project layer after it, so limits only intersect. The keys it narrowed
|
||||
are recorded.
|
||||
- **exclude** removes the agent and records who removed it.
|
||||
|
||||
Rules:
|
||||
|
||||
- The most specific level that defines a name wins, and the whole entry
|
||||
wins: definition and vars, never a field-by-field mix.
|
||||
- A narrow or exclude entry naming an agent that no lower level provides
|
||||
refuses. A typo never silently does nothing.
|
||||
- A project entry of any form naming the organization's chief (an instance
|
||||
whose definition is `chief-of-staff`) or the assistant refuses. A project
|
||||
cannot remove, narrow or replace the chief or the assistant.
|
||||
- Two levels never merge vars, apart from a project narrowing. Root vars stay
|
||||
with the root definition, and an org instance of the same name brings its
|
||||
own.
|
||||
- The result for each agent: name, definition, level, source (which file),
|
||||
the overrides it shadows, the keys narrowed, and its scope. Every agent in a
|
||||
union is scoped to O, an organization's own `assistant` instance included.
|
||||
Only the root assistant, which no union holds, is cross-organization.
|
||||
- The union also lists what O or P does not get: root agents turned off for
|
||||
O and agents P excluded. A name that a more specific level then defines is
|
||||
available under that definition and is not listed.
|
||||
|
||||
Implementation: `packages/organization/src/agent-levels.mjs`
|
||||
(`loadRootAgents`, `checkRootAgents`, `checkProjectAgents`,
|
||||
`resolveAgentLevels`, `projectUnion`). It is pure apart from the one root file
|
||||
read.
|
||||
|
||||
## 4. Isolation of every non-assistant agent
|
||||
|
||||
Every agent except the root assistant resolves to scope `{ org: O }` in the
|
||||
organization it runs in, whichever level defined it. The scope fixes three
|
||||
things:
|
||||
|
||||
- **Memory and records**: under the organization's root,
|
||||
`<dataRoot>/org-runtime/O/...`, on that organization's bus, as every org
|
||||
instance has today. A root agent's copy in O and its copy in O2 share
|
||||
nothing: no thread, no record, no session file.
|
||||
- **Files**: the project workspaces of O only. The sandbox mounts come from
|
||||
O's project mapping, never from another organization's.
|
||||
- **Credentials**: only from O's credential map. A root definition carries
|
||||
none (refused, section 2). In A1 a root or project agent is shown as
|
||||
unbound: it has no credential and cannot be launched, because the bus
|
||||
builds instances from `roles` alone and the loaded business object does not
|
||||
carry project agents.
|
||||
|
||||
When root copies run (slice A3), each copy becomes a bus instance in O under
|
||||
O's writer, with O's credential bindings, through the same `checkRoles` path an
|
||||
org instance takes. A copy never reads the root file at run time; it gets its
|
||||
definition when O's bus boots.
|
||||
|
||||
## 5. The assistant across organizations
|
||||
|
||||
### Reads
|
||||
|
||||
The assistant reads another organization only through the Console's
|
||||
membership-checked cross-organization read: `organizations.members(session)`
|
||||
(`packages/webui/src/organizations.mjs`), the read `GET /api/workspace` uses.
|
||||
Each organization is admitted by `showOrg` (owner check, registry digest,
|
||||
document validation) and `resolveOrg` (marker, modes) before any transport is
|
||||
built, and each bus read goes over that organization's own socket with a
|
||||
deadline. The assistant process gets no org root path, no bus socket path and
|
||||
no file mount of any organization. It calls a read broker that the Console
|
||||
holds for the owner's session. A direct read of another organization's bus
|
||||
files does not exist as a code path: the assistant sandbox mounts only its own
|
||||
memory directory.
|
||||
|
||||
### Shared memory
|
||||
|
||||
The assistant's one memory lives at `<dataRoot>/assistant/` (mode 0700, owned
|
||||
by the instance user). It sits inside the instance data root and outside every
|
||||
`org-runtime/<org>` root, so deleting or archiving an organization never
|
||||
touches it, and no organization's agents can mount it. It holds the
|
||||
assistant's notes and session files. It never holds a copy of an
|
||||
organization's records; what the assistant learns from an organization stays
|
||||
in that organization, plus whatever the assistant chose to write in its own
|
||||
memory.
|
||||
|
||||
### Authority
|
||||
|
||||
REQ-ASSIST-1: the assistant talks with the user and hands each organization's
|
||||
work to that organization's chief. It does not direct agents, create tasks or
|
||||
launch sessions. Its role keeps `message.send` and `record.write` only. The
|
||||
roles proposal (section 8) adds no action to it.
|
||||
|
||||
### A1 reality
|
||||
|
||||
Today each organization file declares its own `assistant` instance. A1 shows
|
||||
those as org agents. A root `assistant` entry, if the user writes one, shows in
|
||||
the band as the root assistant with cross-organization scope. Nothing reads
|
||||
across organizations for it yet.
|
||||
|
||||
## 6. Fail-closed boundaries
|
||||
|
||||
| Boundary | A1 behavior | Later slices |
|
||||
|---|---|---|
|
||||
| Root file (new) | `O_NOFOLLOW` regular file, process uid, no group or other write; any problem refuses the whole root level and the grid says so; a missing file is "no root agents". The refusal names the file and the key at fault, never a value from the file: parser text and a var check's quote of the refused value are dropped, since a value can be a secret typed in the wrong place. Project var refusals drop the value the same way. | The root writer accepts only the human session (section 7). |
|
||||
| Organization file `projects.<p>.agents` (new key) | Checked by `validateBusinessDocument` for every reader; a bad entry refuses the whole organization file, as any bad key does. Not copied into the loaded business, so buses gain nothing. The projects fence keeps every agent writer out. | A project-agent writer verb, with its own guard, like `addProject`. |
|
||||
| Union | A narrow or exclude naming nothing refuses that project; touching the chief or assistant refuses that project; the grid shows the refusal for that project. | A launch resolver refuses to launch any agent in a project whose union refuses. |
|
||||
| Console read `GET /api/agents/levels` (new) | Owner only (granted users and the tester get `owner-only`, 403); organizations come only from `organizations.members(session)`; the body carries ids, names, role definition names, model var and level. It never carries a path, a credential reference or a var other than `model` and the narrowed key names. | Unchanged. |
|
||||
| Org isolation | Root and project agents are not instances on any bus; nothing reads across organizations except the existing Console read. | Each copy is materialized by O's own writer and runs under O's credentials. |
|
||||
| Assistant memory | Not built. | `<dataRoot>/assistant/` with 0700; the sandbox mounts it and nothing else. |
|
||||
| Roles | No change in commits. | The proposal patch, applied only after Jason approves it. |
|
||||
|
||||
A root file that refuses hides the root level everywhere rather than showing a
|
||||
partial list: an exclusion that did not parse must not let an excluded agent
|
||||
appear.
|
||||
|
||||
## 7. Chief-of-staff hire and fire, per level
|
||||
|
||||
Today `roles/chief-of-staff.json` lists `role.hire` and `role.fire` within its
|
||||
role, with `"hireable": []`: it can hire nothing. Each level adds a write path;
|
||||
none exists in A1.
|
||||
|
||||
- **Organization level** (REQ-COS-3): the chief hires or fires an
|
||||
organization instance through the organization's trusted writer, for a role
|
||||
in its `hireable` list. Each hire lands in the action trail. The user can
|
||||
override any setting at any time through REQ-ROLE-3.
|
||||
- **Project level**: the chief adds or removes a project entry through a new
|
||||
writer verb that changes only `projects.<p>.agents` (a guard like
|
||||
`addProject`'s), for a role in `hireable`. The projects fence still refuses
|
||||
every other change.
|
||||
- **Root level** (U0 ruling): the chief cannot write the root file. It raises
|
||||
an inbox decision (REQ-DEC-2) in its own organization that names the role
|
||||
definition, the agent name and the reason. The user approves or denies it in
|
||||
the Console inbox. On approval the Console, acting with the human's session,
|
||||
writes the root file through a root writer that refuses any actor other than
|
||||
the signed-in owner (`human-required`). A denied or expired decision changes
|
||||
nothing. A fire works the same way. The chief never learns the file's path.
|
||||
|
||||
`hireable` decides what can be hired at all; widening it is a `roles/` change
|
||||
and needs Jason's reviewed commit. The proposal (section 8) names the roles.
|
||||
|
||||
## 8. Authority changes proposed, not applied
|
||||
|
||||
The patch is at `/mnt/storage/scratch/mosaic-lead/reports/org-a1-roles-proposal.patch`.
|
||||
It is never applied on this branch. It applies with `git apply` on
|
||||
`origin/refactor` and needs a reviewed commit that Jason approves.
|
||||
|
||||
What it changes:
|
||||
|
||||
- `roles/chief-of-staff.json`: `hireable` goes from `[]` to `coder` and the
|
||||
five advisory chiefs (`chief-executive-officer`, `chief-financial-officer`,
|
||||
`chief-information-officer`, `chief-information-security-officer`,
|
||||
`chief-marketing-officer`). These are the only shipped definitions that pass
|
||||
today's hire check: version 2, not creative, and every action on
|
||||
`HIRE_ALLOWED`. The chief can then hire at the organization level with the
|
||||
existing `hire` verb. The project level (A4) uses the same list.
|
||||
- `roles/chief-of-staff.md`: drops "your empty hireable list", names the list,
|
||||
says a hired coder has no credential until the human binds one, and says the
|
||||
chief hires only in its own organization, never at the root.
|
||||
- `packages/organization/tests/chief-of-staff.test.mjs` and `role.test.mjs`:
|
||||
the tests that pin the shipped chief file move to the new list, and the
|
||||
contract test pins the new sentence. Two bus test comments that said "shipped chief hireable:[]" are
|
||||
reworded. These go in the same commit, or the organization suite fails.
|
||||
|
||||
What it leaves alone, and why:
|
||||
|
||||
- `roles/assistant.json`: no change. The cross-organization read is the
|
||||
Console's read broker acting for the owner's session (section 5), not a
|
||||
role action, so the assistant needs no new grant. It keeps `message.send`
|
||||
and `record.write`. Adding an action would widen it for no gain.
|
||||
- `roles/assistant.md`: no change yet. Its "one user in one business" text
|
||||
stays true until A6 builds the root assistant; the contract text for the
|
||||
cross-organization read and the shared memory lands with A6, in that
|
||||
slice's own reviewed commit.
|
||||
- Root-level hire text in `chief-of-staff.md` lands with A5, when the inbox
|
||||
decision exists. A contract sentence for a tool the chief doesn't have would
|
||||
invite it to try.
|
||||
- `HIRE_ALLOWED`: unchanged. `reviewer`, `security-reviewer` (`review.verdict`),
|
||||
`pm` (`task.create`), `cto` and `merge-gate` stay unhireable. Making any of
|
||||
them hireable is a separate vocabulary decision, not part of this proposal.
|
||||
|
||||
## 9. Phase A slices after the roles change is approved
|
||||
|
||||
1. **A2, root and project writers.** Owner-only root file writer (human
|
||||
session, trail entry, revision check) and the project-agent writer verb
|
||||
under the projects fence, both refusing every agent actor. Settings,
|
||||
Agents gets the edit forms.
|
||||
2. **A3, root copies run.** Each organization's bus boot materializes its
|
||||
root copies as instances through `checkRoles`, with the organization's
|
||||
credential bindings; unbound copies stay unlaunchable and say why. Project
|
||||
agents launch only in their project.
|
||||
3. **A4, chief hire and fire at org and project level**, bounded by the
|
||||
approved `hireable` list, trailed.
|
||||
4. **A5, root-level hire and fire as inbox decisions**, with the owner's
|
||||
approval writing the root file through A2's writer.
|
||||
5. **A6, the root assistant.** `<dataRoot>/assistant/` memory, the Console
|
||||
read broker over `organizations.members`, and the per-organization
|
||||
`assistant` instances retired in favor of the one root assistant.
|
||||
6. **A7, the Agents grid's write controls** (model Change, override markers,
|
||||
turn off for an organization), each a trailed human action.
|
||||
|
||||
## 10. Slice A1 as built
|
||||
|
||||
- **Store and union**: `packages/organization/src/agent-levels.mjs`, with the
|
||||
old-path re-export `packages/business/src/agent-levels.mjs` that the rename
|
||||
r2 stage B test requires of every organization module.
|
||||
`validateBusinessDocument` checks `projects.<p>.agents` and drops it from
|
||||
the loaded business.
|
||||
- **Read**: `GET /api/agents/levels` (`packages/webui/src/agent-levels-read.mjs`),
|
||||
wired in `cli.mjs` with `loadRoot` reading `<configDir>/agents.json`. The
|
||||
route is instance-wide, like `/api/workspace`, so `display.js` leaves it
|
||||
unscoped: its fetch wrapper would otherwise rewrite it to
|
||||
`/api/orgs/<id>/agents/levels`, which does not exist.
|
||||
- **Console editor**: `SERVER_PROJECT_KEYS` holds `agents`, so a browser draft
|
||||
of the organization file keeps the server's value. The CLI's full-document
|
||||
path (`mosaic org update --from`) is the owner's and can set project agents;
|
||||
A1 adds no other writer.
|
||||
- **Agents grid** (sample 08): the level band shows the root agents and their
|
||||
per-organization opt-outs, each organization's instances, and each
|
||||
project's own definitions. Below it, a project picker chooses which union
|
||||
the table shows: agent, level, defined in, memory, status, model. Root and
|
||||
project agents read "not running", because nothing launches them yet. The
|
||||
root assistant's status reads "status not reported" and its model is the
|
||||
one the root file sets, shown as configured. A
|
||||
narrowed agent names the keys, an override names what it replaces, and a
|
||||
line under the table lists what this project does not get and why. A
|
||||
refused root file, organization or project shows its reason in place of
|
||||
its rows. The grid has no write controls; those are A7.
|
||||
@@ -0,0 +1,671 @@
|
||||
# Roles, rails and hiring
|
||||
|
||||
Date: 2026-11-10. Lane org-roles-design, issue #1873. Status: proposed, doc
|
||||
only. Nothing here changes `AGENTS.md`, `roles/`, `contracts/`, rights policy
|
||||
or code. The slices in section 13 that touch role or rights policy land only
|
||||
with Jason's approval.
|
||||
|
||||
Round 2 follows ruling 13. Merge and policy decisions stay with the arbiter
|
||||
agent. The rails split into system rails and role rails (section 3), and a
|
||||
per-organization autonomy setting decides where each class of decision goes
|
||||
(section 9).
|
||||
|
||||
Read with `docs/plans/2026-11-10_agent-levels-design.md` (levels, isolation,
|
||||
Phase A) and `docs/plans/2026-11-10_accounts-design.md` (rights, host trust
|
||||
boundary). This design replaces agent-levels section 7 and the A1 roles patch,
|
||||
which widened the chief's `hireable` list. That patch is withdrawn and nothing
|
||||
here extends it.
|
||||
|
||||
Line numbers are against `origin/refactor` at `f98f278fb`. Its code is the
|
||||
same as at `50be33355`.
|
||||
|
||||
## 1. Rulings
|
||||
|
||||
Jason, 2026-11-10:
|
||||
|
||||
1. Who hires: the chief asks, a human approves. A hire is a decision, not a direct write.
|
||||
2. Which roles, verbatim: "Chief should be capable of hiring ANY role with any capacity. This will include custom roles invented by the user and the chief at any time."
|
||||
3. No cap on the number of hires.
|
||||
4. Timing: lands with A4.
|
||||
5. Templates, verbatim: "Mosaic Stack should ship role templates for the most common roles. These stay within the system as templates. Chief and user have the capability to import the roles from template and customize them as needed. New roles outside of the supplied templates must follow a set of rules from within the system to prevent an absolute destruction of the system's workings. We need a set of primitives available to the agent roles to allow for adherence. A new role constructed by chief and the user must plugin and follow the rules with the RBAC and other tools available. The agent will have an arbitrary set of tools specified by chief and/or the user. The agent will also have a personality and other traits specified by the user. The system must remain unopinionated upon the opinionated basal code provided. We provide the operational rails and allow an open system to build upon it."
|
||||
6. Ceiling: the approval is the ceiling. A hire may carry grants above the chief's own, but the decision lists those grants separately and flags them.
|
||||
7. Approver: a new grantable right `org.hire` (owner yes, member no) in the M1 rights table.
|
||||
8. Only the chief raises hires. No hire chains: a hired agent cannot hire.
|
||||
9. Rails, summarized (full text in the lead tracker; ask nothing, design from this): a base of inalienable rails the system needs to function, with everything else customizable on top. The smallest working system is user, assistant and chief; the chief staffs the rest from the user's plan. Start with the fewest agents, add agents as roles are needed, and keep configurations apart with organizations and projects. Full extensibility: modules and plugins, preconfigured systems for any use (software factory, writing workshop, video production, business, home, health), installed from GitHub, a zip or another source, and maybe a marketplace later. Flexibility comes from a set of primitives controlled by routing, agent configuration and plugins. Jason: "The Venn diagram for usability and security/rails needs to be understood."
|
||||
10. Credentials: the hire decision lists each credential and scope the agent needs; approving the decision binds them.
|
||||
11. Template updates: an imported role is a frozen copy. When the shipped template changes, the system offers a diff; it never changes the copy by itself.
|
||||
12. Personality: the chief may draft personality and traits inside the hire decision. After the hire only a human changes them.
|
||||
13. Trust, verbatim, answering whether merge and policy decisions should go to a person instead of the arbiter agent: "I need to be able to trust the agents to do their jobs. The agent role should establish the rails they operate upon. Tooling and instructions should be clear. We are aiming for a move from "Human in the loop" to "Human near the loop" to "Human out of the loop" operations. We are currently HEAVILY in Human in the loop." They stay with the arbiter.
|
||||
|
||||
Also binding, Jason 2026-11-07: the system is modules joined by documented
|
||||
calls. Swapping or disabling one must not break core, and adding one must not
|
||||
need a core rewrite (`docs/plans/2026-11-07_organizations-owner-rulings.md:43-47`).
|
||||
|
||||
## 2. Today
|
||||
|
||||
- A role is `roles/<name>.json` plus its `.md` contract. Version 2 allows
|
||||
only known keys, so an unknown field such as `personality` refuses
|
||||
(`packages/organization/src/role.mjs:21,190`). Tools come from one closed
|
||||
core list (`role.mjs:24-29`, `packages/organization/src/vocabulary.mjs:142`).
|
||||
Actions come from `ACTIONS` (`vocabulary.mjs:11-65`); an unknown one
|
||||
refuses, and so does one outside `ROUTED`, because those are hard waits the
|
||||
human always decides (`role.mjs:42-43`, `vocabulary.mjs:109`).
|
||||
- The chief hires by writing directly. `instance.hire` checks that the caller
|
||||
is the instance named in `launch.by` (`packages/bus/src/broker.mjs:6193`)
|
||||
and holds `role.hire`, refuses kept names (`broker.mjs:6200`), then runs
|
||||
`#hireable` (`broker.mjs:6291-6306`). That needs a version 2 definition whose
|
||||
every action is on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`, checked by
|
||||
`unhireableAction` at `vocabulary.mjs:127`), named in the chief's own
|
||||
`hireable` list, and not in `launch.noHire`.
|
||||
- The shipped chief's list is empty (`roles/chief-of-staff.json:13`), so the
|
||||
chief can hire nothing today. Its contract says so
|
||||
(`roles/chief-of-staff.md:17`).
|
||||
- Creative definitions are human-only. The writer refuses them for the chief
|
||||
(`packages/organization/src/instance-settings.mjs:365-367`) and the role
|
||||
check refuses them in a `hireable` list (`role.mjs:86-97`).
|
||||
- A hire writes `roles[name] = { definition, vars? }` and appends the name to
|
||||
`launch.instances` (`instance-settings.mjs:354-391`). Its vars may set only
|
||||
`model` and `limits.*` (`broker.mjs:216-221`). So `launch` refuses the new
|
||||
instance with `no-route` until a human sets harness, model and thinking
|
||||
(`packages/harness/src/bundle.mjs:96`, `roles/chief-of-staff.md:53-58`).
|
||||
- Fire revokes the instance's service credentials and delists it
|
||||
(`instance-settings.mjs:396-414`).
|
||||
- Rights for people are a separate table: `RIGHTS`, the `ROLE_RIGHTS`
|
||||
bundles and `canGrant` (`packages/organization/src/org-rights.mjs:6-17,21-26,122-129`).
|
||||
It has no hire right yet.
|
||||
|
||||
## 3. The rails
|
||||
|
||||
Rails come in two layers. System rails hold for every role at every autonomy
|
||||
level, and only a reviewed commit changes one. Role rails are what each role's
|
||||
definition says its agent may do. Ruling 13 asks for both: agents trusted to
|
||||
do their jobs, inside rails their role sets.
|
||||
|
||||
### System rails
|
||||
|
||||
Nothing changes these while the system runs: no role, template, plugin,
|
||||
approval or autonomy setting. Each row says where the code enforces the rail
|
||||
today and which slice closes the gap.
|
||||
|
||||
| # | Rail | Enforced today | Gap, and the slice that closes it |
|
||||
|---|---|---|---|
|
||||
| R1 | Secrets never enter the repository, images, role files, decision text or the trail. A credential reaches an agent only through a human-approved binding, scoped and revocable. | Message and record bodies pass `credentials.assertClean` (`packages/bus/src/runtime.mjs:833`, `broker.mjs:840,1313,2081`). Credential verbs are human-only (`broker.mjs:6144`). `credential.mint` decisions route to the human (`broker.mjs:2266-2268,2368`). Fire revokes (`instance-settings.mjs:396`). | Role definitions, personality text and hire decisions are not scanned. P1 runs the same check on every string in a composition and a hire decision. |
|
||||
| R2 | The trail and run records are write-once. | `events` and `decision_events` refuse update, delete and replace (`packages/bus/src/migrations/3b.sql:131-132,137-138,144,147`). | None. Hire, deny, expiry, binding, setting and reverse entries use the same tables (A4, AU). |
|
||||
| R3 | Fail closed: an unknown action, tool, right, plugin kind, role field or autonomy value refuses. | Role keys, actions and tools (`role.mjs:190,42,24-29`). Rights (`org-rights.mjs:122-129`). Plugin kinds (`packages/plugins/src/kinds.mjs:147-148`). | Inline roles and plugin-declared tools and actions have no check yet. P1 and PL1. AU refuses an unknown class or level. |
|
||||
| R4 | Hard waits stay with the human. A role can never hold an action outside `ROUTED`. | `vocabulary.mjs:109`, `role.mjs:43`. This covers `git.push.protected`, `git.push.remote`, `host.sudo`, `credential.mint`, `spend`, `deploy` and `data.delete` (`vocabulary.mjs:22,26,29,49,50,51,55`). | The brief's system-rail list did not name it, and "any capacity" must not dissolve it. A hard wait leaves the list only when a PRD version moves it into `ROUTED` (REQ-WAIT-3, `vocabulary.mjs:67-69`), never through the autonomy setting. P1 keeps it for every composition. |
|
||||
| R5 | No agent widens its own authority. No agent resolves a decision it raised, a decision whose subject is itself or a definition it runs, or anything that changes the autonomy setting or a rail. | An arbiter's own raise goes to the human (`broker.mjs:7075`). PRD approval refuses an arbiter that is the PM or an author (`broker.mjs:1060`). | Rewritten in round 2. Round 1 also sent merge and policy decisions to a person; ruling 13 reverses that, and REQ-DEC-2 stays as ratified. Nothing checks a decision's subject against its resolver today. AU adds that check, which sends such a decision to a person. |
|
||||
| R6 | Every agent runs sandboxed as the instance uid, with only the tools its role names. A tool exists only if core or an installed, enabled plugin declares it. | bwrap with user, pid and ipc namespaces and a read-only root (`packages/seat/src/sandbox.mjs:76,176`). Tools are a closed core list (`vocabulary.mjs:142`) and the typed bus tools are fixed in core (`packages/harness/src/tools.mjs:96`). | No plugin declares a tool today. PL1 adds plugin-declared tools. A sandbox escape holds the instance uid, which equals root for the instance (accounts design, "Host trust boundary"). |
|
||||
| R7 | Organizations are isolated. Cross-organization reads go only through the assistant's Console read broker. | Each organization has its own bus and every non-assistant agent's scope is one organization (agent-levels design section 4). | The read broker is not built. It is A6. Until then nothing reads across organizations. |
|
||||
| R8 | No grant beyond the granter, except a flagged line the owner approves. This holds at every autonomy level. | People: `canGrant` subset (`org-rights.mjs:122-129`). Agents: a hire is bounded by `HIRE_ALLOWED` and the chief's list (`broker.mjs:6303-6305`). | A4 replaces that bound with the approval itself. The broker computes the flagged lines; the chief cannot omit them. |
|
||||
| R9 | No hire chains. Only the chief raises hires, and a hired agent cannot hire. | `role-not-chief` (`broker.mjs:6193`). `role.hire` is not on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`). | From ruling 8. P1 refuses `role.hire` and `role.fire` in any hired composition, so the rail survives `HIRE_ALLOWED` going away. |
|
||||
| R10 | Plugin code runs with the host's authority, so installing one is an instance-level human act. An organization's setting cannot move it. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | The loader says a module "runs with the host's authority" (`loader.mjs:5`). Install from GitHub or a zip has to keep that visible. No hash pin exists. PL1. |
|
||||
| R11 | System config is written only by bootstrap. | `scripts/bootstrap.sh:5`, AGENTS.md invariant 2. | None. Nothing in this design writes system config. |
|
||||
| R12 | Rails change only by reviewed commit, and an organization's autonomy setting only by its owner. | The vocabulary and `roles/` change by reviewed commit (AGENTS.md invariant 7). | New in round 2. The setting does not exist yet; AU adds it as an owner-only Console verb. |
|
||||
|
||||
Round 1 changed R5 and R6 from the brief. Round 2 rewrites R5 again and adds
|
||||
R12.
|
||||
|
||||
### Role rails
|
||||
|
||||
A role's definition is its rails. Each part says what that agent may do, and
|
||||
the broker enforces it:
|
||||
|
||||
| Part | What it says | Enforced today | Gap |
|
||||
|---|---|---|---|
|
||||
| `tools` | The tools the agent has | Typed bus tools follow the held actions (`tools.mjs:97`); built-ins follow the policy (`packages/harness/src/bundle.mjs:368`) | None |
|
||||
| `authority.withinRole` | Actions the agent takes alone | The broker's classification (`broker.mjs:7048`) | None |
|
||||
| `authority.crossRole` | Actions it raises to its domain's arbiter | `broker.mjs:7072-7075` | The raiser picks the domain (`broker.mjs:7067`), so it picks its arbiter. RR fixes the domain for merge and policy. |
|
||||
| `decision.resolve.<domain>` in `withinRole` | Which decisions it may resolve for other agents | Only for PRD approval (`broker.mjs:1059`) | An ordinary resolve checks only that the caller's role is the route (`broker.mjs:7212-7215`). The organization's `arbiters` map names the arbiter, not its role (`org-template.json:5`), and the default arbiter, the chief, holds no resolve right (`roles/chief-of-staff.json:8-9`). RR makes an arbiter hold the right for its domain. |
|
||||
| `credentials` | Each service and scope | Bound by a person only (`broker.mjs:6144`) | None |
|
||||
| `messaging` | Whom it may message | `#checkMessaging` (`broker.mjs:7082`) | None |
|
||||
|
||||
**The validator carries them.** P1's `checkComposition` checks every part
|
||||
against the registry, and treats every `decision.resolve.*` action as a
|
||||
flagged line, because it lets the holder decide for other agents.
|
||||
|
||||
**The instructions state them plainly.** The contract keeps the duties and
|
||||
says what to do on a refusal. The list of what the agent may do is generated
|
||||
from the resolved definition into its prompt (section 10), so the wording
|
||||
cannot drift from what the broker enforces. Every shipped contract has an
|
||||
`## Authority` section today; RR makes the role check refuse a version 2
|
||||
contract without one (`role.mjs:146-157`).
|
||||
|
||||
## 4. The primitives
|
||||
|
||||
A role is a composition of these parts and nothing else.
|
||||
|
||||
**Core set.** Shipped in `vocabulary.mjs`, versioned, changed only by reviewed
|
||||
commit:
|
||||
|
||||
- `tools`: core built-ins and `web` (`vocabulary.mjs:142`).
|
||||
- `network`: one of `NETWORKS`.
|
||||
- `authority.withinRole` and `authority.crossRole`: actions from `ROUTED`
|
||||
(`vocabulary.mjs:73-102`).
|
||||
- `credentials`: a known service with grantable scopes. Gitea `admin` and
|
||||
`all` are never granted (`GITEA_SCOPE_CATEGORIES`).
|
||||
- `messaging.sendTo` and `messaging.receiveFrom`.
|
||||
- `skills`.
|
||||
- `instructions`: the contract text, today the `.md` file.
|
||||
- `personality`: new. Bounded text fields (summary, traits, voice), no
|
||||
authority. Ruling 5 and ruling 12.
|
||||
|
||||
**Plugin-declared set.** From an enabled plugin's manifest (section 7):
|
||||
tools, actions and credential services. A plugin action is always routed. A
|
||||
plugin cannot declare a hard wait, cannot reuse a core name, and every name
|
||||
carries the plugin's prefix (`<plugin>.<name>`).
|
||||
|
||||
**The validator.** P1 adds `checkComposition(role, registry, chief)`. It
|
||||
replaces `HIRE_ALLOWED` and `unhireableAction` at hire time. It:
|
||||
|
||||
1. refuses any unknown key, as `role.mjs:190` does;
|
||||
2. refuses any action, tool, network, service or scope the registry lacks,
|
||||
where the registry is the core set plus the plugins enabled in that
|
||||
organization;
|
||||
3. refuses any action outside `ROUTED` or a plugin's routed set (R4);
|
||||
4. refuses `role.hire` and `role.fire` (R9);
|
||||
5. returns every `decision.resolve.*` action as a flagged line (role rails);
|
||||
6. refuses any string that fails `assertClean` (R1), and any field over its
|
||||
length bound;
|
||||
7. returns the flagged lines: every action, tool, network, credential scope
|
||||
and messaging target the chief's own resolved role does not hold (R8).
|
||||
|
||||
The validator accepts every composition that passes. There is no allowlist of
|
||||
roles and no list of hireable definitions.
|
||||
|
||||
**What "any role with any capacity" means.** The chief can propose any name,
|
||||
any mix of registered tools and routed actions, any credential scope a service
|
||||
grants, any personality, any number of agents. A human approves each one.
|
||||
|
||||
**What it cannot mean.** A hired agent cannot hold a hard wait, hire, resolve
|
||||
its own request or a decision about itself, read another organization, name a tool nothing declares,
|
||||
carry a secret in its text, or run outside the sandbox. A new capability that
|
||||
needs new enforcement, for example a new kind of external write, needs a
|
||||
plugin that declares and enforces it. A name with no code behind it gives an
|
||||
agent nothing.
|
||||
|
||||
## 5. Templates and organization roles
|
||||
|
||||
**Templates.** The shipped files in `roles/` become templates. They stay
|
||||
there, versioned by reviewed commit. A template's identity is its name plus
|
||||
the sha256 of its JSON and contract together, so no new field goes into
|
||||
`roles/`. Plugins may ship templates too (section 7).
|
||||
|
||||
**Organization roles.** An organization's own definitions live in its file
|
||||
under a new `definitions` key, one entry per name:
|
||||
|
||||
```json
|
||||
"definitions": {
|
||||
"release-writer": {
|
||||
"rev": 3,
|
||||
"from": { "template": "coder", "sha256": "<64 hex>" },
|
||||
"role": { "tools": ["read", "edit"], "authority": { "withinRole": ["record.write"] } },
|
||||
"instructions": "...",
|
||||
"personality": { "summary": "...", "traits": ["..."], "voice": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An instance names `definition: "org:release-writer"` instead of a template
|
||||
name. Project agents use the organization's definitions. Root agents keep
|
||||
their own `definitions` in `<configDir>/agents.json`.
|
||||
|
||||
- **Import** copies a template into `definitions` with `from` set. The copy is
|
||||
frozen: nothing changes it except an approved change.
|
||||
- **Customize** edits the copy. **Invent** writes a definition with no `from`.
|
||||
Both pass `checkComposition` before they are written.
|
||||
- **Who writes.** A human holding `org.hire` drafts or edits a definition in
|
||||
the Console. A draft no agent uses does nothing. A change to a definition a
|
||||
hired agent uses takes effect only through a change decision (section 6).
|
||||
The chief proposes a definition only inside a decision.
|
||||
- **Template changes.** When a template's current hash differs from a copy's
|
||||
`from.sha256`, the Console shows a diff and offers apply or keep. Apply is a
|
||||
change decision (section 6). Nothing applies by itself (ruling 11).
|
||||
- **Versioning.** `rev` counts up on every write. Every hire and change
|
||||
decision records the definition's `rev` and hash.
|
||||
- **A running agent.** Its sandbox, tools and contract are fixed at launch
|
||||
from the bundle. A change that narrows authority takes effect at the broker
|
||||
on the next call. Anything else takes effect at the next launch, and the
|
||||
Console shows the agent as running an older `rev`.
|
||||
- **Creative templates** are templates like the others. They still get no
|
||||
host grant (`broker.mjs:2415`).
|
||||
|
||||
## 6. Hire as a decision
|
||||
|
||||
One decision action covers hire, fire and change, at every level.
|
||||
|
||||
**Fields.** The chief supplies:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `op` | `hire`, `fire` or `change` |
|
||||
| `level` | `org`, `project` (with the project name) or `root` |
|
||||
| `name` | the agent's instance name, unused, not kept or retired |
|
||||
| `role` | `{ template, sha256 }`, `{ definition, rev }` or `{ inline }` |
|
||||
| `credentials` | each service and scope the agent needs |
|
||||
| `personality` | the chief's draft |
|
||||
| `route` | a proposed harness, model and thinking level |
|
||||
| `reason` | 1 to 500 characters |
|
||||
|
||||
The broker adds, and the chief cannot set or edit:
|
||||
|
||||
- `flagged`: the lines `checkComposition` returns above the chief's own role;
|
||||
- `credentialLines`: one line per service and scope;
|
||||
- `digest`: a hash over the whole decision, as the Console resolution path
|
||||
already checks (`broker.mjs:7200-7204`);
|
||||
- `expiresAt`: seven days.
|
||||
|
||||
**Raise.** Among agents, only the instance in `launch.by` may raise one, as
|
||||
today (`broker.mjs:6193`). It routes by the organization's autonomy setting
|
||||
for authority changes (section 9). At the default, in the loop, it goes to a
|
||||
person, so the arbiter path at `broker.mjs:7074` does not apply. A hire needs no
|
||||
approval count and has no cap (ruling 3). Spend stays bounded by the
|
||||
organization's limits.
|
||||
|
||||
**Approve, in the loop.** A human whose membership in that organization holds
|
||||
`org.hire` approves in the Console, against the current digest. Two limits,
|
||||
at every autonomy level:
|
||||
|
||||
- a decision with any flagged line or any credential line needs the owner.
|
||||
This mirrors `decide` stopping short of credential mints (accounts design,
|
||||
section 3 "Rights"). A member with `org.hire` approves plain hires.
|
||||
- a root-level decision needs the instance owner, since the root file covers
|
||||
every organization (agent-levels section 7).
|
||||
|
||||
The human may change the route, narrow the role or drop credential lines
|
||||
before approving. Widening means a new decision.
|
||||
|
||||
**Near and out of the loop.** Only a plain decision moves: no flagged line, no
|
||||
credential line, organization or project level. An instance whose role holds
|
||||
`decision.resolve.authority` resolves it. It is never the chief that raised it
|
||||
and never the agent the decision names (R5). Near the loop, the owner is told
|
||||
and can reverse it (section 9).
|
||||
|
||||
**What approval writes.** One writer call, with the human as actor and the
|
||||
decision id in the context:
|
||||
|
||||
- org level: the organization writer, extended from `prepareHire`
|
||||
(`instance-settings.mjs:354`). It writes the definition, the instance with
|
||||
its route, and `launch.instances`;
|
||||
- project level: the A2 project-agent writer, behind the projects fence;
|
||||
- root level: the A2 root writer.
|
||||
|
||||
Credential bindings then go through the existing human-only credential path
|
||||
(`broker.mjs:6144`). Until every binding succeeds, `launch` refuses the agent
|
||||
with `credential-unbound` and names the missing one, as A3 does for unbound
|
||||
copies. Because approval sets the route, launch no longer refuses `no-route`
|
||||
for an approved hire.
|
||||
|
||||
**Deny, expire, withdraw.** Deny and expiry write nothing but the trail entry.
|
||||
The chief may withdraw its own decision (`broker.mjs:7205-7211`).
|
||||
|
||||
**Fire and change** are the same decision with `op` set. Fire revokes and
|
||||
delists as today (`instance-settings.mjs:396`).
|
||||
|
||||
**A person starting a hire.** A human holding `org.hire` may start a hire,
|
||||
fire or change in the Console. It is still a decision, with the same fields,
|
||||
broker-computed lines, digest and trail, raised and resolved in one step by
|
||||
that person. Flagged and credential lines still need the owner. I read ruling
|
||||
8 as limiting which agent raises hires, not as stopping a person from hiring.
|
||||
|
||||
**Trail.** Raise, each resolution, the writer receipt and each binding are
|
||||
events in the write-once tables (R2).
|
||||
|
||||
**What this replaces.** The direct `instance.hire` and `instance.fire` paths
|
||||
(`broker.mjs:6185-6280`) and the `hireable` check (`broker.mjs:6291-6306`) go
|
||||
away. The chief's `hire` tool (`packages/harness/src/tools.mjs:225-226`)
|
||||
raises the decision instead of writing. Agent-levels section 7 had three
|
||||
paths, one per level. Now there is one decision with a `level` field, so A4
|
||||
and A5 become one slice, with the root level waiting for A2's root writer.
|
||||
|
||||
## 7. Plugins and modules
|
||||
|
||||
**Today.** Plugin kinds are a closed core list (`kinds.mjs:4-5,22-105`). The
|
||||
loader reads only `packages/plugins/<kind>/<name>/index.mjs` in its own tree,
|
||||
checks symlinks, exports and interface version, and pins no hash
|
||||
(`loader.mjs:1-5,41-72`). A refused module keeps the kind's last good set
|
||||
(`loader.mjs:7-9`). Enabling is `plugins.set` under `org.setup`
|
||||
(`org-rights.mjs:74`), owner-only.
|
||||
|
||||
**Manifest.** Each plugin gets a `plugin.json`: name, version, kinds,
|
||||
interface versions, a sha256 over its files, and what it declares: tools
|
||||
(name, description, argument schema, whether it runs in the sandbox or on the
|
||||
host), routed actions, credential services, and role templates.
|
||||
|
||||
**Install.** Sources are a GitHub URL at a commit, a zip, or a local
|
||||
directory. The instance owner installs at the host with the expected hash.
|
||||
The install refuses a hash mismatch, a core name, a hard-wait action or an
|
||||
unknown kind. Installed plugins live read-only in an instance-level plugin
|
||||
root, listed with their hashes in a lock file. That reads a path outside the
|
||||
loader's own tree, which changes lead decision 143 (`loader.mjs:3-5`). Jason
|
||||
decides that in PL1.
|
||||
|
||||
**Pin and load.** Every load checks the hash in the lock file. A mismatch
|
||||
refuses the plugin and logs it. The loader's existing refusal handling keeps
|
||||
core running.
|
||||
|
||||
**Enable.** Per organization, by the owner, through `plugins.set`. Install is
|
||||
instance-wide because the code runs as the instance uid for every
|
||||
organization (R10).
|
||||
|
||||
**A disabled or missing plugin.** Core keeps running. An agent whose role
|
||||
names the plugin's tools or actions refuses at launch with
|
||||
`plugin-disabled` and the plugin's name. A running agent's calls to those
|
||||
tools refuse with the same code. Nothing is fired or rewritten.
|
||||
|
||||
**Preconfigured systems** such as a software factory or a writing workshop
|
||||
are plugins that ship templates, a system type and tools. Installing one hires
|
||||
nobody. The chief still raises a decision for each agent.
|
||||
|
||||
**Marketplace.** Out of scope. The design must not preclude one, so the
|
||||
manifest keeps the source URL, version and hash, and reserves a `signature`
|
||||
field. The install screen already lists every tool that runs on the host.
|
||||
|
||||
## 8. The minimal base
|
||||
|
||||
A new organization already starts with the human, a chief and an assistant
|
||||
(`org-template.json:4,6`). That matches ruling 9. A6 later moves the
|
||||
assistant to the root level (agent-levels section 9).
|
||||
|
||||
The chief reads the user's plan and raises one hire decision per agent it
|
||||
needs, each citing the plan in its reason. It starts with the fewest agents
|
||||
and adds one when a task needs a role nobody holds. Separate concerns go in
|
||||
separate organizations or projects.
|
||||
|
||||
Every other shipped organization role becomes a template only: coder, pm,
|
||||
reviewer, security-reviewer, merge-gate, cto, the five chiefs (executive,
|
||||
financial, information, information security, marketing) and the six creative
|
||||
roles (companion, continuity, fact-checker, ideas, proofreader, research).
|
||||
`researcher.json` is a version 1 worker role (`roles/researcher.json:2`), which
|
||||
a hire refuses (`broker.mjs:6303`), and `conductor-policy.json` is not a role.
|
||||
Neither changes.
|
||||
|
||||
## 9. Autonomy, usability and security
|
||||
|
||||
Read this first. It says who decides what, and what changes as trust grows.
|
||||
|
||||
Ruling 13 sets the direction: from a person in the loop, to near the loop, to
|
||||
out of the loop. Today most decisions sit in the loop.
|
||||
|
||||
**The three levels.**
|
||||
|
||||
- **In the loop.** A person approves before anything happens.
|
||||
- **Near the loop.** An agent whose role holds the resolve right decides, and
|
||||
the decision takes effect. A person is told in the Console and can reverse
|
||||
it within a window the owner sets. I propose 24 hours as the default.
|
||||
- **Out of the loop.** An agent whose role holds the resolve right decides.
|
||||
The trail records it, and nobody is told.
|
||||
|
||||
For a class that routes to an arbiter today (cross-role work, merge, policy),
|
||||
the arbiter resolves at every level, as PRD 1.5 REQ-DEC-2 and acceptance 314
|
||||
step 5 require. The level only decides what happens after the arbiter
|
||||
resolves. In the loop, the resolution waits for a person to confirm it before
|
||||
it takes effect. A person who declines sends it back to the arbiter with a
|
||||
reason. Near and out of the loop, it takes effect at once. No level makes a
|
||||
person the resolver of these classes.
|
||||
|
||||
**The setting.** An organization's file gets an `autonomy` key with one level
|
||||
per class below. A missing key means today's routing. Only the owner changes
|
||||
it, in the Console: the verb maps to `null` in `VERB_RIGHTS`, the explicit
|
||||
owner-only mark (`org-rights.mjs:28-29`), and it is not on the tester's verb
|
||||
list (`broker.mjs:192`). Each change is a trail event. No agent changes it at
|
||||
any level (R12). The owner can pull a class back toward the loop at any time
|
||||
with no preconditions.
|
||||
|
||||
**Classes and levels.** "Default" marks where each class sits today.
|
||||
|
||||
| Class | In the loop | Near the loop | Out of the loop |
|
||||
|---|---|---|---|
|
||||
| Cross-role work, such as a task scope change | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default, `broker.mjs:7072-7075`) |
|
||||
| Merge, `git.merge.protected` | The technical arbiter resolves; a person confirms before it lands | The technical arbiter resolves; a person is told | The technical arbiter resolves (default). Acceptance 314 step 5 has the CTO do it (`docs/acceptance/314.md:116-120`) |
|
||||
| Policy, `policy.change` | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default). No shipped role holds this action |
|
||||
| Plain hire, fire or role change | A person with `org.hire` approves (default, ruling 1) | An agent holding `decision.resolve.authority` resolves; the owner is told | The same agent resolves |
|
||||
| Flagged grants and credential bindings | The owner approves (default) | Does not move (R8) | Does not move |
|
||||
| Root-level agents | The instance owner (default) | Not an organization setting | Not an organization setting |
|
||||
| Plugin install | The instance owner, at the host (default) | Not an organization setting (R10) | Not an organization setting |
|
||||
| Hard waits: push, deploy, spend, mint, delete, sudo | A person, every time (R4) | Not a setting | Not a setting |
|
||||
| The autonomy setting | The owner (R12) | Never | Never |
|
||||
|
||||
PRD approval already has its own switch: a request in `autonomous` mode sends
|
||||
it to the technical arbiter, and one in `guided` mode to a person
|
||||
(`packages/bus/src/request-lifecycle.mjs:308`, `broker.mjs:1057-1060`). A
|
||||
person sets the mode (`broker.mjs:5918-5919`). This design leaves it as is.
|
||||
|
||||
**What reverse can undo.** Near the loop, reverse is a one-step act by the
|
||||
owner, recorded against the decision. After the window it is an ordinary new
|
||||
decision.
|
||||
|
||||
| Class | Reverse does | It cannot undo |
|
||||
|---|---|---|
|
||||
| Cross-role work | Restores the task's prior scope or state | Messages sent, records written (write-once, R2), work done in the window |
|
||||
| Merge | Lands a revert commit through the normal path | The merge in history (no force push, AGENTS.md invariant 8), or anything that ran or shipped the merged code in the window |
|
||||
| Policy | Restores the prior policy | Actions taken under the new policy in the window |
|
||||
| Hire | Fires the agent, which revokes its credentials (`instance-settings.mjs:396-414`) | What the agent did or spent in the window |
|
||||
| Fire | Rehires the same definition `rev` | The run and session state the fire ended, and the credential bindings the fire revoked. Each needs a new binding the owner approves |
|
||||
| Role change | Returns the definition to the prior `rev`, and relaunches the agent if it runs the new one. A narrowing back takes effect at the next broker call (section 5) | What the agent did under the new `rev` |
|
||||
| Credential binding | Not offered: it does not move | Each use of the token at the service. That is why it stays in the loop |
|
||||
|
||||
**Before a class moves a level.** The organization needs:
|
||||
|
||||
- to move any class to near the loop: a resolver instance whose role holds
|
||||
that class's resolve right and is not the raiser (RR), the class's reverse
|
||||
built and tested (AU), and Console notices on for the owner;
|
||||
- to move any class to out of the loop: time at near the loop, and the
|
||||
owner's review of its reversals in the trail;
|
||||
- for merge, in addition: a reviewer role whose approve verdict a landing
|
||||
needs, and the land gate's tests. Acceptance 314 has both, which is why
|
||||
merge stays out of the loop by default;
|
||||
- for policy, in addition: a reviewer role that sees the policy diff first;
|
||||
- for plain hires, in addition: organization spend limits that cover a new
|
||||
agent, and a resolver other than the chief. Out of the loop also needs
|
||||
per-agent limits in every definition the resolver can approve.
|
||||
|
||||
Flagged grants, credential bindings, root-level agents and plugin installs do
|
||||
not move in this design. Moving one needs a new ruling.
|
||||
|
||||
**A person changes directly.** Organizations and projects (owner). Drafts of
|
||||
role copies and invented roles, which do nothing until a decision uses them
|
||||
(`org.hire`). An agent's model, limits and route (`org.hire`). An agent's
|
||||
personality and traits after hire (ruling 12). Enabling a plugin in an
|
||||
organization (owner). Installing a plugin (instance owner, at the host).
|
||||
|
||||
**Only a reviewed code change.** The rails in section 3, the core list of
|
||||
tools and actions, the shipped role templates, the rights table including
|
||||
`org.hire`, plugin kinds, and system config, which only bootstrap writes.
|
||||
|
||||
**What never moves, at any level:**
|
||||
|
||||
- An agent cannot push to a protected branch, push to a remote, deploy, spend,
|
||||
mint a credential, delete data or use sudo by itself. A person decides each
|
||||
of those every time.
|
||||
- An agent cannot resolve its own request or a decision about itself, and it
|
||||
cannot change the autonomy setting or a rail.
|
||||
- An agent cannot grant more than the chief holds, or bind a credential,
|
||||
without the owner.
|
||||
- Only the chief raises hires, and a hired agent cannot hire.
|
||||
- An agent cannot see another organization's work. The assistant's read
|
||||
across organizations is planned (A6) and not built yet.
|
||||
- A plugin runs with the same power as the Mosaic service itself. Install
|
||||
only code you trust.
|
||||
|
||||
## 10. Tooling and instructions
|
||||
|
||||
An agent trusted to work without a person has to know its own limits without
|
||||
asking one. Three things today fall short.
|
||||
|
||||
**Its rails, readable at runtime.** The prompt names the agent's built-in and
|
||||
bus tools (`packages/harness/src/bundle.mjs:283-284`) and the held actions
|
||||
that have no bus tool (`bundle.mjs:308`). It does not say which actions the
|
||||
agent takes alone, which go to which arbiter, which go to a person, or what it
|
||||
may resolve. Six contracts tell the agent "the launch record you received
|
||||
shows the resolved list" (`roles/coder.md:41`, `roles/cto.md:46`,
|
||||
`roles/merge-gate.md:54`, `roles/pm.md:109`, `roles/reviewer.md:38`,
|
||||
`roles/security-reviewer.md:27`). No session receives one. Launch records are
|
||||
host-side, for principal checks (`packages/bus/src/runtime.mjs:885-901`,
|
||||
`broker.mjs:2047`).
|
||||
|
||||
Fix, in TI: the prompt gets a generated "## Your rails" section from the
|
||||
resolved definition. It lists each held action with where it goes (alone,
|
||||
which arbiter instance, or a person), each resolve right with its class's
|
||||
autonomy level, the network, each credential service and scope by name, and
|
||||
the messaging targets. A read-only `my_rails` bus tool returns the same from
|
||||
the broker, since a narrowing takes effect at the next call (section 5). The
|
||||
six contracts drop the launch-record sentence and point at that section.
|
||||
|
||||
**Refusals that say what to do next.** The broker's `fail(code)` throws a
|
||||
bare code (`broker.mjs:251-253`), and 653 lines of `broker.mjs` call it. A
|
||||
reason travels only where a call builds one (`broker.mjs:243-249`, for
|
||||
example `941`, `961` and `6468`). The prompt says only that "a refusal names
|
||||
its reason" (`bundle.mjs:284`).
|
||||
|
||||
Fix, in TI: one table maps each refusal code to the rail or role rail it
|
||||
enforces and the next step, for example "raise a decision for the delivery
|
||||
arbiter" or "a person decides this; raise it with blocking false". The
|
||||
envelope carries both. A test fails when a `fail` code has no entry.
|
||||
|
||||
**The tools it holds.** These are covered. The prompt names each tool
|
||||
(`bundle.mjs:283-284`) and the bundle writes each tool's description and
|
||||
schema to `tools.json` for the harness (`bundle.mjs:372`). What was missing
|
||||
is the routing behind each tool, which the rails section adds.
|
||||
|
||||
## 11. Threat model
|
||||
|
||||
| Threat | What stops it | What is left |
|
||||
|---|---|---|
|
||||
| A prompt-injected chief proposes a hostile role | The broker computes the flagged and credential lines; the chief cannot hide them. Hard waits, hiring and unknown parts refuse (R4, R9, R3). A human approves against the digest. | A human can approve a hostile role. Flagged and credential lines need the owner. |
|
||||
| A custom role names an unknown tool | `checkComposition` refuses it at the decision and at the writer. Launch refuses it again (R3). | None. |
|
||||
| A plugin from an untrusted source | Instance owner only; hash pin; the install screen lists host-run tools. | Approved plugin code runs as the instance uid (R10). A pin does not make code safe. |
|
||||
| A credential scope wider than the task | Each scope is its own line; owner-only approval; the approver may drop lines; admin and all never granted. | The service's own scope granularity. |
|
||||
| An arbiter agent resolves a bad merge | Its role must hold `decision.resolve.technical` (RR). It never resolves its own raise (R5). The review verdict and the land gate come first (acceptance 314). The owner can pull merge back toward the loop. | A wrong merge is reverted, not undone. |
|
||||
| An agent widens its own authority through a decision | No agent resolves its own raise or a decision about itself (R5). Flagged grants and credentials stay with the owner at every level (R8). Only the owner changes the setting (R12). | One agent raises a plain change for another. Plain means within the chief's own authority, and near the loop tells the owner. |
|
||||
| A prompt-injected resolver near the loop | The owner is told and can reverse within the window. | What reverse cannot undo (section 9). |
|
||||
| Approval fatigue on flagged grants | Flagged lines shown apart from plain ones; credential and flagged decisions go to the owner; no batch approval; seven-day expiry. | A busy owner who approves without reading. A later slice could limit how many flagged decisions one approval screen holds. |
|
||||
| An imported template drifts from its source | The copy is frozen with its source hash. A changed template produces a diff offer, never a silent change. | An owner who keeps an old copy keeps its old behaviour. That is the ruling. |
|
||||
|
||||
## 12. Invariant 7 amendment
|
||||
|
||||
Proposed only. Jason approves it through a reviewed commit.
|
||||
|
||||
Replace AGENTS.md invariant 7 (`AGENTS.md:45-47`) with:
|
||||
|
||||
> 7. **Policy**: missions govern tasks; a task narrows, never widens. The
|
||||
> rails, the core vocabulary of tools and actions, and the shipped role
|
||||
> templates in `roles/` change only by reviewed commit. An organization's
|
||||
> own roles are copies or compositions of known parts. One takes effect
|
||||
> only through a decision, resolved where the organization's owner-set
|
||||
> autonomy level sends it, by default a human with `org.hire`. The
|
||||
> decision lists every grant above the chief's own as a flagged line,
|
||||
> which only the owner approves. Only the chief raises hires, and a hired
|
||||
> agent cannot hire.
|
||||
|
||||
Touched role-model lines:
|
||||
|
||||
- `AGENTS.md:107`: keep "policy/role expansion" as a protected act, and add
|
||||
"an approved hire decision is that authority for the one agent it names".
|
||||
- `AGENTS.md:209`: change to "`roles/`: shipped role templates and the
|
||||
conductor policy".
|
||||
|
||||
## 13. Slices
|
||||
|
||||
Phase A after A3, in order: R1, P1, T1, A4, RR, TI, AU, PL1. A6 and A7 stay
|
||||
as agent-levels section 9 has them.
|
||||
|
||||
Slices that change `roles/`: A4 (the chief), RR (the chief, and any role an
|
||||
organization names as delivery arbiter) and TI (six contracts). Slices that
|
||||
change rights policy: R1 and AU. P1 changes no file in `roles/` but defines
|
||||
authority policy. All of them need Jason's approval.
|
||||
|
||||
**R1. The `org.hire` right.** Add `'org.hire': flag(true)` to `RIGHTS` and a
|
||||
verb entry for the hire resolution in `VERB_RIGHTS`. The owner bundle picks it
|
||||
up from `Object.keys(RIGHTS)` (`org-rights.mjs:22`); member, collaborator and
|
||||
approver do not get it. Rights policy, so Jason's approval
|
||||
(accounts design, `Grant needed` at line 1391). Tests: the owner has it; the
|
||||
three templates lack it; `canGrant` lets an owner grant it to a member; an
|
||||
unknown right still refuses.
|
||||
|
||||
**P1. Primitives validator.** `checkComposition` and the registry, beside
|
||||
`role.mjs`. `HIRE_ALLOWED` stays until A4 switches over. It changes no role
|
||||
file but it defines authority policy, so Jason's approval. Tests: an unknown
|
||||
key, action, tool, service or scope refuses; each hard wait refuses;
|
||||
`role.hire` and `role.fire` refuse; a secret in instructions or personality
|
||||
refuses; a `decision.resolve.*` action comes back as a flagged line;
|
||||
flagged lines match a hand-built expected set for coder against the
|
||||
shipped chief.
|
||||
|
||||
**T1. Organization definitions and templates.** The `definitions` key,
|
||||
`org:` references, import, the human edit verbs under `org.hire`, the
|
||||
personality fields, the template hash and the diff read. Tests: import pins
|
||||
the hash; editing a template file changes no copy; the diff read shows the
|
||||
change; an `org:` reference to a missing definition refuses at launch; an
|
||||
agent cannot call the edit verbs.
|
||||
|
||||
**A4. Hire as a decision, all levels.** The decision action, raise, Console
|
||||
approval, the writer calls, credential lines, expiry, fire and change, the
|
||||
Console path that raises and resolves in one step, and removal of the direct
|
||||
path and `#hireable`. Every hire decision sits in the loop until AU. Org and project land first; root
|
||||
waits for A2's root writer. This edits `roles/chief-of-staff.json` and `.md`
|
||||
(drops `hireable`, rewords hire), the vocabulary and AGENTS.md invariant 7,
|
||||
so Jason's approval. Tests: a hired agent's raise refuses `role-not-chief`; a
|
||||
chief decision cannot carry its own `flagged`; a member with `org.hire`
|
||||
approving a credential or flagged decision refuses; a stale digest refuses;
|
||||
deny and expiry write nothing; approval writes the role, the route and the
|
||||
trail, and launch refuses `credential-unbound` until binding; fire revokes;
|
||||
the old `instance.hire` verb refuses; a cross-role `git.merge.protected`
|
||||
decision still routes to the technical arbiter and the CTO resolves it, as
|
||||
acceptance 314 step 5 expects; a hire a person starts in the Console leaves a decision record
|
||||
with its flagged lines.
|
||||
|
||||
**RR. Role rails and resolve rights.** An arbiter must hold
|
||||
`decision.resolve.<domain>` for its domain, checked when the organization
|
||||
file is written and again at resolve, which extends `broker.mjs:1059` to
|
||||
every decision. The vocabulary adds `decision.resolve.delivery`. A fixed
|
||||
domain per action for `git.merge.protected` (technical) and `policy.change`
|
||||
(delivery), so a raiser cannot pick its arbiter. The chief gets both resolve
|
||||
rights, since the template names it arbiter for both
|
||||
(`org-template.json:5`). The role check refuses a version 2 contract with no
|
||||
`## Authority` section. Changes `roles/chief-of-staff.json` and the
|
||||
vocabulary, so Jason's approval. Tests: naming an arbiter without the right
|
||||
refuses when written; an arbiter whose role lost the right refuses
|
||||
`not-resolver`; a merge raised with domain `delivery` still routes to the
|
||||
technical arbiter; acceptance 314 step 5, the CTO resolving the merge, still
|
||||
passes; a contract with no `## Authority` refuses.
|
||||
|
||||
**TI. Tooling and instructions.** The generated "## Your rails" prompt
|
||||
section, the read-only `my_rails` tool, the refusal table and envelope
|
||||
fields, and the six contract edits (section 10). Changes `roles/*.md`, so
|
||||
Jason's approval. Tests: coder's prompt lists each held action with its
|
||||
route; `my_rails` matches the broker after a narrowing; every `fail` code in
|
||||
`broker.mjs` has a table entry; a refused cross-role call's envelope names
|
||||
its rail and next step.
|
||||
|
||||
**AU. Autonomy setting and routing.** The `autonomy` key, the owner-only
|
||||
Console verb, routing per class, the subject check that sends a decision
|
||||
about its resolver to a person (R5), `decision.resolve.authority`, the
|
||||
Console notice, reverse per class with its window, and the trail events.
|
||||
Needs A4 and RR. Changes rights policy (a `VERB_RIGHTS` entry) and the
|
||||
vocabulary, but no role file, so Jason's approval. Acceptance tests:
|
||||
|
||||
- with no `autonomy` key, routing is exactly today's: a merge goes to the
|
||||
technical arbiter, a hire to a person;
|
||||
- a member, an agent and the dev tester each refuse to change the setting;
|
||||
an unknown class or level refuses;
|
||||
- with merge in the loop, the technical arbiter still resolves the merge
|
||||
decision, a person's resolve refuses `not-resolver`, and the merge does not
|
||||
land until a person confirms; a decline returns it to the arbiter;
|
||||
- with merge near the loop, the arbiter's resolve takes effect, the owner
|
||||
gets a notice, and reverse within the window lands a revert commit
|
||||
recorded against the decision;
|
||||
- with hires near the loop, an instance holding `decision.resolve.authority`
|
||||
resolves a plain hire; a hire with a flagged or credential line still goes
|
||||
to the owner; the chief cannot resolve its own raise; no agent resolves a
|
||||
change that names itself;
|
||||
- reverse after the window refuses and names the ordinary decision path;
|
||||
- each setting change is a trail event with the owner as actor;
|
||||
- no setting value moves a hard wait, a flagged grant or a credential
|
||||
binding away from a person.
|
||||
|
||||
**PL1. Plugin manifest, install and declared tools.** Manifest, pinned
|
||||
install root, lock file, the registry merge, and `plugin-disabled` refusals.
|
||||
Changes lead decision 143 and the loader's trust rule, so Jason's approval.
|
||||
Tests: a hash mismatch refuses and core keeps running; a manifest naming a
|
||||
core tool or a hard wait refuses; disabling a plugin makes its agents refuse
|
||||
at launch with its name; a running agent's call to a disabled tool refuses.
|
||||
@@ -0,0 +1,212 @@
|
||||
# System-type toolsets at launch
|
||||
|
||||
Status: design only. Nothing here is built and no `roles/` or contract file is
|
||||
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
|
||||
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
|
||||
project gets the toolsets of the system types that project uses."
|
||||
|
||||
## What exists today
|
||||
|
||||
- **Modules.** `installedSystemTypes()` (`packages/plugins/src/system-types.mjs`)
|
||||
loads every installed `system-type` module and returns
|
||||
`{id, label, description, toolsets}`. It is fail-closed: a refused module, a
|
||||
missing or extra key, or a toolset name that fails `NAME` throws
|
||||
`system-type-invalid`. All five shipped modules (business-management, coding,
|
||||
creative-writing, marketing, montage) declare `toolsets: []`.
|
||||
- **Organization enable.** The organization file's
|
||||
`plugins['system-type']` holds the enabled ids. `plugins.set` writes it
|
||||
through the organization writer behind the owner fence
|
||||
(`organization-system-types-human-only`) and returns a transaction receipt.
|
||||
The bus reads the current set with `system-types.current` on every read and
|
||||
write, so it never trusts a stale snapshot.
|
||||
- **Project selection.** `project.system-types.set` appends a row to
|
||||
`project_system_types` (bus store, `seq`-ordered, latest wins) and accepts
|
||||
only enabled ids (`system-type-not-enabled`). A read returns
|
||||
`{selected, active, inactive}`, where `active = selected ∩ enabled` and
|
||||
`inactive = selected − enabled`.
|
||||
- **Launch.** `resolveInstance` (`packages/organization/src/resolve.mjs`)
|
||||
starts from the role definition's `tools` and `network`, narrows them by
|
||||
each layer's `limits.tools` and `limits.network` (system, business,
|
||||
project, agent), and freezes the result with a digest. The role's `tools`
|
||||
is therefore a ceiling: a launch gets those tools minus whatever a layer
|
||||
limit removes, never more. The launcher (`packages/cli/src/launcher.mjs`) passes that
|
||||
result to `buildBundle` (`packages/harness/src/bundle.mjs`), which writes
|
||||
`policy.json` (`tools: builtinTools(resolved.limits.tools)`), `tools.json`
|
||||
(typed host tools) and `manifest.json` once each (`flag: "wx"`). Typed
|
||||
host tools (`typedTools`, `packages/harness/src/tools.mjs`) are not granted
|
||||
by tool name: each is built only when the resolved `authority` holds the
|
||||
action it needs.
|
||||
- **Run tasks.** `scripts/mosaic-task.mjs` validates a mission's
|
||||
`capabilities.tools` as a ceiling and a task's `capabilities.tools` as the
|
||||
allowlist, both drawn from `SUPPORTED_TOOLS`. A task narrows the mission.
|
||||
It never widens it.
|
||||
|
||||
Nothing on the launch path reads system types today, so every launch gets
|
||||
its role's tools as narrowed by the layer limits. That is the coding
|
||||
module's behavior, and the design keeps it as the default.
|
||||
|
||||
## Resolution at launch
|
||||
|
||||
A launch resolves its toolsets in the host-side launcher, before
|
||||
`resolveInstance` freezes the digest. In order:
|
||||
|
||||
1. **Pick the project.** The launch's project is the one the session is
|
||||
started for: the thread's project for a project thread, or none. A launch
|
||||
with no single project (an organization-wide session, or a role started
|
||||
with several code projects in scope) gets no system-type toolsets. Taking
|
||||
the union across projects would widen one project's agent with another
|
||||
project's tools, so the union is ruled out.
|
||||
2. **Read the active set.** The launcher asks the bus, as the host, for the
|
||||
project's selection and computes `active = selected ∩ enabled`. `enabled`
|
||||
comes from `system-types.current`, the same trusted read the routes use.
|
||||
Inactive selections are dropped and recorded in the manifest. They do not
|
||||
refuse the launch, because the owner may disable a type at any time and
|
||||
that must not strand running work.
|
||||
3. **Load the modules.** `installedSystemTypes()` runs. If it throws
|
||||
`system-type-invalid`, the launch refuses with that code. An active id
|
||||
with no installed module refuses `system-type-missing`.
|
||||
4. **Map toolset names to tools.** Each active module's `toolsets` are looked
|
||||
up in the reviewed mapping file (below). The result is a set of tool names,
|
||||
each a member of the role vocabulary `TOOLS`
|
||||
(`packages/organization/src/vocabulary.mjs`). Typed host tools cannot be
|
||||
named: they stay gated by the role's `authority` actions exactly as today,
|
||||
and a system type never changes `authority`.
|
||||
5. **Bound by the ceilings.** The role's `tools` stays the hard ceiling for
|
||||
every effective tool, including any a system type switches on. A role may
|
||||
hold some of its tools back for typed projects by listing them in
|
||||
`systemTypeTools` (new, see the proposal), which must be a subset of the
|
||||
role's `tools`. Then:
|
||||
- `base` = (role `tools` − `systemTypeTools`), narrowed by every layer's
|
||||
`limits.tools`, as `resolveInstance` does today;
|
||||
- `added` = mapped tools ∩ `systemTypeTools`, narrowed by every layer's
|
||||
`limits.tools` the same way;
|
||||
- `effective` = `base ∪ added`;
|
||||
- for a run task, `effective` is then intersected with the mission's
|
||||
`capabilities.tools` and then the task's. That intersection covers
|
||||
base tools too, as it does today.
|
||||
|
||||
Every term is a subset of role `tools` ∩ every layer limit, so a system
|
||||
type can only switch on a tool the role already holds and the layers
|
||||
already allow. Invariant 7 holds because each later step can only remove
|
||||
entries. A role without `systemTypeTools` behaves exactly as today.
|
||||
6. **Record it.** `resolveInstance` takes the added list as an input and
|
||||
includes `systemTypes: {project, active, dropped, toolsets, added}` in the
|
||||
resolved object, so the digest covers it. `buildBundle` writes the same
|
||||
block into `manifest.json`. The run record then shows which types and
|
||||
mapping produced each tool.
|
||||
|
||||
No step adds credentials, network reach or authority. `limits.network` and
|
||||
`authority` are computed exactly as today. A toolset that needs a credential
|
||||
gets it the way any role does, through the role file's `credentials` and a
|
||||
reviewed commit. A system type never grants one.
|
||||
|
||||
## The mapping file
|
||||
|
||||
The mapping is repository policy, so it lives under `roles/` and changes only
|
||||
through a reviewed commit, the same rule that governs `roles/*.json`
|
||||
(invariant 7, AGENTS.md "Role model"). Proposed path:
|
||||
`roles/toolsets/system-types.json`. It sits in a subdirectory because two
|
||||
loaders read every `roles/*.json` as a role definition
|
||||
(`instance-settings.mjs` `definitions()`, `harness/tests/tools.test.mjs`).
|
||||
A mapping file at `roles/` top level would be read as a broken role.
|
||||
|
||||
Proposed format:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolsetsVersion": 1,
|
||||
"toolsets": {
|
||||
"repository": { "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"] },
|
||||
"web-research": { "tools": ["web"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The two entries are illustrations, not part of the proposal. No shipped
|
||||
module names a toolset today, so the file would ship as
|
||||
`{"toolsetsVersion": 1, "toolsets": {}}`.
|
||||
|
||||
Rules a loader enforces, fail-closed:
|
||||
|
||||
- Exactly the keys `toolsetsVersion` (must be `1`) and `toolsets`. Each entry
|
||||
has exactly the key `tools`.
|
||||
- Each toolset name matches `NAME` from `packages/plugins/src/kinds.mjs`, the
|
||||
same pattern modules must use.
|
||||
- `tools` is a non-empty list of distinct names, each in `TOOLS`. A typed
|
||||
host tool name, or anything else, refuses `toolset-mapping-invalid` at
|
||||
load.
|
||||
- The file is read from the release copy, like the role files, and its
|
||||
sha256 goes into `manifest.sources`.
|
||||
|
||||
Who may change it: a reviewed commit on the repository, landed by the lead
|
||||
with Jason's authority over role policy. An organization owner cannot change
|
||||
it at runtime, and neither the Console nor the bus has a write path to it.
|
||||
Organization owners choose which types are enabled and projects choose among
|
||||
them. The repository decides what a type's toolsets may contain.
|
||||
|
||||
## Fail-closed behavior
|
||||
|
||||
| Condition | Result |
|
||||
| --- | --- |
|
||||
| An installed module is refused or malformed | launch refuses `system-type-invalid` |
|
||||
| An active id has no installed module | launch refuses `system-type-missing` |
|
||||
| A module names a toolset the mapping lacks | launch refuses `toolset-unmapped` |
|
||||
| The mapping file is missing, unreadable or invalid | launch refuses `toolset-mapping-invalid`, but only when at least one active type declares a toolset; a launch with nothing to map never reads it |
|
||||
| A mapped tool is outside the role's `systemTypeTools` (and so not switched on) | the tool is dropped and recorded under `dropped`, launch continues |
|
||||
| The bus cannot answer the project's selection | launch refuses `system-types-unavailable`; it does not fall back to "no types", which could hide a missing toolset the operator expects |
|
||||
| A selection is inactive (disabled in the organization) | dropped and recorded, launch continues |
|
||||
|
||||
The two "continue" rows are narrowing, never widening. Every row that could
|
||||
change what an agent may do refuses instead.
|
||||
|
||||
## The coding module
|
||||
|
||||
Coding declares `toolsets: []`. It adds nothing, and a coding project's agent
|
||||
keeps its role's tools as the layer limits narrow them: for
|
||||
`roles/coder.json` the ceiling is `read, write, edit, bash, grep, find, ls`
|
||||
with network `api-only`, the same as today. Turning the design on changes nothing for any shipped module until a
|
||||
module declares a toolset and a reviewed commit maps it.
|
||||
|
||||
## Proposed `roles/` change (not applied)
|
||||
|
||||
1. Add an optional V2 key `systemTypeTools` to the role schema
|
||||
(`packages/organization/src/role.mjs`): a distinct list from `TOOLS` (same
|
||||
check as `checkTools`, but empty is allowed) that must be a subset of the
|
||||
role's own `tools`, or the role file refuses at load. It names the tools
|
||||
the role holds back unless an active system type maps them. Absent means
|
||||
`[]`: nothing is held back and no system type changes that role's tools.
|
||||
Every existing role file stays valid and behaves as today, and
|
||||
`checkCreative` keeps inspecting `tools`, which still bounds everything.
|
||||
|
||||
```diff
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
-const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
|
||||
+const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable", "systemTypeTools"];
|
||||
```
|
||||
|
||||
2. Add `roles/toolsets/system-types.json` with an empty `toolsets` object, and
|
||||
a loader beside the role loader with the checks listed above.
|
||||
3. Have `resolveInstance` accept `systemTypes` (the launcher's step 1-4
|
||||
result) and apply step 5. Have `buildBundle` copy the block into the
|
||||
manifest. `scripts/mosaic-task.mjs` already bounds tools by mission and
|
||||
task. It would apply that filter to the final effective set.
|
||||
|
||||
No contract under `contracts/` changes: the constitution's least-privilege
|
||||
rule already covers this. The design adds no grant path: the effective set
|
||||
stays within role `tools` and every layer limit, and `authority`, network and
|
||||
credentials are untouched. What it does add is a runtime input (the
|
||||
project's active system types) that can switch a held-back tool on, so a
|
||||
role author who lists a tool in `systemTypeTools` is deciding that an
|
||||
organization owner's enable plus a project's selection may turn it on. Each of the
|
||||
three steps is a reviewed change to role policy and needs Jason's sign-off
|
||||
before it is built.
|
||||
|
||||
## Open points for the build
|
||||
|
||||
- Business-file projects (`b.projects`, used for checkouts) and bus projects
|
||||
(UUIDs in `project_system_types`) are separate records today. Step 1 needs
|
||||
the launch to carry the bus project id of the thread it serves. The build
|
||||
should confirm how a project thread names its project at launch before
|
||||
wiring step 2.
|
||||
- A running session keeps the toolsets it launched with. Disabling a type
|
||||
takes effect at the next launch, which matches how role changes behave.
|
||||
@@ -0,0 +1,450 @@
|
||||
# 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.
|
||||
@@ -189,8 +189,15 @@ 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). |
|
||||
@@ -1117,3 +1124,14 @@ For lifecycle tasks, the first review request includes the exact raw JSON
|
||||
verification-document and verdict-body contract; unrelated reviews retain their
|
||||
existing free-text contract. The typed record tools describe the existing closed
|
||||
requirement schema without relaxing host validation.
|
||||
|
||||
## Built-in task store writes
|
||||
|
||||
The outside-agent human session can call `tasks.create` with `title` and a `requirement` ID
|
||||
(such as `REQ-TASK-1`), plus optional task fields. `tasks.edit` accepts `task_ref` and the
|
||||
fields to change, including `requirement`; archive, unarchive, and delete take `task_ref`.
|
||||
Each write records `human.input`. A task read returns a `digest` that can be supplied as
|
||||
`expect` to a later write; any intervening revision, including archive or unarchive, refuses
|
||||
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
|
||||
must be current roles. Automatic tail placement and an explicitly occupied position refuse
|
||||
`task-position-full` when there is no room; no task row is written.
|
||||
@@ -655,3 +655,44 @@ CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_t
|
||||
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
|
||||
-- U4a (#1873): the task store built into each organization, the default task-tracker module
|
||||
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
|
||||
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
|
||||
-- verb, and the event that records the write. Task numbers count up per organization and are
|
||||
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
|
||||
-- organization is removed, which removes this whole database with it. Only a new append-only
|
||||
-- table, its index and its guards; no existing row changes and nothing is deleted.
|
||||
CREATE TABLE store_tasks (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task INTEGER NOT NULL CHECK (task >= 1),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
|
||||
verb TEXT NOT NULL,
|
||||
by_role TEXT,
|
||||
by_run TEXT,
|
||||
by_human TEXT,
|
||||
event TEXT NOT NULL REFERENCES events(id),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
|
||||
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
|
||||
UNIQUE (business, task, revision)
|
||||
) STRICT;
|
||||
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
|
||||
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
|
||||
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
|
||||
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
|
||||
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
|
||||
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
|
||||
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
|
||||
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
|
||||
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.op IN ('archive','unarchive')
|
||||
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
|
||||
+520
-20
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -7,7 +9,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 { 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';
|
||||
import { pluginName, 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([
|
||||
@@ -50,6 +52,7 @@ export const ACTIONS = Object.freeze([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
@@ -78,6 +81,7 @@ const ROUTED = new Set([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
@@ -88,6 +92,8 @@ const ROUTED = new Set([
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
const GATED = new Set(HARD_WAITS);
|
||||
const CLOSED = "('resolved','withdrawn','expired')";
|
||||
// RR (#1873): these actions never take their domain from the raising agent.
|
||||
const DECISION_DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
|
||||
export const RECENT = 50;
|
||||
// How many replies `replies` returns, oldest first.
|
||||
@@ -95,7 +101,8 @@ export const REPLIES = 50;
|
||||
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
|
||||
export const STRANDED = 50;
|
||||
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
|
||||
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot'];
|
||||
// U4a (#1873): `archived` reads the built-in store's archived tasks; a Vikunja task is never archived.
|
||||
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot', 'archived'];
|
||||
export const TASK_PAGE = 50;
|
||||
export const TASK_PAGE_MAX = 200;
|
||||
// D3 (#797): raw UTF-8 pre-bound for the approved requirement texts quoted into one coder/reviewer
|
||||
@@ -184,6 +191,8 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
|
||||
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
|
||||
// collaborator or viewer, is refused every one at the USER_VERBS gate.
|
||||
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
|
||||
// The person's task-store writes, distinct from the agent's singular task.* actions.
|
||||
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -238,10 +247,11 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
|
||||
// Guidance is static policy text; never quote arguments or file bytes here.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
Object.assign(this, refusalGuidance(code));
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -295,6 +305,105 @@ function identifier(x) {
|
||||
return x;
|
||||
}
|
||||
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
|
||||
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
|
||||
// module. Its rows live in the organization's own bus.sqlite (store_tasks, migration 3u-3v). These
|
||||
// shapes are exported for packages/tasks/src/store.mjs, the module, so the broker and the module
|
||||
// share one definition; they stay in this file because its import list is the sandbox's closure.
|
||||
// A task's fields, whole after every write:
|
||||
// title, description text, as the Vikunja verbs take them
|
||||
// due_date canonical ISO 8601 UTC with milliseconds, or null
|
||||
// priority 0 (unset) to 5
|
||||
// percent_done 0 to 1
|
||||
// state todo, in-progress, in-review, blocked or done
|
||||
// bucket, done what state implies, under the names the Console and `mosaic tasks` read
|
||||
// labels positive label ids, sorted, at most 50
|
||||
// assignees role instance names, sorted, at most 50
|
||||
// group null or a short name: the column or list the task sits in
|
||||
// position a whole number ordering tasks inside their group
|
||||
// comments, comment how many comments, and the latest ({ text, by })
|
||||
// verdict the review verdict a close cited
|
||||
// request, op what a role's create cited; null on a person's
|
||||
// requirement the requirement cited on create (or edited by a person)
|
||||
export const STORE_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
|
||||
// The states task.update.assigned moves between; done is task.close's (slice 1 field table).
|
||||
export const ASSIGNED_STATES = Object.freeze(STORE_STATES.filter((x) => x !== 'done'));
|
||||
// A built-in task's ref is its number in the organization, never reused.
|
||||
const STORE_REF = /^task:([1-9][0-9]{0,14})$/;
|
||||
export const storeRef = (x) => typeof x === 'string' && STORE_REF.test(x);
|
||||
export const storeTaskNumber = (x) => (storeRef(x) ? Number(STORE_REF.exec(x)[1]) : null);
|
||||
export const STORE_POSITION_MAX = 1_000_000_000;
|
||||
// What a person's tasks.create and tasks.edit may set. Roles write through the eight task verbs,
|
||||
// each held to the fields the field table gives it.
|
||||
export const STORE_EDIT_FIELDS = Object.freeze(['title', 'description', 'due_date', 'priority', 'percent_done', 'state', 'labels', 'assignees', 'group', 'position', 'requirement']);
|
||||
const isoMillis = (x) =>
|
||||
typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x;
|
||||
// One check per field: the raw value in, the stored value out, or a refusal.
|
||||
export function storeFieldChecks(roles) {
|
||||
const str = (x, max, min = 0) => {
|
||||
if (typeof x !== 'string' || x.length < min || x.length > max || x.includes('\0')) fail('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const set = (x, item, order) => {
|
||||
if (!Array.isArray(x) || x.length > 50) fail('invalid-request');
|
||||
return [...new Set(x.map(item))].sort(order);
|
||||
};
|
||||
return {
|
||||
title: (x) => (str(x, 250, 1).trim() ? x : fail('invalid-request')),
|
||||
description: (x) => str(x, 65536),
|
||||
due_date: (x) => (x === null || isoMillis(x) ? x : fail('invalid-request')),
|
||||
priority: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : fail('invalid-request')),
|
||||
percent_done: (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : fail('invalid-request')),
|
||||
state: (x) => (STORE_STATES.includes(x) ? x : fail('invalid-state')),
|
||||
labels: (x) => set(x, (l) => (Number.isSafeInteger(l) && l >= 1 ? l : fail('invalid-request')), (a, b) => a - b),
|
||||
assignees: (x) => set(x, (r) => (typeof r === 'string' && Object.hasOwn(roles, r) ? r : fail('unknown-role'))),
|
||||
group: (x) => (x === null ? null : str(x, 64, 1).trim() && !/[\u0000-\u001f\u007f]/.test(x) ? x : fail('invalid-request')),
|
||||
position: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : fail('invalid-request')),
|
||||
requirement: (x) => (typeof x === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(x) ? x : fail('invalid-request')),
|
||||
};
|
||||
}
|
||||
// Checked edits on a copy of the fields. A state sets the bucket and done it implies. A move to
|
||||
// another group with no position goes to the end of that group (a null position, which the write settles).
|
||||
export function storeApply(fields, edits) {
|
||||
const out = { ...fields, ...edits };
|
||||
if (Object.hasOwn(edits, 'state')) Object.assign(out, { bucket: edits.state, done: edits.state === 'done' });
|
||||
if (Object.hasOwn(edits, 'group') && !Object.hasOwn(edits, 'position') && edits.group !== fields.group) out.position = null;
|
||||
return out;
|
||||
}
|
||||
// A new task's fields. A null position means after the last task in its group; the write settles it.
|
||||
export function storeNewTask({ title, request = null, requirement = null, op = null }) {
|
||||
return {
|
||||
title, description: '', due_date: null, priority: 0, percent_done: 0, state: 'todo', bucket: 'todo', done: false,
|
||||
labels: [], assignees: [], group: null, position: null, comments: 0, comment: null, verdict: null, request, requirement, op,
|
||||
};
|
||||
}
|
||||
// Every field a stored task has, no more and no fewer.
|
||||
export const STORE_FIELDS = Object.freeze(Object.keys(storeNewTask({ title: 'x' })));
|
||||
// The fields each role verb may change: the slice 1 field table (packages/tasks/fields.json) on the
|
||||
// store's fields. The table's labels and due date go with task.schedule, so the store's group and
|
||||
// position do too; a comment's count goes with the latest comment; a close records its verdict; and
|
||||
// a create sets what it cited. The broker refuses a role's write that changes any other field
|
||||
// (field-writer), whatever the module asked for.
|
||||
export const STORE_VERB_FIELDS = Object.freeze({
|
||||
'task.create': Object.freeze(['title', 'description', 'due_date', 'priority', 'labels', 'group', 'position', 'request', 'requirement', 'op']),
|
||||
'task.assign': Object.freeze(['assignees']),
|
||||
'task.reassign': Object.freeze(['assignees']),
|
||||
'task.schedule': Object.freeze(['due_date', 'labels', 'group', 'position']),
|
||||
'task.priority.change': Object.freeze(['priority']),
|
||||
'task.scope.change': Object.freeze(['title', 'description']),
|
||||
'task.update.assigned': Object.freeze(['percent_done', 'state', 'bucket', 'comments', 'comment']),
|
||||
'task.close': Object.freeze(['state', 'bucket', 'done', 'verdict']),
|
||||
});
|
||||
// Gaps between settled positions, so a person can place a task between two others.
|
||||
export const STORE_POSITION_STEP = 1024;
|
||||
// The most one task's fields take, as JSON.
|
||||
const STORE_TASK_BYTES = 262144;
|
||||
// Board order: ungrouped first, then group name, position and task number. Every read sorts this way.
|
||||
export function storeOrder(a, b) {
|
||||
const ga = a.group ?? '', gb = b.group ?? '';
|
||||
if (ga !== gb) return ga < gb ? -1 : 1;
|
||||
if ((a.position ?? 0) !== (b.position ?? 0)) return (a.position ?? 0) - (b.position ?? 0);
|
||||
return a.task - b.task;
|
||||
}
|
||||
// A git commit id, SHA-1 or SHA-256.
|
||||
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
|
||||
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
|
||||
@@ -424,7 +533,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) || GATED.has(v)))
|
||||
if (new Set(all).size !== all.length || all.some((v) => (!ACTIONS.includes(v) && !pluginName(v)) || GATED.has(v)))
|
||||
fail('invalid-authority');
|
||||
}
|
||||
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
|
||||
@@ -484,6 +593,7 @@ export class Broker {
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
#proxyPage;
|
||||
@@ -544,8 +654,9 @@ export class Broker {
|
||||
#accessRevoked = new Set();
|
||||
#clock = 0;
|
||||
#processing;
|
||||
#taskTracker;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -555,6 +666,7 @@ export class Broker {
|
||||
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
|
||||
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
|
||||
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
|
||||
if (taskTracker !== null && typeof taskTracker !== 'function') throw new TypeError('taskTracker must be a function');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
|
||||
@@ -567,6 +679,7 @@ export class Broker {
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
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;
|
||||
@@ -596,6 +709,11 @@ export class Broker {
|
||||
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
|
||||
// stands, for the hire checks. Without it a hire refuses not-configured.
|
||||
this.#roleFile = roleFile;
|
||||
// U4a (#1873): taskTracker(business) names the task-tracker module the bus runtime chose for the
|
||||
// business at start. The store's writes refuse tracker-not-store under any other module, so a
|
||||
// Vikunja organization never grows a second task list. Without it (the unit tests' brokers)
|
||||
// every business uses the store.
|
||||
this.#taskTracker = taskTracker ?? (() => 'store');
|
||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||
this.#requirements = loadRequirements();
|
||||
// These are host capabilities, like project/library; no filesystem module enters the client closure.
|
||||
@@ -1104,6 +1222,17 @@ 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;
|
||||
if (!object(roles)) fail('invalid-business');
|
||||
return structuredClone(roles);
|
||||
}
|
||||
// Lifecycle Task 2 (#797): the chat project a bootstrapped product lists under. Host-only, so no verb
|
||||
// reaches it. State is 'free', 'associated' (this exact id, name and product) or 'conflict' (anything else
|
||||
// holding the id or the name), which lets a retry insert the row once.
|
||||
@@ -1412,11 +1541,40 @@ 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 (!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(
|
||||
@@ -1730,7 +1888,15 @@ export class Broker {
|
||||
this.#revokeLost('access-expiry', true, doc);
|
||||
rows = this.#threadRows('t.business=?', business);
|
||||
}
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
|
||||
// #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) }));
|
||||
}
|
||||
// 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
|
||||
@@ -1987,6 +2153,13 @@ 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');
|
||||
@@ -4504,7 +4677,14 @@ export class Broker {
|
||||
fail('role-end-refused');
|
||||
}
|
||||
#classification(s, action, target = null) {
|
||||
if (!ACTIONS.includes(action)) fail('unknown-action');
|
||||
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 (
|
||||
this.#hardWait(s, action, target) ||
|
||||
(action === 'role.launch' && this.#business(s.business).launch?.by !== s.role)
|
||||
@@ -4632,11 +4812,16 @@ export class Broker {
|
||||
// the short form in task_ref order: no description, and the assignees that are a role's tracker
|
||||
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
|
||||
// every task the filters match; `next` is the `after` for the next page, null on the last.
|
||||
// U4a (#1873, REQ-WS-8): the store's tasks come after the Vikunja rows, in board order, in the same
|
||||
// shape (task_ref, fields, digest, priority, requirement, at), with source 'store'. An archived store
|
||||
// task leaves the no-argument read and the filtered read unless `archived: true` asks for archived
|
||||
// tasks; `task_ref` alone still gives it, with archived true.
|
||||
#tasks(business, a) {
|
||||
const rows = this.#taskRows(business);
|
||||
if (!Object.keys(a).length) return rows;
|
||||
const rows = this.#taskRows(business), store = this.#storeRows(business);
|
||||
if (!Object.keys(a).length) return [...rows, ...store.filter((r) => !r.archived)];
|
||||
if ('task_ref' in a) {
|
||||
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
|
||||
if (Object.keys(a).length !== 1 || !(taskRef(a.task_ref) || storeRef(a.task_ref))) fail('invalid-request');
|
||||
if (storeRef(a.task_ref)) return this.#storeCurrent(business, a.task_ref);
|
||||
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
|
||||
}
|
||||
const roles = this.#business(business).roles ?? {};
|
||||
@@ -4649,7 +4834,8 @@ export class Broker {
|
||||
('project_key' in a && !projectKey(a.project_key)) ||
|
||||
('requirement_snapshot' in a && !id(a.requirement_snapshot)) ||
|
||||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
|
||||
('after' in a && !taskRef(a.after))
|
||||
('archived' in a && typeof a.archived !== 'boolean') ||
|
||||
('after' in a && !(taskRef(a.after) || storeRef(a.after)))
|
||||
)
|
||||
fail('invalid-request');
|
||||
const bots = new Map(
|
||||
@@ -4680,10 +4866,209 @@ export class Broker {
|
||||
(!('project_key' in a) || t.project_key === a.project_key) &&
|
||||
(!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot),
|
||||
);
|
||||
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
|
||||
// The store's short form adds its group, position and archived flag; its assignees are role names.
|
||||
const archived = a.archived ?? false;
|
||||
const filed = store
|
||||
.filter((r) => r.archived === archived)
|
||||
.map(({ task_ref, fields: f, priority, requirement, at, archived: gone }) => ({
|
||||
task_ref,
|
||||
title: f.title,
|
||||
done: f.done,
|
||||
bucket: f.bucket,
|
||||
assignees: [...f.assignees],
|
||||
labels: [...f.labels],
|
||||
priority,
|
||||
requirement,
|
||||
changed_at: at,
|
||||
group: f.group,
|
||||
position: f.position,
|
||||
archived: gone,
|
||||
}))
|
||||
.filter(
|
||||
(t) =>
|
||||
(!('done' in a) || t.done === a.done) &&
|
||||
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
|
||||
(!('label' in a) || t.labels.includes(a.label)) &&
|
||||
(!('requirement' in a) || t.requirement === a.requirement) &&
|
||||
!('project_key' in a) &&
|
||||
!('requirement_snapshot' in a),
|
||||
);
|
||||
// Archived tasks are store tasks only. Pages run through the Vikunja tasks, then the store's: a
|
||||
// store `after` skips every Vikunja task and the store tasks up to it in board order. A cursor whose
|
||||
// task moved since continues from where it now stands; one whose task was deleted reads its last row.
|
||||
const all = [...(archived ? [] : matched), ...filed];
|
||||
let rest = all;
|
||||
if (taskRef(a.after ?? null)) rest = all.filter((t) => !taskRef(t.task_ref) || t.task_ref > a.after);
|
||||
else if ('after' in a) {
|
||||
const n = storeTaskNumber(a.after);
|
||||
const last = this.#store.get('SELECT fields FROM store_tasks WHERE business=? AND task=? ORDER BY seq DESC LIMIT 1', business, n);
|
||||
if (!last) fail('task-not-found');
|
||||
const f = JSON.parse(last.fields), at = { group: f.group, position: f.position, task: n };
|
||||
rest = filed.filter((t) => storeOrder({ group: t.group, position: t.position, task: storeTaskNumber(t.task_ref) }, at) > 0);
|
||||
}
|
||||
const limit = a.limit ?? TASK_PAGE;
|
||||
const tasks = rest.slice(0, limit);
|
||||
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
|
||||
return { tasks, total: all.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
|
||||
}
|
||||
// U4a (#1873, REQ-TASK-1): the built-in task store. A task is its latest store_tasks row, and it is
|
||||
// archived when its latest archive or unarchive row is an archive. A deleted task's latest row is
|
||||
// its delete, so it leaves every read here. Every read builds from the rows; nothing is cached.
|
||||
#storeLatest(business, task = null) {
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT t.*, (SELECT a.op FROM store_tasks a WHERE a.business=t.business AND a.task=t.task AND a.op IN ('archive','unarchive') ORDER BY a.seq DESC LIMIT 1) AS archive
|
||||
FROM store_tasks t WHERE t.business=?${task === null ? '' : ' AND t.task=?'}
|
||||
AND t.seq=(SELECT max(seq) FROM store_tasks WHERE business=t.business AND task=t.task) AND t.op<>'delete' ORDER BY t.task`,
|
||||
...(task === null ? [business] : [business, task]),
|
||||
)
|
||||
.map((r) => {
|
||||
const fields = JSON.parse(r.fields);
|
||||
// The keys a Vikunja `tasks` row has that the Console and `mosaic tasks` read (task_ref,
|
||||
// fields, digest, priority, requirement, at, seq, source), with source 'store', and the
|
||||
// store's own: revision, the verb that wrote it, who (role and run, or the person), the event.
|
||||
return {
|
||||
seq: r.seq, at: r.at, business: r.business, task_ref: `task:${r.task}`, tracker: 'store', source: 'store',
|
||||
updated: r.at, revision: r.revision, verb: r.verb, role: r.by_role, run: r.by_run, human: r.by_human, event: r.event,
|
||||
digest: createHash('sha256').update(JSON.stringify([r.revision, r.fields])).digest('hex'), fields,
|
||||
priority: fields.priority ?? null, requirement: fields.requirement ?? null, archived: r.archive === 'archive',
|
||||
};
|
||||
});
|
||||
}
|
||||
// The live tasks, archived ones too, in board order.
|
||||
#storeRows(business) {
|
||||
const at = (r) => ({ group: r.fields.group, position: r.fields.position, task: storeTaskNumber(r.task_ref) });
|
||||
return this.#storeLatest(business).sort((a, b) => storeOrder(at(a), at(b)));
|
||||
}
|
||||
#storeOn(business) {
|
||||
this.#business(business);
|
||||
if (this.#taskTracker(business) !== 'store') fail('tracker-not-store');
|
||||
}
|
||||
#storeCurrent(business, ref) {
|
||||
const n = storeTaskNumber(ref);
|
||||
if (n === null) fail('invalid-request');
|
||||
return this.#storeLatest(business, n)[0] ?? fail('task-not-found');
|
||||
}
|
||||
// A role's create cites the person's instruction that asked for it, as a Vikunja create does.
|
||||
#storeRequest(business, request) {
|
||||
return (
|
||||
typeof request === 'string' &&
|
||||
!!this.#store.get(
|
||||
"SELECT 1 FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL AND json_extract(body,'$.kind')='instruction'",
|
||||
business,
|
||||
request,
|
||||
)
|
||||
);
|
||||
}
|
||||
// The whole task a write leaves: every field the store keeps and no other, each already in the form
|
||||
// its check gives (labels and assignees sorted, no repeats), and the bucket and done its state implies.
|
||||
#storeFields(business, f, previous) {
|
||||
if (!object(f) || Object.keys(f).length !== STORE_FIELDS.length || !STORE_FIELDS.every((k) => Object.hasOwn(f, k))) fail('invalid-request');
|
||||
const roles = { ...this.taskRoles(business), ...Object.fromEntries((previous?.assignees ?? []).map(role => [role, true])) };
|
||||
const check = storeFieldChecks(roles);
|
||||
for (const k of STORE_EDIT_FIELDS) {
|
||||
if (k === 'requirement' && f[k] === null) continue; // historical tasks
|
||||
if (JSON.stringify(check[k](f[k])) !== JSON.stringify(f[k])) fail('invalid-request');
|
||||
}
|
||||
const text = (x, max) => x === null || (typeof x === 'string' && x.length >= 1 && x.length <= max && !x.includes('\0'));
|
||||
if (
|
||||
f.bucket !== f.state ||
|
||||
f.done !== (f.state === 'done') ||
|
||||
!(Number.isSafeInteger(f.comments) && f.comments >= 0) ||
|
||||
!(f.comment === null || (object(f.comment) && Object.keys(f.comment).length === 2 && text(f.comment.text, 10000) && f.comment.text !== null && Object.hasOwn(f.comment, 'by') && text(f.comment.by, 128) && f.comment.by !== null)) ||
|
||||
!text(f.verdict, 256) ||
|
||||
!text(f.request, 64) ||
|
||||
!(f.requirement === null || (typeof f.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(f.requirement))) ||
|
||||
!text(f.op, 256)
|
||||
)
|
||||
fail('invalid-request');
|
||||
}
|
||||
// Appends one row and the event that records it, and answers the task as it now reads (or that it
|
||||
// was deleted). The caller owns the transaction and has checked who may write. A null position
|
||||
// settles after the last task in the task's group.
|
||||
#storeWrite(s, { current, op, verb, fields, by, kind, body }) {
|
||||
const task = current
|
||||
? storeTaskNumber(current.task_ref)
|
||||
: (this.#store.get('SELECT max(task) AS n FROM store_tasks WHERE business=?', s.business).n ?? 0) + 1;
|
||||
const ref = `task:${task}`;
|
||||
const peers = this.#storeRows(s.business).filter((r) => r.task_ref !== ref && (r.fields.group ?? null) === (fields.group ?? null));
|
||||
if (fields.position === null) {
|
||||
const last = peers.reduce((m, r) => Math.max(m, r.fields.position ?? 0), -STORE_POSITION_STEP);
|
||||
if (last + STORE_POSITION_STEP > STORE_POSITION_MAX) fail('task-position-full');
|
||||
fields = { ...fields, position: last + STORE_POSITION_STEP };
|
||||
} else if ((!current || fields.group !== current.fields.group || fields.position !== current.fields.position) && peers.some(r => r.fields.position === fields.position)) {
|
||||
fail('task-position-full');
|
||||
}
|
||||
this.#storeFields(s.business, fields, current?.fields);
|
||||
const json = JSON.stringify(fields);
|
||||
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
|
||||
this.#secretCheck(fields);
|
||||
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
|
||||
this.#store.run(
|
||||
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
this.#now(),
|
||||
s.business,
|
||||
task,
|
||||
current ? current.revision + 1 : 1,
|
||||
op,
|
||||
verb,
|
||||
by.role,
|
||||
by.run,
|
||||
by.human,
|
||||
event,
|
||||
json,
|
||||
);
|
||||
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
|
||||
}
|
||||
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
|
||||
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
|
||||
// as #consumeAuthority spends it), a close's verdict check, the event and the row. `change` is the
|
||||
// module's: it gets a copy of the task's fields (a new task's on a create) and of its row (null on a
|
||||
// create) and returns the whole new fields. The write may change only what STORE_VERB_FIELDS gives
|
||||
// its verb. An archived task takes no role write; a person unarchives it first. A create that names
|
||||
// an `op` the same role already created with answers that task again, with nothing written; the same
|
||||
// op with other fields, or from another role, refuses (task-create-conflict).
|
||||
storeTask(cap, { verb, task_ref = null, decision = null, expect = null, verdict = null, change } = {}) {
|
||||
const s = this.#session(cap);
|
||||
if (!TASK_VERBS.includes(verb) || typeof change !== 'function' || (verb === 'task.create') !== (task_ref === null)) fail('invalid-request');
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
this.#storeOn(s.business);
|
||||
const current = task_ref === null ? null : this.#storeCurrent(s.business, task_ref);
|
||||
if (current?.archived) fail('task-archived');
|
||||
if (expect !== null && current && expect !== current.digest) fail('task-conflict');
|
||||
const before = current?.fields ?? storeNewTask({ title: null }), allowed = STORE_VERB_FIELDS[verb];
|
||||
const fields = change(structuredClone(before), current && structuredClone(current));
|
||||
if (!object(fields)) fail('invalid-request');
|
||||
for (const k of new Set([...Object.keys(before), ...Object.keys(fields)]))
|
||||
if (JSON.stringify(before[k]) !== JSON.stringify(fields[k]) && !allowed.includes(k)) fail('field-writer');
|
||||
if (!current && !this.#storeRequest(s.business, fields.request)) fail('request-not-found');
|
||||
if (!current && typeof fields.op === 'string') {
|
||||
const old = this.#store.get(
|
||||
"SELECT task, by_role, fields FROM store_tasks WHERE business=? AND op='create' AND json_extract(fields,'$.op')=? ORDER BY seq LIMIT 1",
|
||||
s.business,
|
||||
fields.op,
|
||||
);
|
||||
if (old) {
|
||||
const was = JSON.parse(old.fields), same = (k) => JSON.stringify(was[k]) === JSON.stringify(fields[k]);
|
||||
if (old.by_role !== s.role || !STORE_FIELDS.every((k) => same(k) || (k === 'position' && fields.position === null))) fail('task-create-conflict');
|
||||
this.#checkAuthority(s, verb, { decision, target: null });
|
||||
const ref = `task:${old.task}`;
|
||||
return this.#storeLatest(s.business, old.task)[0] ?? { task_ref: ref, deleted: true };
|
||||
}
|
||||
}
|
||||
const authority = this.#checkAuthority(s, verb, { decision, target: task_ref });
|
||||
if (authority.decision && this.#spent(s.business, authority.decision)) fail('decision-consumed');
|
||||
if (verb === 'task.close') this.#closeVerdict(s, task_ref, verdict);
|
||||
return this.#storeWrite(s, {
|
||||
current,
|
||||
op: current ? 'change' : 'create',
|
||||
verb,
|
||||
fields,
|
||||
by: { role: s.role, run: s.run, human: null },
|
||||
kind: 'action.allowed',
|
||||
body: { action: verb, ...authority, target: task_ref, ...(verb === 'task.close' ? { verdict } : {}) },
|
||||
});
|
||||
});
|
||||
}
|
||||
#checkAuthority(s, action, context = {}) {
|
||||
this.#processingWork();
|
||||
@@ -4741,6 +5126,20 @@ export class Broker {
|
||||
);
|
||||
return evidence ? JSON.parse(evidence.body) : null;
|
||||
}
|
||||
#checkResolver(s, d) {
|
||||
const b = this.#business(s.business), role = b.roles[s.role];
|
||||
const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
// Pre-RR evidence has no domain. Assignments may have changed since raise,
|
||||
// so require both possible rights rather than infer one from today's file.
|
||||
const domains = domain === undefined ? ['technical', 'delivery'] : [domain];
|
||||
for (const kind of domains) {
|
||||
if (!['technical', 'delivery'].includes(kind)) fail('not-resolver');
|
||||
const right = `decision.resolve.${kind}`;
|
||||
if (!role?.authority?.withinRole.includes(right)) {
|
||||
throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whether an executor already used the decision. decision.raise records context, not permission
|
||||
// consumed by an executor.
|
||||
#spent(business, decision) {
|
||||
@@ -5460,16 +5859,18 @@ export class Broker {
|
||||
}
|
||||
#refused(s, e, request, other = 'storage-refused') {
|
||||
const error = e instanceof BusError ? e : new BusError(other);
|
||||
// No request content or raw exception text in refusal evidence.
|
||||
// No request content or raw exception text in refusal evidence. U4a: a built-in store task's
|
||||
// ref files the refusal on that task's trail too.
|
||||
const subject = (x) => taskRef(x) || storeRef(x);
|
||||
try {
|
||||
this.#store.transaction(() =>
|
||||
this.#event(
|
||||
s,
|
||||
'action.refused',
|
||||
{ code: error.code },
|
||||
taskRef(request?.args?.task_ref)
|
||||
subject(request?.args?.task_ref)
|
||||
? request.args.task_ref
|
||||
: taskRef(request?.args?.target)
|
||||
: subject(request?.args?.target)
|
||||
? request.args.target
|
||||
: null,
|
||||
),
|
||||
@@ -5530,6 +5931,15 @@ 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') {
|
||||
@@ -5767,6 +6177,12 @@ 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(() => {
|
||||
@@ -6595,7 +7011,16 @@ export class Broker {
|
||||
#dispatch(s, verb, a, spec = null, binding = null) {
|
||||
const b = this.#business(s.business),
|
||||
by = s.human || s.run;
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case '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');
|
||||
@@ -6740,8 +7165,8 @@ export class Broker {
|
||||
if (!choices.includes(a.recommendation)) fail('invalid-recommendation');
|
||||
const approvalChoice = a.approvalChoice ?? 'yes';
|
||||
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
|
||||
const domain = a.domain ?? 'delivery';
|
||||
if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain');
|
||||
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
|
||||
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
// PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit
|
||||
// goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any
|
||||
// hard wait go to the human. readSpec already refused a stale or unconfirmed citation.
|
||||
@@ -6823,6 +7248,7 @@ export class Broker {
|
||||
decision: did,
|
||||
action: a.action,
|
||||
operation: 'decision.raise',
|
||||
domain,
|
||||
task_ref: a.task_ref ?? null,
|
||||
target: a.target ?? null,
|
||||
approvalChoice,
|
||||
@@ -6889,6 +7315,7 @@ export class Broker {
|
||||
else {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
if (verb === 'decision.resolve') this.#checkResolver(s, d);
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
@@ -7611,6 +8038,41 @@ export class Broker {
|
||||
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
|
||||
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
|
||||
// nothing; a repeat of the state the project already holds refuses.
|
||||
// U4a (#1873, REQ-TASK-1): the person's own writes to the built-in task store, from the terminal
|
||||
// (human-cli). Each writes a human.input event (body kind 'task') that its store_tasks row cites, so a
|
||||
// person's edit comes in as an event like any other input. The person writes any field, may reopen a
|
||||
// done task, and is the only one who archives, unarchives or deletes. `expect` is the digest the
|
||||
// person last read; a later write refuses task-conflict.
|
||||
case 'tasks.create':
|
||||
case 'tasks.edit':
|
||||
case 'tasks.archive':
|
||||
case 'tasks.unarchive':
|
||||
case 'tasks.delete': {
|
||||
this.#human(s);
|
||||
this.#storeOn(s.business);
|
||||
const creating = verb === 'tasks.create', editing = verb === 'tasks.edit';
|
||||
keys(a, creating ? STORE_EDIT_FIELDS : editing ? ['task_ref', 'expect', ...STORE_EDIT_FIELDS] : ['task_ref', 'expect'], creating ? ['title', 'requirement'] : ['task_ref']);
|
||||
if (Object.hasOwn(a, 'expect') && !(typeof a.expect === 'string' && /^[0-9a-f]{64}$/.test(a.expect))) fail('invalid-request');
|
||||
const current = creating ? null : this.#storeCurrent(s.business, a.task_ref);
|
||||
const roles = { ...this.taskRoles(s.business), ...Object.fromEntries((current?.fields.assignees ?? []).map(role => [role, true])) };
|
||||
const check = storeFieldChecks(roles), edits = {};
|
||||
for (const k of STORE_EDIT_FIELDS) if (Object.hasOwn(a, k)) edits[k] = check[k](a[k]);
|
||||
if (editing && !Object.keys(edits).length) fail('invalid-request');
|
||||
if (current && Object.hasOwn(a, 'expect') && a.expect !== current.digest) fail('task-conflict');
|
||||
if (editing && current.archived) fail('task-archived');
|
||||
if (verb === 'tasks.archive' && current.archived) fail('task-already-archived');
|
||||
if (verb === 'tasks.unarchive' && !current.archived) fail('task-not-archived');
|
||||
const fields = creating ? storeApply(storeNewTask({ title: edits.title }), edits) : editing ? storeApply(current.fields, edits) : current.fields;
|
||||
return this.#storeWrite(s, {
|
||||
current,
|
||||
op: creating ? 'create' : editing ? 'change' : verb.slice('tasks.'.length),
|
||||
verb,
|
||||
fields,
|
||||
by: { role: null, run: null, human: this.#owner(s) },
|
||||
kind: 'human.input',
|
||||
body: { kind: 'task' },
|
||||
});
|
||||
}
|
||||
case 'project.archive':
|
||||
case 'project.unarchive': {
|
||||
this.#human(s);
|
||||
@@ -7644,7 +8106,17 @@ 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');
|
||||
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
|
||||
// #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();
|
||||
if (!members.length) fail('thread-instance-missing');
|
||||
const owner = this.#owner(s);
|
||||
// Preflight all collisions/missing threads before open creates or send writes anything.
|
||||
@@ -7732,7 +8204,13 @@ 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');
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
// #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 instance = a.instance === undefined
|
||||
? (instances.length === 1 ? instances[0] : null)
|
||||
: identifier(a.instance);
|
||||
@@ -7746,9 +8224,18 @@ 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(
|
||||
@@ -8391,6 +8878,19 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
}
|
||||
// U4a (#1873): a store task's rows (its whole history, a delete included) and the person's
|
||||
// instruction a role's create cited, with that instruction's message.
|
||||
if (storeRef(subject)) {
|
||||
const own = this.#store.all('SELECT * FROM store_tasks WHERE business=? AND task=? ORDER BY seq', s.business, storeTaskNumber(subject));
|
||||
add('store_tasks', own);
|
||||
const request = own.length ? JSON.parse(own[0].fields).request : null;
|
||||
const input = request && this.#store.get("SELECT * FROM events WHERE business=? AND kind='human.input' AND id=?", s.business, request);
|
||||
if (input) {
|
||||
add('events', [input]);
|
||||
const body = JSON.parse(input.body);
|
||||
if (body.message) add('messages', this.#store.all('SELECT * FROM messages WHERE business=? AND id=?', s.business, body.message));
|
||||
}
|
||||
}
|
||||
for (const m of [...rows.values()])
|
||||
if (m.table === 'messages')
|
||||
add('deliveries', this.#store.all('SELECT * FROM deliveries WHERE message=?', m.id));
|
||||
|
||||
@@ -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, log, makeLoader = createLoader, keyForSubject = () => null,
|
||||
export function createLimits({ store, read, pluginDataRoot = null, 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, log, makeLoader = createLoader, keyF
|
||||
return validLineages(value) ? value : null;
|
||||
}
|
||||
const loaderFor = business => {
|
||||
if (!loaders.has(business)) loaders.set(business, makeLoader({ log }));
|
||||
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
|
||||
return loaders.get(business);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- U4a (#1873): the task store built into each organization, the default task-tracker module
|
||||
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
|
||||
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
|
||||
-- verb, and the event that records the write. Task numbers count up per organization and are
|
||||
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
|
||||
-- organization is removed, which removes this whole database with it. Only a new append-only
|
||||
-- table, its index and its guards; no existing row changes and nothing is deleted.
|
||||
CREATE TABLE store_tasks (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task INTEGER NOT NULL CHECK (task >= 1),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
|
||||
verb TEXT NOT NULL,
|
||||
by_role TEXT,
|
||||
by_run TEXT,
|
||||
by_human TEXT,
|
||||
event TEXT NOT NULL REFERENCES events(id),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
|
||||
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
|
||||
UNIQUE (business, task, revision)
|
||||
) STRICT;
|
||||
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
|
||||
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
|
||||
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
|
||||
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
|
||||
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
|
||||
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
|
||||
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
|
||||
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
|
||||
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.op IN ('archive','unarchive')
|
||||
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
|
||||
@@ -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, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
|
||||
export function createModelCatalogue({ client, settings, pluginDataRoot = null, 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, now = Date.now, makeLoa
|
||||
try {
|
||||
if (off(id)) return disabled();
|
||||
const names = [...settings(id)['model-provider']];
|
||||
const loader = makeLoader({ log: () => {} });
|
||||
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
|
||||
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();
|
||||
|
||||
@@ -16,8 +16,10 @@ 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.
|
||||
|
||||
@@ -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, thread: m.thread, session: m.session });
|
||||
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 === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
|
||||
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||||
|
||||
@@ -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, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
|
||||
const loader = makeLoader({ log });
|
||||
export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
|
||||
const loader = makeLoader({ log, dataRoot:pluginDataRoot });
|
||||
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
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// 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("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;
|
||||
+109
-29
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -35,7 +37,7 @@ import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -63,16 +65,18 @@ export const ACCESS_SWEEP_MS = 30_000;
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
|
||||
// Human limits writes use the shared organization writer behind the verified transport.
|
||||
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
|
||||
// U4a (#1873): it serves the businesses whose task-tracker module is Vikunja; the others get theirs
|
||||
// through the plug-in loader, the built-in store by default (see trackerOf in startBroker).
|
||||
// REQ-HARN-2, REQ-PLUG-2 (decision 0b81b0c5): one web service per business. Its search source is
|
||||
// the "search-source" plug-in the business's organization file switches on: searxng-url (the
|
||||
// business var web.searxng) or searxng-bundled. With none, web.search refuses "not configured" and
|
||||
// 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 } = {}) {
|
||||
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) {
|
||||
const services = new Map();
|
||||
for (const [name, b] of Object.entries(businesses)) {
|
||||
const loader = createLoader({ log });
|
||||
const loader = createLoader({ log, dataRoot });
|
||||
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.
|
||||
@@ -88,7 +92,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`) } = {}) {
|
||||
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
|
||||
const levels = new Map();
|
||||
let names = [];
|
||||
try {
|
||||
@@ -96,8 +100,12 @@ 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 });
|
||||
const loader = createLoader({ log, dataRoot });
|
||||
if ((await loader.load({ harness: [name] })).refused.length) continue;
|
||||
try {
|
||||
const list = loader.modules('harness')[0].levels();
|
||||
@@ -282,7 +290,14 @@ 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');
|
||||
@@ -397,20 +412,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,exists:hasModule}):null;
|
||||
const levels=organization?await harnessLevels():()=>null;
|
||||
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;
|
||||
// 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({ client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
|
||||
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, 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({log:()=>{}});
|
||||
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,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:{}};
|
||||
@@ -470,16 +485,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 }) }) : null;
|
||||
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : 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, businesses, log: usageLog, keyForRun,
|
||||
const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun,
|
||||
proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) });
|
||||
proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
|
||||
proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => {
|
||||
if (!modelKeys) return null;
|
||||
await modelKeys.verifyIdentity();
|
||||
return modelProxyClient;
|
||||
@@ -489,7 +504,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, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
|
||||
const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, 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);
|
||||
@@ -562,7 +577,7 @@ export async function startBroker({
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
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));
|
||||
@@ -582,7 +597,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 }) : null;
|
||||
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : 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');
|
||||
@@ -593,7 +608,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 = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const full = busOrganization(b, resolved);
|
||||
@@ -620,7 +635,11 @@ 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).
|
||||
function admission({ business: id, project, projects, thread, session }) {
|
||||
// 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 }) {
|
||||
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');
|
||||
@@ -629,9 +648,21 @@ 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 };
|
||||
}
|
||||
@@ -653,7 +684,22 @@ export async function startBroker({
|
||||
return no(declared ? 'project-pending' : 'project-unknown');
|
||||
}
|
||||
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
|
||||
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1): the task-tracker module each business uses, fixed for this
|
||||
// start. The organization file's "task-tracker" plug-in names it. With none, a business the boot
|
||||
// config gives a Vikunja tracker keeps Vikunja (so does every business when an in-process caller
|
||||
// hands `tasks` and no `trackers`), and any other business gets the built-in store. A business that
|
||||
// names another module while the boot config also gives it a Vikunja tracker refuses the boot:
|
||||
// two trackers can't both hold its tasks.
|
||||
const trackerOf = new Map();
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
const named = b?.plugins?.['task-tracker'];
|
||||
const tracked = trackers !== null && typeof trackers === 'object' && Object.hasOwn(trackers, id);
|
||||
if (named !== undefined && named !== 'vikunja' && tracked) throw new BusError('tracker-config');
|
||||
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
|
||||
}
|
||||
broker = new Broker({
|
||||
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,
|
||||
requestLifecycle: services => {
|
||||
@@ -721,7 +767,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 }).readRaw(id) : { readable: false }),
|
||||
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }),
|
||||
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
|
||||
GuardError: libraryModule.GuardError,
|
||||
},
|
||||
@@ -737,7 +783,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 = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const current = loadOrg(id);
|
||||
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));
|
||||
@@ -752,7 +798,7 @@ export async function startBroker({
|
||||
known.set(id, canonicalJson(next.projects));
|
||||
return getProjectRemote(writer, id, args.project);
|
||||
}
|
||||
if (verb === 'system-types.installed') return installedSystemTypes();
|
||||
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
|
||||
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 ?? []}};
|
||||
@@ -793,7 +839,10 @@ export async function startBroker({
|
||||
});
|
||||
result=settings.setRoutes(id,args,actor);
|
||||
}
|
||||
else if(verb==='plugins.set'){result=plugins.set(id,args,actor);modelCatalogues?.invalidate();}
|
||||
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();
|
||||
}
|
||||
// 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.
|
||||
@@ -829,7 +878,7 @@ export async function startBroker({
|
||||
return settings;
|
||||
} : null,
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
|
||||
: 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.
|
||||
@@ -888,12 +937,43 @@ export async function startBroker({
|
||||
}
|
||||
const bound = launches.map(bindLaunch);
|
||||
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
|
||||
if (tasks) {
|
||||
adapter = await tasks({ broker, credentials, businesses, processing });
|
||||
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
|
||||
// U4a: one adapter per task-tracker module in use, behind one handle that routes each call by the
|
||||
// caller's business. The host's `tasks` factory is the Vikunja module. Any other module, and Vikunja
|
||||
// when the host gave no factory but an organization file names it, loads through the plug-in loader
|
||||
// with the boot config's `trackers`. A module that refuses to load or has no factory refuses
|
||||
// startup with tracker-config. A module whose factory makes no
|
||||
// adapter refuses the boot (invalid-adapter), and every adapter made so far is closed. The host's
|
||||
// factory is always made when given, used or not, so its own checks of `trackers` still run.
|
||||
const made = new Map();
|
||||
adapter = { async close() { for (const a of made.values()) await a?.close?.(); } };
|
||||
const pluginLog = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`);
|
||||
for (const name of new Set([...(tasks ? ['vikunja'] : []), ...trackerOf.values()])) {
|
||||
let factory = null;
|
||||
if (name === 'vikunja' && tasks) factory = tasks;
|
||||
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
|
||||
const loader = createLoader({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog });
|
||||
const { refused } = await loader.load({ 'task-tracker': name });
|
||||
const [module] = loader.modules('task-tracker');
|
||||
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
|
||||
}
|
||||
// A host may omit its implicit Vikunja adapter, retaining the legacy unconfigured path.
|
||||
// An explicitly selected module must always supply a factory.
|
||||
if (!factory && name === 'vikunja' && !tasks && !Object.values(businesses).some(b => b?.plugins?.['task-tracker'] === 'vikunja')) continue;
|
||||
if (typeof factory !== 'function') throw new BusError('tracker-config');
|
||||
const one = await factory({ broker, credentials, businesses, processing });
|
||||
made.set(name, one);
|
||||
if (typeof one?.handle !== 'function' || !Number.isSafeInteger(one.timeout) || one.timeout < 1)
|
||||
throw new BusError('invalid-adapter');
|
||||
}
|
||||
const web = await webServices(businesses);
|
||||
if (made.size) {
|
||||
adapter.timeout = Math.max(...[...made.values()].map((a) => a.timeout));
|
||||
adapter.handle = async (cap, verb, args) => {
|
||||
const one = made.get(trackerOf.get(broker.identity(cap).business));
|
||||
if (!one) throw new BusError('tracker-unconfigured');
|
||||
return one.handle(cap, verb, args);
|
||||
};
|
||||
}
|
||||
const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot});
|
||||
// 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.
|
||||
@@ -921,7 +1001,7 @@ export async function startBroker({
|
||||
web: (business) => web.get(business) ?? null,
|
||||
host: host ? { busy: (...args) => host.busy(...args),
|
||||
run: (...args) => processing.work(() => host.run(...args)) } : null,
|
||||
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
|
||||
tasks: adapter?.handle ? { handle: adapter.handle, timeout: adapter.timeout } : null,
|
||||
broker,
|
||||
path,
|
||||
peerReader,
|
||||
@@ -1146,13 +1226,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({log:()=>{}});
|
||||
const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,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({log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
|
||||
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,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');
|
||||
|
||||
@@ -9,11 +9,12 @@ 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) };
|
||||
// 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 };
|
||||
// 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 } : {}) };
|
||||
};
|
||||
// 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
|
||||
@@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.end(JSON.stringify(result) + '\n');
|
||||
};
|
||||
socket.setTimeout(timeout, () => {
|
||||
finish({ ok: false, error: 'request-timeout' });
|
||||
finish(refusal(new BusError('request-timeout')));
|
||||
socket.destroySoon();
|
||||
});
|
||||
socket.on('data', async (chunk) => {
|
||||
if (done) return;
|
||||
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
|
||||
if (processing) return finish(refusal(new BusError('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({ ok: false, error: 'request-too-large' });
|
||||
finish(refusal(new BusError('request-too-large')));
|
||||
return;
|
||||
}
|
||||
input = Buffer.concat([input, chunk]);
|
||||
@@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
}
|
||||
});
|
||||
socket.on('end', () => {
|
||||
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
|
||||
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '6f6f0c0fd471b7895648a71aa9f320a769b145da792c4152ee8282006eff30c7';
|
||||
export const SCHEMA_VERSION = '3u';
|
||||
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8';
|
||||
export const SCHEMA_VERSION = '3v';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -115,6 +115,11 @@ const migrations = [{
|
||||
}, {
|
||||
from: '3t', to: '3u',
|
||||
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// U4a (#1873): the task store built into each organization, the default task-tracker module.
|
||||
// Only a new append-only table, its index and its guards; no existing row changes.
|
||||
from: '3u', to: '3v',
|
||||
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -142,6 +147,7 @@ const timedTables = [
|
||||
'request_project_events',
|
||||
'project_archives',
|
||||
'project_system_types',
|
||||
'store_tasks',
|
||||
];
|
||||
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
||||
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
||||
|
||||
@@ -53,10 +53,10 @@ export function turnRecords(store, business) {
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
|
||||
export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
|
||||
const loaders = new Map();
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
const loader = createLoader({ log });
|
||||
const loader = createLoader({ log, dataRoot:pluginDataRoot });
|
||||
await loader.load(b.plugins ?? {});
|
||||
loaders.set(id, loader);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3h(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -95,6 +95,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -152,7 +153,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
});
|
||||
|
||||
@@ -164,14 +165,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// #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');
|
||||
});
|
||||
@@ -37,7 +37,9 @@ 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.
|
||||
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
|
||||
// #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",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set
|
||||
const outside = [
|
||||
{ launch: { by: 'coder' } },
|
||||
{ human: 'mallory' },
|
||||
{ arbiters: { technical: 'coder', delivery: 'coder' } },
|
||||
{ arbiters: { technical: 'chief', delivery: 'chief' } },
|
||||
{ roles: {} },
|
||||
{ actor: 'jason' },
|
||||
{ 'launch.by': 'coder' },
|
||||
@@ -616,7 +616,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
@@ -1026,7 +1026,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -138,7 +139,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 envelope is byte-identical', async t => {
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', 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());
|
||||
@@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope
|
||||
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.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
|
||||
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
|
||||
});
|
||||
|
||||
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
|
||||
|
||||
@@ -18,8 +18,8 @@ export function citationWorld(t, { definition = 'coder', library = true, guard:
|
||||
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
...(library ? { projects: { books: map } } : {}),
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
const limits = () => {
|
||||
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
|
||||
catch { return resolveReplyLimits({ readable: false }); }
|
||||
|
||||
@@ -18,8 +18,8 @@ test('C2-06 runtime reads changed and unreadable reply limits without restarting
|
||||
// Runtime takes the host's broker projection, not the business validator's unresolved role entries.
|
||||
// No agent action is needed here: use the same empty-authority shape as limits-runtime.test.mjs.
|
||||
const business = { id: 'acme', human: 'jason', projects: checked.projects,
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => runtime.close());
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3v';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3u', to: '3v', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3u ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3v ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -48,8 +48,8 @@ function setup(t) {
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
@@ -227,7 +227,7 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
|
||||
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
|
||||
const f = setup(t);
|
||||
for (const route of ['pm', 'chief']) {
|
||||
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
f.call(route, 'decision.resolve', { id, choice: 'yes' });
|
||||
|
||||
@@ -30,8 +30,8 @@ async function setup(t, { storeRoot = true } = {}) {
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
|
||||
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
|
||||
},
|
||||
@@ -58,7 +58,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
const calls = [];
|
||||
const b = new Broker({
|
||||
store: s,
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
|
||||
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
|
||||
});
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
@@ -97,7 +97,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
|
||||
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
|
||||
// No lifecycle without the runtime's store.
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
|
||||
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
|
||||
});
|
||||
@@ -182,8 +182,8 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
|
||||
chmodSync(join(root, 'secrets', 'agents'), 0o750);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
|
||||
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
@@ -24,8 +24,8 @@ async function setup(t, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
@@ -76,8 +76,8 @@ test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
|
||||
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
|
||||
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } };
|
||||
|
||||
@@ -22,8 +22,8 @@ const options = [
|
||||
];
|
||||
const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } };
|
||||
const roles = {
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
// Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter.
|
||||
coder: { definition: 'coder', ...pusher },
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = {
|
||||
|
||||
@@ -36,11 +36,11 @@ function source3j(t) {
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -85,6 +85,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -106,7 +107,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -132,7 +133,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -143,14 +144,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3j,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ function setup(t, { keygen, check, hang = false } = {}) {
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -42,7 +42,7 @@ function source3i(t) {
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -88,6 +88,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -111,7 +112,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -136,7 +137,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -147,14 +148,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3i,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ test('row 298 runtime: human scope approval writes the file and organization his
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
runtime = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -27,8 +27,8 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
||||
const root = mkdtempSync(join(tmpdir(), 'hard-waits-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority } } } });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority } } } });
|
||||
for (const action of WAITS) {
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action);
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action);
|
||||
@@ -54,19 +54,19 @@ test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbite
|
||||
}
|
||||
}
|
||||
const d = await f.raise({ action: 'data.delete', ...cite(f) });
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } } } });
|
||||
f.agent('cto');
|
||||
for (const action of LEFT) {
|
||||
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
|
||||
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: 'reviewer' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,15 +74,15 @@ test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser,
|
||||
const f = await fixture(t, { roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
f.agent('pm');
|
||||
const d = await f.raise({ action: 'role.revoke', target: 'pm', ...cite(f) });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/);
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, roles: {
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
|
||||
} });
|
||||
const cto = f.agent('cto'), gate = f.agent('merge-gate');
|
||||
@@ -106,7 +106,7 @@ test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an
|
||||
await assert.rejects(f.raise({ action: 'policy.change', ...old, spec_commit: edit }), { code: 'spec-unconfirmed' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
const current = f.save('# Current\n');
|
||||
const own = await f.call(f.reviewer, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
const own = await f.call(f.chief, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
@@ -136,7 +136,7 @@ const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind=
|
||||
test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats,
|
||||
chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief'), lead = f.agent('lead');
|
||||
const chief = f.chief, lead = f.agent('lead');
|
||||
// No thread is held yet, so there is nothing to end.
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
const a = held(f, 'a-run'), b = held(f, 'b-run');
|
||||
@@ -158,11 +158,11 @@ test('Q169: the chief of staff\'s end of the assistant stops every thread run; i
|
||||
|
||||
test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief');
|
||||
const chief = f.chief;
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) {
|
||||
const d = await revoke(f, chief, 'assistant', args);
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
}
|
||||
// Resolved by the human, the chief's revoke citing it stops the thread run, once.
|
||||
const a = held(f, 'a-run');
|
||||
@@ -181,8 +181,8 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
// One raised while the instance's definition wasn't protected went to the arbiter. The same
|
||||
// store under a business that now makes it the assistant still refuses it.
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } };
|
||||
const later = new Broker({ store: f.store, businesses: { demo: now } });
|
||||
const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' });
|
||||
@@ -192,7 +192,7 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
const d = await revoke(f, f.coder, 'assistant', cite(f));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
// Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the
|
||||
// revoke reaches the thread claims and stops the held one.
|
||||
@@ -205,8 +205,8 @@ test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbi
|
||||
const f = await fixture(t, { roles: seats });
|
||||
f.agent('pm');
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ test('Q169: a role granted role.revoke at either class still sends a revoke of t
|
||||
boss: { definition: 'boss', ...grants(['role.revoke']) } } });
|
||||
const lead = f.agent('lead'), boss = f.agent('boss');
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'chief' });
|
||||
const own = await revoke(f, boss, 'assistant', { choice: 'yes' });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -21,7 +21,7 @@ export function grantDecision(store, { business = 'demo', action = 'credential.m
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
|
||||
|
||||
@@ -572,8 +572,8 @@ test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a
|
||||
await new Promise((r) => dead.on('close', r));
|
||||
writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await start();
|
||||
@@ -640,8 +640,8 @@ test('M3: a host_grants record gives Credentials a store reference at start, and
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
|
||||
|
||||
@@ -65,7 +65,7 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
|
||||
@@ -26,7 +26,7 @@ const A = 'a'.repeat(40);
|
||||
const B = 'b'.repeat(40);
|
||||
const C = 'c'.repeat(40);
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
@@ -221,15 +221,15 @@ test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land',
|
||||
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
|
||||
});
|
||||
|
||||
test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } });
|
||||
test('a cross-role merge that routes to the human, because the cto lander is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { ...roles, cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [MERGE] } } } });
|
||||
w.push(A);
|
||||
w.approve(A);
|
||||
const d = w.raise();
|
||||
const d = w.raise('cto');
|
||||
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' });
|
||||
assert.throws(() => w.resolve(d), /human-required/);
|
||||
w.resolve(d, 'yes', 'human');
|
||||
const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id });
|
||||
const { ticket, by, via } = w.b.checkLand(w.caps.cto, { task_ref: TASK, decision: d.id });
|
||||
assert.deepEqual([by, via], ['jason', 'cli']);
|
||||
w.b.recordLand(ticket, w.land);
|
||||
assert.equal(w.merges()[0].body.class, 'cross-role');
|
||||
|
||||
@@ -119,7 +119,7 @@ const TASK = 'vikunja:1/2';
|
||||
|
||||
test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => {
|
||||
const w = setup(t, {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
|
||||
@@ -43,9 +43,9 @@ async function until(fn, ms = 10000) {
|
||||
}
|
||||
|
||||
const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
// sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent.
|
||||
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
...(instances ? { launch: { instances } } : {}),
|
||||
});
|
||||
@@ -77,7 +77,7 @@ function world(t, options) {
|
||||
// chief-of-staff instance, fired by boss. The real read grant targets the library project books.
|
||||
function verbDoc(url, root) {
|
||||
return {
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
boss: { definition: 'pm', authority: chiefAuthority },
|
||||
// sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference.
|
||||
|
||||
@@ -31,8 +31,8 @@ async function world(t, { projects, adapter, claimed = true, now = () => NOW, fi
|
||||
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const context = { business, project: 'books', dataRoot };
|
||||
let mirror = null, state = null, fetches = 0;
|
||||
if (business.projects.books?.kind === 'library') {
|
||||
@@ -222,8 +222,8 @@ test('real socket awaits lookup results and rechecks rejected query arguments',
|
||||
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', plugins: { harness: ['pi'] }, projects: { books: mappingEntry() },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
|
||||
@@ -21,7 +21,7 @@ function world(t, adapter) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-sync-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-sync-agent', harness: 'pi' });
|
||||
@@ -81,7 +81,7 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
mkdirSync(join(configDir, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(configDir, 'secrets', 'acme', library.SLOT), TOKEN + '\n', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' }, quiet: mappingEntry() } };
|
||||
const calls = [];
|
||||
// A git that succeeds without fetching: the remote-tracking ref stays absent, so each pass fails.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 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';
|
||||
@@ -180,7 +181,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' });
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
@@ -234,8 +235,8 @@ test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never u
|
||||
const pushes = [];
|
||||
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
|
||||
const initial = (await bare.state()).lastFetch;
|
||||
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
const business = { ...bare.context.business, human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' } };
|
||||
const runtime = await startBroker({ dataRoot: bare.context.dataRoot, businesses: { acme: business },
|
||||
librarySync: { configDir, command, intervalMs: 60000 } });
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ function world(t, adapter = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-agent', harness: 'pi' });
|
||||
@@ -68,7 +68,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
const f = repo(t), dataRoot = join(f.root, 'data'), token = join(f.root, 'fixture-token');
|
||||
mkdirSync(dataRoot, { mode: 0o700 }); writeFileSync(token, 'made-up-fixture-token-123456', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry(), stack: { root: f.repo } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
await runtime.close();
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
@@ -81,7 +81,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a socket', async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const mirror = join(dataRoot, 'projects/acme/books'); mkdirSync(mirror, { recursive: true });
|
||||
writeFileSync(join(mirror, 'keep'), 'made-up note');
|
||||
await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }), { code: 'library-git' });
|
||||
@@ -92,7 +92,7 @@ test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a s
|
||||
test('C2-04 runtime: real library adapters reach the socket and lock waits leave other bus reads responsive', { timeout: 15000 }, async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
t.after(() => runtime.close());
|
||||
const cap = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -26,7 +26,7 @@ const doc = (extra = {}) => ({
|
||||
retired: ['old'],
|
||||
...extra,
|
||||
});
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 'only-there': none } };
|
||||
|
||||
function setup(t, { organization = null, testerOn = undefined, processing = undefined } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
|
||||
@@ -349,7 +349,7 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename
|
||||
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
|
||||
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
|
||||
// The bus reloads after the commit, as the runtime does.
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'chief', delivery: 'chief' } });
|
||||
delete next.roles.pm;
|
||||
f.b.reloadBusiness('demo', next);
|
||||
const writes = w.calls.length;
|
||||
|
||||
@@ -13,8 +13,8 @@ import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const businesses = () => ({ demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
|
||||
@@ -20,8 +20,8 @@ async function setup(t, plugins, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Broker} from '../src/broker.mjs';
|
||||
import {Store} from '../src/store.mjs';
|
||||
test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[];
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}};
|
||||
for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
|
||||
// Row 175 (#1673): the tester reads limits; limits.set stays the human's.
|
||||
@@ -19,7 +19,7 @@ test('limits binds actor from human proof and rejects role, reader, a tester set
|
||||
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
|
||||
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('uncertain organization receipts leave bus startup and reads alone but refu
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7;
|
||||
const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8');
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=bus.broker.request(human,{verb:'limits'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes);
|
||||
// The operator reconciles the fixture to the recorded old whole document.
|
||||
writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1);
|
||||
|
||||
@@ -15,7 +15,7 @@ function fixture(t, cross = false) {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []),
|
||||
cto: role(),
|
||||
cto: role(['decision.resolve.technical']),
|
||||
coder: role(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test('launch admission refreshes selection and refuses a removed model and incom
|
||||
writeJson(join(root,'businesses/demo.json'),doc); mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let present=true, reads=0;
|
||||
const service={invalidate(){},read:async(_,{fresh})=>{assert.equal(fresh,true);reads++;return {status:'ready',models:present?[{module:'cliproxy',upstream:'zai',model:'fixture-id',available:true,harnesses:['pi']}]:[]};}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const revision=bus.broker.request(human,{verb:'limits'}).revision;
|
||||
const {checkedTransport}=await import('../../harness/src/bundle.mjs');
|
||||
@@ -78,7 +78,7 @@ test('a different enabled fixture provider supplies legacy mapping and admission
|
||||
const makeProviderLoader=()=>({load:async()=>({refused:[]}),snapshot:()=>[{name:'second-fixture',module}]});
|
||||
const offered={key:'fixture-model',module:'second-fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const modelCatalogue={read:async()=>({status:'ready',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=await bus.broker.request(human,{verb:'routes'});
|
||||
assert.equal(view.catalogue.legacy['fixture-legacy'].module,'second-fixture');
|
||||
const answer=await bus.modelAdmission({business:'demo',instance:'coder',modelKey:'fixture-legacy',harness:'pi',revision:view.revision,entry:doc.launch.models['fixture-legacy'],provider:'fixture',model:'fixture-model',account:null,route:'primary',transport});
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
|
||||
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
|
||||
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
|
||||
// The established scratch aide pattern only; repository roles/policy stay untouched.
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
|
||||
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
|
||||
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
|
||||
|
||||
@@ -176,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
|
||||
async function bootBusiness(f, binding = {}) {
|
||||
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
|
||||
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -15,7 +15,7 @@ async function hireFixture(t,{revoking=false}={}){
|
||||
if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}old.credentials.close();
|
||||
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
|
||||
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
|
||||
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
|
||||
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
|
||||
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
|
||||
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
|
||||
// Existing row331 scratch aide pattern only; repository role authority unchanged.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
|
||||
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
|
||||
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
const role={authority:{withinRole:['message.send'],crossRole:[]}};
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
test('proxy.init only accepts path-bound outside-agent human; refuses agent/tester/reader',async t=>{const calls=[];const f=fixture(t,async a=>{calls.push(a);return {port:a.port,binary:'/fixture'};});for(const cap of [f.tester,f.agent])assert.throws(()=>f.b.request(cap,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='human-required');assert.throws(()=>f.b.request(f.reader,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='read-only');assert.equal(calls.length,0);assert.deepEqual(await f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management',port:18432}}),{port:18432,binary:'/fixture'});assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
|
||||
for(const [code,args]of [['proxy-key-invalid',{key:'a\nb'}],['proxy-config-invalid',{key:'p1-management',port:8317}],['invalid-request',{key:'p1-management',binary:'/forged'}]])test(`proxy.init ${code}`,t=>{const f=fixture(t,()=>{throw Error('must not call');});assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args}),e=>e.code===code);});
|
||||
test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='not-configured');});
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
|
||||
let boot,writer,chief,human,busBoots=0;
|
||||
if(runtime){
|
||||
const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
|
||||
boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
|
||||
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
|
||||
|
||||
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -24,7 +24,7 @@ const demo = () => ({
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles,
|
||||
definitions: { reviewer: {} },
|
||||
vars: { 'bus.tester': true },
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createLimits, modelUsageKey } from '../src/limits.mjs';
|
||||
const key='012345abcdef';
|
||||
test('P5 broker preserves original plugin subject and derives internal run context from authenticated native caller',async t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'p5-context-')),store=new Store(root),asked=[];t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
limits:{mustStop:async(...args)=>{asked.push(args);return {stop:false};}},usage:{collect:async()=>({recorded:true}),view:()=>({})}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=b.bindLaunch({business:'demo',role:'coder',run:'native-run',harness:'pi',provider:'fixture',model:'fake'});b.request(cap,{verb:'role.claim'});
|
||||
|
||||
@@ -30,7 +30,7 @@ test('P5 real runtime binds stack usage to issued key, daily stops at stack coun
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
assert.equal(calls.some(p=>p.includes('usage/queue')),false);
|
||||
const model=await bus.modelPreflight({business:'demo',role:'coder'}),keyId=hash(model.key);
|
||||
@@ -77,7 +77,7 @@ test('R4 real runtime: a quota spent on A and moved to B with no reset still ref
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
const stored=()=>JSON.parse(readFileSync(join(config,'businesses/demo.json'),'utf8')).launch.limits.tokens.keys;
|
||||
const may=async()=>(await bus.mayLaunch({business:'demo',instance:'coder',provider:'fixture',model:'fake'})).allow;
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function telemetryFixture(t, { foreign = false, hold = false, waitM
|
||||
});
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
let bus=null,startPromise=null,closePromise=null;
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value)));
|
||||
const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}};
|
||||
const close=()=>bus?(closePromise??=bus.close()):Promise.resolve();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as bus from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pt-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new bus.Broker({ store, businesses: {
|
||||
acme: { id: 'acme', human: 'jason', roles: {
|
||||
pm: { authority: { withinRole: [], crossRole: [] } },
|
||||
cto: { authority: { withinRole: [], crossRole: [] } },
|
||||
}, arbiters: { technical: 'cto', delivery: 'pm' } },
|
||||
} });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
return { broker, human };
|
||||
}
|
||||
|
||||
test('person task vocabulary pins exactly the five frozen store verbs', () => {
|
||||
assert.deepEqual(bus.PERSON_TASK_VERBS, [
|
||||
'tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(bus.PERSON_TASK_VERBS));
|
||||
});
|
||||
|
||||
test('every person task handler is declared in the vocabulary, even if the dispatch guard regresses', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
const handlers = [...source.matchAll(/^\s*case\s+(['"])(tasks\.[^'"]+)\1\s*:/gm)].map(match => match[2]);
|
||||
assert.deepEqual(handlers.sort(), [...bus.PERSON_TASK_VERBS].sort());
|
||||
});
|
||||
|
||||
test('every listed person task verb reaches its handler, while an unlisted verb is unknown', t => {
|
||||
const { broker, human } = fixture(t);
|
||||
assert.ok(Array.isArray(bus.PERSON_TASK_VERBS));
|
||||
for (const verb of bus.PERSON_TASK_VERBS) {
|
||||
// Missing required fields reaches the handler's field validation, not unknown-verb.
|
||||
assert.throws(() => broker.request(human, { verb, args: {} }), { code: 'invalid-request' }, verb);
|
||||
}
|
||||
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: {} }), { code: 'unknown-verb' });
|
||||
});
|
||||
|
||||
test('unknown person task verbs retain common argument and reader refusal precedence', t => {
|
||||
const { broker, human } = fixture(t);
|
||||
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: [] }), { code: 'invalid-request' });
|
||||
const reader = broker.bindReader({ business: 'acme' });
|
||||
assert.throws(() => broker.request(reader, { verb: 'tasks.foo' }), { code: 'read-only' });
|
||||
for (const verb of ['tasks.foo', 'tasks.create']) {
|
||||
assert.throws(() => broker.request(reader, { verb }), { code: 'read-only' }, verb);
|
||||
}
|
||||
});
|
||||
@@ -37,7 +37,7 @@ test('runtime: the switch changes the organization file, the receipt carries the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
@@ -72,7 +72,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -97,7 +97,7 @@ test('plugins read advertises loadable harnesses even when switched off', async
|
||||
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const human=host.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=host.broker.request(human,{verb:'plugins'});
|
||||
assert.deepEqual(view.plugins.harness,['claude-code']);assert.ok(view.available.harness.includes('pi'));assert.ok(view.available.harness.includes('claude-code'));
|
||||
|
||||
@@ -199,6 +199,8 @@ test('3p through 3s migration preserves existing request rows and the append-onl
|
||||
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
|
||||
const db = new DatabaseSync(path); db.exec(ddl);
|
||||
const at = '2026-11-03T00:00:00.000Z';
|
||||
@@ -214,7 +216,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
|
||||
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
|
||||
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Client } from '../src/client.mjs';
|
||||
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
|
||||
import { readControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
async function broker(root) {
|
||||
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
|
||||
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -13,7 +13,7 @@ const state = await import('../../processing-control/src/state.mjs').catch(e =>
|
||||
throw e;
|
||||
});
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
|
||||
} } };
|
||||
function fixture(t, extra = {}) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mj
|
||||
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
|
||||
const AT = '2026-11-05T00:00:00.000Z';
|
||||
const business = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
@@ -143,7 +143,8 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
|
||||
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
|
||||
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
|
||||
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '');
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither.
|
||||
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
|
||||
|
||||
function source3s(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
|
||||
@@ -178,7 +179,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
|
||||
assert.deepEqual(master(path), was, 'the refused open changed nothing');
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
||||
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
|
||||
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
|
||||
@@ -211,7 +212,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3s';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';")
|
||||
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -221,7 +222,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
|
||||
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
assert.throws(() => new Store3s(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
|
||||
const { root: old } = source3s(t);
|
||||
|
||||
@@ -10,7 +10,7 @@ function fixture(t, project, secretCheck = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-await-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
|
||||
} };
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
|
||||
|
||||
@@ -5,6 +5,7 @@ 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);
|
||||
@@ -109,11 +110,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' });
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('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' });
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('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' });
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
});
|
||||
|
||||
test('two saves racing for one repository make one commit', async t => {
|
||||
|
||||
@@ -13,6 +13,8 @@ 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';",
|
||||
@@ -39,7 +41,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 { 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';",
|
||||
"import { pluginName, 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/);
|
||||
@@ -64,7 +66,7 @@ test('real startBroker injects the project adapter for socket reads and bound-au
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
// Row 65: the bound run's harness is switched on.
|
||||
const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: {
|
||||
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
|
||||
} } };
|
||||
const initialized = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
|
||||
@@ -15,7 +15,7 @@ test('role must not materialize a new catalogue key by submitting legacy entry s
|
||||
const offered={key:'zai-fixture-new',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-new',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
let status='ready';
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status,models:[offered],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const allowed=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -20,7 +20,7 @@ async function fixture(t, options={}) {
|
||||
if(options.fallback)Object.assign(doc.roles.coder.vars,{'fallback.harness':'pi','fallback.model':'existing-alias','fallback.thinking':'low'});
|
||||
writeJson(file,doc);fs.mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status:'ready',models:[offer],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const count=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -18,8 +18,8 @@ function setup(t, proxyPage = async () => ({ ok: true })) {
|
||||
access: () => ({ accessVersion: 1, revision: 1, users: [], grants: [
|
||||
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['coder'], level: 'collaborator' },
|
||||
] }),
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
vars: { 'bus.tester': true }, roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
});
|
||||
const caps = {
|
||||
human: broker.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
||||
|
||||
@@ -24,9 +24,9 @@ function businesses(upstream = 'https://git.example/mosaic/stack') {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
|
||||
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
|
||||
plain: { authority: pusher },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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/);
|
||||
});
|
||||
@@ -211,13 +211,13 @@ test('T14 reloadBusiness refuses an unknown business, a changed human and a docu
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } };
|
||||
const b = new Broker({ store: new Store(root), businesses: { demo } });
|
||||
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
|
||||
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
|
||||
b.reloadBusiness('demo', { ...demo, roles: { ...demo.roles, 'sec-1': role } });
|
||||
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
|
||||
});
|
||||
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
|
||||
});
|
||||
|
||||
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
|
||||
assert.equal(SCHEMA_VERSION, '3u');
|
||||
assert.equal(SCHEMA_VERSION, '3v');
|
||||
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
|
||||
@@ -310,8 +310,11 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
|
||||
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
|
||||
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
|
||||
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
|
||||
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks.
|
||||
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
|
||||
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
|
||||
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
|
||||
const ddl3q = current.replace(step3t, '').replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
|
||||
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
|
||||
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
|
||||
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
|
||||
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
|
||||
@@ -328,7 +331,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
|
||||
const store = new Store(root);
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
|
||||
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
|
||||
|
||||
@@ -231,6 +231,8 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
|
||||
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(",'advanced'", ''));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
|
||||
@@ -251,7 +253,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
|
||||
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
|
||||
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
|
||||
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
|
||||
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
|
||||
|
||||
@@ -279,6 +279,8 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
// predate it and the replayed chain recreates it, so the step's text comes out here too.
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
|
||||
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
|
||||
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
|
||||
@@ -291,7 +293,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n'); assert.equal(SCHEMA_VERSION, '3u');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v');
|
||||
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
||||
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
|
||||
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
|
||||
@@ -315,6 +317,8 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
|
||||
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
|
||||
const db = new DatabaseSync(path); db.exec(ddl3o);
|
||||
@@ -331,7 +335,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
|
||||
const store = new Store(root);
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
|
||||
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// RR (#1873): resolution checks current role rights, and callers cannot choose a merge/policy arbiter.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadRole } from '../../organization/src/index.mjs';
|
||||
import { REPO_ROLES } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
async function setup(t, { shared = false } = {}) {
|
||||
const roles = Object.fromEntries(['cto', 'pm', 'chief-of-staff'].map(definition => {
|
||||
const role = loadRole(REPO_ROLES, definition);
|
||||
return [definition === 'chief-of-staff' ? 'chief' : definition, { definition, authority: structuredClone(role.authority) }];
|
||||
}));
|
||||
roles.coder = { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: ['task.scope.change', 'git.merge.protected', 'policy.change'] } };
|
||||
const f = await fixture(t, { roles, arbiters: { technical: 'cto', delivery: shared ? 'cto' : 'pm' } });
|
||||
return { ...f, roles, cto: f.agent('cto'), pm: f.agent('pm'), chief: f.chief };
|
||||
}
|
||||
|
||||
for (const [domain, resolver] of [['technical', 'cto'], ['delivery', 'pm']]) {
|
||||
test(`RR: ${domain} arbiter that loses its right refuses not-resolver`, async t => {
|
||||
const f = await setup(t);
|
||||
const d = f.raise({ action: 'task.scope.change', domain });
|
||||
f.business.roles[resolver].authority.withinRole = ['message.send'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }), error => {
|
||||
assert.equal(error.code, 'not-resolver');
|
||||
assert.match(error.reason, new RegExp(`${resolver}.*${resolver}.*decision\\.resolve\\.${domain}`));
|
||||
return true;
|
||||
});
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', d.id).n, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: a shared arbiter cannot resolve a delivery decision using only its technical right', async t => {
|
||||
const f = await setup(t, { shared: true });
|
||||
const d = f.raise({ action: 'task.scope.change', domain: 'delivery' });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
});
|
||||
|
||||
test('RR: merge raised with delivery still routes to CTO and resolves (acceptance 314 step 5)', async t => {
|
||||
const f = await setup(t);
|
||||
const d = await f.raise({ action: 'git.merge.protected', domain: 'delivery', project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', d.id).op, 'resolved');
|
||||
});
|
||||
|
||||
for (const resolver of ['chief', 'pm']) {
|
||||
test(`RR: ${resolver} named delivery arbiter resolves policy.change even when raised technical`, async t => {
|
||||
const f = await setup(t); f.business.arbiters.delivery = resolver;
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
const d = f.raise({ action: 'policy.change', domain: 'technical' });
|
||||
assert.equal(d.route_to, resolver);
|
||||
f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT by FROM decision_events WHERE decision=?', d.id).by, `${resolver}-run`);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: the chief holds both rights when named for both domains', async t => {
|
||||
const f = await setup(t); f.business.arbiters = { technical: 'chief', delivery: 'chief' };
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
for (const action of ['git.merge.protected', 'policy.change']) {
|
||||
const d = f.raise({ action }); assert.equal(d.route_to, 'chief');
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
}
|
||||
});
|
||||
|
||||
// Old raise evidence did not preserve domain. Current assignments cannot prove the old domain.
|
||||
test('RR: remapping arbiters cannot let a legacy decision resolve with only the other domain right', async t => {
|
||||
const f = await setup(t);
|
||||
const id = grantDecision(f.store, { action: 'task.scope.change', route: 'pm', domain: null, close: null });
|
||||
f.business.arbiters = { technical: 'pm', delivery: 'cto' };
|
||||
f.business.roles.pm.authority.withinRole = ['message.send', 'decision.resolve.technical'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0);
|
||||
// Human recovery closes the old route by withdrawing it, before the raiser creates a new decision.
|
||||
f.call(f.human, 'decision.withdraw', { id });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', id).op, 'withdrawn');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('C2-10c check hydration preserves unchecked rows and one broadcast with two
|
||||
const messaging = JSON.parse(readFileSync(new URL('../../../roles/companion.json', import.meta.url))).messaging;
|
||||
const role = { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', vars: {},
|
||||
arbiters: { technical: 'bean', delivery: 'bean' }, roles: { bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args) => broker.request(human, { verb, args });
|
||||
const project = call('project.create', { name: 'Room' }).id;
|
||||
|
||||
@@ -110,7 +110,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -157,7 +157,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s
|
||||
const file = join(root, 'businesses/demo.json'); writeJson(file, doc); mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const offered={ key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture-id',available:true,reason:null,harnesses:['pi'] };
|
||||
const catalogues={ read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate:()=>{} };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
t.after(() => bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const before=await bus.broker.request(human,{verb:'routes'});
|
||||
@@ -179,7 +179,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${
|
||||
let mode='absent';
|
||||
const offered={key:'fixture-persisted',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:mode==='absent'?[]:[{...offered,harnesses:mode==='incompatible'?['claude-code']:['pi']}],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=writer==='organization.set'?bus.broker.bindLaunch({business:'demo',role:'chief',run:'provider-chief',harness:'pi'}):human;
|
||||
if(writer==='organization.set')bus.broker.request(cap,{verb:'role.claim'});
|
||||
@@ -201,7 +201,7 @@ test('legacy routes follow enabled module mappings while unchanged unavailable s
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let state='ready';const offered={key:'zai-fixture-two',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-two',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:state,observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const request=(verb,args)=>bus.broker.request(human,{verb,args});
|
||||
let view=await request('routes');assert.deepEqual(view.catalogue.legacy['legacy-one'],{module:'cliproxy',upstream:'claude',model:'fixture-one'});
|
||||
const pick=(harness,model,fallback=null)=>({harness,model,thinking:'low',fallback});
|
||||
|
||||
@@ -527,8 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
|
||||
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
|
||||
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
|
||||
// 3t to 3u adds project_system_types and four guards.
|
||||
assert.equal(count('table') - 1, 32);
|
||||
assert.equal(count('trigger'), 123);
|
||||
// 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards.
|
||||
assert.equal(count('table') - 1, 33);
|
||||
assert.equal(count('trigger'), 130);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
const after = tables(path);
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
delete after[name];
|
||||
}
|
||||
@@ -571,7 +571,7 @@ async function engine3m(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
|
||||
const Store3m = await engine3m(root);
|
||||
assert.throws(() => new Store3m(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
|
||||
const { root, path } = source3k(t);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
@@ -430,7 +430,7 @@ async function engine3k(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
|
||||
const Store3k = await engine3k(root);
|
||||
assert.throws(() => new Store3k(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -12,8 +12,8 @@ const businesses = {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
cto: { authority: { withinRole: [], crossRole: [] } },
|
||||
pm: { authority: { withinRole: [], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -16,6 +16,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: {
|
||||
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
|
||||
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
|
||||
chief: { definition: 'chief-of-staff', authority: authority(['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']) },
|
||||
...roles,
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
@@ -31,7 +32,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(cap, { verb: 'role.claim' }); return cap;
|
||||
};
|
||||
const coder = agent('coder'), reviewer = agent('reviewer');
|
||||
const coder = agent('coder'), reviewer = agent('reviewer'), chief = agent('chief');
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
|
||||
const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options,
|
||||
@@ -42,5 +43,5 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Spec');
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, human, call, raise, save };
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, chief, human, call, raise, save };
|
||||
}
|
||||
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -19,7 +19,7 @@ async function setup(t, { running = ['chief', 'pm', 'cto'], thread = true } = {}
|
||||
cto: { definition: 'cto', ...within }, assistant: { definition: 'assistant', ...within },
|
||||
} });
|
||||
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
|
||||
f.cap = Object.fromEntries(running.map(role => [role, f.agent(role)]));
|
||||
f.cap = Object.fromEntries(running.map(role => [role, role === 'chief' ? f.chief : f.agent(role)]));
|
||||
if (thread) {
|
||||
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
f.threadId = f.call(f.human, 'thread.create', { project, name: 'alpha' }).id;
|
||||
|
||||
@@ -139,7 +139,7 @@ test('REQ-COS-1: the request goes to the business launcher, and only when that i
|
||||
// launcher `chief`, so a check on the name passes there; these use names that differ from the definitions.
|
||||
test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the pm definition can ask, an instance `pm` of the coder definition can\'t', (t) => {
|
||||
const lead = setup(t, {
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
lead: { definition: 'pm', authority: authority.pm },
|
||||
@@ -172,9 +172,9 @@ test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the p
|
||||
|
||||
test('REQ-COS-1: the launcher is judged by definition: an instance `boss` of the chief-of-staff definition receives, a `chief` of the coder definition gives no-chief-of-staff', (t) => {
|
||||
const boss = setup(t, {
|
||||
arbiters: { technical: 'pm', delivery: 'boss' },
|
||||
arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
launch: { by: 'boss', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
boss: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
pm: { definition: 'pm', authority: authority.pm },
|
||||
coder: { definition: 'coder', ...none },
|
||||
|
||||
@@ -67,7 +67,9 @@ test('3t store migrates offline without altering existing project rows', async t
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
|
||||
assert.equal(ddl.split(step).length, 2);
|
||||
db.exec(ddl.replace(step, ''));
|
||||
// 3u-3v (U4a) adds store_tasks after this step; a 3t store has neither.
|
||||
const later = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
|
||||
db.exec(ddl.replace(step, '').replace(later, ''));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
|
||||
@@ -77,7 +79,7 @@ test('3t store migrates offline without altering existing project rows', async t
|
||||
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
|
||||
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
|
||||
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
|
||||
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
});
|
||||
|
||||
test('runtime reads the current organization file for project selections and console discovery', async t => {
|
||||
@@ -89,7 +91,7 @@ test('runtime reads the current organization file for project selections and con
|
||||
doc.plugins = { 'system-type': ['coding'] };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { storeAdapter } from '../../tasks/src/store.mjs';
|
||||
import { loadFields } from '../../tasks/src/fields.mjs';
|
||||
|
||||
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the
|
||||
// default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs
|
||||
// over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every
|
||||
// read is the bus's `tasks` verb, the same shape whichever module holds the tasks.
|
||||
|
||||
const AUTHORITY = {
|
||||
pm: {
|
||||
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send'],
|
||||
crossRole: ['task.priority.change', 'task.scope.change'],
|
||||
},
|
||||
coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.scope.change'] },
|
||||
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
|
||||
cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'] },
|
||||
};
|
||||
const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }]));
|
||||
const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra });
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
// One bus (one data root) holding the given organizations, with the store module over it.
|
||||
async function org(t, ids = ['acme'], { taskTracker = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ts-'));
|
||||
const businesses = Object.fromEntries(ids.map((id) => [id, business(id)]));
|
||||
const store = new Store(root);
|
||||
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
|
||||
const adapter = await storeAdapter()({ broker, businesses });
|
||||
let removed = false;
|
||||
t.after(() => {
|
||||
adapter.close();
|
||||
if (!removed) {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const caps = new Map(), humans = new Map();
|
||||
const agent = (role, id = ids[0]) => {
|
||||
const key = id + '/' + role;
|
||||
if (!caps.has(key)) {
|
||||
const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' });
|
||||
broker.request(cap, { verb: 'role.claim' });
|
||||
caps.set(key, cap);
|
||||
}
|
||||
return caps.get(key);
|
||||
};
|
||||
const human = (id = ids[0]) => {
|
||||
if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }));
|
||||
return humans.get(id);
|
||||
};
|
||||
const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
|
||||
const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle);
|
||||
const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args });
|
||||
const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args });
|
||||
const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id);
|
||||
// A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it.
|
||||
const approve = (role, action, target, id = ids[0]) => {
|
||||
const d = broker.request(agent(role, id), {
|
||||
verb: 'decision.raise',
|
||||
args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false },
|
||||
});
|
||||
broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
return d.id;
|
||||
};
|
||||
const remove = () => {
|
||||
removed = true;
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
};
|
||||
return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove };
|
||||
}
|
||||
const codeOf = async (p) => {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const syncCode = (fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => {
|
||||
assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort());
|
||||
for (const [verb, list] of Object.entries(STORE_VERB_FIELDS))
|
||||
for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 every role verb writes the store through the module', async (t) => {
|
||||
const f = await org(t);
|
||||
const request = f.instruction();
|
||||
const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' });
|
||||
assert.equal(made.task_ref, 'task:1');
|
||||
assert.match(made.digest, DIGEST);
|
||||
let row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.source, 'store');
|
||||
assert.equal(row.tracker, 'store');
|
||||
assert.equal(row.role, 'pm');
|
||||
assert.equal(row.verb, 'task.create');
|
||||
assert.equal(row.revision, 1);
|
||||
assert.equal(row.digest, made.digest);
|
||||
assert.equal(row.archived, false);
|
||||
assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 });
|
||||
// The row's event is the action.allowed the write recorded, on the task's subject.
|
||||
const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event);
|
||||
assert.equal(event.kind, 'action.allowed');
|
||||
assert.equal(event.subject, 'task:1');
|
||||
assert.equal(JSON.parse(event.body).action, 'task.create');
|
||||
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
||||
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned');
|
||||
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' });
|
||||
assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']);
|
||||
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' });
|
||||
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role');
|
||||
|
||||
await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } });
|
||||
row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z');
|
||||
assert.deepEqual(row.fields.labels, [3, 5]);
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request');
|
||||
|
||||
await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' });
|
||||
row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.role, 'coder');
|
||||
assert.equal(row.fields.state, 'in-progress');
|
||||
assert.equal(row.fields.bucket, 'in-progress');
|
||||
assert.equal(row.fields.percent_done, 0.5);
|
||||
assert.equal(row.fields.comments, 1);
|
||||
assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' });
|
||||
// done is task.close's, never the assignee's.
|
||||
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state');
|
||||
|
||||
// Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once.
|
||||
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required');
|
||||
const d = f.approve('pm', 'task.priority.change', 'task:1');
|
||||
await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d });
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).priority, 4);
|
||||
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed');
|
||||
const scope = f.approve('coder', 'task.scope.change', 'task:1');
|
||||
await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope });
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well');
|
||||
|
||||
const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' });
|
||||
row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.digest, closed.digest);
|
||||
assert.equal(row.fields.done, true);
|
||||
assert.equal(row.fields.state, 'done');
|
||||
assert.equal(row.fields.verdict, 'review-1');
|
||||
// A done task takes no role write; reopening it is the person's.
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done');
|
||||
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done');
|
||||
// Nine writes; no refusal wrote a row.
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9);
|
||||
assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => {
|
||||
const f = await org(t);
|
||||
await f.create('Owned fields');
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
||||
// The module holds the pm verbs to the pm definition.
|
||||
assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer');
|
||||
assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer');
|
||||
assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer');
|
||||
assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer');
|
||||
// task.update.assigned needs the role among the assignees.
|
||||
assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned');
|
||||
// The broker holds every write to its verb's fields, whatever the module asks for.
|
||||
const pm = f.agent('pm'), coder = f.agent('coder');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer');
|
||||
// A person's session is not a role here.
|
||||
assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required');
|
||||
// Vikunja-only arguments refuse; nothing was written by any refusal.
|
||||
for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }])
|
||||
assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2);
|
||||
// A refusal on a store task is filed on its trail.
|
||||
const trail = f.person('trail', { subject: 'task:1' });
|
||||
assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned'));
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => {
|
||||
const f = await org(t);
|
||||
const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] });
|
||||
assert.equal(made.task_ref, 'task:1');
|
||||
assert.equal(made.human, 'jason');
|
||||
assert.equal(made.role, null);
|
||||
assert.equal(made.fields.position, 0);
|
||||
assert.equal(made.fields.request, null);
|
||||
const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event);
|
||||
assert.equal(event.kind, 'human.input');
|
||||
assert.equal(event.subject, 'task:1');
|
||||
assert.equal(event.actor_role, null);
|
||||
assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' });
|
||||
assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role');
|
||||
// expect is the digest the person last read; a later write refuses.
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict');
|
||||
const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] });
|
||||
assert.equal(done.fields.done, true);
|
||||
assert.equal(done.fields.bucket, 'done');
|
||||
// The person may reopen a done task.
|
||||
assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false);
|
||||
// Agents and the reader don't reach the person's verbs.
|
||||
assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required');
|
||||
assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null);
|
||||
|
||||
const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' });
|
||||
f.person('tasks.archive', { task_ref: 'task:1' });
|
||||
assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived');
|
||||
// An archived task leaves the board read and the filtered read, unless archived: true asks.
|
||||
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
|
||||
assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]);
|
||||
const archived = f.read({ archived: true });
|
||||
assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]);
|
||||
assert.equal(archived.total, 1);
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).archived, true);
|
||||
f.person('tasks.unarchive', { task_ref: 'task:1' });
|
||||
assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived');
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).archived, false);
|
||||
|
||||
const gone = f.person('tasks.delete', { task_ref: 'task:1' });
|
||||
assert.equal(gone.task_ref, 'task:1');
|
||||
assert.equal(gone.deleted, true);
|
||||
assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found');
|
||||
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found');
|
||||
assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found');
|
||||
// The history stays in the organization's bus until the organization is removed.
|
||||
const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks');
|
||||
assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']);
|
||||
assert.equal(trail[0].fields.title, 'Plan');
|
||||
// Numbers are never reused.
|
||||
assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3');
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => {
|
||||
const f = await org(t);
|
||||
const request = f.instruction();
|
||||
const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' };
|
||||
const first = await f.call('pm', 'task.create', args);
|
||||
const again = await f.call('pm', 'task.create', args);
|
||||
assert.deepEqual(again, first);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1);
|
||||
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request');
|
||||
const trail = f.person('trail', { subject: 'task:1' });
|
||||
assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input'));
|
||||
assert.ok(trail.some((r) => r.table === 'messages'));
|
||||
// A stale expect refuses a role write too.
|
||||
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict');
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest });
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => {
|
||||
const f = await org(t);
|
||||
for (const title of ['A', 'B', 'C']) await f.create(title);
|
||||
await f.create('D', { group: 'review' });
|
||||
await f.create('E', { group: 'build' });
|
||||
const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]);
|
||||
assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]);
|
||||
// A move to another group with no position goes to its end; a position alone reorders in place.
|
||||
await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' });
|
||||
await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 });
|
||||
f.person('tasks.edit', { task_ref: 'task:1', position: 5000 });
|
||||
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]);
|
||||
// A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too.
|
||||
f.person('tasks.edit', { task_ref: 'task:4', group: 'build' });
|
||||
f.person('tasks.edit', { task_ref: 'task:5', group: null });
|
||||
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]);
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request');
|
||||
// The filtered short form pages in board order with the store's group, position and archived.
|
||||
const one = f.read({ limit: 2 });
|
||||
assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']);
|
||||
assert.equal(one.total, 5);
|
||||
assert.equal(one.next, 'task:1');
|
||||
assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']);
|
||||
const two = f.read({ limit: 2, after: one.next });
|
||||
assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']);
|
||||
const three = f.read({ limit: 2, after: two.next });
|
||||
assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']);
|
||||
assert.equal(three.next, null);
|
||||
assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found');
|
||||
// The other filters apply to store tasks as to Vikunja's.
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' });
|
||||
assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']);
|
||||
assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5);
|
||||
assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => {
|
||||
// Jason 11-07: each organization has its own bus and data root.
|
||||
const a = await org(t, ['acme']), b = await org(t, ['beta']);
|
||||
await a.create('acme task');
|
||||
await b.create('beta task');
|
||||
b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' });
|
||||
assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]);
|
||||
assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]);
|
||||
// Removing an organization removes its data root, and its tasks with it.
|
||||
a.remove();
|
||||
assert.equal(existsSync(a.root), false);
|
||||
assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']);
|
||||
await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
||||
// Within one bus, too, a business reads and writes only its own rows.
|
||||
const both = await org(t, ['one', 'two']);
|
||||
await both.create('one task', {}, 'one');
|
||||
assert.deepEqual(both.read({}, 'two'), []);
|
||||
assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found');
|
||||
assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found');
|
||||
assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found');
|
||||
assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1');
|
||||
assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']);
|
||||
});
|
||||
|
||||
test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => {
|
||||
const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' });
|
||||
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store');
|
||||
assert.equal(await codeOf(f.create('x')), 'tracker-not-store');
|
||||
assert.deepEqual(f.read(), []);
|
||||
assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => {
|
||||
const f = await org(t);
|
||||
await f.create('Guarded');
|
||||
const event = f.store.get('SELECT event FROM store_tasks').event;
|
||||
const fields = f.store.get('SELECT fields FROM store_tasks').fields;
|
||||
const insert = (task, revision, op, by = ['pm', 'pm-run', null]) =>
|
||||
f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields);
|
||||
for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'],
|
||||
["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]])
|
||||
assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql);
|
||||
assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/);
|
||||
assert.throws(() => insert(1, 2, 'create'), /a store task is created once/);
|
||||
assert.throws(() => insert(3, 1, 'create'), /with the next number/);
|
||||
assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/);
|
||||
assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/);
|
||||
assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/);
|
||||
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/);
|
||||
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/);
|
||||
insert(1, 2, 'archive', [null, null, 'jason']);
|
||||
assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/);
|
||||
insert(1, 3, 'delete', [null, null, 'jason']);
|
||||
assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3);
|
||||
});
|
||||
|
||||
// --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. ---
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8');
|
||||
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
|
||||
const master = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
const version = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => {
|
||||
assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once');
|
||||
assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql');
|
||||
const root = mkdtempSync(join(tmpdir(), 'ts-3u-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
||||
db.exec(currentDDL.replace(stepSQL, ''));
|
||||
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
|
||||
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
|
||||
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
|
||||
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run();
|
||||
db.close();
|
||||
chmodSync(path, 0o600);
|
||||
const was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
assert.deepEqual(master(path), was, 'the refused open changed nothing');
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(version(path), '3v');
|
||||
assert.equal(store.get('SELECT id FROM events').id, 'preserved');
|
||||
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
new Store(freshRoot).close();
|
||||
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
||||
});
|
||||
|
||||
// --- The bus runtime: the store is the default module; Vikunja stays the host's. ---
|
||||
test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ts-rt-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const businesses = {
|
||||
acme: business('acme', { plugins: { harness: ['pi'] } }),
|
||||
beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }),
|
||||
};
|
||||
const seen = [];
|
||||
let closed = 0;
|
||||
const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' });
|
||||
const runtime = await startBroker({
|
||||
dataRoot: root,
|
||||
businesses,
|
||||
launches: [launch('acme'), launch('beta')],
|
||||
trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } },
|
||||
tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }),
|
||||
});
|
||||
try {
|
||||
const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap }));
|
||||
await acme.call('role.claim');
|
||||
await beta.call('role.claim');
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
|
||||
const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' });
|
||||
assert.equal(made.task_ref, 'task:1');
|
||||
assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' });
|
||||
assert.deepEqual(seen, ['task.create']);
|
||||
// The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way.
|
||||
const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' });
|
||||
assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]);
|
||||
assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store');
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
assert.equal(closed, 1);
|
||||
// A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot.
|
||||
await assert.rejects(
|
||||
startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }),
|
||||
(e) => e.code === 'tracker-config',
|
||||
);
|
||||
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
|
||||
});
|
||||
|
||||
test('review: an unknown task-tracker module must refuse boot', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'unknown-'));
|
||||
t.after(() => rmSync(root, {recursive:true, force:true}));
|
||||
let runtime;
|
||||
try {
|
||||
await assert.rejects(async () => {
|
||||
runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}});
|
||||
}, (e) => e.code === 'tracker-config');
|
||||
} finally { await runtime?.close(); }
|
||||
// Startup cleanup releases the same store so a corrected configuration can start.
|
||||
const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}});
|
||||
await corrected.close();
|
||||
});
|
||||
|
||||
test('review: a stale expect must reject a delete after an intervening archive', async (t) => {
|
||||
const f=await org(t);
|
||||
const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'});
|
||||
const archived=f.person('tasks.archive', {task_ref:old.task_ref});
|
||||
assert.equal(archived.revision, old.revision+1);
|
||||
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict');
|
||||
const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref});
|
||||
assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict');
|
||||
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict');
|
||||
assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current');
|
||||
});
|
||||
|
||||
test('review: human tasks can cite the PRD-required requirement id', async (t) => {
|
||||
const f=await org(t);
|
||||
const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'});
|
||||
assert.equal(made.fields.requirement,'REQ-TASK-1');
|
||||
assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request');
|
||||
for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) {
|
||||
assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request');
|
||||
}
|
||||
const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'});
|
||||
assert.equal(edited.fields.requirement, 'REQ-TASK-2');
|
||||
assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input');
|
||||
});
|
||||
|
||||
test('review: a newly hired assignee can use the store after the business reloads', async (t) => {
|
||||
const f=await org(t);
|
||||
const made=await f.create('For a new coder');
|
||||
const next=business('acme');
|
||||
next.roles.newcoder=structuredClone(next.roles.coder);
|
||||
f.broker.reloadBusiness('acme',next);
|
||||
f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']});
|
||||
await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'});
|
||||
assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress');
|
||||
});
|
||||
|
||||
test('review: deleting an assignee does not prevent archiving the existing task', async (t) => {
|
||||
const f=await org(t);
|
||||
const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']});
|
||||
const next=business('acme'); delete next.roles.coder; next.retired=['coder'];
|
||||
f.broker.reloadBusiness('acme',next);
|
||||
const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']});
|
||||
assert.deepEqual(edited.fields.assignees,['coder']);
|
||||
assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role');
|
||||
const archived=f.person('tasks.archive',{task_ref:made.task_ref});
|
||||
assert.equal(archived.archived,true);
|
||||
assert.deepEqual(archived.fields.assignees,['coder']);
|
||||
f.person('tasks.delete',{task_ref:made.task_ref});
|
||||
const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields);
|
||||
assert.deepEqual(last.assignees,['coder']);
|
||||
});
|
||||
|
||||
test('review: saturated and adjacent positions refuse without a write', async (t) => {
|
||||
const f = await org(t);
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'});
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000});
|
||||
const before = f.store.get('SELECT count(*) n FROM store_tasks').n;
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full');
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before);
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10});
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11});
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
|
||||
});
|
||||
Loaded 100 of 371 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user