Compare commits

..
Author SHA1 Message Date
jason.woltje 82df17347d honker: PRD 1.8 draft r7 takes Mos's r6 rulings (mos-1152, sage-1631): the heading reads "For Jason at approval:" with Mos's reading of the Q188 revokers, the machine-grant citation names r1 to r4, the revoke note drops the Q-S5 claim 2026-10-20 20:15:53 -05:00
jason.woltje 47c2cb33c3 honker: PRD 1.8 draft r7 on r5 762a6711f (Jason Q191, mos-1149, sage-1623): the assistant and the chief of staff are separate roles and only the chief raises a grant request; REQ-GRANT-1 adds that no agent holds both; a note says what stops one holder claiming both at refactor 011d41ba6 2026-10-20 20:10:08 -05:00
jason.woltje 762a6711ff honker: PRD 1.8 draft r5: duckworth-0061 N1, two stale notes (Duckworth has reviewed the machine-grant design through r4, and its r3 and r4 revoke follows mos-1124 d with Q-S5 answered); no requirement text changes 2026-10-20 17:34:22 -05:00
jason.woltje de928da2dc honker: row 194 S5 webui: Settings' Agents section (agents.js) with launch, terminate with a reason, regenerate and delete behind a second click, keep, unkeep, no-hire and hire, each request followed to its close and nothing retried; serve /api/agent/launch, terminate, regenerate and request, /api/instances op delete answering the request, the tester listener limited to launch and terminate and refused a delete without a call; the CLI agent verbs; the no-route note in the section 2026-10-20 17:33:11 -05:00
jason.woltje 781d91624a honker: PRD 1.8 draft r4: Mos's rulings on duckworth-0057 (mos-1129, sage-1538): B1 in Duckworth's text, B2 his second option (only an Approve creates a grant, a hand-written grant makes nothing, row 210's hand grants a test path until #436a, the hand path out), N1 the target or its enrolled identity, N2 a revoke during creation leaves nothing usable, N3 old or field-less credential.mint makes nothing, N4 one credential.mint decision that shows the account name, N5 the arbiter route for host.sudo, N6 messaging lists and role contracts, C1 account.create as in 1.7; the B7 residual and the root-command change named for Jason 2026-10-20 17:32:14 -05:00
jason.woltje d16ccdb910 honker: PRD 1.8 draft r3: Duckworth's C1 (the chief-only raise and the makes-nothing rule apply only to an account.create that carries a grant method, a PM's service sign-up routes as in 1.7), N1 (a hand-written grant is held to the same bounds at load, rotate-by included), N2 (a revoke never rewrites the user's file); Gizmoduck's enrollment wording (mos-1120, mos-1125) and the Q-S5 revoke note 2026-10-20 17:27:12 -05:00
jason.woltje 60f5ca8fa8 honker: PRD 1.8 draft r2: Mos's edits a-f (mos-1124, sage-1534): account.create with Q190 quoted, the chief-only reading put to Jason at approval, revoke by the assistant in its own organization and the tester on a dev instance, Approve is mosaic decide until #412, REQ-WEB-2 stays with #266, the Gitea and GitHub units to a later design; Duckworth's B1 (an unfinished grant undone on the stack side, the rest pending) and B2 (Approve or the user's own business file), N1-N6 2026-10-20 17:24:21 -05:00
jason.woltje 3c30f1036a honker: PRD 1.8 draft for Vikunja #434 (mos-1117, sage-1525): trusted actions within role guardrails, the manual-mode action list, grant requests the chief of staff raises and the user approves, the grant methods, the user's tokens, REQ-CRED-4 generalized from row 210, instant revoke, Q188 quoted as the source 2026-10-20 17:16:42 -05:00
jason.woltje 2ad8c4f5b0 honker: row 194 BUILD.md: sage-1521 rulings (route gap (b), exit 3 stands, S3a choices stand, roles/chief-of-staff.md flagged as policy), rebase onto row 192 as honker/row194-b2 2026-10-20 17:05:38 -05:00
jason.woltje 29cb93aa6a honker: row 194 S4 harness: the launch tool takes a role instance name with the id pattern instead of the start-time enum, so the chief launches its own hire in the same session and the host checks the listing, the T8 end to end through hire, the human route step, launch and fire, the chief contract names the route step 2026-10-20 17:03:48 -05:00
jason.woltje 0622680756 honker: row 194 S3b cli: business instance launch, regenerate, terminate and delete with --wait polling instance.request, business veto, the instances read gains running, every lifecycle outcome but done exits 3 with the request id, the CLI end to end through the pass, T2a, T4 unbound and mismatched runs, T6 evidence bytes, T11 two roots and two businesses on one bus 2026-10-20 17:03:48 -05:00
jason.woltje 8ddd8ada58 honker: row 194 S3a cli: the launcher's lifecycle pass takes, acts on and closes the human's launch, end, regenerate and delete requests (D1.1, T2-T5, V5 rename and retry), human launches of unlisted instances, regenerate-timeout, --no-launch closes requests launches-disabled, the retry answers the failed delete's transaction 2026-10-20 17:03:48 -05:00
jason.woltje eecb77b549 honker: row 194 S2e: role.hire and role.fire routed and within-role on the chief under PRD 1.7 REQ-COS-3 (sage-1471, mos-1089), reason form with no decision, the typed hire and fire tools, unhireableAction moved to vocabulary.mjs so the sandboxed runner loads broker.mjs again (S2d correction) 2026-10-20 17:03:48 -05:00
jason.woltje d46f0aa6de honker: row 194 S2d bus: the chief's instance.hire and instance.fire through a resolved decision raised with the call's arguments, the hire checks against the role files (W-N2, M60), fire steps A, B, C with kill: true, W-N1 instance-firing at the verb, the take and role.launch (D2.1-D2.3, T8-T10, T9c, T18) 2026-10-20 17:03:48 -05:00
jason.woltje 56af37187e honker: row 194 S2c bus: lifecycle requests for launch, regenerate, delete and end, the take, done and delete host ops, terminate revokes and cancels, V5 delete order with instances.check, the retry after workspace-rename-failed, tester launch and end (D1.1, V2-V5, T6 bus part, T12, T12a) 2026-10-20 17:03:48 -05:00
jason.woltje f953b1d383 honker: row 194 S2b bus: veto.set human-only through the writer, LIFECYCLE_HUMAN_VERBS and the D4 hook, keep refuses the reason end and the chief's model write, tester delete refused, human delete held until the request (V6, D2.4, D4, T10, T12, T13, T18 part) 2026-10-20 17:03:48 -05:00
jason.woltje b149ba3e04 honker: row 194 S2a bus: live reload after every committed organization write, reloadBusiness, instance-running on a definition change, restart only when boot-fixed references, roots or bots differ (D3, T14, N4) 2026-10-20 17:03:48 -05:00
jason.woltje a3a378204d honker: row 194 S1 business: role.hire and role.fire hard waits, HIRE_ALLOWED and hireable, keep/noHire/retired, instance delete, veto, hire and fire writer ops (T6 business part, T7, T8c, T17) 2026-10-20 17:03:48 -05:00
jason.woltje 8de42b55b0 honker: row 194 design r4 (webby-0010/0011: HIRE_ALLOWED allowlist, decision field compare, fire order store-first; sage-1380: fire SIGKILL via row 191 stop; megavolt-0008: REQ-LAND-2 oid binding, row 199 r4) 2026-10-20 17:03:48 -05:00
jason.woltje 4641555b0a honker: row 194 design r3 (sage-1374, sage-1377: Q4 hireable stays empty, instance.name on the hire floor, fire ends runs F4) and PRD 1.7 draft REQ-LAND-2 git.push.remote for coder 2026-10-20 17:03:48 -05:00
jason.woltje 9619734322 honker: row 194 design r2 (webby-0003 B1-B8, Q2, N1-N8; Mos mos-1017 hard waits, hire floor, per-agent forbid) and PRD 1.7 draft 2026-10-20 17:03:48 -05:00
jason.woltje e95c917a14 honker: row 194 design r1b, sage-1355 rulings (tester verbs, bounded O1, credentials row) 2026-10-20 17:03:48 -05:00
jason.woltje f39f518d0d honker: row 194 design, agent lifecycle and per-agent veto (#1692) 2026-10-20 17:03:48 -05:00
146 changed files with 5633 additions and 7496 deletions
+6 -11
View File
@@ -25,15 +25,10 @@ green at every step. Not production software — a proven foundation.
1. **Root is bootstrap-only.** First-class system configuration lives at the
repository root; everything else gets a dedicated directory (`roles/`,
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
2. **Configuration**: each stack instance has exactly one system config. The
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
that names it. `~` is the home of the OS user that runs the instance. A
process reads only the config `MOSAIC_CONFIG` names, or the default's when
that is unset. Configs are created only by
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
any problem. Repo-scoped role authority lives in `roles/*.json`
(versioned, reviewed commits only).
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
config — created only by `scripts/bootstrap.sh`, never overwritten,
fail-closed on any problem. Repo-scoped role authority lives in
`roles/*.json` (versioned, reviewed commits only).
3. **Secrets** never enter the repository or container images; auth is
runtime-only (read-only mount or environment variable).
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
@@ -182,8 +177,8 @@ Full reference — usage, fields, exit codes, safety notes:
## Data map (canon)
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
per named instance: system config (user-authored; never auto-written).
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
auto-written).
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
- `sessions/` — pi JSONL session trees, one directory per named session
+476
View File
@@ -0,0 +1,476 @@
# Row 194 build notes
Design r4 approved: DESIGN.md sha256 824ef884 at 7d0788236 (Webby webby-0014, review at e1e64ff01 on
webby/reviews; Mos mos-1042; Sage sage-1387 "build now"). Build base: refactor aa1bd209e merged into
honker/row194 at fa9eaa4c3.
## Build notes from the approval (each gets a test and a mutant)
- W-N1 (webby-0014 N1): launch refuses `instance-firing` while I has a `fire.stopped` with no `fire.done`.
Test case in T9c. Mutant M59: the check is dropped.
- W-N2 (webby-0014 N2): the broker's hire-time check requires roleVersion 2, so a definition with no
action lists refuses. Test case in T8b. Mutant M60: the version check is dropped.
- Grace (sage-1387): 5 s, one constant shared with row 191.
## Chain state (2026-10-20T19:45Z)
Rows 190, 191 and 193 have no product commits yet (rocko/row191 and darkwing/row190 sit at da230ca48, the
brief commit). Slices that don't need row 191 build first. S6 waits for row 191's candidate.
## Slices
| Slice | Scope | Tests | State |
|---|---|---|---|
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) and S2e (PRD 1.7 routing, sage-1471) done |
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) and S3b (verbs, veto, end to end, T2a, T4, T6, T11) done |
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | done, see S4 notes |
| S5 | webui: Agents section and routes | T16 | done, see S5 notes. The "still running after fire" flag waits for S6 |
| S6 | row 191 chain: halted checks, fire SIGKILL through row 191's stop | T9a, T9b, T15 | waits on row 191 |
## Changed assertions (D3 item 4, N4)
Listed here as the build changes them.
- `packages/business/tests/instance-settings.test.mjs:53, :67, :72`: the row 165 read and add views
gain `kept: false` (D1, the read gains `kept` per instance). No other field changed.
- `packages/business/tests/instance-settings.test.mjs:70` (`added.restart` true to false) and `:91`
(`redefined.restart` true to false): the writer no longer asks for a restart. The bus decides,
after its reload (D3 items 2-3). The two test names say so.
- `packages/cli/tests/instances.test.mjs`: `setup` boots the bus from the file through `bootConfig`, as
the unit does, instead of a one-role stub. The add test (T1) now expects the "no restart" line and
binds `sec-1` on the running bus. The `RESTART_NEEDED` text pins moved, unchanged, to a new test that
boots the old stub (`setup(t, { stub: true })`). Its coder has no credential references and the
file's has, so the bus doesn't reload, prints `RESTART_NEEDED` and doesn't bind `sec-1` (D3 item 3).
- `packages/webui/tests/instances.test.mjs:25`: the fake writer's default answer is `restart: false`,
the usual answer now. The real-broker test boots through `bootConfig` and expects `restart: false`
(`:71`) and the log line `done restart=false` (`:74`). The answer-shape rows at `:140-145` keep
`restart: true`. Each row tests another field, or `restart: 'yes'`, so the value is not what they
check.
- `packages/bus/tests/instances-verb.test.mjs` T12 (S2c): the human delete now answers `{request}`
where S2b refused `invalid-request`. The stub writer answers `instances.check` with the revision,
and the calls list gains that one check, actor `jason`. Nothing else in the test changed.
- `packages/bus/tests/tester-writes.test.mjs:19` (row 175 pin): TESTER_VERBS gains `instance.launch`,
`instance.end` and `instance.request` (D4.1). TESTER_ORGANIZATION_VERBS is unchanged.
- `packages/bus/tests/instances-verb.test.mjs:78` keeps `restart: true`, now for a D3 item 3 reason:
its boot copy is a stub without the file's credential references. A comment there says so.
`reload.test.mjs` covers the reload from a file boot.
- `packages/bus/tests/hard-waits.test.mjs:38` (S2d): the every-hard-wait loop gives `role.hire` and
`role.fire` a target and `arguments`, which their raise now requires (D2.1). The class and route it
checks are unchanged.
- S2e (PRD 1.7 REQ-COS-3) takes back the S2d entry above. `role.hire` and `role.fire` left the hard
waits, so `packages/bus/tests/hard-waits.test.mjs` and `packages/business/tests/hard-waits.test.mjs`
drop them from WAITS, and the bus file's every-hard-wait loop is back to its S2c form. The business
file gains a test that both are routed and not in GATED_ONLY.
- `packages/bus/tests/reload.test.mjs` (S2e): its hard-wait example used `role.hire`, now routed. It
uses `spend`. The check is unchanged.
- `packages/business/tests/chief-of-staff.test.mjs` (S2e): the chief's pinned authority and the test
name gain `role.hire` and `role.fire`. `packages/business/tests/role.test.mjs` gains the check that
no other shipped role holds either.
- `packages/harness/tests/tools.test.mjs` and `packages/harness/tests/bundle.test.mjs` (S2e): the
chief's pinned tool lists gain `hire` and `fire`. Every held action needs a typed tool
(`untypedActions`), so the tools come forward from S4.
- `packages/bus/tests/chief-of-staff.test.mjs` (S2e): the S2d decision-form tests are rewritten to the
reason form (below). The S2d raise tests with `arguments` are gone with the key.
- `packages/bus/tests/lifecycle-verbs.test.mjs` V5 retry (S3a): `lifecycleDelete` on a retry now
answers `{transaction, retry: true}` with the failed delete's transaction, where S2c answered
`{transaction: null, retry: true}`. The launcher needs it to name `.retired/I-<transaction>`. The
test checks the value and that the retry still neither checks nor writes the file.
- `packages/harness/tests/tools.test.mjs:48` (S4): the launch tool's `instance` was an enum of the
start-time listing. It is now `{type: "string", description, pattern: ID_PATTERN.source}`, and the
listing moves to the tool description, which the test matches. `:107` (the pm test's chief check)
asserts there is no enum and the description names `chief.launch.instances`.
- `packages/business/tests/chief-of-staff.test.mjs` (S4): the contract test gains a match on the new
sentence that a hire is listed for `launch` at once but runs only after the human sets its route.
- `packages/webui/tests/browser.test.mjs:21-22` (S5): the page's script list pins the scripts in
order. It now has `/agents.js` between `/chat.js` and `/bus.js`, and the comment names row 194.
## S1 notes
- Tests: `tests/lifecycle-settings.test.mjs` (new) holds T6's business part (V5 order, last listed,
clean delete, retired refusals for add and hire), T7 (V6) and the writer's hire and fire (D2.2).
T8c is in `role.test.mjs`, T17 in `business.test.mjs`.
- `deleteRefusal`'s entry half (`entry.tracker`, `entry.credentials`) follows V5's wording "holds
credentials or a tracker bot". In a file that validates it is the same as the definition check: a
definition with no credential refuses an entry tracker or credentials at load. So no test reaches it
alone, and no mutant is listed for it.
- END_PROTECTED is `assistant` only. The chief's own instance refuses delete and fire as
`organization-instance-referenced` (launch.by), not as protected.
- The writer refuses an `assistant` hire `organization-definition-refused` through `definitionFor`, as
row 165's add does.
- PRD draft: 69544f342 (sha256 c1f52d98…), sage-1395: adding to the fixed list is a PRD change.
## S2a notes (D3 live reload)
- `Broker.reloadBusiness(id, doc)` runs the boot check (`checkBusiness`, moved out of the
constructor unchanged) on a clone, then refuses a changed `human` and swaps the document in.
- The runtime reloads after every committed write, through the chain `bootConfig` uses
(`loadBusiness`, `resolveInstance` with `organization.system`, `busBusiness`). Then `limits.refresh`
runs. `instances.set` answers `restart: !live`. Any other verb name now throws `invalid-request`
where it used to fall through to `plugins.set`.
- D3 item 3 check: a role in the boot copy must keep its credential references and tracker bot. A role
not in the boot copy must have neither. The tracker sync block must match. Otherwise no reload and
`restart: true`. The first version compared one fingerprint over all roles, so every create failed
it. T14's create test caught that.
- Consumers checked against item 3 (`runtime.mjs` startBroker):
- The Credentials store takes the role and sync references and the project roots it protects at
start. The tasks adapter takes the bots at start. All of these are compared.
- Limits read `settings.current` at each ask. The human proof reads `human` from the boot copy,
and `reloadBusiness` refuses a changed human, so the two agree.
- Web grants: the broker reads `roles.<I>.web` from its own document (`broker.mjs:1545`), so a
reload carries the grant. A hireable definition has no `web` tool.
- Usage loads its `usage-source` plug-ins from `b.plugins` at start. That was already so before
this row (`plugins.set` never restarted them). A reload doesn't change it, and it isn't compared.
- Instance-running: `instances.set` op change with a `definition` on a declared instance with a
claim holder (or thread holders for a thread instance) refuses `instance-running` before the
writer. A vars or launch change still passes.
- Tests: `packages/bus/tests/reload.test.mjs` (T14, 6 tests).
## S2b notes (veto.set, the D4 hook, keep checks)
- `LIFECYCLE_HUMAN_VERBS` (exported from `broker.mjs`) has one entry per verb and per `instances.set`
op: `instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`, `instances.set add`,
`instances.set change`, `instances.set delete`. Choice: `#mayHuman(s, verb, target)` refuses
`human-required` for an entry missing from the list. So the list and the calls can't drift apart,
and T12 shows the hook got the exact entry: each listed human call passes. The tradeoff is that a
new human verb has to join the list before it works, which is what row 193 needs anyway.
- `bindHuman` already refuses any human but the owner, so the hook's owner check can't fail on any
path today. It is the body row 193 replaces.
- M46 (`veto.set` skips `#human`) is equivalent while the generic check runs first: no session
without `s.human` gets past `human-required`, and only `bindHuman` sets `s.human`, always with
`via: 'cli'` and `outsideAgent: true`. T18 shows the refusal. `#human` stays as the design asks.
- `instances.set` now checks `op` in the broker (`add`, `change`, `delete`, else `invalid-request`).
The tester's delete refuses `tester-refused` (B6). Until S2c records the delete request, a human
delete refuses `invalid-request` after the hook. Without that, the writer's `set` op `delete` would
commit with no running or starting check and no workspace rename.
- Keep: the `role.revoke` reason branch refuses `instance-kept` for any caller, not only the chief.
Only the chief holds `role.end` today, so this is the same set. A caller with `role.end` added
later is held to the forbid too. The `organization.set` check refuses when any key of
`args.instances` is kept, before the writer and before the grant is spent. The human's `limits.set`
isn't checked: the human owns the forbid.
- T13 needed no code. `organization.set` and `limits.set` take only `ORGANIZATION_KEYS`, and
`limits` and `models` write only `launch.limits` and `launch.models`.
- Tests: `chief-of-staff.test.mjs` T10 (reason end, decision end, model write), T13 and T18's
veto.set and instances.set paths on a stub writer. T18's file-bytes check and its hire and fire
cases come with S2d. `instances-verb.test.mjs` T12 (sessions, tester, hook). `reload.test.mjs`
gains veto.set.
## S2c notes (lifecycle requests, terminate, delete)
- A request is an `action.allowed` event, action `lifecycle.launch`, `.regenerate`, `.delete` or
`.end`, body `{action, request, via, by, ...}`. The human's also gets `human.input {kind: 'admin'}`.
The tester's gets none. Steps are `lifecycle.taken`, `lifecycle.done {request, run?, transaction?,
refusal?}` and `lifecycle.cancelled {request, by}`, with no role or run in the session. No new
table or event kind, so no migration.
- Host ops for the launcher pass (S3), each in LAUNCH_OPS: `lifecycleRequests`, `lifecycleTake`,
`lifecycleDelete`, `lifecycleDone`. Choice: `lifecycleDelete` commits through the bus's own
organization callback, so the D3 reload runs after it as after any other write. The tradeoff is
one more host op instead of the launcher calling the writer.
- The take re-checks: request-expired (60 s, `LIFECYCLE_REQUEST_MS`), then launch-revoked and
instance-retired for launch and regenerate only, then tester-off for a tester request. A refused
take writes `lifecycle.done` with the refusal, so the request closes. `lifecycle.end` never
expires: the runs it names still need the signal.
- instance-starting counts any taken request of I, and any open one still under 60 s, delete
included. An open request past 60 s can only close request-expired, so it doesn't count. The end
request doesn't count.
- Delete order at the verb (V5): keys and identifier, the 64-hex revision, the retired-name retry
gate, then `instances.check` (the writer's plan and validation, no write). A named organization
refusal fails at once. A BusinessError (the last-listed refusal) is held until instance-running
and instance-starting have passed, then answers invalid-request. Then revision-conflict, then the
request with `{revision}`. `lifecycleDelete` checks running and starting again after the take
(other requests only), then calls `instances.set` op delete with actor = the request's `by`.
- Retry: a retired name that isn't in roles deletes again only when its last delete closed
`workspace-rename-failed`. The request carries `retry: true`, and `lifecycleDelete` answers
`{transaction: null, retry: true}` with no check and no write. The launcher (S3) retries the rename.
- `lifecycleDone` carries `transaction`, so the delete's done names the commit the rename used.
- Terminate (V3): every claim holder of I, or every thread holder for a thread instance, is revoked
with the reason (default "ended by the human" or "ended by the tester"). The answer names the held
runs plus the bound runs of I not yet ended, cancels open requests of I and lists taken ones. With
none of these it refuses not-held. The tester ends only its own threads and cancels only its own
requests.
- The tester's business scope needs no check: its cap is bound to one business (M19), so another
business's instance answers unknown-role. `instance.request` needs no tester-file check: a tester
reads only requests it made.
- The tester switch is read through a `testerOn` option. The runtime reads `bus.tester` from the
live file through `settings.current`, so a change applies with no restart. Anything but `true`,
or a read error, is off.
- Refactor: the organization error mapping moved into `#organizationFail(e, verb)`, shared by the
organization verbs and `lifecycleDelete`. Same mapping as before.
- Deferred: the halted checks (S6, row 191) and W-N1 (S2d with fire).
- Tests: `packages/bus/tests/lifecycle-verbs.test.mjs` (12 tests: T12 lifecycle part, T12a, V2-V5,
the take and the runtime delete end to end). Probe mutants on the expiry, starting (verb and
take), the take's expiry and tester checks, the retry gate, the tester's own-request read and
not-held all died. They are not the M-list run.
## S2d notes (the chief's hire and fire, W-N1)
- Still hard waits (PRD 1.6). Under PRD 1.7 they move to ROUTED in S2e, with the reason form.
- The raise: `decision.raise` takes `arguments`, only for `role.hire` (`{name, definition, vars?}`)
or `role.fire` (`{name, reason}`), and requires it for those. `target` must equal
`arguments.name`. The raise event records it, and the human's decision view shows it under
`authorization.arguments`. The shape checks are the call's (`#hireFireArgs`): identifiers, not
`human` or `tester`, vars only `model` and `limits.*`, reason 1-500 characters.
- The call: `instance.hire {name, definition, vars?, decision}` and `instance.fire {name, reason,
decision}`, from a chief session only. Order: tester-refused, read-only and agent-required
(`#agent`), keys, `role-not-chief` (launch.by), not-configured, decision-mismatch for a decision of
another business, `#checkAuthority`, the call's arguments against the raise's (canonical JSON,
decision-mismatch), decision-consumed, instance-kept. The writer gets the chief as actor
(`chief-of-staff.chief`) and the context `{instance, run, decision}`, which changes.jsonl records.
- Hire: `#hireable` loads the definition and the chief's own role file through the new `roleFile`
option (the runtime passes `loadRole(rolesDir, name)`). Version 1, any action off HIRE_ALLOWED
(`unhireableAction`), not on the chief's `hireable` list, on `noHire` or no file all refuse
definition-not-hireable before the writer. Then the writer's hire, then the consume with
`{transaction}`. Without `roleFile` a hire refuses not-configured.
- Fire, before step A: instance-kept, unknown-role, organization-definition-refused (END_PROTECTED),
organization-instance-referenced (launch.by), and invalid-request for the last listed name.
Step A, one transaction: revoke the claims with the decision and reason, cancel open requests,
list taken ones, write `lifecycle.end {via: 'chief', by, runs, reason, kill: true}` when runs exist,
and `fire.stopped {cites, request}`. Step B: the writer's fire (already delisted is done). Step C,
one transaction: the consume with `{reason, transaction}` and `fire.done {cites, transaction}`.
- The fire's own events cite the decision under `cites`, not `decision`, so `#spent` counts only the
consume. A failure at B or C leaves the decision unspent, so the repeat runs.
- Choice: a repeat after a failed B or C asks for the kill again for any run of I still bound. A
revoked run stays bound until the launcher reports it ended, and a second signal to a dead
process does nothing. The tradeoff is a duplicate `lifecycle.end` against a run that may outlive
the first one.
- W-N1 `#firing`: a `fire.stopped` with no `fire.done` for the same `cites`. It refuses
instance-firing at the human and tester verbs (`#launchable`), at the take of a launch or
regenerate (`#takeRefusal`), and at the chief's `role.launch` (`#authorityFor`).
- `#organizationFail` passes the writer's own definition-not-hireable and instance-kept through by
name for hire and fire. They come from the file as it stands, after the broker's checks passed.
- `lifecycleView` shows `kill: true` when the request carries it.
- Deferred: the kill escalation in the launcher (S3, S6).
- Tests: `packages/bus/tests/chief-of-staff.test.mjs`, 16 new (T8 x5, T8a, T8b x2, T9 x2, T9c x2,
T10, T18, the runtime end to end, W-N1 at the take). 39 in the file pass. Probe mutants on 21
lines (the arguments check, each W-N1 site, M60's version check, keep, the other-business check,
last-listed, kill, the chief's list, noHire, role-not-chief, the pre-check consume, both consumes'
events, the raise's argument rules, the revoke, the cancel, the pass-through, the view's kill) all
died. They are not the M-list run.
## S2e notes (PRD 1.7 REQ-COS-3 routing, sage-1471, mos-1089)
- Role-policy change, for Webby to review on its own: `roles/chief-of-staff.json` withinRole gains
`role.hire` and `role.fire`. `packages/business/src/vocabulary.mjs` and `packages/bus/src/broker.mjs`
move both from the hard waits to ROUTED. `roles/chief-of-staff.md` and `roles/pm.md` drop them from
the hard-wait list, which `workflow-roles.test.mjs:113` pins to GATED_ONLY. No other shipped role
gains either action, and `role.test.mjs` pins that.
- The call takes the reason form, as `role.end` does: `instance.hire {name, definition, vars?, reason}`
and `instance.fire {name, reason}`, with no decision. The reason is 1-500 characters for both.
- Choice: the decision form is gone, not kept beside the reason form. A within-role raise closes at
once, so a decision there adds an event and no check. The `decision.raise` `arguments` key from S2d
goes with it. Tradeoff: if a later PRD gates hire again, the raise path comes back as new code.
- Choice: hire takes a reason too. The PRD logs every hire and fire with its reason.
- Order: tester-refused, read-only and agent-required, keys, `role-not-chief` (launch.by),
not-configured, `#checkAuthority` with `{target: name}`, instance-kept. `role-not-chief` comes before
the authority check, so a non-chief role refuses `role-not-chief` even when its file grants the
actions. A chief whose file lacks the grant refuses `decision-required`.
- The writer gets only its own keys. The reason goes in the change context `{instance, run, reason}`,
which changes.jsonl records. The hire's consume detail is `{reason, transaction}`.
- W-N1 `#firing` now goes by order, since the reason form has no decision id: a `fire.stopped` of the
subject with no later `fire.done` (`seq` after it) for the same subject. `fire.stopped` carries
`{action, request}` and `fire.done` `{action, transaction}`.
- Harness: the typed `hire` and `fire` tools (verbs `instance.hire` and `instance.fire`) come forward
from S4, because a held action with no typed tool fails `untypedActions`. Each tool follows its own
grant. `roles/chief-of-staff.md` already names them. The rest of the S4 contract text and the runner
end to end for hire and fire stay in S4.
- Going live is a later re-pin step with Mos (sage-1471). Landing the change doesn't put it on the live
stack.
- Correction to S2d (found in S2e): S2d's broker imported `business/src/role.mjs`, which is not in
`seat/src/sandbox.mjs` RUNNER_FILES. The runner imports broker.mjs, so a session died with
ERR_MODULE_NOT_FOUND: cli `launcher.test.mjs` failed 15 of 29 and seat `sandbox.test.mjs` 4 of 10 at
089b92ca3. A bisect showed S2c passes 29 of 29. I had not run those two files for S2d. Fix:
`unhireableAction` moves into vocabulary.mjs, which is mounted, and role.mjs re-exports it.
RUNNER_FILES is unchanged, so the sandbox boundary does not widen. Rule from here (sage-1497): a
change to broker.mjs imports, or those of any RUNNER_FILES file, runs these four singly: seat
`sandbox.test.mjs`, cli `launcher.test.mjs`, cli `thread-launch.test.mjs` and cli
`git-socket.test.mjs`.
- Mutant (sage-1497): broker.mjs imports `unhireableAction` from role.mjs again. Killed: sandbox.test
fails 4 of 10 (role.mjs outside RUNNER_FILES), launcher.test 15 of 29, thread-launch 7 of 11. git-socket
passes 24 of 24 under it, so it does not catch this one and stays on the list for its own sandbox runs.
- Tests: `packages/bus/tests/chief-of-staff.test.mjs` S2d block rewritten to the reason form (39 pass),
with new tests for the reason bounds, `role-not-chief` with the grant, `decision-required` for a chief
without each grant, W-N1 by order, and the runtime end to end with the reason in the receipt context.
`packages/harness/tests/tools.test.mjs` gains the hire and fire tool shape test, and that no coder,
reviewer or pm gets either tool.
- Probe (not the M-list run): 16 mutants, all killed. ROUTED drops of each action in the broker and in
vocabulary, the role file drops of each, `#firing` without the order and without the subject, the
reason unchecked, the writer given the reason, the context without it, no `#checkAuthority`, no
`role-not-chief`, the consume without the reason, the hire tool's reason optional, and the fire tool
gated on the hire grant. The last one survived at first and a one-grant check in tools.test killed it.
## S3a notes (the launcher's lifecycle pass)
- `packages/cli/src/launcher.mjs` `lifecyclePass` runs on the tick after the thread and land passes.
It lists the open requests (host op `lifecycleRequests`) and carries each under `serialize`: the
take, the act, then `lifecycleDone`. A take that answers no closes nothing more here. A refusal
goes in the launch log as `refused` with the request id. A done that throws leaves the request
taken with no done, which S3b's CLI reports as `outcome-unknown`. The log line names a run that
did start.
- Launch: `check()` and `start()` gain `human`. The human's request may name any declared instance,
as V2 asks. The listing limits the chief. launch.by still takes the `pm` branch (N8). A tester
request launches with `launchedBy: "tester"`.
- End: SIGTERM to each run the verb named and to any other run of the instance in the registry,
each after the start-time check, with a `lifecycle-end` entry. The exit handler writes the one
`end` record. A thread instance's end signals only the runs the verb named, so the tester's end
leaves the human's threads alone. No SIGKILL escalation here: that is row 191's stop (S6).
- Regenerate: the end's signals, then a wait until the old run leaves the registry, then a launch.
The wait runs under `serialize`, so no other start runs meanwhile. Past the request's mark
(`at` + 60 s, `lifecycleRequestMs` for tests) it closes `regenerate-timeout` and starts nothing. A
closing host closes it `host-closing`. Tradeoff: one stuck regenerate holds other starts for up to
60 s. The other choice, a second pass that comes back for it, needed state across ticks for the
same result.
- Delete (V5 steps 1-3): `instance-running` against the registry first, then `lifecycleDelete` (the
writer's commit), then the rename of `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<tx>/`,
both 0700. The file goes first, so a failed rename can't leave a launchable name on the old
workspace. A failed rename closes `workspace-rename-failed` with the transaction. A later delete of
the name is the retry, and the broker now answers the failed transaction, so the rename target is
the same (changed assertion above). No workspace means nothing to rename.
- `--no-launch` (row 163): the ticker now runs on a no-launch host, but only the lifecycle pass, and
`act` closes each request `launches-disabled` before any signal, start or rename. The thread and
land passes stay off, so `launcher.test.mjs`'s no-launch tests pass unchanged.
- Tests: new `packages/cli/tests/lifecycle.test.mjs`, 9 tests on the in-process host of
thread-launch.test.mjs, with ticks called by the test: T2 (unlisted and launch.by launches, a
request past 60 s, a check refusal with its code in the log), T3 (`--no-launch`, `capacity-full`
with the limit in the log, `instance-running`), T4 (end at the verb, the signal, one end record,
the cancelled request never runs), T5 (a new run on the same workspace, `regenerate-timeout`
with a SIGSTOPped runner, `regenerate-thread` at the verb), V5 (a failed rename then the retry,
modes 0700, no workspace).
- Singles at S3a: seat sandbox 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24,
bus lifecycle-verbs 12/12, cli instances 6/6, cli lifecycle 9/9, bus process 8/8. broker.mjs
imports did not change, and the four must-run files ran anyway.
## S3b notes (the CLI verbs)
- `packages/cli/src/instances.mjs`: `business instance launch|regenerate BUSINESS I [--wait S]
[--tester]`, `terminate BUSINESS I [--reason TEXT] [--tester]`, `delete BUSINESS I --revision REV
[--wait S]` and `business veto BUSINESS keep|unkeep INSTANCE|nohire|hire DEFINITION --revision
REV`. `--wait` takes 0 to 600 s and defaults to 30. Every usage error exits 4 before any bus call.
add and set refuse `--wait` and `--reason`.
- Launch, regenerate and delete print the request id on stderr, then poll `instance.request` every
200 ms. Closed with a run or a transaction prints it and exits 0. A refusal prints its reason, the
request id and any transaction. A cancel prints who cancelled it. Still open at `--wait` says so and
names the 60 s expiry. Taken with no done is `outcome-unknown` and points at the launch log.
Terminate doesn't wait: it prints the revoked runs, the cancelled requests and any taken request
past cancelling.
- Exit codes: every lifecycle outcome that isn't done (refusal, cancel, still open, outcome unknown)
exits 3, as the design's `--wait` timeout does. Tradeoff: errors.mjs has 1 for an unknown outcome
elsewhere. One code lets a script treat "the request did not do it" as one case, and the message
says which. A refusal at the verb keeps the transport's code (2), as add and set do.
- The bus `instances` read gains `running` per instance (`#running`, from the claims), so the CLI
and S5 can show it without a second verb. An answer with no instances map passes through as
before. `bus instances-verb.test.mjs`'s stub writer answers `{ok: true}` and caught the first
version, which threw. The CLI view marks `kept` and `running r1, r2` and adds `retired:` and
`the chief may not hire:` lines.
- `scripts/mosaic` sends `business veto` to packages/cli with instances and instance.
- Tests: cli `instances.test.mjs` +8: the V6 veto ops and kept-delete refusal, the `running` read,
launch and regenerate printing the run, a closed refusal with its id (exit 3), `--wait 0` still
open and taken `outcome-unknown`, request-cancelled, terminate output and `not-held`, delete to
its transaction and `workspace-rename-failed` naming it, usage errors (exit 4, no call), and the
scripts/mosaic veto route. The existing route test gains the veto assertions. cli
`lifecycle.test.mjs` +5: the CLI end to end through the pass (launch, regenerate, `capacity-full`
with the request id, terminate, delete), T2a (`launch-revoked` at the take with no process, a
done that fails once leaves the request taken and the next pass starts nothing), T4 (a spawned
run the bus never bound is signalled, a mismatched start time is not), T6 (run evidence bytes
unchanged by a delete, the launch log only grows, a create and a launch of the retired name
refuse), T11 (two roots with business `acme`: a launch, a terminate and a delete in one leave the
other's files byte-identical). bus `chief-of-staff.test.mjs` +1: T11's bus half, a chief on a bus
with two businesses hires and fires only in its own.
- Not covered here: T6's `instance-starting` and the V5 refusal order are bus tests from S2c.
`check()` refusing a retired name is shown through the verb and the take, since `check()` reads a
file that no longer lists the name.
- Singles at S3b: cli instances 14/14, cli lifecycle 14/14, bus lifecycle-verbs 12/12, bus
instances-verb 3/3 (after the fix), bus chief-of-staff 40/40, webui instances 5/5, seat sandbox
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, usage 2/2, project 4/4, app
30/30, host 42/42, tester-threads 5/5, tester-reads 6/6, stack-copy 12/12, queue dispatch 4/4,
runs task-cli 9/9, webui pinned-copy 10/10, service 31/31, views 3/3. The broker change adds no
import. The four must-run files ran while the `instances` guard was being written, and the guard
touches only the read. No test pins a hash of scripts/mosaic.
## S4 notes (the launch tool takes a hire, D3 item 5)
- `packages/harness/src/tools.mjs`: the launch tool's `instance` is a string with the ID_PATTERN
pattern, not an enum fixed when the bundle was built. The start-time listing stays in the
description. The host checks the listing at each launch (`instance-not-listed`), so the chief
launches its own hire in the same session. The hire and fire tools came forward in S2e.
- The runner does not enforce a tool's schema pattern. A bad name reaches the host and refuses
`instance-not-listed`, so the end-to-end test asserts that. M15 (the enum kept) is killed by the
tools.test shape assertion, not by the end-to-end test.
- `packages/cli/tests/lifecycle.test.mjs` gains the T8 end to end: the chief's session hires
`sec-2` (definition chief-information-security-officer, the shipped definition `hireable` accepts:
message.send and record.write, no credential) with `TOOL hire`, a launch refuses `no-route`, the
human sets the route with `instances.set` op change, the same session launches it (launchedBy
chief), `NOT A NAME` and `reviewer` refuse `instance-not-listed`, then `TOOL fire` ends its run,
leaves it `launched: false` and a later launch refuses `instance-not-listed`.
- Route gap, raised with Sage (honker-0027): harness, model and thinking are agent-layer vars
(`business/src/vars.mjs:142-147`) and hire vars allow only model and limits.* (N3). So a hire runs
only after the human sets harness and thinking. D3 item 5 says the same session launches it, and
it does, after that human step. Letting hire vars carry the route would widen the chief's
authority, a policy change that is not mine to make. As built the human step stays, and
`roles/chief-of-staff.md` says so. Sage ruled (b) in sage-1521: the human route step stays, and
the manifest notes say that launch refuses `no-route` until the human sets the route.
- sage-1521 also keeps exit 3 for every lifecycle outcome but done (outcome-unknown included, the
S3b tradeoff line above stands for Webby) and the S3a choices (a `--no-launch` host runs only the
lifecycle pass, `lifecycleDelete` answers the transaction on a retry). `roles/chief-of-staff.md`
is role policy: the manifest names it and the review request flags it as a policy change.
- `roles/chief-of-staff.md` (role policy, named in the manifest): one paragraph on the hire's route
step and on a fired instance staying in the file off the list.
- Singles, each run alone: business chief-of-staff 10/10, spec-review-contracts 3/3, role 19/19,
resolve 10/10, harness bundle 22/22, tools 23/23, runner 34/34, pi-session 8/8, seat sandbox
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, lifecycle 15/15, webui reads
11/11. No test pins a hash of tools.mjs or roles/chief-of-staff.md. tools.mjs gains an import from
vocabulary.mjs, already in RUNNER_FILES, and the four must-run files passed after it.
## Rebase onto row 192 (2026-10-20T22:10Z)
Row 192 (4429a104f, Q175 phase 1 SSO) changed webui `serve.mjs`, `cli.mjs`, `app.js` and
`index.html`, the files S5 changes. Before S5 the branch was rebased onto origin/refactor
f41729178 as `honker/row194-b2`. `honker/row194` stays at f70b4da50 (no force-push). The only file
both sides changed was `PRD-1.7-DRAFT.md`, whose blob was the same on both (9bfc6f76), so its five
draft commits dropped as already upstream. The tree check: the rebased tip differs from f70b4da50
by exactly the base's changes. Every test file the branch changes, the four must-run files and
webui sso, service and reads were run again singly on the new base: 23 files, all pass.
## S5 notes (the Console's Agents section, D7)
- `packages/webui/src/public/agents.js` (new, `window.MosaicAgents.section`): Settings draws it after
the role instances, from the same instances read. The table has instance, definition, launched, kept
and running runs, and each row has Launch, a reason box and Terminate. A human also gets Regenerate,
Keep or Unkeep, and Delete. The protected assistant has no Delete. Each definition has a No-hire or
Allow-hiring button, and a Create form adds an instance of an accepted definition. Retired names show
as one line. D7's "retired" column is that line, since a retired name has no instance row.
- Regenerate and Delete take a second click. The confirm says what happens: for Delete, "Its evidence
and its workspace under .retired stay." Cancel asks nothing.
- Choice: every answer that names a request is followed at `/api/agent/request` every 2 s, up to 90
reads, until the request closes. D7 names launch and regenerate. Delete and terminate also answer a
request (S2c), so the page follows them too, and the receipt shows the close or its refusal reason.
A failed read is shown and the poll goes on. The request is never sent again. Past 90 reads the page
says the request is still open and asks for a fresh read. A sign-out stops the poll.
- With `send` false the section shows "Agent actions are unavailable here. Use mosaic business instance
from the instance CLI." and every control is disabled. The tester sees Launch and Terminate only.
- `serve.mjs`: `/api/instances` takes op `delete` with the revision and the instance alone and answers
the request. `/api/agent/launch`, `/regenerate`, `/terminate` and `/api/veto` each have
`instanceSend`'s boundary and one call to `agentWrite`, with only the route's keys. A reason is never
logged. `GET /api/agent/request?id=` takes one request id and adds the reason for a closed refusal or
a cancel. Every refusal code the S2/S3 verbs answer has a reason line (`AGENT_REASONS`), and a code
missing there shows as itself. No route has a `--send` exception.
- `cli.mjs`: `agentWrite` reaches only `instance.launch`, `instance.regenerate`, `instance.end`,
`veto.set` and `instance.request` (`agentCaller` throws for any other). The tester listener gets
launch, end and request only (D4.1).
- D4.1 at the webui: the tester listener lets launch and terminate through and refuses regenerate and
veto with no call. A tester delete on `/api/instances` is refused in `instanceSend` with no call
(`tester-refused`). The bus refuses it too (broker tester-refused), and this keeps the webui from
asking. The agents test covers it, and with the guard removed that test fails (control run).
- Deferred to S6: the "still running after fire" flag needs the `lifecycle.end.alive` event, which
comes with row 191's stop.
- Tests: webui `agents.test.mjs` (new, 8: actions and logs, boundary refusals, read-only, refusal and
unknown outcomes, the request read, delete, tester routes, agents.js served) and
`agents-browser.test.mjs` (new, 1: read-only, launch followed to close, confirms, terminate reason,
refusal, keep and unknown outcome).
- Singles, each run alone, after the last code change: webui agents 8/8, agents-browser 1/1, browser
1/1, instances 5/5, instances-browser 1/1, serve 4/4, service 31/31, sso 28/28, sso-browser 2/2,
routes 5/5, routes-browser 1/1, tester-listener 10/10, tester-browser 2/2, tester-cli 2/2,
settings-write 4/4, send 13/13, send-browser 2/2, shell 5/5, cli launcher 29/29, thread-launch
11/11, git-socket 24/24, stack-copy 12/12, cli stack-release 174/174, seat sandbox 10/10. No test
pins a hash of a file S5 changes. shell.test reads bus.js strings and its POST count, which S5 leaves
alone because agents.js holds the section's writes.
+707
View File
@@ -0,0 +1,707 @@
# Row 194 design: agent lifecycle from the Console and the CLI, with a per-agent veto
Brief: `docs/plans/2026-10-20_agent-lifecycle.md` (blob a907109409ac). Gitea #1692. MVP T1 item 5 (mos-0974,
mos-0977, DECISIONS Q176). Owner Honker, reviewer Webby, gate owner Sage. Base: refactor at 53bb59462.
Branch `honker/row194`.
Related designs read: row 190 `agents/darkwing/work/queue-190/DESIGN.md` (r2, after mos-0986), row 191
`agents/rocko/work/queue-191/DESIGN-r2.md`. Row 193 is still in design. All line numbers below are at
53bb59462.
## Summary
- One verb family on the CLI, `mosaic business instance <verb> BUSINESS INSTANCE`, and one Console section.
Create and delete change the business file. Launch, terminate and regenerate act on runs.
- Every human verb goes through the bus with the human proof. Launch, regenerate and delete are requests the
launcher takes once and carries out under `serialize`. Launches go through the one `start()`, so
`--no-launch`, the limits, `launch.revoke` and row 191's switch all apply.
- The chief hires (adds and lists an instance) and fires (ends its runs and delists it). It never deletes.
`role.hire` and `role.fire` are new hard waits, so Jason resolves each one (Mos, mos-1017). The routed
form waits on a PRD 1.7 draft in this packet.
- A hire allowlist in code: the chief can hire only a definition whose every action is on `HIRE_ALLOWED`
(message, record, review request, assigned-task updates, and two actions that matter after row 199).
Launch, end, organization, naming, hire, verdict, merge and every later action stay out.
- The per-agent forbid is two lists in the business file that only the human writes: `launch.keep`
(instances the chief can't fire, replace or end) and `launch.noHire` (definitions the chief can't hire).
- The broker reloads a business from its file after each committed write. Create, delete, hire, fire and
veto changes then count without a bus restart. Hand edits still need one, except `bus.tester`.
- No bus schema change and no migration. Named for review: two new vocabulary actions (hard waits), the
role-file `hireable` key, `"hireable": []` in `roles/chief-of-staff.json`, and three business-file keys.
## What exists today (checked at 53bb59462)
| # | Fact | Where |
|---|---|---|
| E1 | Instances are keys of the business file's `roles`. `launch.instances` lists those the chief may launch. The list must be non-empty | `business.mjs:121-132` |
| E2 | The human adds an instance and changes its definition, vars and listing. No delete. Credentialed definitions are refused | `instance-settings.mjs:1-5, 22-31, 75-156` |
| E3 | The broker copies the businesses once at start and refuses a role it doesn't know | `broker.mjs:208, 446` |
| E4 | The chief launches over `launch.sock`. `fromSession` checks identity, then `check()` (which reloads the file), then `authorizeLaunch`, then `start()` | `launcher.mjs:232-327, 624-668` |
| E5 | The chief's `launch` tool takes an enum of the instances listed when its bundle was built | `harness/src/tools.mjs:116-119` |
| E6 | The chief ends a run with `role.revoke` plus a reason (`role.end`). Ending then launching again is already a replace | `broker.mjs:1601-1628`, `tools.mjs:167` |
| E7 | `mosaic stop RUN` is a same-uid SIGTERM. It has no bus call and no human proof | `cli.mjs:447-454`, `seat/src/session.mjs:249-270` |
| E8 | The Console stops only the assistant (`assistant.stop`) and writes instances through `instances.set` | `serve.mjs:282-289, 315-363, 535` |
| E9 | The launcher already starts runs for the human on its 2 s tick: threads with pending messages, and merge holders | `launcher.mjs:133, 487-597` |
| E10 | `launch.revoke` and `launch.restore` are human-only. A revoked business refuses `role.launch` | `broker.mjs:770-774, 2087-2095` |
| E11 | Only the assistant is END_PROTECTED. Coder, reviewer, pm and cto all declare gitea and vikunja credentials | `vocabulary.mjs:135`, `roles/*.json` |
The chief's veto today comes from the human-only membership in E2. The chief can only launch what the human
listed. It has no way to create an instance, and the human has no way to stop the chief ending a given
instance's runs.
## D1. The verbs
All of them are human verbs. Each is a broker verb behind `#human(s)` and then the D4 hook. Each logs its
actor. The CLI usage joins `INSTANCES_USAGE` (`cli/src/instances.mjs:9`).
| Verb | CLI | Bus verb | Effect |
|---|---|---|---|
| V1 create | `business instance add B I --definition DEF [--vars FILE] [--launch] --revision REV` | `instances.set` op `add` | as today, with no restart (D3) |
| V2 launch | `business instance launch B I [--wait SECONDS]` | `instance.launch` | one run, through the launcher |
| V3 terminate | `business instance terminate B I [--reason TEXT]` | `instance.end` | ends every run of I and cancels I's open requests |
| V4 regenerate | `business instance regenerate B I [--wait SECONDS]` | `instance.regenerate` | V3, then V2 once the run has exited |
| V5 delete | `business instance delete B I --revision REV [--wait SECONDS]` | `instances.set` op `delete` | a request. The launcher removes I from the business file and renames its workspace |
| V6 veto | `business veto B (keep\|unkeep) I` and `business veto B (nohire\|hire) DEF`, each `--revision REV` | `veto.set` | edits `launch.keep` and `launch.noHire` |
`mosaic business instances B` (read) gains, per instance: `running` (run ids from the bus claims), `kept`
and `retired`. The top level gains `noHire`. `mosaic stop RUN` stays as it is. It is the same-uid operator
tool, and V3 is the human verb.
### D1.1 Requests (V2, V4, V5)
The broker never starts a process or touches a workspace. Launch, regenerate and delete are requests the
launcher carries out, as E9 already does for the human's threads. A request has three states, open, taken
and closed, all recorded as `action.allowed` events with reserved action names:
| Event | Written by | Body |
|---|---|---|
| `lifecycle.launch`, `lifecycle.regenerate`, `lifecycle.delete` | the broker, at the verb | `{ request, via }`, subject I, actor the human or `tester`. A `human.input` event goes with it, as `launch.revoke` does |
| `lifecycle.taken` | host op `lifecycleTake` | `{ request }` |
| `lifecycle.done` | host op `lifecycleDone` | `{ request, run? , refusal? }` |
| `lifecycle.cancelled` | V3, for each open request of I | `{ request, by }` |
1. The launcher's new `lifecyclePass` runs on the same tick as `threadPass` and `landPass`. It lists open
requests with host op `lifecycleRequests`.
2. For each one, under `serialize`, it calls `lifecycleTake(request)`. That op runs in one store
transaction. It re-checks, in order: the request is still open (not taken, cancelled or closed), it is
less than 60 s old (else it closes as `request-expired`), the business isn't revoked (`launch_state`,
`launch-revoked`), I isn't retired, row 191's switch is off (`processing-halted`) for launch and
regenerate, and for a tester request `bus.tester` is true in the file (D4.1). Any failure closes the
request with that code. Otherwise it writes `lifecycle.taken` and answers go.
3. Only after go does the pass act: `check(I, { human: true })` then `start()` for a launch, the V5 steps
for a delete.
4. The pass then calls `lifecycleDone`. A taken request never runs again, whatever happens after the take.
If `lifecycleDone` throws, the request stays taken with no done event. The CLI reports that as
`outcome-unknown` with the request id, and the launcher log names the run.
5. The CLI polls a read verb, `instance.request`, until the request closes or `--wait` runs out (default
30 s). A timeout exits 3 with the request id. An untaken request can still be taken within its 60 s.
So the human's `launch.revoke` stops every untaken request, not only new ones (B4), and a bus restart
can't start a request older than 60 s.
### V2 launch
1. At the verb the broker checks that I exists, isn't a thread instance (`not-thread-launch`: threads start
on a message), isn't retired, and that the business isn't revoked and, with row 191, isn't halted. These
repeat at the take (D1.1 item 2).
2. After the take, `check(I, { human: true })` and `start({ instance: I, launchedBy })`, where
`launchedBy` is the business human or `tester`. `human: true` lets the human launch an instance that
isn't in `launch.instances`. The listing limits the chief, not the human. `launch.by` still goes through
the `pm: true` branch (`launcher.mjs:235`), as `--pm` does today. Every other step of `check()` and
`start()` is unchanged.
### V3 terminate
1. The broker revokes every claim I holds (`#claimEnd(s, h, 'revoke', null, reason, actor)`). A thread
instance has all its thread claims revoked, which is exactly `assistant.stop`.
2. In the same transaction it writes `lifecycle.cancelled` for every open (untaken) request of I, so no
launch the human asked for before the terminate starts after it (B5).
3. It writes `lifecycle.end` with the runs. The launcher pass sends SIGTERM to each run's runner after
checking its start time, as the idle stop does (`launcher.mjs:526`). It also signals any run of I in the
launcher's registry that has spawned but not yet bound, which holds no claim yet. The existing exit
handler records the end, so the launch journal and `session.ended` are unchanged.
4. Nothing held, nothing open and nothing in the registry answers `not-held`.
5. Terminate is allowed while row 191's switch is on. It starts nothing.
A taken launch request is past cancelling. Its run reaches the registry under `serialize`, and the next
terminate ends it. The CLI's terminate output says so when a taken request exists.
There is no SIGKILL escalation in this row. A run that ignores SIGTERM keeps running. `mosaic stop` and the
limits are the operator's tools for it (N6).
### V4 regenerate
A fresh session with the same identity and role. It is V3 then V2 under one request. The pass takes the
request, waits for the old run's exit handler, then runs `check()` and `start()`. The workspace stays, as
it does across every launch today. The session is new because every non-thread launch gets a new run dir
(`seat/src/session.mjs:107`, newRun). If the old run hasn't exited by the request's 60 s mark, the request
closes as `regenerate-timeout`, nothing starts, and the old run is still running (N6).
A thread instance (the assistant) refuses `regenerate-thread`. Its context lives in the thread's session
directory, and clearing that is T1 item 7 (clear context). This row doesn't take it.
### V5 delete
`instances.set` gains op `delete`. At the verb the broker and the writer refuse, in this order:
| Refusal | When |
|---|---|
| `tester-refused` | the caller is the tester (B6) |
| `organization-definition-refused` | I is END_PROTECTED |
| `organization-instance-referenced` | I is `launch.by` or an arbiter. The message names the reference |
| `organization-instance-kept` | I is in `launch.keep`. The human runs `veto unkeep` first. Delete never clears a veto silently |
| `organization-credentials-out-of-scope` | I holds credentials or a tracker bot. That stays a hand edit until the credentials row (row 199) |
| `instance-running` | the broker sees a claim for I. The human terminates first |
| `instance-starting` | an open or taken request for I has no done event |
| BusinessError | I is the last name in `launch.instances` (E1). The message says so |
The checks pass with no write, and the broker records a `lifecycle.delete` request. After the take, the
launcher pass, under `serialize` so no `start()` of I runs alongside it:
1. Re-checks the registry for I (`instance-running`) and re-runs the writer's checks against the file as it
stands. The `--revision` the human gave must still match, else `revision-conflict`.
2. Commits the file through the organization writer. It removes `roles.I` and the references that exist
only for I: its `launch.instances` entry, its `launch.limits.tokens.instances` entry and its
per-instance model entry. It adds I to a new top-level list, `retired`. The build lists every place
`validateBusinessDocument` reads a role instance name and puts each one in one of the two groups (refuse
or remove). A reference the table doesn't name stops the build and goes back to Webby.
3. Renames `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<transaction>/`, mode 0700. No workspace is
no rename.
4. Calls `lifecycleDone` with the transaction.
The file goes first. Once it is committed, `check()` refuses I as unknown, so no launch can open the old
workspace even if step 3 fails. A failed rename closes the request with `workspace-rename-failed`, the
transaction and the path left behind. A second `delete` of a retired name skips steps 1 and 2 and retries
step 3 only. The file and evidence are already right, so this is the whole recovery.
What stays:
- Run evidence (`launches/<b>/<run>/`, `launches/<b>.jsonl`) and bus rows are untouched. They are
write-once.
- The workspace, under `.retired`. This row deletes no file. A prune with a receipt is a later act.
- `retired` stops a later create or hire from reusing the name. Decisions bind to the role instance and
outlive restarts (`broker.mjs:783-788`). A new `coder` would otherwise inherit an approval raised by the
old one. The tradeoff is a name that can't come back without a hand edit. I chose that over voiding
decisions, which would touch a second store.
### V6 veto
`veto.set` args: `{ revision, op: "keep" | "unkeep" | "nohire" | "hire", name }`. It goes through the
organization writer, so it is logged with actor and diff in `changes.jsonl`. `keep` needs a declared,
non-END_PROTECTED instance. `nohire` needs a name that matches the id pattern. A no-op answers
`organization-no-change`. It is human-only and isn't in `TESTER_ORGANIZATION_VERBS`.
## D2. Chief hire and fire
### D2.1 Authority (Mos, mos-1015 and mos-1017, through Sage, sage-1362 and sage-1365)
This row adds `role.hire` and `role.fire` to `ACTIONS` (`vocabulary.mjs:11`). Under PRD REQ-WAIT-3 a new
action is a hard wait until a PRD version says it isn't (`vocabulary.mjs:52-57`), so both ship as hard
waits. Jason resolves each one. There is no PRD change and no grant in a role file: `role.mjs:42` refuses
a withinRole that lists a hard wait, and REQ-WAIT-2 forbids it.
The flow in this row:
1. The chief raises a `role.hire` or `role.fire` decision whose body carries the arguments (business,
name, definition, vars, or name and reason). A hard wait goes to the human only. No arbiter resolves it.
2. Jason resolves it in the Console or the CLI.
3. The chief calls `instance.hire` or `instance.fire` citing the decision. The broker checks it with
`#consumeAuthority(s, 'role.hire', { decision, target })`, as the `role.revoke` decision branch does,
and the arguments must equal the decision body field by field (N1, webby-0010). For hire that is
`name`, `definition` and `vars` (vars compared with `canonicalJson`, absent equal to absent only). For
fire it is `name` and `reason`. A key in the call that the body lacks, or a body key the call lacks,
refuses `decision-mismatch`. Every check below runs again at this call, so a veto set after the resolve
still refuses.
The routed form, where the chief hires and fires without a decision each time, needs a PRD version that
moves both actions to `ROUTED`. My draft is `agents/honker/work/queue-194/PRD-1.7-DRAFT.md`. It quotes
TARGET.md line 66 and carries Mos's bounds. Jason approves it as a `prd.approve` decision in the Console,
and Sage opens that decision. When it lands, a later commit adds the two actions to both `ROUTED` lists
(`vocabulary.mjs`, `broker.mjs:43-69`) and to `roles/chief-of-staff.json` withinRole. The broker then
takes the `reason` form instead of a decision. The verbs and every check below stay the same. This row
builds and tests the hard-wait form only. It writes the `hireable` key and its allowlist now (D2.3), because
both forms use them.
### D2.2 What the chief may do
| Act | Bus verb | Rule |
|---|---|---|
| launch | `launch` (host) | as today: listed instances only |
| end a run | `role.revoke` + reason | as today, and refused `instance-kept` when the target is kept (D2.4) |
| hire | `instance.hire { name, definition, vars?, decision }` | adds and lists an instance. Refusals below |
| fire | `instance.fire { name, reason, decision }` | ends I's runs, cancels I's open requests, and removes I from `launch.instances`. Refusals below |
Only the business's chief, the instance named by `launch.by`, may call either verb. Any other caller
refuses `role-not-chief`, even with a resolved decision, as `#classification` already limits
`role.launch`. The broker reads `hireable` from that chief's own role file. Both act only in the chief's
own business. Neither verb takes a business argument. The broker uses the
cap's business, and a decision raised in another business refuses `decision-mismatch`. Row 191's switch
refuses both while halted (D6). Every hire and fire writes `changes.jsonl` through the organization writer
with actor the chief's instance and run, and the decision id.
Hire refuses, in order:
1. `definition-not-hireable` when the definition holds an action outside `HIRE_ALLOWED` (D2.3), whatever
any list says.
2. `definition-not-hireable` when the definition isn't in the chief role file's `hireable` list, or is in
the business's `launch.noHire`.
3. `organization-definition-refused` for END_PROTECTED, `organization-credentials-out-of-scope` for a
credentialed definition (until the credentials row, row 199), `invalid-request` for version 1.
4. `organization-instance-retired` when the name is retired.
5. When the name exists: a fired instance (in `roles`, not listed) is listed again only if `definition`
equals its current one and `vars` is absent. Any other definition or vars refuses
`organization-instance-exists`, and so does a listed name. A re-hire never moves an instance's
decisions to a different role (B3).
Hire's `vars` may hold only the keys the chief can already set on an instance through `organization.set`
(the model key, `broker.mjs:1266-1269`) and narrowing `limits.*` keys. Anything else, such as `harness`
or a route, refuses `invalid-request`. The human sets those (N3).
Fire refuses `instance-kept` for a kept instance, `organization-definition-refused` for END_PROTECTED,
`organization-instance-referenced` for `launch.by`, and a BusinessError for the last listed instance. A
fired instance stays in `roles`. The chief never deletes. Delete stays a human act because it retires the
name.
Fire ends the fired instance's processes, not only its claims (F4, Megavolt through sage-1374). Every run
gets the shared model token at start: `ENV_ALLOW` (`packages/seat/src/session.mjs:83-94`) passes
`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` and the other provider variables into the sandbox. A revoke
can't take that token back, so a fired agent whose process lives on still holds it.
The delist is a business file commit through the organization writer, and the revoke and events are store
rows. No transaction covers both (webby-0011 B2), so fire runs in a fixed order, store side first:
A. One store transaction: steps 1 to 3 below, plus `fire.stopped` with the decision id. The decision isn't
consumed yet.
B. The delist through the organization writer (`launch.instances` loses I, logged in `changes.jsonl`).
C. One store write consuming the decision and recording `fire.done`.
If B or C fails, fire answers the step's code and the decision stays unconsumed. A second fire citing the
same decision runs again. A finds nothing to revoke, cancel or stop, and that isn't a refusal for fire
(unlike V3's `not-held`). B finds I already delisted and counts as done. C consumes. So a fire is safe to
repeat until it's consumed. The other order would leave a delisted agent with a live process and its model
token when the store step fails, which is the case F4 exists to stop. A halt between A and B doesn't stop
B or C, because neither starts anything. A second fire while halted refuses (D6), and the halt's own stop
covers the runs.
Steps 1 to 3 are V3's, with one change:
1. It revokes every claim the instance holds.
2. It writes `lifecycle.cancelled` for each of the instance's open requests.
3. It writes `lifecycle.end` with the runs and `kill: true`. The launcher pass stops each runner, spawned
but unbound registry runs included, with escalation (sage-1380 item 1):
- SIGTERM after the identity check.
- After the grace, SIGKILL to any runner still alive, after the same identity check again, and
`lifecycle.end.killed` with the run and pid.
- A runner still alive after SIGKILL and the observe wait gets `lifecycle.end.alive`. The Console's
Agents section shows it to the human.
A runner whose identity no longer matches is never signalled. Every check, the recheck after the
grace included, uses the identity (start ticks plus boot id), never a bare pid, so a reused pid never
counts as the fired run (webby-0011 N2).
Fire's answer lists the runs it is stopping. The delist stops any new chief launch (`launch` takes listed
instances only). A human launch request already taken is past cancelling, as in V3. The human made it, the
fire answer names it, and the human's terminate ends its run.
The stop is row 191's per-run stop, not a second one, so the two can't fight (sage-1380 item 1). Row 191's
design r2 (`agents/rocko/work/queue-191/DESIGN-r2.md`, "Stop policy and completion") sends SIGTERM, waits
5 s, sends SIGKILL to verified groups and namespaces, and observes for 2 s. Its identity check is start
ticks plus boot id, which replaces the start-time check at `launcher.mjs:526`. Fire calls that routine for
one instance's runs and uses its constants, so the grace is row 191's 5 s, not the 10 s in r3. The order:
- Fire is refused while halted (D6), so a halt sweep never meets a fire that started after it.
- A halt that comes during a fire's stop finds those runs already stopping. Each run has one stop receipt
(row 191's), and the first stop owns it. The halt sweep joins it instead of sending a second SIGTERM or
restarting the deadline, and counts the run in its remaining or quiescent totals.
- The record says which stop owned it: reason `fire` with the decision, or row 191's `kill-switch` with
its generation.
V3 terminate is unchanged. It still sends SIGTERM only (N6). Only fire escalates, because only a fired
agent must lose the token at once. If row 191 hasn't landed when this row builds, the build chains on its
candidate (Q177), as D6 already says.
### D2.3 The hire allowlist (B2, sage-1365 item 4, webby-0010 B1)
r2 and r3 had a deny list, `HIRE_FLOOR`. Webby's B1 showed it missed routed actions (`git.merge.protected`,
which `roles/merge-gate.json` holds with no credentials, `decision.resolve.technical`, `policy.change`,
`prd.approve`), and that any action added later would be hireable by default. That isn't fail-closed. r4
replaces it with an allowlist.
A definition is hireable only if every action in its withinRole and crossRole is on `HIRE_ALLOWED`:
| Allowed action | Held today by |
|---|---|
| `message.send`, `record.write` | every v2 role file |
| `review.request`, `task.update.assigned` | coder, cto, pm, reviewer |
| `task.scope.change`, `git.push.working` | coder, cto, pm (credentialed, so they matter only after row 199) |
Everything else in `ACTIONS` fails, including the actions of the old floor (`role.launch`, `role.end`,
`role.revoke`, `organization.change`, `instance.name`, `review.verdict`, `role.hire`, `role.fire`) and those
B1 named. An action added to `ACTIONS` later fails until a reviewed commit adds it here.
`git.push.remote` (row 199, routed to coder by PRD 1.7) isn't on the list. A coder holding it stays
unhireable until a reviewed commit adds it, after the credentials bound is lifted.
Against today's role files: chief-of-staff, cto, merge-gate, pm, reviewer and security-reviewer fail.
Assistant, researcher, coder and the five C-suite files pass the allowlist. Assistant is END_PROTECTED and
coder is credentialed, so D2.2 item 3 refuses them. The C-suite files hold only `message.send` and
`record.write`, so hiring one gives no authority beyond its name. `hireable` still has to list it.
The list is a constant in `business/src/vocabulary.mjs`, `HIRE_ALLOWED`. It is checked twice:
- At load. `role.mjs` validates the chief role file's `hireable` list: distinct definition names, each
present in `rolesDir`, version 2, and every action on `HIRE_ALLOWED`. A list naming one that isn't refuses
the role file (`hireable names <def>, which holds <action>`). The check reads only the named file's authority lists,
never its `hireable`, so a list can't recurse. `hireable` is an optional key on any role file. This is a
role schema change, named for review.
- At hire, in the broker, against the definition as loaded then. A role file edited after load is caught
here.
`hireable` in `roles/chief-of-staff.json` stays empty in this row (`[]`, Q4, sage-1374). Naming the first
role kind is a separate policy commit in `roles/`, made after Jason resolves the `prd.approve` decision, as
AGENTS.md invariant 7 requires. With an empty list the chief can hire nothing, even with Jason's resolve,
until that commit. The row's tests use scratch role files whose `hireable` names a kind.
### D2.4 The per-agent forbid and the veto (sage-1365 item 3)
TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids
per agent". The forbid is two lists in the business file that only the human writes:
- `launch.keep`: instance names. The chief can't fire, replace or end a kept instance. The broker refuses
`instance-kept` for every chief act aimed at it except launch:
- `instance.fire`, even with a resolved `role.fire` decision. Mos's limit is that Jason's veto applies
to every hire and fire, so the human unkeeps before a fire.
- a hire of that name.
- `role.revoke` in the reason branch (`broker.mjs:1607-1613`). End then launch is a replace (E6).
- an `organization.set` write of that instance's model key (B7). A model change is a replace at the next
launch.
The `role.revoke` decision branch (`broker.mjs:1614-1615`) still ends a kept run. It ends a run, it
doesn't fire or replace, and it cites a decision the human resolved (Webby's Q2 ruling). The human's V3
and the limits also still end a kept run.
- `launch.noHire`: definition names the chief can't hire into this business (D2.2 item 2).
Why the chief can't change the forbid:
- Only `veto.set` writes the lists. It is human-only (`#human(s)` then the D4 hook) and refuses the tester.
- `organization.set`, the chief's one write verb, reaches only `ORGANIZATION_KEYS` (`broker.mjs:145`),
which exclude `launch` and `retired`.
- `instances.set` is human or tester only (`broker.mjs:1248-1298`).
- Hire and fire carry no field that writes either list.
T18 tests each of those paths from a chief session and checks the business file bytes are unchanged.
END_PROTECTED instances can't be kept (N7). That is intended: Q169 lets the chief end the assistant's
runs, and the assistant's own stop is `assistant.stop`. Default, per "unless user forbids": nothing is
kept and nothing is on `noHire`.
### D2.5 Where it lives and how the broker enforces it
The forbid lives in the business file, under `launch`, beside the membership it limits:
`launch.keep: [instance]`, `launch.noHire: [definition]`, and top-level `retired: [instance]`. `hireable`
lives in the chief's role file, because it bounds the role, not one business.
`checkLaunch` (`business.mjs:118`) gains the two keys. Each list is distinct. `keep` names declared,
non-protected instances. `noHire` entries need only the id pattern (N2). A stale deny entry is harmless,
and a reviewed commit that removes a role file must not stop every business that lists it. `retired`
names none of `roles`. This is the business-file schema change for review. Old files without the keys load
unchanged. An old bus loader rejects a file that has them (`rejectUnknownKeys`), so a rollback past this
row needs the keys removed first. The activation plan has to say so (R4).
The broker enforces from its live copy of the business, which D3 keeps current. The checks sit in the
`role.revoke` reason branch, in `organization.set`'s model write and in the two chief verbs, before any
write.
## D3. Live changes
Today the broker's `#businesses` is fixed at start (E3), and `instances.set` answers `restart: true` for
add and redefine.
1. `Broker.reloadBusiness(id, document)` swaps in one business's validated document. It is synchronous, so
no request sees half of it. It refuses an unknown id and a document whose `human` changed (that is row
193's ground).
2. The runtime's organization callback (`runtime.mjs:121-133`) calls it in one place, after every verb
that commits a write, whatever the verb (N1). Today that is `limits.set`, `instances.set`, `routes.set`
and `plugins.set`, and after this row `veto.set`, `instance.hire`, `instance.fire` and the launcher's
delete through the same writer. It loads from the file through the loader `bootConfig` uses
(`cli/src/config.mjs:88-105`), so the broker holds exactly what a restart would give it. Then
`limits.refresh(id)` runs, as `plugins.set` does now.
3. Other startup consumers of `businesses` in `startBroker` (`runtime.mjs:75-81`): credential references,
usage, the web services and the tasks adapter's bots. A create or hire adds no credentials, no tracker
bot and no web grant, because credentialed definitions are refused. The build checks each consumer
against that claim. Any change that would need one of them still answers `restart: true` and doesn't
reload.
4. A definition change on a running instance refuses `instance-running`. A live cap must not change
authority under its run. This is new: today the change waits for the restart. Existing tests that pin
`restart: true` change with it (N4): `business/tests/instance-settings.test.mjs`,
`bus/tests/instances-verb.test.mjs`, `cli/tests/instances.test.mjs:76-94` (the `RESTART_NEEDED` text and
the restart line) and `webui/tests/instances.test.mjs:25, 71, 74, 140-145`. The build names each
changed assertion in its manifest.
5. The chief's `launch` tool (E5) takes `instance` as a string with the id pattern instead of the start-time
enum. Its description says the host refuses an unlisted instance. `check()` already refuses one
(`launcher.mjs:235`). Without this, the chief can't launch its own hire until its session restarts.
`tools.mjs` may carry hash pins in tests. The build greps for them (R5).
6. Hand edits of the business file still need a restart. Nothing watches the file. The one exception is
`bus.tester` (D4.1), read from the file at each tester call.
After this, `restart` is false for create, delete, hire, fire and veto changes, and true only under item 3.
## D4. RBAC until row 193
Every human verb above calls `#human(s)` and then one hook, `#mayHuman(s, verb, target)`. Until row 193
lands, the hook passes for the business's one `human`, which is the owner. The verbs are listed once, in
an exported `LIFECYCLE_HUMAN_VERBS` (`instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`,
and `instances.set` ops `add`, `change`, `delete`), so row 193 maps each verb to the human roles that may
use it. Row 193 replaces the hook body and nothing else.
### D4.1 The tester (Sage, sage-1355)
What the tester may do after this row:
| Verb | Tester |
|---|---|
| `instance.launch`, `instance.end` | yes, new |
| `instances.set` ops `add`, `change` | yes, as today (row 175, `broker.mjs:103`, `serve.mjs:537`) |
| `instances.set` op `delete` | no, `tester-refused` in the broker (B6) |
| `instance.regenerate`, `veto.set` | no, `tester-refused` |
The tester's cap is bound to one business, and only when that business sets `bus.tester` to true
(`broker.mjs:542-546`). That stays Jason's switch, and no bus verb writes it. Two checks run on each tester
lifecycle call, before any effect, and again at the take (D1.1):
1. The request's business is the cap's business. Any other refuses `tester-refused`.
2. `bus.tester` is exactly true in the business file as it stands, read through `settings.current(id)` as
`harnessOn` does (`runtime.mjs:136-142`), not from the broker's copy (B8). An unreadable file refuses
`tester-off`. So a hand edit turning testing off counts at the next tester call, without a restart.
The request and done events record actor `tester`. The tester's Q173 tests of the Agents section then cover
launch and terminate on a scratch business, and the rest through fixtures.
## D5. One stack instance
Row 190 carries the stack instance as the config `MOSAIC_CONFIG` names. These verbs take no instance name.
The CLI reaches the broker socket under that config's `dataRoot`. The broker acts on its own businesses,
launcher and data root. A business id that exists in two stack instances is two separate businesses. A test
runs two scratch roots with the same business id and shows a launch, a terminate and a delete in one leave
the other byte-identical (T11).
## D6. The kill switch
- Launch and regenerate end in `start()`, which row 191 gates (its D3 inventory, `launcher.mjs:329-457`).
The new pass adds no second start path.
- The broker refuses `instance.launch` and `instance.regenerate` with `processing-halted` while the switch
is on (V2 step 1), and the take checks again (D1.1), so no request is waiting when a human resumes.
- `instance.hire` and `instance.fire` are agent work verbs, so row 191's default refuses them while halted.
The build adds them to row 191's work table, not its read list. A hire Jason resolved before a halt
still refuses while halted, and the chief calls it again after the resume.
- Terminate, create, delete and veto start nothing and stay allowed.
- If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177). The two halted
checks are written against its reader and tested with its fixture.
## D7. The Console
A new Settings section, "Agents", in its own `agents.js` file. Settings itself isn't rewritten. It lists
each instance with definition, listed, kept, retired and running runs, and flags a run with a
`lifecycle.end.alive` event as "still running after fire" (D2.2). It has these actions:
- Create (definition select, from `definitions`), Delete (with a confirm naming what stays: the evidence
and the workspace under `.retired`).
- Launch, Terminate (with an optional reason), Regenerate (with a confirm).
- Keep and Unkeep per instance, and a No-hire toggle per definition.
The routes are `POST /api/instances` (op `delete` joins `add` and `change`), `POST /api/agent/launch`,
`/api/agent/terminate`, `/api/agent/regenerate` and `POST /api/veto`. Each uses `instanceSend`'s boundary:
signed-in user, exact Origin, same-origin Fetch Metadata, JSON within 16 KiB, strict keys, one bus call
through the human transport, and `outcome-unknown` on an unexpected answer. Launch and regenerate show the
request, then poll `GET /api/agent/request?id=` until it closes.
`agents.js` joins the static file list (`serve.mjs:28`) and `index.html` (N5).
When `/api/config` says `send` is false, every action button shows as unavailable with the text "Agent
actions are unavailable here. Use mosaic business instance from the instance CLI." This is the live state
until Q175 phase 2. Every write test uses a fixture server and fixture writers, as rows 165 and 191 do. No
route gets a `--send` exception.
## D8. Tests
Every test uses scratch roots under `TMPDIR` and the existing fixtures (`business/tests/helpers.mjs`,
`cli/tests/helpers.mjs`, `webui/tests/tester-fixture.mjs`). None touches `~/.mosaic-dev`,
`~/.config/mosaic-dev`, a live unit or a slot file.
| Test | Shows | File |
|---|---|---|
| T1 | V1 create on a running bus: a launch of the new instance binds with no restart, and the answer says `restart: false` | cli `instances.test.mjs` |
| T2 | V2: a human launch of an unlisted instance runs. `launch.by` goes through the `pm` branch (N8). A thread instance, a retired one and a revoked business refuse at the verb. A request older than 60 s never launches. A refused `check()` closes the request with its code | cli `lifecycle.test.mjs` (new) |
| T2a | Take: a `launch.revoke` between the request and the pass closes it `launch-revoked` with no process. A `lifecycleDone` that throws once leaves the request taken, and the next tick starts nothing. A taken request is never taken twice | same |
| T3 | V2 with `--no-launch` and with each limit refusal: no process, request closed with the code | same |
| T4 | V3: claims revoked, runner SIGTERMed after the start-time check, one end record. A mismatched start time sends no signal. An open request is cancelled and never starts. A spawned, unbound run in the registry is signalled | same |
| T5 | V4: the new run starts only after the old exit, with a new run dir and the same workspace. A run that ignores SIGTERM closes the request `regenerate-timeout` with nothing started. The assistant refuses `regenerate-thread` | same |
| T6 | V5: each refusal in the V5 table, in order, including `instance-starting` with an open request. A clean delete leaves the evidence bytes unchanged, commits the file before the rename, adds `retired`, and a create or hire of the same name refuses. A failed rename closes `workspace-rename-failed`, `check()` refuses the name, and a second delete renames only | business `instance-settings.test.mjs` and cli `lifecycle.test.mjs` |
| T7 | V6: each op, the no-op refusal, an unknown `noHire` name accepted, and a `changes.jsonl` entry with actor `human` | business |
| T8 | Chief hire (hard-wait form): no decision refuses. A decision raised by the chief goes to the human only, no arbiter. After the resolve, the hire adds and lists the instance, logs actor and decision, and the same chief session launches it (D3 item 5). Arguments that differ from the decision body refuse. A non-chief caller with a resolved decision refuses `role-not-chief`. Outside `hireable`, in `noHire`, credentialed, END_PROTECTED, retired, and extra vars each refuse | bus `chief-of-staff.test.mjs` |
| T8a | Re-hire (B3): a fired name with the same definition and no vars is listed again. Another definition or vars refuses `organization-instance-exists` | same |
| T8b | Hire allowlist (D2.3): one case per action in `ACTIONS` that is neither on `HIRE_ALLOWED` nor a hard wait (`role.mjs` already refuses those), looped from the lists, not typed by hand. A fixture definition, clean when the chief's role file loads and listed in `hireable`, is rewritten to hold that action before the hire. The hire refuses `definition-not-hireable`. A definition holding one allowed and one refused action refuses. One holding only allowed actions hires. A pin test fixes `HIRE_ALLOWED` to the six actions | same |
| T8c | Allowlist at load: a role file whose `hireable` names a definition holding each refused action refuses at `loadRole`, one case per action, looped the same way. The mixed case refuses. A clean list loads. A missing, version 1 or duplicate name refuses | business `role.test.mjs` |
| T9 | Chief fire: runs end, open requests cancelled, delisted, still in `roles`. Kept (even with a resolved decision), END_PROTECTED, `launch.by` and last-listed refuse | bus `chief-of-staff.test.mjs` |
| T9a | Fire ends processes (F4, sage-1380 item 1): with a bound run and a spawned but unbound registry run of I, both fixture runners holding a stand-in model variable, fire answers both runs. After the launcher pass, neither runner pid is alive and I holds no claim. A fixture runner that ignores SIGTERM gets SIGKILL after the grace, is gone, and has `lifecycle.end.killed` with its run and pid. A stand-in whose kill is stubbed to fail gets `lifecycle.end.alive`. A runner whose identity changed before the SIGTERM, or between SIGTERM and SIGKILL, gets no signal. V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL | cli `launcher.test.mjs` |
| T9b | Fire and row 191's halt: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason `fire` and the decision, and counts the run in its totals. A fire while halted refuses (D6) | cli `launcher.test.mjs`, on row 191's fixture |
| T9c | Fire order (webby-0011 B2): the organization writer stubbed to fail after A commits. Fire answers the writer's code, I's claims are revoked and its runs stopped, I is still listed, and the decision is unconsumed. A second fire citing the same decision delists I and consumes it. A third fire refuses `decision-consumed`. With C stubbed to fail, the second fire finds I delisted and only consumes | bus `chief-of-staff.test.mjs` |
| T10 | Keep: chief `role.revoke` with a reason, fire, hire of that name and an `organization.set` model write aimed at a kept instance each refuse `instance-kept`. A `role.revoke` citing a human-resolved decision ends the kept run (Q2). The human's V3 works | same |
| T11 | Two scratch stack instances with the same business id: V2, V3 and V5 in A leave B's root and config byte-identical. A chief of business A can't hire into business B on the same bus | cli `lifecycle.test.mjs`, bus |
| T12 | Agent and reader sessions refuse every human verb before any effect. The tester gets exactly the D4.1 table, including `tester-refused` for `instances.set` op `delete` (B6). The D4 hook is called with the verb | bus `instances-verb.test.mjs` |
| T12a | Tester launch and terminate work on the cap's business with `bus.tester` true, record actor `tester`, and refuse `tester-refused` for a second business on the same bus. A file edit setting `bus.tester` to false, with no restart, refuses the next call and the take `tester-off`. An unreadable file refuses `tester-off` | bus `instances-verb.test.mjs` |
| T13 | `organization.set` with `launch`, `retired`, `keep` or `noHire` keys refuses | bus |
| T14 | Live reload: after each committed verb, `plugins.set` and `routes.set` included, the broker answers from the new document. A definition change on a running instance refuses `instance-running` | bus |
| T15 | Kill switch (with row 191's fixture): launch and regenerate refuse `processing-halted` at the verb and at the take, hire and fire refuse, terminate works | bus and cli |
| T16 | Console: each route's boundary refusals, a fixture write per action, `outcome-unknown` on a bad shape, unavailable buttons when `send` is false, request polling, and `agents.js` served from the static list | webui `agents.test.mjs` (new) |
| T17 | Business file: the new keys validate, each bad entry refuses, and a file without them loads byte for byte as before | business `business.test.mjs` |
| T18 | The forbid can't be changed by the chief (D2.4): from a chief session, `veto.set`, `organization.set` with each list key, `instances.set`, and hire and fire with extra fields all refuse, and the business file bytes are unchanged | bus `chief-of-staff.test.mjs` |
### Mutants
Each one must fail at least one test. They run against the candidate, and the file is restored and its
sha256 checked after each one.
| # | Change | Killer |
|---|---|---|
| M1 | `instance.launch` skips `#human` | T12 |
| M2 | the pass calls `check()` without `human: true` | T2 |
| M3 | the take accepts a request older than 60 s | T2 |
| M4 | the take skips the `launch_state` check | T2a |
| M5 | terminate signals without the start-time check | T4 |
| M6 | regenerate starts before the old exit | T5 |
| M7 | delete skips the running check | T6 |
| M8 | delete skips `retired` | T6 |
| M9 | delete clears `keep` instead of refusing | T6 |
| M10 | delete removes the workspace instead of renaming it | T6 |
| M11 | the `keep` check is removed from the `role.revoke` reason branch | T10 |
| M12 | `noHire` is not read by hire | T8 |
| M13 | fire deletes the instance from `roles` | T9 |
| M14 | `reloadBusiness` isn't called after `instances.set` | T1, T14 |
| M15 | the tool enum is kept | T8 |
| M16 | the halted check is dropped from `instance.launch` | T15 |
| M17 | the Console route skips the Origin check | T16 |
| M18 | `veto.set` joins `TESTER_ORGANIZATION_VERBS` | T12 |
| M19 | the tester's business scope check is dropped | T12a |
| M20 | the tester's `bus.tester` file read is dropped | T12a |
| M21 | hire skips the role file's `hireable` list | T8 |
| M22 | the take doesn't write `lifecycle.taken`, so a request can be taken twice | T2a |
| M23 | terminate doesn't cancel open requests | T4 |
| M24 | delete skips the `instance-starting` check | T6 |
| M25 | delete renames before it commits the file | T6 |
| M26 | the tester's `instances.set` op `delete` refusal is dropped | T12 |
| M27 | the `keep` check is dropped from `organization.set`'s model write | T10 |
| M28 | the `keep` check is added to the `role.revoke` decision branch | T10 |
| M29 | a re-hire accepts a different definition | T8a |
| M30 to M36 | the broker's hire check accepts one action outside `HIRE_ALLOWED`, one mutant per action: `role.launch`, `role.end`, `organization.change`, `instance.name`, `review.verdict`, `git.merge.protected`, `decision.resolve.technical` | T8b |
| M37 to M43 | the same seven, accepted by the load check in `role.mjs` only | T8c |
| M44 | fire skips `instance-kept` when a decision is cited | T9 |
| M45 | `instance.hire` skips the `launch.by` caller check | T8 |
| M46 | `veto.set` skips `#human` | T18 |
| M47 | the broker's hire check needs some action on `HIRE_ALLOWED` instead of every action | T8b |
| M48 | the same in `role.mjs` only | T8c |
| M49 | fire revokes the claims but writes no `lifecycle.end`, so no runner is signalled | T9a |
| M50 | fire's signal skips spawned but unbound registry runs | T9a |
| M51 | fire's stop drops the SIGKILL escalation | T9a |
| M52 | SIGKILL is sent without the second identity check | T9a |
| M53 | a killed runner gets no `lifecycle.end.killed` | T9a |
| M54 | V3 terminate escalates to SIGKILL as fire does | T9a |
| M55 | the halt sweep sends its own SIGTERM and restarts the deadline on a run fire is stopping | T9b |
| M56 | fire delists before the store step | T9c |
| M57 | fire consumes the decision in step A | T9c |
| M58 | a second fire with nothing to revoke refuses `not-held` | T9c |
## Scope and boundaries
- In the row: the verbs and passes above in `business` (vocabulary, role validation, instance-settings,
business validation), `bus` (broker verbs, reload, runtime callback), `cli` (instances command, launcher
pass, host ops), `harness` (the launch tool's schema, and the hire and fire tools), `webui` (routes and
the Agents section), the tests, the package READMEs and the PRD draft in this packet. `docs/TOOLS.md`
text goes to Sage.
- Named for review:
- Vocabulary: `role.hire` and `role.fire` join `ACTIONS` as hard waits, under REQ-WAIT-3. The commit
message names them as hard waits, as REQ-WAIT-3 requires.
- Role schema: the optional `hireable` key, validated in `role.mjs` (D2.3).
- Role file: `roles/chief-of-staff.json` gains `"hireable": []`. No authority change.
- Business file schema: `launch.keep`, `launch.noHire`, `retired` (D2.5).
- Not in the row: the routed form (after PRD 1.7), filling `hireable`, credentialed hires and deletes (row
199), clear context for the assistant (T1 item 7), human roles (row 193), SIGKILL escalation, and any
live activation or Console start.
## Risks
- R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11), and pm, cto and
reviewer fail the hire allowlist anyway. Neither the human verb nor the chief can create or delete credentialed
roles until the credentials row (row 199). That is the largest gap against Case 1, and it isn't this
row's to close.
- R2. The human proof is path-bound and same-uid (P19 333). Any same-uid process that runs the tree's
`human-cli.mjs` can launch and delete. Row 190b's separate uid and row 193 narrow it. This row adds no
check of its own.
- R3. The 60 s request window means a slow `check()` (checkouts) can still start a run up to that point
after the CLI gave up. The CLI says so on timeout.
- R4. Rollback past this row needs `keep`, `noHire` and `retired` removed from each business file, and
`hireable` from the role file, or the old loaders refuse them. The activation plan states this.
- R5. `tools.mjs`, `serve.mjs`, `broker.mjs`, `vocabulary.mjs` and `role.mjs` may carry sha256 pins in
other packages' tests. The build greps the tests for every changed path plus a 64-hex value before
in-review, without a `head`.
- R6. With `hireable` empty, the chief can hire nothing until the policy commit in `roles/` that follows
the `prd.approve` resolve names a kind (Q4). Case 1's "as needed" waits on that commit and on PRD 1.7.
- R7. Fire's SIGKILL closes the SIGTERM-ignoring case (sage-1380). A runner still alive after SIGKILL
(an unkillable process, or a stop whose identity check fails) keeps the shared model token until the
human acts on `lifecycle.end.alive`. Only a per-agent model token (row 199 Q-M4, a later proxy row)
would let a fire revoke the token itself.
## Questions and rulings
- Q1, ruled by Mos (mos-1015, mos-1017, through sage-1365): `role.hire` and `role.fire` ship as hard
waits Jason resolves. The routed form needs PRD 1.7, drafted in `PRD-1.7-DRAFT.md`. D2.1.
- Q2, ruled by Webby (webby-0003): keep blocks the chief's `role.revoke` reason branch, not its decision
branch. D2.4.
- Q3, ruled by Sage (sage-1355): launch and terminate are tester verbs scoped to the tester's own
business, behind `bus.tester`. D4.1.
- Q4, ruled by Sage (sage-1374): `hireable` stays empty in this row. Naming the first role kind is a
separate policy commit in `roles/` after Jason resolves the `prd.approve` decision. Tests use scratch role
files that name a kind. D2.3.
## r2 changes against webby-0003
| Finding | Where |
|---|---|
| B1 | D2.1: hard waits under REQ-WAIT-3, no role grant. The routed form is PRD 1.7 (packet draft) |
| B2 | D2.3: the floor from sage-1365 item 4, checked at load and at hire, T8b, T8c, M30 to M43 (an allowlist since r4) |
| B3 | D2.2 hire item 5: a re-hire keeps the definition, T8a, M29 |
| B4 | D1.1: one `lifecycleTake` transaction re-checks and writes `lifecycle.taken`, T2a, M4, M22 |
| B5 | V3 cancels open requests and signals unbound runs. V5 refuses `instance-starting`, runs under `serialize`, commits the file before the rename, and states the recovery. T4, T6, M23 to M25 |
| B6 | V5 table and D4.1: the tester's delete refuses, T12, M26 |
| B7 | D2.4: keep covers `organization.set`'s model write, T10, M27 |
| B8 | D4.1: `bus.tester` is read from the file at each call and at the take, T12a, M20 |
| Q2 | D2.4, T10, M28 |
| N1 | D3 item 2: one reload after every committed write |
| N2 | D2.5: `noHire` takes any id-pattern name |
| N3 | D2.2: hire's vars limited to the model key and narrowing `limits.*` |
| N4 | D3 item 4 names the tests that change |
| N5 | D7 and T16: `agents.js` joins `serve.mjs:28` and `index.html` |
| N6 | V3 and V4: no SIGKILL, `regenerate-timeout` |
| N7 | D2.4: intended, Q169 |
| N8 | V2 item 2 and T2 |
## r3 changes against sage-1374, sage-1377 and Megavolt's F4
| Item | Where |
|---|---|
| Q4 ruled: `hireable` stays empty, the first kind is a later policy commit in `roles/` | D2.3, R6, Questions |
| `instance.name` joins the floor (Webby B2, mos-1023 item 6) | D2.3 table, T8b, T8c, M47, M48 |
| F4: fire ends the fired agent's processes, since `ENV_ALLOW` gives every run the shared model token | D2.2, D7, T9a, M49 to M51, R7 |
| `git.push.remote` routed to coder only (mos-1028 item 3, row 199 r3) | `PRD-1.7-DRAFT.md`, REQ-LAND-2 and the REQ-WAIT-3 append |
## r4 changes against webby-0010, sage-1380 and megavolt-0008
| Item | Where |
|---|---|
| webby-0010 B1: the deny-list floor becomes the allowlist `HIRE_ALLOWED`, so merge-gate, `decision.resolve.technical`, `policy.change`, `prd.approve` and any later action are refused | D2.3, Summary, R1, T8b, T8c, M30 to M43, M47, M48, PRD REQ-COS-3 bound |
| webby-0010 N1: field-by-field compare of the call against the decision body, extra or missing keys refuse | D2.1 item 3 |
| webby-0010 N2: the model write is in REQ-COS-3's text | `PRD-1.7-DRAFT.md` |
| sage-1380 item 1: fire escalates to SIGKILL through row 191's per-run stop, and the order against a halt is fixed | D2.2, T9a, T9b, M51 to M55, R7 |
| sage-1380 item 2 and megavolt-0008: REQ-LAND-2 binds one commit oid, and Megavolt's three text changes against row 199 r4 (e1aa6216c) | `PRD-1.7-DRAFT.md` |
| webby-0011 B1: same as webby-0010 B1, M47 and M48 now allowlist mutants | D2.3 |
| webby-0011 B2: fire runs store side first, then the delist, then the consume, safe to repeat | D2.2 order A to C, T9c, M56 to M58 |
| webby-0011 N1: the remote-owner host check at each push | sent to Megavolt for row 199 |
| webby-0011 N2: identity, not bare pid, at every check | D2.2 step 3 |
| Q4: Webby's view matches Sage's ruling (empty) | no change |
@@ -0,0 +1,342 @@
# PRD 1.8 draft: trusted actions within role guardrails, the manual mode, and grants the chief of staff
# requests and the user approves
Draft by Honker for Vikunja #434, under Mos's assignment mos-1117 (through Sage, sage-1525). Not approved.
It changes nothing until Jason approves it as a `prd.approve` decision. Sage opens that decision and sends
Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 008746b6e, plus the version 1.7 text Jason
approved (PRD-1.7-DRAFT.md r2, 9ba31b5b3, decision d52febb5, mos-1089). Where 1.7 is not yet written into
the PRD file, this draft's anchors name 1.7's sections.
Revision 2 makes Mos's edits a to f (mos-1124, through Sage, sage-1534) and fixes Duckworth's B1 and B2
with notes N1 to N6 (duckworth-0057) on r1 at 3c30f1036. Revision 3 fixes Duckworth's C1 and takes
notes N1 and N2 (duckworth-0058) on r2 at 60f5ca8fa, with Gizmoduck's enrollment wording
(gizmoduck-0067). Revision 4 makes Mos's rulings on duckworth-0057 (mos-1129, through Sage, sage-1538)
on r3 at d16ccdb91: only an Approve creates a grant, one decision for a grant that makes an account, and
the arbiter route for `host.sudo`. Revision 5 fixes two stale notes from Duckworth's r4 check
(duckworth-0061 N1), with no change to the requirement text. Revision 7 is on r5 at 762a6711f, not r6
(Jason, Q191, through Mos mos-1149 and Sage sage-1623): the assistant and the chief of staff are separate
roles, and only the chief raises a grant request. It also takes Mos's rulings on r6 items 1, 3 and 4
(mos-1152): the heading below reads "For Jason at approval:" again with Mos's reading of the Q188 revokers,
the machine-grant citation names r1 to r4, and the revoke note no longer says their Q-S5 is answered.
Only the chief of staff raises a grant request. The assistant, the user's main agent, never raises one.
It passes the user's request to the chief and can revoke a grant in its own organization.
For Jason at approval:
- Q188's 'you, Mos or the tester' is read as the user, the assistant for grants in its own organization, and the
tester only on a dev instance (Mos's reading, mos-1124 d).
- Only an Approve creates a grant. A grant written into the business file by hand makes nothing. An agent
that runs as the same OS user as the stack can still write the stack's own store. That is the B7
residual (Q182 on dragon-lin, #423 on .53 and .54), and this version doesn't close it.
- Who decides a root command on another host changes. Once `host.sudo` is routed, a raise for it that
cites the spec and isn't within-role goes to the arbiter for its domain, as version 1.7 set for
`git.push.remote`, not to you. The grant and its allowlist still limit the command. In manual mode the
decision comes to you (REQ-TRUST-2).
## What it changes
Today an agent acts without a raise only on actions its role file grants within-role, and the role
files grant few. Every other use of a tool or command waits on a decision. Row 210 builds `host.sudo` as a
hard wait, so Jason would decide each command run on another machine. Grants themselves are hand work:
Jason makes every token and key from a runbook (REQ-CRED-1).
Jason's ruling Q188 turns this around. Roles set what each agent may do, and by default agents do it at
will within their sandbox and guardrails. A per-use raise happens only in a manual mode. No agent creates
a grant. The chief of staff brings a grant request to the user's inbox, the user clicks Approve or Deny, and
protected stack code does every step of making the grant. The user's own tokens are entered once in the
WebUI and never reach an agent.
This version adds:
1. Trusted actions within role guardrails as the default mode (REQ-TRUST-1).
2. Manual mode, a list of actions that each need a decision per use: who sets it, where it lives and what
it changes (REQ-TRUST-2).
3. Grant requests, raised only by the chief of staff through the chain of command (REQ-GRANT-1).
4. Approve and the protected grant code, with three grant methods: machine key, Gitea and GitHub
(REQ-GRANT-2).
5. The user's tokens, entered in the WebUI and used only by the grant code (REQ-GRANT-3).
6. Row 210's REQ-CRED-4, generalized from `host.sudo` to every role-listed action that uses a grant
(REQ-CRED-4).
7. Instant revoke (REQ-GRANT-4).
The hard waits stay with the user (Mos, mos-1117): spending money, deleting data, new external accounts,
messages outside the fleet and the rest of REQ-WAIT-1. Approving a grant is the user resolving one of
those hard waits, `credential.mint`. A grant that makes an account, such as a seat's own Gitea user
(Q190), is the same one decision: its method says it makes an account, and the inbox shows the account
name before Approve. So no hard wait moves to an agent.
When this version lands, reviewed commits:
1. add the manual-mode list to the organization file schema, written only on the human's path
(REQ-TRUST-2), and make the broker classify a listed action as needing a decision for each use, before
any other routing.
2. add the grant request: a decision the chief of staff raises with action `credential.mint`, naming the
agent, the method and target, the exact rights and, when the method makes an account, the account
name (REQ-GRANT-1). The broker refuses a `credential.mint` raise from any other role. An
`account.create` raise isn't a grant request and routes as it does in version 1.7, such as a PM's
sign-up for a service.
3. add the grant code that runs on a resolved Approve, one method at a time, and the grant record it
writes (REQ-GRANT-2, Vikunja #435 and #436).
4. add the user's token store, written from the WebUI (or the CLI until the Console knows the signed-in
human) and read only by the grant code (REQ-GRANT-3).
5. move `host.sudo` into both `ROUTED` lists, as row 199 does for `git.push.remote`, and route it under
REQ-CRED-4, with the arbiter route version 1.7 set for `git.push.remote`. Which definitions list it
within-role is a separate reviewed role commit in `roles/`.
6. update the role contracts that list the hard waits: `roles/chief-of-staff.md:75` raises grant
requests with the REQ-GRANT-1 fields, and `roles/pm.md:55` asks the chief of staff for a grant instead
of raising `credential.mint` itself (line numbers on origin/refactor). Other roles that list the hard
waits get the same line as the PM. A role's messaging list carries the chain of command.
## Text for the PRD
Status line, appended to the current paragraph:
> Version 1.8 records Jason's ruling Q188 of 2026-10-20: trusted actions within role guardrails by
> default, a manual mode for per-use raises, and grants the chief of staff requests and the user
> approves, approved by Jason in decision <prd.approve decision id>.
Source, two new bullets above "Version 1.7 source":
> - Version 1.8 source, Jason's ruling Q188 (DECISIONS, 2026-10-20), his answers to Q8 and Q10, in part.
> Q8: "I would rather grant the grant right to an agent that runs with strict guardrails. That agent
> presents the final authorization for the user. Upon approval, the grant is created programmatically.
> I see this as: User configures their PATs, API tokens, authorizations within the WebUI. The agent
> presents the request for grant with X rights for another agent. The user reviews, then clicks
> Approve/Deny. Click of Approve fires off protected mechanical methods to handle the grants on Github,
> Gitea, .... services as needed. The tooling would need to exist and must be guarded." And "Disallowing
> the agents to create grants autonomously is a necessary evil for better security. Requiring the user
> to manually perform the steps is a no-fly." Q10: "Agents should have tools and command access based on
> roles. Those roles dictate the allowed tools and commands. The use of a tool or command should not be
> raised to the user unless the system is configured with a manual mode that requires user
> interaction. By default I want to have the agents capable of running commands and tools at will
> within their sandbox/guardrails. This allows autonomous actions by agents within their lanes. Perhaps
> a "Trusted actions within approved guardrails" methodology." And "The main agent or chief of staff
> would be responsible for surfacing the request to the user's inbox for approval. Not all agents are
> capable or authorized to request directly. It must follow the chain of command properly." He agreed
> with Mos's Q10 points 3 to 6: protected stack code does every step on Approve, his tokens are entered
> in the WebUI and agents never see them, a command on the approved list runs without a click and any
> other is refused, and he, Mos or the tester can revoke a grant at once.
> - Version 1.8 source, Jason's ruling Q190 (DECISIONS, 2026-10-20), his answer "Q12: O1": each agent seat
> gets its own account on his Gitea, created through the Q188 approval path once the Gitea grants exist.
Under "Decisions", after REQ-DEC-6, a new section:
> ### Trusted actions
>
> Added in version 1.8 (Q188).
>
> - **REQ-TRUST-1.** Agents act within their role guardrails without asking. The default mode is
> "trusted actions within approved guardrails". An action a role file grants within-role is done at
> will, inside the agent's sandbox, its tool limit and its grants, and is logged. It raises nothing to
> the user. An action the file doesn't grant within-role still routes by REQ-DEC-2, and a hard wait
> still goes to the user (REQ-WAIT-1). Only a reviewed role change adds an action to a role file, and
> REQ-WAIT-2 still keeps every hard wait out of every role file.
> - **REQ-TRUST-2.** The user can put chosen actions in manual mode. The organization file holds the list
> of actions in manual mode, empty by default. While an action is on the list, every use of it by any
> role becomes a decision for that use, raised to the user, and the action waits until the user
> resolves it. A decision denied, or not resolved, refuses the action. Each decision is used once. For a
> command on another host, the decision names the host, the command and its input. Actions not on the
> list are unchanged. Only the user changes the list, through the WebUI or the CLI. The chief of
> staff's organization writes (REQ-ROLE-3) can't change it, and no agent can. Every change is logged
> with who made it. A change applies to the next use, and a run already in progress keeps no
> exemption.
Under "Credentials", after REQ-CRED-3 (version 1.7):
> Added in version 1.8 (Q188):
>
> - **REQ-CRED-4.** An action a role definition lists within-role, and that uses a grant, runs without a
> decision for each use only when all of these hold:
> - the definition declares the credential kind the action uses;
> - a grant names that agent, and that host or service;
> - for a command, the command matches the grant's allowlist. A grant lists exact argv prefixes. A
> wildcard grant is refused;
> - the agent's key or token for that grant is issued and not revoked.
>
> A command off the allowlist is refused, and adding one takes a new grant request (REQ-GRANT-1). An
> action whose definition doesn't list it within-role still needs a decision for each use. Only a
> reviewed role change adds an action to a definition. `host.sudo` routes under this requirement.
> - **REQ-GRANT-1.** No agent creates or writes a grant. A grant request follows the chain of command,
> and only the chief of staff raises it to the user's inbox. Any other role that needs a grant for
> itself or for an agent it directs asks the chief of staff, through its chain: a worker role through its
> PM, the PM and the CTO directly. The assistant passes the user's own request to the chief of staff
> (REQ-ASSIST-1). The request names the agent, the grant method and its target, the exact rights, and a
> rotate-by or expiry date. The chief raises one only for an agent whose reviewed role definition
> allows that kind of credential, and the broker refuses any other. A grant can't name a wildcard, a
> shell or an empty command. The request is a decision with the action `credential.mint`, so it goes
> to the user as a hard wait (REQ-WAIT-1). A grant whose method makes an account, such as a seat's own
> Gitea user (Q190), is the same one decision: the method in the grant says it makes an account, the
> request names the account, and the inbox shows the account name before Approve. The user's one
> resolution covers the account and its credential, so the hard-waits list doesn't change. An
> `account.create` raise isn't a grant request and routes as version 1.7 has it. The broker checks
> only that the chief of staff raised the request. The chain of command before that is in the
> messaging lists and the role contracts, not a broker check. The assistant and the chief of staff are
> separate roles. No agent holds both, and the broker refuses a claim that would.
> - **REQ-GRANT-2.** The user approves or denies each grant request in the inbox. Approve is the only thing
> that creates a grant, and the code uses a grant only when it has a matching Approve record. No agent
> creates a grant. A grant written into the business file by hand makes nothing. Row 210's hand-written
> grants stay a test path until Vikunja #436a moves `host.run` to grants made by Approve. On Approve,
> protected stack code does every step, not an agent and not the user: it records the grant, makes the
> key or token, installs it on the machine or creates it on the service, and logs each step. On Deny
> nothing is made, and the denial is logged. The grant methods are:
> - machine key: a key for one agent on one enrolled host, with the command allowlist of REQ-CRED-4. A
> host's identity comes from the host side when the user approves its enrollment, never from a first
> connection. A host user with sudo receives a grant only after the user approved that user by name;
> - Gitea: a token for one agent, and for an agent seat its own Gitea user (Q190), with an expiry. The
> all scope and the admin scope are never granted;
> - GitHub: a grant for one agent.
>
> The Gitea and GitHub rights units come in a later design that Jason approves.
>
> The approval binds the exact rights the user saw and the target or its enrolled identity. A change to
> the agent, its definition, the target, the target's identity or the rights after the raise voids it. An
> approval is used once. A grant the code couldn't finish is undone at once on the stack's side, so it
> is never used. What it left on the host or service stays visible as pending until it is removed, as
> for a revoke (REQ-GRANT-4). A new grant needs a new approval, never an automatic retry. The grant
> code runs only on a request the user resolved. A request resolved by an arbiter, the tester or any
> agent makes nothing. A `credential.mint` decision raised before version 1.8, or without the fields of
> REQ-GRANT-1, makes nothing when it is approved. Until the Console
> knows the signed-in human (Vikunja #412), Approve is `mosaic decide` in the CLI (REQ-DEC-3).
> - **REQ-GRANT-3.** The user enters their own personal access tokens and API tokens in the WebUI, or the
> CLI. Only the grant code reads them, to make grants. No agent ever sees a token value or a key's
> private half. Values appear in no event, log, message, prompt or agent file, and no agent can read
> them back (REQ-VAR-2). Until the Console knows the signed-in human, they are entered through the
> CLI.
> - **REQ-GRANT-4.** A grant can be revoked at once by the user, by the assistant for a grant in its own
> organization, or by the tester on a dev instance (Q173). A revoke needs no decision and no new trust.
> It takes effect at once on the stack's side, without waiting on the network, so the next use is
> refused. Removing the key or token at the host or service follows. Until that removal is done it stays
> visible as pending. A revoke while the grant is being made stops it, and nothing it made stays usable.
> Every revoke is logged with who made it.
REQ-CRED-1, appended:
> Since version 1.8, a token or key for a grant is made by the grant code on the user's Approve
> (REQ-GRANT-2), not by hand. Tokens that exist stay as they are.
REQ-WAIT-3, appended:
> `host.sudo` was added as a hard wait and routes under REQ-CRED-4 since version 1.8.
Under "Risks and open questions", a new bullet:
> - Trusted mode lets an agent run its allowed commands at will. On another host, an allowed command that
> writes a file as root or starts a shell can plant a key, and the seat sandbox doesn't confine the
> network, so root can outlive the grant and a revoke. The allowlist narrows this. A seat sandbox with
> its own network namespace closes it. Until then the host's own audit log is the check after the fact
> (row 210 design section 6).
## Notes for the reviewer and for Mos
- REQ-TRUST-1 restates what REQ-DEC-2 already does for within-role actions ("routine and within-role
decisions are logged") and names it as the default mode. It grants nothing new by itself. What widens
is the role files, one reviewed commit at a time.
- The hard waits stay with the user (mos-1117). REQ-GRANT-1 makes the grant request a `credential.mint`
decision, already a hard wait under REQ-WAIT-1, so Approve is the user resolving it. REQ-WAIT-2 stands:
no role file grants `credential.mint`. The grant code acts on the user's resolution, not on a role
grant. A grant that makes an account, such as a seat's Gitea user under Q190, is the same one
`credential.mint` decision. So 1.8 doesn't change the hard-waits list (mos-1124 a, mos-1129 N4).
- Manual mode: Sage asked who sets it, where it lives and what it changes. Mos accepted these choices
(mos-1124 b), and Jason can change them: a list of actions in the organization file, empty by
default, changed only by the user, outside the chief's organization writes. A list rather than one
switch follows Gizmoduck's #436 draft section 7 (gizmoduck/machine-grant-design f6b6a04b6), so Jason
can make host commands manual without making every action manual. Listing every within-role action
gives the whole-organization manual mode. Row 210's per-call `host.run` decision, bound to host, argv
and stdin and spent once, is this mode's decision for a host command (Q188, Mos's reading).
- Gizmoduck's draft proposes REQ-CRED-5 (section 8) for the machine grant. Its content is in REQ-GRANT-1
to REQ-GRANT-4 here, generalized to all three methods, so 1.8 has no REQ-CRED-5. Their bounds in
gizmoduck-0062 items 4a to 4g are in the text: the chief raises only for an allowed definition, the
approval binds the rights shown, it is used once with no automatic retry, no value is ever seen, revoke
is at once on the stack's side with the host or service removal visible until done, every grant has a
rotate-by or expiry date, and the host identity comes from the host side. Duckworth has reviewed their
design r1 to r4 (duckworth-0056 and duckworth-0060), so a later change there may need a change here.
- B1 (duckworth-0057, mos-1129): a grant the code couldn't finish is undone at once on the stack's side.
What it left on the host or service stays visible as pending until it is removed, as for a revoke.
- B2 (duckworth-0057, mos-1129): Mos chose Duckworth's second option. Only an Approve creates a grant,
and the code uses a grant only when it has a matching Approve record. A grant written by hand makes
nothing. Jason's Q188 gives the reason: "Disallowing the agents to create grants autonomously is a
necessary evil for better security" and "Upon approval, the grant is created programmatically." Row
210's hand-written grants stay a test path until #436a moves `host.run` to grants made by Approve. r2
and r3 had a hand path in the business file, and r4 takes it out, with r3's load check on hand grants
(duckworth-0058 N1) and the note that a revoke never rewrites the user's file (duckworth-0058 N2).
The same-uid write to the stack's own store stays the B7 residual, and the top of this draft names it
for Jason.
- C1 (duckworth-0058, sage-1538): `account.create` is the hard wait for any new external account, not
only a grant (REQ-WAIT-1, `vocabulary.mjs:43-46`, `roles/pm.md:55`). It stays as version 1.7 has it.
Only `credential.mint` is chief-only, and a grant that makes an account is a `credential.mint`
decision (N4), so a PM's sign-up for a service routes as before.
- Gizmoduck's machine-grant r2 (eb4c756a1, gizmoduck-0067): enrollment is a request the user approves
and the stack runs (mos-1120), and a host user with sudo is enrolled by name, the user's Approve
covering that user (mos-1125). REQ-GRANT-2's machine key says both. Their design r3 and r4 (1c5705d6d
section 6) give revoke to the user, the assistant in its own organization and the tester on a dev
instance, as REQ-GRANT-4 does (mos-1124 d).
- N1 (duckworth-0057, mos-1129): the approval binds the target or its enrolled identity.
- N2 (duckworth-0057, mos-1129): a revoke during creation stops it, and nothing it made stays usable.
- N3 (duckworth-0057, mos-1129): landing step 6 lists the role contract updates (`roles/pm.md:55`,
`roles/chief-of-staff.md:75`). A `credential.mint` decision raised before 1.8, or one without the
grant fields, makes nothing when it is approved.
- N4 (duckworth-0057, mos-1129): a decision carries one action (`a.action`, broker.mjs:1678 on
origin/refactor). A grant that makes an account is one `credential.mint` decision: the method in the
grant says it makes an account, and the inbox shows the account name before Approve. The user clicks
once, and there is no separate `account.create` action for it. Gizmoduck's machine-grant r2 reads it
the same way.
- N5 (duckworth-0057, mos-1129): `host.sudo` keeps the arbiter route version 1.7 set for
`git.push.remote`. A raise for it that cites the spec and isn't within-role goes to the arbiter for
its domain (broker.mjs:1677-1678 on origin/refactor), and the grant and its allowlist still limit the
command. In manual mode the decision goes to the user. The top of this draft names this for Jason as a
change in who decides a root command on another host.
- N6 (duckworth-0057, mos-1129): the broker enforces only that the chief of staff raised a grant request.
The rest of the chain of command is the messaging lists and the role contracts (step 6 of "When this
version lands"). REQ-GRANT-1 says so.
- Approving a grant doesn't by itself let a command run without a click. That needs REQ-CRED-4 and
`host.sudo` in the routed lists, step 5 of "When this version lands" (gizmoduck-0062 item 4i).
- Which roles may request: Q188 says "Not all agents are capable or authorized to request directly. It
must follow the chain of command properly." The text makes the chief of staff the only role that raises,
the PM and the CTO ask it directly, and worker roles ask through their PM. Jason's words also name "the
main agent". That is the assistant, a separate role from the chief of staff. Reading the two as one role
would give grant rights to both and overlap their authority (Jason, Q191). So the assistant never raises
a grant request: it passes the user's request to the chief (REQ-ASSIST-1) and can revoke a grant in its
own organization (REQ-GRANT-4). Jason accepted this at Q191 ("Q13: that looks better").
- What stops one holder claiming both roles today, at refactor 011d41ba6. Nothing names the pair. The
separation holds by construction, and no check or test asserts it:
- An instance has exactly one definition (`packages/business/src/business.mjs:118-120` in checkRoles).
An assistant is an instance of the definition `assistant` (`packages/business/src/vocabulary.mjs:134`)
and the chief of staff is an instance of `chief-of-staff` (`packages/bus/src/broker.mjs:164`, `:2137`).
- A run binds one instance. `bindLaunch` takes one `role` (`broker.mjs:451-456`), refuses a second bind of
the same run in the business (`duplicate-run`, `:479`), and refuses a rebind under another role
(`launch-record-mismatch`, `:499`). The launcher spawns one process per launch and binds it to one
instance (`packages/cli/src/launcher.mjs:438-449`).
- `role.claim` claims only the run's own bound role (`broker.mjs:1689-1734`). An assistant run claims its
thread in `thread_claims` and never writes `role_claims` (`:1693-1709`). The `role_one_holder` trigger
(`packages/bus/src/migrations/3b.sql:117-119`) allows one claim per role, and nothing limits the roles
one run holds, because a run has only one.
Not enforced: the run key is per business (`broker.mjs:478`), so nothing stops one run id being bound
in two businesses. Nothing ties two runs to two different processes or OS users beyond the launcher's
own record (the B7 residual). `credential.mint` is an action (`broker.mjs:34`) and a hard wait
(`:71`), but no check limits which role raises it. The landing step adds a broker refusal for a claim
that would put both roles on one holder, with a test for each path above.
- REQ-CRED-4 keeps row 210's conditions (origin/gizmoduck/row210-on-199-r2,
agents/gizmoduck/work/queue-210/DESIGN.md:376-392): the definition declares the kind, the grant names the
agent, an exact argv-prefix allowlist with wildcards refused, the key issued, and only a reviewed role
change adds the action. Row 210 put the grant in a hand-edited business file, which stays a test path until
#436a. Under 1.8 the grant is what Approve records, so "the business file grants that agent that host"
becomes "a grant names that agent and that host".
- Revoke: Q188 says "you, Mos or the tester". Mos is Jason's assistant (REQ-ROLE-4), so the text says the
assistant, limited to grants in its own organization, and the tester only on a dev instance (Q173).
Mos accepted this with those limits (mos-1124 d).
- Approve in the Console needs the Console to know the signed-in human (Q175 phase 2, #412). Until then
Approve is `mosaic decide`, which REQ-DEC-3 already allows (mos-1124 e). This draft doesn't take
REQ-WEB-2, which stays with the Console decide buttons amendment (Vikunja 19/266). That amendment, or a
later version, moves Approve into the Console.
- Roles already use a scoped Gitea token (`<read|write>:<category>`, all and admin never granted,
`packages/business/src/vocabulary.mjs:105-106` on origin/refactor). It is a likely Gitea unit, not a settled
one. Only the machine key is designed so far. Gitea and GitHub are a later #436 draft, and minting a Gitea
token needs an admin credential the user enters, which is a design of its own (Gizmoduck's draft section
11). GitHub's rights unit isn't settled. So the text leaves both units to a later design Jason approves
(mos-1124 f), and keeps only the bounds: an expiry, and never the all or admin scope. The PRD names outcomes
and checks, not the grant code's steps, as REQ-CRED-3 does.
- Jason expects the inbox Approve and the Gitea and GitHub grants by 10-27 (Q188 point 6). That is a
delivery date, so it is in the source and not in the requirement text.
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
paragraph never edited in place except to append.
-202
View File
@@ -1,202 +0,0 @@
# Row 200: `stack-release.sh activate --sso` (Q175 phase 2 prerequisite)
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1698. Brief
`docs/plans/2026-10-20_stack-release-sso.md` (blob 426336b177f6). Rulings sage-1389 (R1 uses the activation onto
the current copy, plus a same-copy test and a latest-set mutant), sage-1403 and sage-1407 (N4), mos-1056 item 5
(N4 is a window acceptance item), sage-1432 (round 2 for webby-0028 N1 and N2), sage-1556 (round 3: rebase onto row 190's
landing 00d4cc1f6, and `--sso` for a named instance).
Base 00d4cc1f6 since round 3 (`base.txt`), row 190's landing, which has row 192 (4429a104f) below it. Rounds 1
and 2 were built on 4d487dfdc: aa1bd209e plus row 192's two commits picked. 48359dbfa is 83b32d8d8 (patch-id
8d11f48a9e03) and 4d487dfdc is r2 37582e820 (patch-id 0e32af8aa2a4 on both). The webui tree at the base matches
`launchpad/row192` at 37582e820 path for path. Rounds 1 and 2 are on `launchpad/row200`;
round 3 is on `launchpad/row200-r3` (see Round 3 below).
`candidate-manifest.sha256` lists its 3 paths; the packet files are not in it. Drift check at commit time:
round 3, origin/refactor is 00d4cc1f6 itself, so `git log 00d4cc1f6..origin/refactor` is empty.
No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. T57 runs on
the scratch roots and seeded pointers rows 187 and 189 use. Its config and slot sit in the test's temp dir,
and the slot carries the `proofFile` read watch, so a run that opened it would fail the test.
## Files
| Path | Change |
| --- | --- |
| `scripts/stack-release.sh` | `--sso` on activate (usage, parse, `sso_for_target`), `sso_checks` and `slot_problem` (item 2), `sso_base` (rollback flags, item 3), `config_checks` (item 3 and N4), the `sso:` plan line and step lines. |
| `packages/cli/tests/stack-release.test.mjs` | Fixture copies gain row 192's `--sso` marker, an old loader without `console`, and a C copy whose `ALGS` lack RS256. T57 (T56 before round 3), 11 subtests (2 in round 2, 1 in round 3). |
| `docs/TOOLS.md` | Usage line and two paragraphs after row 189's `--no-send`. |
## What it does
1. **Item 1.** `activate ID BUSINESS --sso` drops `--public-host`, `--auth-header`, `--allow-user` and any proof
pair, and puts `--sso` after the other flags, before `--send` and `--tester-port`, as `webui-service.sh`
renders it (`sso_for_target`, `:775`). It may name the live copy once. After that, both a plain activate and
an `--sso` activate refuse `already-active`. Later activates carry `--sso` (`sso: carried`; T57 covers
activate, not `pm-on`, which carries the pointer's flags as it does for every flag).
`--plan` prints `sso: on; drops ...; carries ...`, plus the proof line's `dropped; --sso drops header ...`.
`--sso` with a pair, with `--no-proof` or on another verb is a usage error.
2. **Item 2** (`sso_checks`, `:817`). These checks run whenever the run's Console flags hold `--sso`, before
anything stops:
- `sso-target`: the target's `cli.mjs` has no `--sso`.
- `sso-config`: the target copy's own `checkConsole` refuses `console`, or there is no `console.sso`. The
config is parsed in node, and only the message's first line is kept. A JSON parse error never quotes
the text.
- `sso-slot`: one per provider, from `lstat`, `stat` and `realpath` in `readSlot`'s order (`slot_problem`,
`:847`). The slot is never opened.
3. **Item 3.**
- **Rollback** (`sso_base`, `:728`): from an `--sso` pointer to a copy without `--sso`, the Console flags
come from the rollback set of the activation that moved the stack onto the pointer's copy from the
target (`from_log arrival`). That set's `show-mosaic-webui.service.txt` is checked against its
`SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
- **`sso-rollback-flags`** refuses when that set is missing, fails its sums, holds no readable argv, or
already ran `--sso`.
- **`config-console-key`** (`config_checks`, `:866`): any activate or rollback refuses before anything
stops when the config has a `console` key that the target's `scripts/mosaic-config.mjs validate` refuses.
The message says to remove the key first (restore step 1's backup), then run the verb again.
4. **N4, the first half** (sage-1403, sage-1407). With no `console.sso` and the stack on another copy, the
`sso-config` refusal names the first step: "activate ID without --sso first, then write console.sso, then
run activate ID --sso". A copy before row 192 refuses the key, so the re-pin comes first. The `--sso`
activate is then the allowed same-copy re-pin.
5. **TOOLS.md.** The flag, its refusals, the rollback rule and the N4 order.
## Window plan acceptance item (N4, mos-1056 item 5, sage-1407)
For the row 200 window plan, word for word:
- **Before the `console` key is written, the window re-pins to row 192 or later.** Check line, run before the
key is written: `git -C /mnt/storage/src/mosaic-stack merge-base --is-ancestor <row 192 landing sha>
<pointer copy sha from scripts/stack-release.sh status acme> && echo REPIN_OK`. It must print `REPIN_OK`.
If the window instead runs `activate <ID> acme --sso --plan` before the key is written, the
`refuses sso-config:` line must end with "activate <ID> without --sso first, then write console.sso, then run
activate <ID> --sso". It names that step only when the pointer is on another copy.
- **A rollback past row 192 removes the key first.** Check line, run before the rollback:
`scripts/stack-release.sh rollback acme --plan` must print no `refuses config-console-key:` line. If it
prints one, restore `config.json` from the window's step 1 backup and run the plan again. The rollback itself
refuses on the same check, so a skipped step stops it before anything stops.
- **Back-out line (webby-0028 N4, sage-1432).** Once a copy with `--sso` support is live, no verb turns `--sso`
off. Only a rollback to a copy before row 192 gets the preview set back, after the `console` key is removed
(the check line above).
## Tests run (this worktree, final tree)
- `node --test --test-name-pattern='^T56' tests/stack-release.test.mjs`: 10/10 on the round 2 tree
(`/mnt/storage/scratch/launchpad-r200/t56-r2c.log`). Round 1 was 8/8 (`t56-6.log`).
- Round 3, this tree: `node --test tests/stack-release.test.mjs`, the whole file, 192/192
(`/mnt/storage/scratch/launchpad-r200/sr-whole-r3b.log`); T57 alone 11/11 (`t57-r3a.log`);
`tests/stack-instances.test.mjs` 23/23 (`si-r3.log`). No other test reads the 3 paths or pins their hashes.
- The cli suite and every `scripts/test-*.sh` go to Sage's gate.
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`, each against T57; T56 in rounds 1 and 2)
| # | Mutant | Result |
| --- | --- | --- |
| 1 | no `sso-target` refusal (item 2) | KILLED |
| 2 | no `sso-config` refusal (item 2) | KILLED |
| 3 | no `sso-slot` refusal (item 2) | KILLED |
| 4 | no `sso-rollback-flags` refusal (item 3) | KILLED |
| 5 | no `config-console-key` refusal (item 3) | KILLED |
| 6 | keep `--auth-header` under `--sso` (item 1) | KILLED |
| 7 | keep `--public-host` under `--sso` (item 1) | KILLED |
| 8 | rollback takes the latest activation onto the copy, not the arrival (sage-1389) | KILLED |
| 9 | `sso-config` names no first step (N4) | KILLED |
| 10 | `slot_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
| 11 | `proof_file_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
| 12 | `config_checks` passes a config that doesn't parse (round 2, N2) | not run; equivalent if it survives, guard kept (sage-1446) |
| 13 | `sso_checks` reads the default config, not `MOSAIC_CONFIG` (round 3) | not run, Sage's gate |
| 14 | `sso_base` reads `show-mosaic-webui.service.txt`, not the instance unit's (round 3) | not run, Sage's gate |
9/9 killed (`/mnt/storage/scratch/launchpad-r200/mutants.log`). The script restored `stack-release.sh`, and it
matches its pre-run sha256. No mutant hit the 300 s timeout. This batch ran without Sage's slot (sage-1414),
while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and doesn't take these results
(sage-1423). Mutants 10 to 12 are new in round 2 and have not run. Sage runs 1 to 12 on the gate (sage-1446). Mutant 5's
pattern now names the `holds` message, because the round 2 message also starts with `config-console-key "$cfg`.
Every pattern matches once on the round 2 tree, and all 14 match once on the round 3 tree.
## Choices
- **R1, `--send` and `--tester-port` follow the pointer.** These two flags come from the pointer, not from the
rollback set, so a `--no-send` or a tester port set after the arrival stays. This is row 189's rule that
rollback carries `--send` off. Tradeoff: the rolled-back Console differs from the set's unit in those two
flags, and the plan line says so.
- **`config_checks` runs for activate too**, not only rollback. An activate onto a pre-192 copy with the key
in place breaks that copy's loader the same way.
- **The inside-repository slot test** symlinks the secrets directory to a temp dir under the fixture repo. The
slot file is then not a link, but its real path is inside the repository.
## Not tested
- The slot owner-uid refusal: it needs a file owned by another uid, which needs privilege.
## Review r1 notes (webby-0028, non-blocking)
- **N1.** `slot_problem` (`:859`) and row 187's `proof_file_problem` (`:988`) compare a resolved path to
`REPO="$(pwd)"` (`:26`), which is not resolved. Run through the `~/src/mosaic-stack-dev-test` symlink, a slot
inside the repository passes here, and the Console refuses it after the stop. Confirmed by reading; Sage
rules on a round 2 or a follow-up.
- **N2.** `config_checks` (`:868`) returns 0 when config.json doesn't parse. Same ruling.
- **N3.** A check this script doesn't make: `slot_problem` refuses only a size of 0. A slot holding only a
newline, or two lines, passes here, and the Console's one-non-empty-line rule (`readSlot`) refuses it after
the stop. Reading the content would break the rule that this script never opens the slot.
- **N4, for the window's back-out step.** Once the live copy supports `--sso`, no verb turns `--sso` off on it.
The only way back to the preview set is a rollback to a copy before row 192, which removes the `console` key
first (the second check line above).
## Round 2 (sage-1432: N1 and N2; N3 and N4 stay notes)
- **N1.** `slot_problem` (`:847`) and `proof_file_problem` (`:989`) now compare the slot's real path with
`realpath -m` of both `REPO` and `COPIES`, and refuse "cannot be resolved" if either fails. T57's
"round 2 (webby-0028 N1)" subtest runs the script through a symlink to the fixture repo
(`repo-link.200/scripts/stack-release.sh`). A proof file inside the repository is refused `proof-file-invalid`,
and a slot whose secrets directory links into the repository is refused `sso-slot` "is inside the repository
or the copies". Mutants 10 and 11 undo each check.
- **N2, with a deviation.** `config_checks` (`:867`) now tells "no key" (node exits 1) from "doesn't parse"
(exits 2) and refuses `config-console-key` "cannot be read as JSON" on the second. But webby-0028's case
doesn't reach it: `load_config` (common.sh, through `scripts/mosaic-config.mjs env`) exits 1 on a config that
doesn't parse, before anything is read or stopped. So the refusal is a guard for a config that changes after
`load_config` ran, and no test reaches it without a race. The T57 subtest "round 2 (webby-0028 N2)" checks
what does happen: activate, rollback and `activate --plan` each exit 1 with "common: configuration load
failed", the pointer, the start/stop log and the activation log are unchanged, and activate works after the
config is restored. Mutant 12 drops the guard and is expected to survive as equivalent for that reason. sage-1446:
the guard stays, and mutant 12 is equivalent if it survives, the same call as row 192's mutant 23.
- **N4.** The back-out line is in the window plan section above.
## Round 3 (sage-1556: rebase onto 00d4cc1f6, `--sso` for a named instance)
- **Rebase.** Row 190 landed as 00d4cc1f6 and changed all 3 paths. The 5 round 1 and 2 commits are picked onto
it on a new branch, `launchpad/row200-r3` (no force-push, no branch delete, #426(a)). `launchpad/row200` stays
as it was. `docs/TOOLS.md` and the test file applied cleanly. `scripts/stack-release.sh` conflicted in `plan()`
and in the `--plan` lines of `do_activate` and `do_rollback`. The merge keeps both sides: the `sso:` plan line
comes before the Console line, the Console and CLI lines carry row 190's `$flags` (`--instance NAME`), and
`--plan` runs `ports_unclaimed` after `console_for_target` and before `plugin_checks` and `config_checks`.
Functions on this tree: `sso_base` `:738`, `sso_for_target` `:785`, `sso_checks` `:827`, `slot_problem`
`:857`, `config_checks` `:877`, `proof_file_problem` `:999`.
- **Named instance: no code change was needed.** Row 190's `instance_select` exports `MOSAIC_CONFIG` as NAME's
config, and the named Console unit sets `Environment=MOSAIC_CONFIG` to the same file. So `sso_checks` and
`config_checks` read `~/.config/mosaic-NAME/config.json`, and the slot path is
`~/.config/mosaic-NAME/secrets/mosaic-stack/<slot>`, the directory the Console's `readSlot` reads under that
unit. `sso_base` reads `show-$CONSOLE_UNIT.txt`, so a named rollback reads `show-mosaic-webui-NAME.service.txt`
from NAME's own set. The pointer, the log and the sets are in NAME's data root. One comment (`:734`) named the
default unit's file and now says `show-$CONSOLE_UNIT.txt`.
- **Test.** New T57 subtest "round 3 (sage-1556)": instance `alpha`. A missing slot refuses `sso-slot` and names
alpha's slot path. `--plan` prints `--instance alpha` on the Console line. `activate C1 acme --sso` writes
`--sso` to `mosaic-webui-alpha.service` (with `MOSAIC_CONFIG` set to alpha's config), leaves the default
Console unit unwritten, and status matches. After the key is removed, `rollback` takes its flags from alpha's
arrival set, `show-mosaic-webui-alpha.service.txt`. The slot carries the read watch. Mutants 13 and 14 undo
the two instance-specific reads.
- **Rename.** Row 190 also named its test T56, so this row's test is now T57; `mutants.py` runs `^T57`, and the
in-repository temp dirs are `t57-slot-` and `t57-proof-`.
- **Defect from round 1, fixed.** Round 1 added `| --sso` to the activate usage line, but T53 test 6 (row 187)
and T55 test 5 (row 189) still matched the old line, so 4 tests failed (2 subtests and their parents). Rounds
1 and 2 ran only this row's test, so the failure went unseen. The round 2 tree d4ec998cc fails the same 4
(`r2-t53t55.log`), and the first whole-file run on this tree was 188/192 (`sr-whole-r3.log`). Both regexes now
include `| --sso` (`:1672`, `:1831`; T57's own check is `:1940`). Webby: please check these two lines; the
round 2 approve did not cover them (sage-1569). A grep of `packages/*/tests` and `scripts/test-*.sh` for the
usage text (`--no-proof]`, `proof-file PATH`, `usage: stack-release`) finds no third copy, and finds no
64-hex pin on any of the 3 paths. The only test files that name `stack-release` are `stack-release.test.mjs`
and `stack-instances.test.mjs` (a comment); both ran as whole files (above).
- **Not changed (note).** The N4 first step reads "activate ID without --sso first"; for a named instance the
operator also passes `--instance NAME`, as with every verb. The message doesn't repeat it.
## Not done here
- No change to the live pointer, units or config. The phase 2 window is Sage's and Mos's.
-1
View File
@@ -1 +0,0 @@
00d4cc1f6987d5eec340361c17efc2b3e58d1505
@@ -1,3 +0,0 @@
4bf241979d5cf2e83a2f209adbf3acb1a8ea091c5c384b27b5e3462fa348d9d0 docs/TOOLS.md
959567d9bf6c2435640060fcb33890a344ead7f7cbc1f8019e45ae2498fcdd4e packages/cli/tests/stack-release.test.mjs
63073fbea264549694b54cc1ae7d46ba9dbe8c5d72670da87985a5223c06ac3f scripts/stack-release.sh
@@ -1,50 +0,0 @@
# Row 200 (#1698) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's scripts/stack-release.sh,
# runs T57 in packages/cli/tests/stack-release.test.mjs and restores the file from a saved copy, checked by sha256.
# Killed = test exit != 0.
import hashlib, os, subprocess, sys, signal
R = os.path.abspath(sys.argv[1])
S = os.path.join(R, 'scripts', 'stack-release.sh')
if not os.path.isfile(S): sys.exit(f'ABORT no scripts/stack-release.sh under {sys.argv[1]}')
T, PAT = 'tests/stack-release.test.mjs', '^T57'
M = [
('no sso-target refusal (item 2)', [(" sso_supported || { check_refuse sso-target", " true || { check_refuse sso-target")]),
('no sso-config refusal (item 2)', [(' check_refuse sso-config "$TO', ' : "$TO')]),
('no sso-slot refusal (item 2)', [('[ -z "$why" ] || check_refuse sso-slot', '[ -z "$why" ] || : sso-slot')]),
('no sso-rollback-flags refusal (item 3)', [(' check_refuse sso-rollback-flags "$TO', ' : "$TO')]),
('no config-console-key refusal (item 3)', [(' check_refuse config-console-key "$cfg holds', ' : "$cfg holds')]),
('keep --auth-header under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --public-host | --allow-user)")]),
('keep --public-host under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --auth-header | --allow-user)")]),
('rollback takes the latest activation onto the copy, not the arrival (sage-1389)', [('o.to===current&&o.from!==current){process.stdout.write(`${o.activation', 'o.to===current){process.stdout.write(`${o.activation')]),
('sso-config names no first step (N4)', [(' first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"', ' first=""')]),
('slot_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]')]),
('proof_file_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]')]),
('config_checks passes a config that does not parse (round 2, webby-0028 N2; expected equivalent)', [(' [ "$key" != 1 ] || return 0', ' [ "$key" = 0 ] || return 0')]),
('sso_checks reads the default config, not the instance config (round 3, sage-1556)', [(' local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why', ' local cfg="$HOME/.config/mosaic-dev/config.json" out id slot path why')]),
('sso_base reads the default Console\'s show file, not the instance unit\'s (round 3, sage-1556)', [(' local f="show-$CONSOLE_UNIT.txt" argv=""', ' local f="show-mosaic-webui.service.txt" argv=""')]),
]
sha = lambda b: hashlib.sha256(b).hexdigest()
only = sys.argv[2:]
os.chdir(os.path.join(R, 'packages', 'cli'))
survived = []
for i, (name, pairs) in enumerate(M, 1):
if only and str(i) not in only: continue
orig = open(S, 'rb').read(); h = sha(orig)
src = orig.decode()
for a, b in pairs:
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
src = src.replace(a, b)
try:
open(S, 'w').write(src); os.chmod(S, 0o755)
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={PAT}', T], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try: out, _ = p.communicate(timeout=300)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
text = out.decode(errors='replace')
ran = [l for l in text.splitlines() if l.startswith('ℹ fail ')]
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
if verdict == 'SURVIVED': survived.append(name)
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
finally:
open(S, 'wb').write(orig); os.chmod(S, 0o755)
if sha(open(S, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {S}')
print('survived:', survived or 'none')
@@ -1,35 +0,0 @@
{
"seq": 22,
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
"at": "2026-10-20T22:41:14.444Z",
"business": "mosaic-stack",
"project": null,
"raised_by_role": "pm",
"raised_by_run": "rc9e993da24cf",
"class": "gated",
"action": "prd.approve",
"route_to": "human",
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
"options": [
{
"key": "yes",
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
},
{
"key": "no",
"text": "Don't approve. Say what changes, and a new revision comes back for review."
}
],
"recommendation": "yes",
"task_ref": null,
"requirement_ref": null,
"blocking": false,
"supersedes": null,
"spec_path": null,
"spec_commit": null,
"authorization": {
"action": "prd.approve",
"target": null,
"approvalChoice": "yes"
}
}
-37
View File
@@ -1,37 +0,0 @@
[
{
"seq": 22,
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
"at": "2026-10-20T22:41:14.444Z",
"business": "mosaic-stack",
"project": null,
"raised_by_role": "pm",
"raised_by_run": "rc9e993da24cf",
"class": "gated",
"action": "prd.approve",
"route_to": "human",
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
"options": [
{
"key": "yes",
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
},
{
"key": "no",
"text": "Don't approve. Say what changes, and a new revision comes back for review."
}
],
"recommendation": "yes",
"task_ref": null,
"requirement_ref": null,
"blocking": false,
"supersedes": null,
"spec_path": null,
"spec_commit": null,
"authorization": {
"action": "prd.approve",
"target": null,
"approvalChoice": "yes"
}
}
]
-13
View File
@@ -1,13 +0,0 @@
[mos test] Please have the PM raise a prd.approve decision for PRD 1.8, the same route as PRD 1.7 (decision d52febb5): blocking false, no task_ref, recommendation yes, gated to the human. The PM must not compute or change the sha256; it copies the values below.
Question: "Approve PRD 1.8 exactly as drafted?"
File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Reviewed by Duckworth (duckworth-0061, confirmed duckworth-0062) and checked by Mos (mos-1140). The branch tip of honker/row194-b2 holds a later r6 (aba3cbdeb) that is not used; the approval covers only the r5 commit and sha256 above.
The decision notes must carry these five items:
1. Mos's reading: "the main agent or chief of staff" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.
2. Mos's reading: revoke by "you, Mos or the tester" means the user, the assistant ("Mos", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.
3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.
4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.
5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.
Reply with the decision id once raised. Do not resolve it.
-8
View File
@@ -1,8 +0,0 @@
sent d54a190e to chief
chief (RESULT, re d54a190e) cc46a7b4:
I passed your request to the pm without changing any of it, and launched the pm so it can act on it. No decision id yet, because the pm hasn't raised the decision.
- **Message to pm:** 6990efb4-8321-4d0e-9b8a-771f65625b83. It says the request comes from a tester and authorizes nothing, and it carries your PRD 1.8 values and the five notes as you wrote them.
- **pm launch:** run rc9e993da24cf.
When the pm answers with the decision id, I'll send it to you.
-5
View File
@@ -1,5 +0,0 @@
trail 3a6c8fb9-a5ab-42f8-9755-902a88375ca2: 4 row(s)
2026-10-20T22:40:47.949Z events#1550 session.launched pm
2026-10-20T22:40:47.969Z role_claims#102 claim pm by rc9e993da24cf
2026-10-20T22:41:14.444Z decisions#22 gated prd.approve → human: Approve PRD 1.8 exactly as drafted? File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit …
2026-10-20T22:41:14.445Z events#1553 action.allowed pm decision.raise decision 3a6c8fb9
@@ -26,8 +26,7 @@ the rollback compare are reused unchanged.
- OLD, live: `490e2771d3d057ab76991e2c7859cd251857e64c`, activation `20261020T192956Z-490e2771d3d0`, pointer
`pm: true`, schema 3i, Console `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
--allow-user jason.woltje --tester-port 7333` (no `--send`, read from `stack-release.sh status` between 21:36Z and 21:39Z).
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`).
The 106 that `status` showed is OLD to HEAD 305857c8f, so it also counts the three record commits below.
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 106 commits past OLD counting records.
The commits after it (5b8def2f1 plans, 176a6bf4b sessions, 305857c8f queue) are records and don't move NEW. If
another code row lands before W0 (row 190 is the likely one), NEW stays here unless Mos rules otherwise.
- REPO: the canonical checkout. `activate` runs REPO's `webui-service.sh`, so the four service scripts are held
@@ -80,7 +79,7 @@ writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4weba
| P1 | Sage | The business file, read only, the same values as the 177-185 plan P1. I report its sha256 now rather than assume the 06:15Z value. `business-check.mjs` for OLD now and for NEW after W0: LOAD_OK both. |
| W0 | Sage | `cd $REPO && scripts/stack-copy.sh copy $NEW`, in a gap between gate-lane runs, since npm ci is heavy. Then the two npm checks above, and `diff <(git -C $REPO show $NEW:package-lock.json) $COPIES/$NEW/package-lock.json` empty. W0 writes only under `copies/` and changes nothing live, so it moves before P2 and P3, which need the copy. |
| P2 | Sage | `console-check.sh $NEW` against a stub Console of NEW on a scratch port, once without and once with `--tester-port` on a second scratch port. CONSOLE_PASS both. |
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. Correction (mos-1107): `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. So Mos also starts NEW's Console by hand with the pointer's flags on two scratch ports and reads healthz and `/api/config` on both, then stops it (`$WIN/p3/flags-check.txt`, `flags-console.log`). |
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's, with the pointer's Console flags. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. |
| P4 | Sage | The tunnel read, as in the 177-185 plan P4. |
| P5 | Sage | `git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh` and an empty `git status --porcelain` on the same four. Row 190 round 3 touches `webui-service.sh`; if it lands before W5, P5 fails and the window waits for Mos's ruling. |
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, as in the 177-185 plan, saved as `$WIN/matrix-old.txt`. |
@@ -96,7 +95,7 @@ writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4weba
| W4 | Sage | The unit reads, as in the 177-185 plan W4. |
| W5 | Sage, on Mos's go | `stack-release.sh activate $NEW mosaic-stack --pm`. Exit 0, `result ok`, every gate check including `pm_launched`. Record the activation id, the backup path and STAMP, and the backup SHA256SUMS sha256. Then `status`. |
| W9 | Sage, after Mos verifies W5 | The 177-185 plan's W9 list unchanged, the chief turn and the one `assistant` thread turn included (mos-0929 Q3), plus two lines: the webui journal since the W5 stamp (with `UTC`) has one `Proxy: proxy proof off` and no `Sign-in:` line, and `ls -l /proc/<Console pid>/cwd` names the NEW copy. |
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. Corrections after the window (mos-1135, mos-1137): the matrix line for a file the pin changes isn't "identical". Line (g) serves `index.html`, which row 192 grows by 121 bytes (6470 to 6591), so (g) is 200 non-json at 6575 bytes, not 6454. The two browser checks come from the 177-185 window, before row 192 and before a second thread project. "Only tester forms" now allows `#sign-out` while it is `hidden` and `/api/config` has no `sso: true`. "Chat lists the W9 thread" now reads every `.chat-projects section.panel` list (chat.js:93 renders one per project), not the first. |
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. |
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386 and P19 412 and on Gitea #1690. |
## Stop conditions and rollback
@@ -1,3 +0,0 @@
chief rbb708846b605 claude-code since 2026-10-20T19:29:59.053Z
pm rc36b17ef3788 claude-code since 2026-10-20T20:20:56.278Z
# exit 0
@@ -1,256 +0,0 @@
{
"settings loaded": {
"pass": true
},
"settings": {
"banner": "Tester Console. Changes are recorded as tester, never as the human.",
"h1": "Settings",
"forms": [
"sign-out",
"conv-form",
"routes-form",
"instance-add-form",
"instance-change-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form"
],
"convFormHidden": true,
"panels": [
"Routing",
"Role instances",
"Seat names"
],
"instancesTable": true,
"nameForms": 6,
"routesForm": true,
"routesSubmitEnabled": true,
"forbidden": [],
"tokenLimitsPanel": false
},
"tester banner": {
"pass": true,
"detail": "Tester Console. Changes are recorded as tester, never as the human."
},
"instance, routes and name editors present": {
"pass": true
},
"only tester forms (conv-form is index.html:21, global and hidden)": {
"pass": false,
"detail": [
"sign-out",
"conv-form",
"routes-form",
"instance-add-form",
"instance-change-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form"
]
},
"no launch settings, assistant, limit reset or send": {
"pass": true,
"detail": []
},
"no Token limits panel (plan W10 item 2, amended)": {
"pass": true
},
"board": {
"busViewHidden": true,
"banner": true,
"headings": [
"Waiting on you –",
"All sessions –"
],
"unavailable": false,
"length": 179,
"forms": [
"sign-out",
"conv-form"
]
},
"board reads": {
"pass": true,
"detail": {
"busViewHidden": true,
"banner": true,
"headings": [
"Waiting on you –",
"All sessions –"
],
"unavailable": false,
"length": 179,
"forms": [
"sign-out",
"conv-form"
]
}
},
"chat": {
"hash": "#/chat",
"banner": true,
"threads": [
{
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
"name": "w9",
"instance": "assistant"
}
],
"conversationOpen": false,
"forms": [
"sign-out",
"conv-form",
"chat-project-form"
]
},
"chat lists the W9 thread": {
"pass": false,
"detail": [
{
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
"name": "w9",
"instance": "assistant"
}
]
},
"W9 thread page not opened": {
"pass": true,
"detail": "#/chat"
},
"tester banner on chat": {
"pass": true
},
"requests": [
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/bus-status",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/inbox",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/agents",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/projects",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/threads",
"refused": false
},
{
"method": "GET",
"url": "/api/business",
"refused": false
}
],
"no write through the listener": {
"pass": true,
"detail": []
},
"no request to the W9 thread messages or open": {
"pass": true,
"detail": []
}
}
W10 browser: FAIL
# exit 1
@@ -1,200 +0,0 @@
{
"config": {
"sso": null
},
"forms": [
{
"id": "sign-out",
"hidden": true,
"action": "/auth/logout",
"method": "post"
},
{
"id": "conv-form",
"hidden": true,
"action": null,
"method": null
},
{
"id": "routes-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "instance-add-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "instance-change-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
}
],
"chat": {
"hash": "#/chat",
"open": false,
"projects": [
{
"project": "window-20261020-0801",
"threads": [
"#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28"
]
},
{
"project": "window-20261020-2205",
"threads": [
"#/chat/threads/446201de-e8b5-4df3-9356-d0d5422cc899"
]
}
]
},
"requests": [
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/bus-status",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/projects",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/threads",
"refused": false
},
{
"method": "GET",
"url": "/api/business",
"refused": false
}
]
}
sign-out hidden true, sso null; W9 thread listed true; thread page open false; writes 0
# exit 0
@@ -1,8 +0,0 @@
a 200 ok=true
b 401 error="sign-in required"
c 401 error="sign-in required"
d 403 error="proxy Host refused"
e 403 error="tester listener required"
f 403 error="proxy header refused"
g 200 non-json 6575 bytes
# exit 0
@@ -1,7 +0,0 @@
# P4 2026-10-20T22:31:29Z
DropInPaths=
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
# exit 0
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
# exit 0
@@ -1,6 +0,0 @@
ActiveState=active
DropInPaths=
MainPID=2950923
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950923 ; code=(null) ; status=0/0 }
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
# exit 0
@@ -1,12 +0,0 @@
● jarvis-curation-gate.service loaded failed failed Jarvis curation-gate liveness check (wake the agent if staged pulls sit uncurated)
● jarvis-retro-check.service loaded failed failed Jarvis weekly retrospective ARTIFACT check (did Sunday's tick produce a retro?)
● jarvis-retro.service loaded failed failed Jarvis weekly retrospective tick
● mosaic-install-drift.service loaded failed failed Assert the installed Mosaic binaries still match the committed source (drift, not failure)
● mosaic-standup-pm.service loaded failed failed Coordinator standup trigger (evening)
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-curation-gate.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro-check.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-install-drift.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-reconcile.service to the local agent seat
● setspark-tmp-guard.service loaded failed failed SetSpark: /tmp and scratch guard
# exit 0
@@ -1,5 +0,0 @@
ActiveState=active
SubState=running
ActiveEnterTimestamp=Tue 2026-10-13 08:40:19 CDT
MainPID=1317
# exit 0
@@ -1,6 +0,0 @@
ActiveState=active
DropInPaths=
MainPID=2950925
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950925 ; code=(null) ; status=0/0 }
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
# exit 0
@@ -1,2 +0,0 @@
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
# exit 0
@@ -1,7 +0,0 @@
PASS markdown-preview.js is served and equals the copy's
PASS /api/config on loopback: send=false edit=false
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
PASS loopback save refused: HTTP 403 error=read-only sent=false
CONSOLE_PASS
# exit 0
@@ -1,19 +0,0 @@
[
{
"business": "mosaic-stack",
"instance": "chief",
"run": "r097436c91816",
"launchedBy": "jason",
"harness": "claude-code",
"model": "claude-opus-5-5",
"provider": "anthropic",
"modelKey": "opus",
"pid": 2951160,
"startTime": "63491912",
"runnerPid": 2951161,
"runnerStartTime": "63491912",
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
"startedAt": "2026-10-20T22:02:08.941Z",
"live": true
}
]
@@ -1,2 +0,0 @@
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
# exit 0
@@ -1,2 +0,0 @@
[]
# exit 0
@@ -1,2 +0,0 @@
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
# exit 0
@@ -1 +0,0 @@
1792533965
@@ -1,21 +0,0 @@
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
log (last five lines):
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
check:
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
result: pass
bus status:
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
socket: present
writer.lock: pid 2950981
boot: organization file loaded at 2026-10-20T22:02:08.125Z
unit flags: match the pointer
drift: 6 commit(s) on refactor since the pointer's copy
# exit 0
@@ -1,4 +0,0 @@
sent c5b43f04 to chief
chief (RESULT, re c5b43f04) c3e236d7:
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
# exit 0
@@ -1,4 +0,0 @@
sent b4b66ced to window-20261020-2205/w9
assistant (RESULT, re b4b66ced) cfa7ea99:
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
# exit 0
@@ -1,32 +0,0 @@
[
{
"seq": 570,
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
"at": "2026-10-20T22:05:58.283Z",
"business": "mosaic-stack",
"from_role": "tester",
"from_run": "tester-cli",
"to_role": "assistant",
"class": "REQUEST",
"in_reply_to": null,
"decision": null,
"corrects": null,
"body": "[mos test] reply with one line, then stop",
"to_name": null
},
{
"seq": 571,
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
"at": "2026-10-20T22:06:04.247Z",
"business": "mosaic-stack",
"from_role": "assistant",
"from_run": "r497f1c817d4d",
"to_role": "tester",
"class": "RESULT",
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
"decision": null,
"corrects": null,
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
"to_name": null
}
]
@@ -1,2 +0,0 @@
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
# exit 0
@@ -1,3 +0,0 @@
window-20261020-0801/w9 assistant d0ba7a66
window-20261020-2205/w9 assistant 446201de
# exit 0
@@ -1 +0,0 @@
446201de-e8b5-4df3-9356-d0d5422cc899
@@ -1,165 +0,0 @@
{
"total": {
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
},
"byRun": [
{
"run": "r8fba7e598156",
"input": 0,
"output": 0,
"cacheWrite": 0,
"cacheRead": 0,
"unknownTurns": 1
},
{
"run": "r4a96e2142e4d",
"input": 2,
"output": 52,
"cacheWrite": 11750,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "rc78c9700c8e9",
"input": 14,
"output": 1519,
"cacheWrite": 18187,
"cacheRead": 89559,
"unknownTurns": 0
},
{
"run": "r293f33bcbc63",
"input": 2,
"output": 44,
"cacheWrite": 18595,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "r34520a581318",
"input": 6,
"output": 323,
"cacheWrite": 26349,
"cacheRead": 14307,
"unknownTurns": 0
},
{
"run": "r7e99838f9f93",
"input": 2,
"output": 44,
"cacheWrite": 9858,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "rc964611b2092",
"input": 4,
"output": 120,
"cacheWrite": 26138,
"cacheRead": 23870,
"unknownTurns": 0
},
{
"run": "rbb708846b605",
"input": 12,
"output": 2089,
"cacheWrite": 17285,
"cacheRead": 73823,
"unknownTurns": 0
},
{
"run": "rc36b17ef3788",
"input": 16,
"output": 5221,
"cacheWrite": 36717,
"cacheRead": 202573,
"unknownTurns": 0
},
{
"run": "r097436c91816",
"input": 2,
"output": 131,
"cacheWrite": 11915,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "r497f1c817d4d",
"input": 2,
"output": 80,
"cacheWrite": 9856,
"cacheRead": 0,
"unknownTurns": 0
}
],
"byInstance": [
{
"instance": "chief",
"input": 36,
"output": 4114,
"cacheWrite": 85486,
"cacheRead": 177689,
"unknownTurns": 1
},
{
"instance": "coder",
"input": 2,
"output": 44,
"cacheWrite": 18595,
"cacheRead": 0,
"unknownTurns": 0
},
{
"instance": "assistant",
"input": 4,
"output": 124,
"cacheWrite": 19714,
"cacheRead": 0,
"unknownTurns": 0
},
{
"instance": "pm",
"input": 20,
"output": 5341,
"cacheWrite": 62855,
"cacheRead": 226443,
"unknownTurns": 0
}
],
"byProvider": [
{
"provider": "anthropic",
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
],
"byModel": [
{
"provider": "anthropic",
"model": "claude-opus-5-5",
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
],
"byAccount": [
{
"account": null,
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
]
}
# exit 0
@@ -1,2 +0,0 @@
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
# exit 0
@@ -1,19 +0,0 @@
[
{
"business": "mosaic-stack",
"instance": "chief",
"run": "r097436c91816",
"launchedBy": "jason",
"harness": "claude-code",
"model": "claude-opus-5-5",
"provider": "anthropic",
"modelKey": "opus",
"pid": 2951160,
"startTime": "63491912",
"runnerPid": 2951161,
"runnerStartTime": "63491912",
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
"startedAt": "2026-10-20T22:02:08.941Z",
"live": true
}
]
@@ -1,153 +0,0 @@
{
"schema": "3i",
"tables": {
"decision_events": {
"rows": 21,
"maxSeq": 21,
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
},
"decisions": {
"rows": 21,
"maxSeq": 21,
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
},
"deliveries": {
"rows": 1726,
"maxSeq": 1726,
"sha256": "8667571f4a05ce3621b04cf2dc5eb0311bac3d3aff1858584c78445c5c4c2fb4"
},
"events": {
"rows": 1525,
"maxSeq": 1525,
"sha256": "e51827e31f2fa5a9a50993e5a10f37f1dee383b7d673f793006bd2ba1e9a5573"
},
"instance_names": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"message_threads": {
"rows": 2,
"maxSeq": null
},
"messages": {
"rows": 567,
"maxSeq": 567
},
"meta": {
"rows": 3,
"maxSeq": null,
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
},
"projects": {
"rows": 1,
"maxSeq": 1,
"sha256": "a3945f7462895e0e302029c542d384a6d33e559da900e6461d78bbcd7cc28b16"
},
"role_claims": {
"rows": 98,
"maxSeq": 98,
"sha256": "ac6694fd0b365469f7486ae1f31486cd52b31be7bc0399938b7361f22454362e"
},
"task_comment_cursors": {
"rows": 215,
"maxSeq": 215,
"sha256": "7f80df8efb90b502a9530a7ed5068d59be59b2eb318aa4e64ce6bf481762a5e9"
},
"task_snapshots": {
"rows": 401,
"maxSeq": 401,
"sha256": "cced8c7f90ecaa60eee3010d987013d8c0301563cb888860fd91b8c5a02f0a00"
},
"thread_claims": {
"rows": 2,
"maxSeq": 2,
"sha256": "6046c7088779aa2f6e9b3fd2c7520404b1678a4fcae71c2b9157a008bba22a5f"
},
"thread_opens": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"threads": {
"rows": 1,
"maxSeq": 1,
"sha256": "3043721efd386f23dfdaad4acf41e0d553ff6182f0068c2133a615141bcf772a"
},
"usage_records": {
"rows": 30,
"maxSeq": 30,
"sha256": "7ecbc0606273043b017d778bb23880377bfe367f788d234289b7c132caa588f2",
"byRun": [
{
"business": "mosaic-stack",
"run": "r8fba7e598156",
"input": "0",
"output": "0",
"unknownTurns": 1
},
{
"business": "mosaic-stack",
"run": "r4a96e2142e4d",
"input": "2",
"output": "52",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc78c9700c8e9",
"input": "14",
"output": "1519",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r293f33bcbc63",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r34520a581318",
"input": "6",
"output": "323",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r7e99838f9f93",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc964611b2092",
"input": "4",
"output": "120",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rbb708846b605",
"input": "12",
"output": "2089",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc36b17ef3788",
"input": "16",
"output": "5221",
"unknownTurns": 0
}
]
}
},
"untimed": [
"message_threads",
"meta",
"task_comment_cursors"
]
}
@@ -1,167 +0,0 @@
{
"schema": "3i",
"tables": {
"decision_events": {
"rows": 21,
"maxSeq": 21,
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
},
"decisions": {
"rows": 21,
"maxSeq": 21,
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
},
"deliveries": {
"rows": 1740,
"maxSeq": 1740,
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
},
"events": {
"rows": 1543,
"maxSeq": 1543,
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
},
"instance_names": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"message_threads": {
"rows": 4,
"maxSeq": null
},
"messages": {
"rows": 571,
"maxSeq": 571
},
"meta": {
"rows": 3,
"maxSeq": null,
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
},
"projects": {
"rows": 2,
"maxSeq": 2,
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
},
"role_claims": {
"rows": 101,
"maxSeq": 101,
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
},
"task_comment_cursors": {
"rows": 220,
"maxSeq": 220,
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
},
"task_snapshots": {
"rows": 408,
"maxSeq": 408,
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
},
"thread_claims": {
"rows": 4,
"maxSeq": 4,
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
},
"thread_opens": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"threads": {
"rows": 2,
"maxSeq": 2,
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
},
"usage_records": {
"rows": 34,
"maxSeq": 34,
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
"byRun": [
{
"business": "mosaic-stack",
"run": "r8fba7e598156",
"input": "0",
"output": "0",
"unknownTurns": 1
},
{
"business": "mosaic-stack",
"run": "r4a96e2142e4d",
"input": "2",
"output": "52",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc78c9700c8e9",
"input": "14",
"output": "1519",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r293f33bcbc63",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r34520a581318",
"input": "6",
"output": "323",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r7e99838f9f93",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc964611b2092",
"input": "4",
"output": "120",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rbb708846b605",
"input": "12",
"output": "2089",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc36b17ef3788",
"input": "16",
"output": "5221",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r097436c91816",
"input": "2",
"output": "131",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r497f1c817d4d",
"input": "2",
"output": "80",
"unknownTurns": 0
}
]
}
},
"untimed": [
"message_threads",
"meta",
"task_comment_cursors"
]
}
@@ -1,193 +0,0 @@
# Re-pin window 490e2771d to 4429a104f (row 192, Q175 phase 1), STATE report (W11)
Operator and tester: Sage. Plan: `agents/sage/work/window-plan/repin-4429a104f.md` at f41729178, sha256
e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9 (checked before W0b, W9). The plan file in
this commit carries three corrections made after the window. They are listed at the end, so its sha256 differs
now. Tool blob 3b068b1546d4 (`scripts/stack-release.sh`). OLD 490e2771d3d057ab76991e2c7859cd251857e64c, NEW
4429a104f6d65ccf165f6c83b5d1dce3147dfde0. Every stamp below comes from `date -u` or from the transcript `W.txt`
in this directory. Mos's gos: mos-1113 (W5), mos-1114 (W9 to W11), mos-1128 (W9b accepted), mos-1135 (W10
browser part), mos-1137 (line g, post W11).
## Outcome
NEW is live for mosaic-stack with its PM, and the store stays on 3i. Row 192's sign-in code is in the copy but
dormant, because the Console runs without `--sso`. W0 to W10 passed, and nothing was rolled back. W10 stopped
once on two failed browser checks. I diagnosed both read-only as checks written before row 192, not faults in
NEW, and Mos passed them (mos-1135). Matrix line (g) isn't byte-identical, for a known cause, and Mos passed it
(mos-1137). Nobody acted as Jason. There was no `--by jason`, no pi, no Save and no `mosaic name`, and
`bus.tester` was not touched.
## Stamps and activation
| Step | Start (UTC) | Result |
| --- | --- | --- |
| P1 values, business-check OLD | file written 21:43:13Z | values as planned, LOAD_OK (`p1.txt`) |
| W0 copy NEW with npm ci, business-check NEW | 21:45:57Z | copied, lockfile diff empty, LOAD_OK (`w0.txt`) |
| P2 console-check on a stub of NEW | 21:46:09Z | CONSOLE_PASS without and with `--tester-port` (`p2.txt`) |
| P3 rehearsal (Mos) | 21:52:31Z to 21:52:34Z | REHEARSAL_PASS, rc 0 (`p3/`) |
| P3 flags check (Mos) | 21:53:52Z to 21:53:56Z | NEW Console with the pointer flags on 7341/7342: healthz 200 both, then gone, 0 listeners |
| P4, P5, P6 | 22:00:17Z | P4 PASS, P5 PASS (HEAD 4385b9ec1), `matrix-old.txt` |
| W0b business file copy | 22:00:49Z | sha256 1e49bdf0 |
| W1 idle check, S1 | 22:00:49.758Z | chief idle; S1 a7649760 |
| W2 status | 22:00:49.819Z | pointer OLD, pm true, 3i, unit flags match |
| W3 plan | 22:00:50.068Z | exit 0 (`W3-plan.txt` 45de68d4) |
| W4 unit read | 22:00:50.238Z | OLD units, no drop-ins, bus `--pm`, no quote or backslash |
| W5 activate (mos-1113) | 22:02:06.776Z, end 22:02:09.556Z | exit 0, gate passed, activate ok |
| W5 status | 22:02:13.885Z | pointer NEW, pm true, 3i, unit flags match |
| W9 checks and turns (mos-1114) | 22:05:51Z to 22:06:06Z | pass |
| W9b run end and S2 | 22:21:05.171Z, S2 22:21:07Z | ended by the launcher (thread-idle); S2 1a444f5a |
| W10 browser (stop), diagnosis | 22:29:14Z, 22:30:02Z | 10 of 12, then both failures traced to the checks |
| W10 matrix, P4 (mos-1135) | 22:31:29Z | a-f identical, g +121 bytes; P4 PASS |
| W11 reads | 22:44:33Z to 22:44:34Z | units, tunnel, business file, failed units |
Activation id 20261020T220206Z-4429a104f6d6. Pointer `at` 2026-10-20T22:02:09Z.
## Backups and hashes
- Store backup: `~/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6`, SHA256SUMS sha256
b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9.
- Rollback set: `~/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6`. Rollback goes to 490e2771d.
- Business file: 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 at P1, at W0b and again at
W11. The W0b copy is `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z` with the
same hash. Nothing in the window wrote it. `backups.txt` holds the W0b and W5 lines.
## Pointer before and after
Before (W2): copy 490e2771d3d0, pm true, schema 3i, activation 20261020T192956Z-490e2771d3d0. Console flags
`--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje
--tester-port 7333`.
After (W5, W9 and W11): copy 4429a104f6d6, pm true, schema 3i, activation 20261020T220206Z-4429a104f6d6. The
Console flags are the same, with no `--send`, no `--sso` and no proof pair. Bus pid 2950923 and Console pid
2950925, both since 22:02:08Z. Status shows drift of 6 commits on refactor since the pointer's copy (records).
## Unit ExecStart lines (no Environment values)
W11, after. Both units active, DropInPaths empty, WorkingDirectory the NEW copy:
```
bus: ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm (MainPID 2950923)
webui: /usr/bin/node ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 (MainPID 2950925)
```
W4 read the same two lines on OLD's copy (W.txt 329-344). The bus Environment has 2 assignments with no quote or
backslash. The values are not recorded. The Console's cwd is the NEW copy (W9). The webui journal since W5 has one
"Proxy: proxy proof off" line and no "Sign-in:" line.
Tunnel: `mosaic-preview-tunnel.service` active, running, MainPID 1317, since 2026-10-13 08:40:19 CDT. The window
didn't touch it. P4 before and after shows one R: spec, `R:0.0.0.0:7332:127.0.0.1:7332`.
## S1, S2 and the store
S1 (`S1.json`, sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b) is W1's held read at
22:00:49Z. S2 (`agents/sage/work/window-repin-4429a104f/S2.json`, sha256
1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173) is the held read after the assistant run
ended, at 22:21:07Z. Both are 3i. SNAP (`snapshot.mjs`, 639fd28a) was checked before each.
| Table | S1 rows | S2 rows | S2 vs S1 |
| --- | --- | --- | --- |
| decisions, decision_events | 21, 21 | 21, 21 | same sha256 |
| meta, instance_names, thread_opens | 3, 0, 0 | 3, 0, 0 | same sha256 |
| deliveries | 1726 | 1740 | +14 |
| events | 1525 | 1543 | +18 |
| role_claims | 98 | 101 | +3 |
| messages | 567 | 571 | +4 (counts only) |
| message_threads | 2 | 4 | +2 (counts only) |
| usage_records | 30 | 34 | +4 |
| projects, threads, thread_claims | 1, 1, 2 | 2, 2, 4 | +1, +1, +2 |
| task_snapshots | 401 | 408 | +7 |
| task_comment_cursors | 215 | 220 | +5 |
For every grown table with a seq, maxSeq grew by exactly the row delta.
The task_snapshots and task_comment_cursors rows (mos-1128 item 2). The writer is the bus's own Vikunja poll, not
an agent. `packages/bus/src/process.mjs:104` loads the tasks adapter. Its timer runs `tick` every `pollSeconds`
(`packages/tasks/src/adapter.mjs:192-197`), and `sync.mjs:169` calls `broker.recordTask` with no cap
(`packages/bus/src/broker.mjs:1123`). That writes `source 'poll'` with role and run null (`broker.mjs:1159-1174`)
and the comment cursors (`:1131-1138`). The adapter logs only on an error (`adapter.mjs:115`), so the bus journal
has no line for a good tick. That's why there's no sync line between 22:05Z and 22:22Z. The rows, read
read-only after the window:
- task_snapshots 402-404 at 22:05:08Z: P19 436, 435 and 434, all updated 22:04:55-56Z. 405-406 at 22:12:38Z: 438
and 437. 407 at 22:17:08Z: 438 again. 408 at 22:19:08Z: 431. All have `source poll`, `via cursor`, role null
and run null. These are tracker edits made by seats during the window, which the poll picked up.
- task_comment_cursors 216-220: tasks 424, 432, 438, 431 and 436, last comments 397 to 401. These are the
comments seats posted on P19 in the same minutes (398 is Mos's comment on #432).
Rows after S2 (task_snapshots 409 at 22:33Z, cursors 221-223) are the same poll and are outside the window.
## W9 output
- Status, console-check (CONSOLE_PASS), the journal counts, the Console cwd and one running chief from this
start (r097436c91816) all passed.
- `talk chief --tester` (c5b43f04): the chief replied that a tester message authorizes nothing and it took no
action.
- Project `window-20261020-2205`, thread 446201de-e8b5-4df3-9356-d0d5422cc899. `talk --thread` (b4b66ced) got one
assistant reply from run r497f1c817d4d. `thread read` shows the tester REQUEST and the assistant RESULT.
- `threads`, `names` (empty) and `usage` read fine. The human-thread probe was skipped: S1's one thread is the
177-185 window's tester thread d0ba7a66, so no human thread exists.
- W9b: the launcher logged launch at 22:06:00.766Z, thread-idle at 22:21:05.149Z and end at 22:21:05.171Z
(reason stopped, exit 0). Nobody stopped it by hand.
## W10
The browser check (`w10-browser.mjs`, dda57ccf) ran against 127.0.0.1:7333 with fetch and XHR limited to GET and
HEAD. It made no writes and sent no W9 thread request. 10 of 12 checks passed. The two that failed:
1. "Only tester forms": `#sign-out` was in the list. It is row 192's form at `packages/webui/src/public/index.html:12`,
`hidden`. `app.js:479` shows it only when `/api/config` has `sso: true`. The live config has no `sso`
key, and the form stayed hidden.
2. "Chat lists the W9 thread": the check read only the first `ul.chat-thread-list`. `chat.js:93` renders one list
per project, and W9 made the second project. The diagnosis (`w10-diag.mjs`, 42d0844b) read every panel and
found 446201de listed.
Both checks come from the 177-185 window (Darkwing's `window-rows-177-185/w10-browser.mjs`), written before row
192 and before a second thread project existed. Corrected, they now read:
- Only tester forms, allowing `#sign-out` when it is `hidden` and `/api/config` has no `sso: true`.
- The W9 thread is in some `.chat-projects section.panel` thread list, not only the first.
Both corrections are in the plan's W10 line, for the next window that copies this one.
The matrix (`matrix-new.txt` against `matrix-old.txt`). Lines a to f are byte-identical. Line g is 200 non-json
both times, but 6454 bytes on OLD and 6575 on NEW. `index.html` is 6470 bytes at 490e2771d and 6591 at 4429a104f,
+121, and the only hunk is the line 12 form. The served page grew by the same 121. The plan's W10 line now says a
line for a file the pin changes isn't "identical", with this case as its example. P4 again at 22:31:29Z: PASS.
## Failed user units (mos-1128 item 3)
All three predate the re-pin at 22:02Z and come from the legacy `~/.mosaic` and jarvis-brain setup, not the
stack. P19 #393, "dragon-lin: seven legacy user units in failed state", covers all three (filed by Mos on
10-20 at about 01:42Z). It has no comments yet. The current failure times are later than the ones #393 lists,
so I added a comment with them. Times are from `systemctl --user show` (CDT) with UTC alongside.
| Unit | Runs | Failed | #393 |
| --- | --- | --- | --- |
| mosaic-standup-pm.service | `~/.mosaic/tools/coordinator/standup_trigger.py pm` | 2026-10-19 19:00:28 CDT (10-20 00:00:28Z), exit 1 | listed |
| mosaic-install-drift.service | `~/src/jarvis-brain/scripts/alerting/mosaic-install-drift` | 2026-10-20 00:12:54 CDT (05:12:54Z), exit 9 | listed, with an older time |
| mosaic-unit-failed@mosaic-reconcile.service | `~/.local/bin/mosaic-unit-failed mosaic-reconcile.service` | 2026-10-20 13:16:12 CDT (18:16:12Z), exit 4 | listed, with an older time |
The marker's journal at 18:16:12Z says the escalation's tmux send to jarvis failed ("tmux target not found",
socket mosaic-fleet), so the page wasn't delivered. The record is on disk in
`~/.local/state/mosaic-unit-failures/20261020.jsonl`. `mosaic-reconcile.service` failed at that time (exit 7)
but last ran OK at 17:16:27 CDT (22:16:27Z). The marker stays failed until something resets it. The journal no
longer has lines for the other two units. The window changed none of these units, and touching them is
outside it (`~/.mosaic` is off limits, and #393 needs Jason or ops-01's charter to disable any). W11's list also
shows `setspark-tmp-guard.service` failed. It belongs to another project, and I've left it.
## Corrections to the plan, made after the window
- NEW is 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`), not 106. The 106 in `status` was OLD
to HEAD 305857c8f, so it also counted the three record commits.
- P3: `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. Mos's
separate flags check covers that (`p3/flags-check.txt`, `p3/flags-console.log`). The line was wrong as
written (mos-1107).
- W10: the matrix line g size change and the two corrected browser checks, as above.
## Files
`W.txt` (transcript), `S1.json`, `S2.json`, `matrix-old.txt`, `matrix-new.txt`, `backups.txt`, `W3-plan.txt`,
`W5.txt`, `W5-status.txt`, `w0.txt`, `p1.txt`, `p2.txt`, `p3/` (Mos's rehearsal and flags check), `p456.sh`,
`w9.sh`, `w9b.sh`, `w9.out`, `w9b.out`, `w10-browser.mjs`, `w10-diag.mjs` with their outputs (the three
screenshots stay on disk in this directory, uncommitted, since the push check refuses binaries),
and the `.w9*`, `.w10*` and `.w11*` reads. The store snapshot tool is `snapshot.mjs` (639fd28a).
File diff suppressed because it is too large Load Diff
@@ -1,116 +0,0 @@
plan: activate for mosaic-stack
from: 490e2771d3d057ab76991e2c7859cd251857e64c
to: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
store schema: 3i (read from the store the running bus holds)
target schema: 3i
migration: none; the store is at the target's version
backup: the run backs up the stopped store to /home/jwoltje/.mosaic-dev/backups/stack/<activation>/ before any migration
commits: the run adds 103 (newest first, at most 200):
4429a104f webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
32b54ac3b plans: Case 2 row briefs from Gosalyn's 51006710a (webby-0034 N1/N2 and webby-0035: row A README :711 one line per variable, C2-13 owns README :713 and says the precheck prints only variable names)
6025ddd9c plans: Case 2 row briefs from Gosalyn's a2d94b2c2 (Webby r9 notes, webby-0032: row A README line on claude-session.env and the dist/providers/data/zai.json path, C2-13 XDG_DATA_HOME precheck and last-assignment-wins key file parse)
ecbfd6deb plans: Case 2 row briefs from Gosalyn's 29e85196b (design r9 for mos-1090: row A out of scope points at design 10 step 2d, C2-13 precheck checks the hub key file by name and mode, claude marker check only for claude-code harness, EnvironmentFile= fixed in case2-unit.sh)
0498c0613 agents: Gosalyn seat dir, README from gosalyn/case2-design, so Case 2 rows can name the seat as owner (#416)
49d1f3a23 plans: Case 2 slice row briefs plus rows A and B, from Gosalyn's f325657c7 on gosalyn/case2-design (#416, design r8, C2-13 copy id and profile asks for row 195, C2-20 count-only note, row A ZAI_API_KEY per mos-1086/mos-1089, row B #428 after 10-27)
628e2ca5c sessions: Filbert row 190 manifest round 1 CHANGES (base drift on row 189)
088087ac9 queue: revs 2248-2361+. Sage: rows 210 (#424 host access, Gizmoduck) and 211 (#426 force-push guard, Rocko) added and briefed; row 195 re-briefed to the Case 1 install on .53; review requests for 200 r1/r2 and 202; plus earlier Sage revs. Other seats' revs: Launchpad 2254, 2283-2284, 2286, 2311-2313, 2328-2329, 2344, 2347-2348 (rows 192/200); Honker 2255; Morgana 2257, 2320, 2326-2327 (row 204); Gander 2262; Herb 2265; Dewey 2268; Bushroot 2273; Megavolt 2276; Webby 2303, 2316, 2339, 2355 (row 192/200 verdicts); Gizmoduck 2304, 2318-2319 and the row 210 claim (rows 201/210); Quackerjack 2317, 2341-2342 (row 202); Duckworth 2323, 2335, 2353 (rows 201/204/202 verdicts); Darkwing 2354, 2357 (row 190 in-review)
a0fbefd48 plans: pre-push force-push hook and failing busy check brief (Vikunja #426, mos-1078, mos-1083; owner Rocko, reviewer Dewey)
f24f2cd40 plans: row 195 brief becomes the Case 1 install on 10.1.1.53 (Q181 hosts, Q182/Q183 recorded acceptance, Q185 existing account, chained on rows 190 and C2-13; ops-01 root steps under #423 step 1; sage-1439)
9ba31b5b3 agents: Honker's PRD 1.7 draft r2 on refactor for the prd.approve raise (11cb0423b, sha256 e896746df595d1b5155130ca163fbf63b0dc38a87a8dd154af77f1a6d1328647, Duckworth approved duckworth-0039, Vikunja #420)
9db578ff5 plans: agent host access with sudo through the credential store brief (#1707, Vikunja #424, Q183/Q184, chained on row 199)
8eb9ddd12 plans: FED R8 brief to r4.11 (b93f098f0, recorded acceptance, Duckworth N1/N2, #416 date), R5 context source (sage-1419), 193a makes covers level required (duckworth-0033 N1)
ae3c36f24 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414)
a2af7ab49 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row
aa1bd209e gate-lane: row 189 gate timing table for row 197 (Herb)
ace27dc5e row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed)
a4a42dbac plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192)
a31f1a0f8 plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019)
992650232 plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir
a7661ada0 plans: row 195 acceptance script takes Gizmoduck's gate item list; B8 issuer and TLS front end (mos-1011)
f871cb309 plans: retire the control board writer on 7331 brief (#1696, Bushroot, reviewer Webby, mos-1008 item 2); Bushroot seat dir
0bbd23c61 plans: row 195 brief adds the identity provider check and flags any new model account for Jason (mos-1008)
0217b71c8 plans: row 195 brief rewritten as the hub on its own host, host left to Jason (#1693, mos-0998, mos-1005)
c6f528a32 plans: second gate lane brief (#1695, Herb, reviewer Webby, mos-0977 item 6); Herb seat dir; gate template and busy check copies for the row
96f243faf plans: human boundary row brief (P19 333, #1694, Gander, reviewer Duckworth, mos-0989); Gander seat dir; seat READMEs name the credentials row, not row 195 (now 190b)
c86269935 sessions: Filbert row 190 design review r3, approved
48ebee0ff plans: row 190 brief scopes isolation to addressing on one uid, points to row 190b (Filbert N7, mos-0986)
66bdd84b8 sessions: Filbert row 190 design review r2, changes requested
6966164f0 plans: row 190b brief, Melanie's hub under its own Linux user (#1693, Darkwing, reviewer Duckworth), mos-0986
53bb59462 sage: common start for MVP T1 builder seats (mos-0974, mos-0977, mos-0981)
5e01c3683 agents: seat dirs and roster rows for Launchpad, Morgana, Honker (Sage's T1 builders) and Webby, Duckworth (Mos's reviewers); row 193 brief takes Mos's B1 starting rule (mos-0985)
9f56d2098 plans: rows 192 and 194 review by Webby, not Filbert or Dewey (mos-0981)
0599b119f plans: MVP T1 briefs, SSO phase 1 (#1690, Launchpad), human users (#1691, Morgana), agent lifecycle (#1692, Honker), mos-0974/0977
b416d2ede sage: Q175 SSO design, Mos C1 condition (mos-0977): straggler check before Jason step 4 in the phase 2 window
5fc73a729 revs 2237-2247: row 189 added, briefed, started, in-review round 1 (Dewey, comment 29457); rows 190 (Darkwing) and 191 (Rocko) added and briefed for MVP T1 (mos-0974); darkwing-190-start (rev 2245, Darkwing's) and rocko-191-start (rev 2244, made for Rocko)
99772421a sage: row 189 candidate manifest for Dewey's review (d6116771f)
da230ca48 plans: MVP T1 briefs, several instances per host (#1688, Darkwing) and the kill switch (#1689, Rocko), mos-0974
4f2d1be68 sessions: Filbert Q175 SSO design D1/D2 ack
d55d86bb0 sage: Q175 SSO design r3 fix, Filbert r3 D1 (loader checks slot shape only) and D2 (slot from config dir, scratch Console dataRoot and unreachable issuer)
c56ccf916 sessions: Filbert Q175 SSO design review r3, approve with D1
817e8f51b sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E
5d019d876 sessions: Filbert Q175 SSO design review r2, changes
59475fc5a sage: row 163 landing record, P19 comments 372/373 on 337/412, and P19 #413 filed for the --no-send row
1a268249e sage: Q175 SSO design r2 for #412, Filbert r1 B1-B6, rulings Q-B/Q-C/Q-D, config in config.json (mos-0973)
def2e7932 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413)
c30b36fff sessions: Filbert Q175 SSO design review r1, changes
9ae8c73ac queue: revs 2235-2236: row 163 Sage landing note (8ecc7a239, patch-id b8d5f8a37c24, gate passed on eaa0f2666) and done
8ecc7a239 row 163 (#1660): stack-release.sh rehearse, the D2 rehearsal on a scratch root from a backup, and bus start --no-launch (Sage; Filbert approve 29447; Sage gate passed)
7f57419b3 sage: Q175 SSO design for #412, OIDC relying party on oauth4webapi 3.8.8, for Filbert review (mos-0970, mos-0971)
4ca8d6e94 sage: row 42 run log, S7 reachability pass (51cebff7 answered by cff1e9ad after a chief launch, mos-0969)
22e5e825b sage: row 42 run log, plain mosaic on a pinned host supersedes the 10-12 full-path line (mos-0968)
e6dea74b2 sessions: Sage row 163 r2 approval intake; P19 337 comment 368 script and record
cd2b377bb queue: rev 2234: row 163 r2 approve from Filbert (op filbert-163-r2, comment 29447, candidate c6f4f4c12596)
ceccb0ef2 sessions: Filbert row 163 r2 review, approve
10ab8ea62 sessions: Sage row 163 r2 in-review; P19 337 comment 367 script and record
c539995cf queue: revs 2231-2233: row 163 r2 in-review from Sage (ops sage-163-r2-start, sage-163-inreview-2, .outcome; request comment 29446, reviewer Filbert, manifest c6f4f4c12596)
92cc6011c queue: rev 2230: row 163 r1 changes from Filbert (op filbert-163-r1, comment 29445, candidate 430212299beb)
7af166574 sage: row 163 r2 packet (candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5: diff, manifest c6f4f4c12596, gate passed, mutants K34-K41 killed)
2244e8baf sessions: Filbert row 163 r1 review, changes
bde0fdec9 sessions: Sage row 163 r1 in-review; P19 337 comment 366 script and record
def677bc7 queue: revs 2228-2229: row 163 r1 in-review from Sage (ops sage-163-inreview-1, .outcome; request comment 29444, reviewer Filbert, manifest 430212299beb)
51f67f1f8 sage: row 163 packet (candidate 4be3f6aec on 580500699: diff, manifest 430212299beb, gate passed, mutants K1-K33 killed after four survivors got tests)
5b298bd00 sessions: Dewey row 66 r1 (changes) and r2 (approve) reviews
565bc32c2 queue: revs 2223-2227: row 66 r2 round from Sage (2223-2225, ops sage-r66-r2-start, sage-r66-inreview-2); r2 verdicts from Filbert (2226, op filbert-66-r2, approve) and Dewey (2227, op dewey-66-r2, approve)
5140aa9e8 sessions: Filbert row 66 r2 review, approve
973cb5a87 queue: revs 2220-2222: row 163 Sage start (2220); row 66 r1 verdicts from Filbert (2221, op filbert-66-r1) and Dewey (2222, op dewey-66-r1)
b82ec9353 sage: row 66 r2 packet (candidate 0f4a57956: diff, manifest cee63e06fdea, gate, mutants K1-K4 killed, singles rerun)
3560c450a sessions: Filbert row 66 r1 review, changes
4f02486d5 sage: row 66 r1 packet (candidate 8ec03b45a: diff, manifest e68c6bce5ec4, gate r2), row 42 run log (trigger (a), mos-0960), SESSIONS line
263ea8a9a queue: revs 2215-2219: row 66 Sage start (2215), in-review round 1 on candidate manifest e68c6bce5ec4 (8ec03b45a) and request comment 29437 (2216-2217); row 163 brief re-pinned to b1500d96ad27 (6020bb43a) and briefed (2218-2219)
6020bb43a plans: D2 brief owner is sage (row 163, #1660), and D2 lands after the Part B window (mos-0958, mos-0959)
5487ed39d sage: row 188 landing record: P19 411 (N1) and comments 361-363 script and record, 409/410 description restore after a partial done update, SESSIONS line
31e328958 queue: revs 2209-2214: row 188 Darkwing start, in-review and request 29432 (2209-2211); Filbert approve 29433 (2212); Sage landing note (8fc0ee216, patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2, gate passed) and done (2213-2214)
8fc0ee216 row 188 (#1686, P19 409, 410): stack-release T54, the proof file checks on a carried pair and a proof file inside the repository (Darkwing; Filbert approve 29433; Sage gate passed)
23a609c5c sessions: Filbert row 188 r1 review, approve
b9c1c9fe2 sage: Part B draft 2 A2 passed (mos-0958), row 188 launched, Vikunja comments 358-360 script and record, SESSIONS line
525990ecb queue: revs 2207-2208: row 188 add and briefed (Sage), #1686, P19 409/410, gates Part B (mos-0956)
148a9503d sage: Part B window file draft 2 for mos-0957 (F1: B6 and B-R1 name the stop's endLaunch rows, PB5 counts per step; F2: PB6, PB7 and B-R1 matrices run (a)-(h)); Q2 answered, Q1 to Jason's list
240f21650 brief: stack-release tests for a carried proof pair and a proof file inside the repository (P19 409, 410, #1686, mos-0956)
742e98976 sage: Part B window file, the read window's end stamp from the clock (10:29Z, not 10:40Z)
288ab1b5a sage: Part B window file for Mos's A2 check (live-copy B3 and B-R1, Q1 on J2 order), SESSIONS lines for Darkwing (row 187, 10:25:22Z) and Sage
a843ae284 sage: row 187 landing, P19 407-410 filed and P19 385 comment 357, #1685 closed (comment 29425), SESSIONS line
652d6f3a8 queue: revs 2201-2206: row 187 Darkwing start, in-review and request comment 29423 (2201-2203); Filbert approve comment 29424 (2204); landing note (9a71e4e80, patch-id 6b57c47bc661, gate passed) and done (Sage 2205-2206)
9a71e4e80 row 187 (#1685): activate sets, carries and drops the Console's proof pair
d17328a55 sessions: Filbert row 187 r1 review, approve
f81ebfbae sage: row 186 follow-ups filed as P19 405 and 406, P19 404 closed (mos-0955)
9760d4967 sage: row 186 landing, P19 404 comment 354 and SESSIONS line, with Darkwing's row 186 SESSIONS line (09:13:21Z)
16a936c79 queue: revs 2198-2200: row 186 Filbert approve comment 29419 (2198); landing note (269134d8d, patch-id c97b9f6e25cd, gate passed) and done (Sage 2199-2200)
269134d8d row 186 (#1684): the activate plan's backup line says what the run will do
3c5da3a91 sessions: Filbert row 186 r1 review, approve
b367f5730 sage: rows 186-187 Vikunja comments (P19 404 351, P19 385 352-353) and SESSIONS line (mos-0954)
5a768d82c queue: row 187 added and briefed (Part B A0, #1685), with Darkwing's row 186 start (rev 2193)
6de3c86be plans: brief for row 187, activate carries the row 178 proof pair (mos-0928 Part B A0, #1685)
1d7fd75e2 queue: row 186 added and briefed (P19 404, #1684)
f5c72a5c3 plans: brief for row 186, the activate plan's backup line (P19 404, #1684)
b724b7b0b sessions: Sage Part A window line, with Darkwing's 06:53:53Z and 08:28:01Z lines
e24a13ad8 sage: Vikunja scripts and read-back records 2026-10-13 to 10-20 (decision 110, mos-0882)
21aa1cab0 darkwing: re-pin window rows 177-185 packet, STATE.md (sha256 b586f11c9b6f1329), W.txt (edd4622c5c45ef3f), S1/S2/S2b/B snapshots, matrices, W10 browser check and P3 rehearsals (#1683; P19 386)
9ddd8a92c plans: repin-rows-177-185 draft 4, R1 snapshot after the plain rollback (mos-0943)
e7637b99c plans: repin-rows-177-185 draft 3, R1post expects one tester action.refused per refused read (mos-0935, mos-0942)
2ea05700b plans: mos-0928 plan draft 2, NEW is the row 185 landing 490e2771d
010666074 queue: revs 2185-2190: row 185 start and in-review on bf87dbba with request comment 29403 (Darkwing 2185-2187); Filbert approve comment 29404 (2188); landing note (490e2771d, patch-id 774cd23186d5, gate passed) and done (Sage 2189-2190)
bus: scripts/bus-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --pm --claude /home/jwoltje/.local/bin/claude
proof: off
send: off
Console: scripts/webui-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333
CLI: scripts/stack-copy.sh wrapper 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --bin /home/jwoltje/.npm-global/bin
@@ -1,21 +0,0 @@
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
log (last five lines):
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
check:
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
result: pass
bus status:
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
socket: present
writer.lock: pid 2950981
boot: organization file loaded at 2026-10-20T22:02:08.125Z
unit flags: match the pointer
drift: 6 commit(s) on refactor since the pointer's copy
# exit 0
@@ -1,8 +0,0 @@
stack-release: captured the rollback set: /home/jwoltje/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6
stack-release: stopped both units
stack-release: stopped store schema: 3i
stack-release: backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6
stack-release: installed 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
stack-release: gate passed on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
stack-release: activate ok: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 is live for mosaic-stack
# exit 0
@@ -1,2 +0,0 @@
W0b 2026-10-20T22:00:49Z business file copy /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 (source /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93)
W5 2026-10-20T22:02:13Z activation 20261020T220206Z-4429a104f6d6 backup /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6 SHA256SUMS sha256 b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9
@@ -1,7 +0,0 @@
a 200 ok=true
b 401 error="sign-in required"
c 401 error="sign-in required"
d 403 error="proxy Host refused"
e 403 error="tester listener required"
f 403 error="proxy header refused"
g 200 non-json 6575 bytes
@@ -1,8 +0,0 @@
# P6 2026-10-20T22:00:17Z
a 200 ok=true
b 401 error="sign-in required"
c 401 error="sign-in required"
d 403 error="proxy Host refused"
e 403 error="tester listener required"
f 403 error="proxy header refused"
g 200 non-json 6454 bytes
@@ -1,4 +0,0 @@
copy 490e2771d: roles 6, projects 1, arbiters 2
HOLDS chief instance.name yes
HOLDS pm instance.name no
LOAD_OK
@@ -1,19 +0,0 @@
{
"bus.tester": true,
"plugins": {
"usage-source": [
"transcript"
],
"channel": [
"cli",
"webui"
],
"harness": [
"claude-code"
]
},
"launch.by": "chief",
"launch.instances has assistant": true,
"roles with pi harness": [],
"sha256": "1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93"
}
@@ -1,35 +0,0 @@
start 2026-10-20T21:46:09Z
business-check.mjs: OK
console-check.sh: OK
1ba3c7efbc84a1d7eb8ee0068d32b7917f4e75cdc4066703944cea4c20db5b93 /mnt/storage/scratch/sage-pin-de352dfaf/fixture/console-stub.mjs
a4500094e7c7a6dc21797eaf2d696f9814f272ece945c2735b79e4593f9c9503 /mnt/storage/scratch/sage-p2-4429/console-stub-tester.mjs
== run 1: pinned stub, no tester listener
stub pid 2495939 port 39561
PASS markdown-preview.js is served and equals the copy's
PASS /api/config on loopback: send=false edit=false
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
PASS loopback save refused: HTTP 403 error=read-only sent=false
CONSOLE_PASS
PORT 39561
CALLS []
run 1 console-check rc=0
== run 2: main listener plus a tester listener on a second port
stub pid 2496085 port 35213 tester port 35945
PASS markdown-preview.js is served and equals the copy's
PASS /api/config on loopback: send=false edit=false
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
PASS loopback save refused: HTTP 403 error=read-only sent=false
CONSOLE_PASS
PASS main refuses x-mosaic-tester: HTTP 403 "tester listener required"
PASS tester /api/config: HTTP 200 "tester":true
PASS tester /api/config send and edit off: HTTP 200 "send":false,"edit":false
PASS tester refuses the main auth header: HTTP 403 "proxy header refused"
PASS tester refuses project-file save: HTTP 403
PORT 35213
TPORT 35945
CALLS []
TESTER_PASS
run 2 console-check rc=0 tester rc=0
end 2026-10-20T21:46:10Z
@@ -1 +0,0 @@
2026-10-20T21:52:34Z
@@ -1,10 +0,0 @@
flags-check start 2026-10-20T21:53:52Z
pid 2729699 alive: yes
127.0.0.1:7341
127.0.0.1:7342
main /healthz: 200
tester /healthz: 200
main /api/config: {"board":"http://127.0.0.1:7331","send":false,"edit":false}
pid 2729699 after TERM: gone
listeners after: 0
flags-check end 2026-10-20T21:53:56Z
@@ -1,7 +0,0 @@
Mosaic Console: http://127.0.0.1:7341/
Board: http://127.0.0.1:7331
Bus: mosaic-stack
Preview: https://mosaic.woltje.com/ through the proxy, signed-in user from x-authentik-username, read-only
Proxy: proxy proof off
Tester Console: http://127.0.0.1:7342/ (binds the tester)
Ctrl-C stops this server.
@@ -1 +0,0 @@
rc=0
@@ -1,20 +0,0 @@
PASS copy: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0, schema 3i
PASS port: 127.0.0.1:7341 is free
PASS backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T192956Z-490e2771d3d0 matches its SHA256SUMS
PASS scratch-root: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data from the backup, config and business file under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/config, business.diff beside them
PASS data-root-live: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
PASS notify-off: binding null, so no Discord bridge
PASS tracker-configured: bootConfig sets no trackers
PASS credential-ref-live: 9 reference(s), each a placeholder
PASS migration: not needed: the backup is already 3i
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
PASS no-session: no sessions.json and no launches under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data
PASS console-stopped: pid 2675437
PASS host-stopped: pid 2675436
PASS host-released-store
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_PASS
rehearsal directory: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6
@@ -1 +0,0 @@
2026-10-20T21:52:31Z
@@ -1,7 +0,0 @@
# P4 2026-10-20T22:00:17Z
DropInPaths=
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
# exit 0
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
rc 0
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# P4-P6 of agents/sage/work/window-plan/repin-4429a104f.md (f41729178), adapted from Darkwing's window-rows-177-185/p456.sh, read only.
# P4 the tunnel unit, P5 the four REPO scripts against NEW, P6 the 7332 matrix on OLD (matrix-old.txt).
# usage: p456.sh p4|p5|p6
set -uo pipefail
REPO=/mnt/storage/src/mosaic-stack
WIN=$REPO/agents/sage/work/window-repin-4429a104f
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
FILES=(scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)
# The plan's <32 letters>: a fixed dummy, not a secret. matrix-new.txt uses the same value.
PROOF=abcdefghijklmnopqrstuvwxyzabcdef
p4() {
echo "# P4 $(date -u +%FT%TZ)"
local out; out="$(systemctl --user show mosaic-preview-tunnel.service -p ExecStart -p DropInPaths)"; local rc=$?
printf '%s\n' "$out"; echo "# exit $rc"
local specs; specs="$(grep -oE 'R:[^ ;]+' <<<"$out")"
local n; n="$(grep -c . <<<"$specs")"
echo "# R: specs ($n): $(tr '\n' ' ' <<<"$specs")"
local drop; drop="$(sed -n 's/^DropInPaths=//p' <<<"$out")"
if [[ $rc -eq 0 && $n -eq 1 && "$specs" == "R:0.0.0.0:7332:127.0.0.1:7332" && -z "$drop" ]]; then
echo "P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty"
else
echo "P4 FAIL"; return 3
fi
}
p5() {
echo "# P5 $(date -u +%FT%TZ); HEAD $(git -C $REPO rev-parse HEAD)"
git -C $REPO diff --quiet $NEW HEAD -- "${FILES[@]}"; local d=$?
echo "# git diff --quiet $NEW HEAD -- ${FILES[*]}: exit $d"
local st; st="$(git -C $REPO status --porcelain -- "${FILES[@]}")"; local s=$?
echo "# git status --porcelain (exit $s): [${st}]"
for f in "${FILES[@]}"; do echo "# $f HEAD blob $(git -C $REPO rev-parse HEAD:$f) NEW blob $(git -C $REPO rev-parse $NEW:$f)"; done
if [[ $d -eq 0 && $s -eq 0 && -z "$st" ]]; then echo "P5 PASS"; else echo "P5 FAIL"; return 3; fi
}
# one matrix line: label, then curl arguments; prints "<label> <code> <error or ok field>"
req() {
local label="$1"; shift
local body; body="$(mktemp -p "${TMPDIR:-/tmp}")"
local code; code="$(curl -s -m 10 -o "$body" -w '%{http_code}' "$@")"
local field; field="$(node -e '
let s = require("fs").readFileSync(process.argv[1], "utf8"), j;
try { j = JSON.parse(s); } catch { console.log("non-json " + s.length + " bytes"); process.exit(0); }
console.log(j.error !== undefined ? "error=" + JSON.stringify(j.error) : "ok=" + JSON.stringify(j.ok));
' "$body")"
rm -f "$body"
echo "$label $code $field"
}
p6() {
req a http://127.0.0.1:7332/healthz
req b -H 'Host: mosaic.woltje.com' http://127.0.0.1:7332/
req c -H 'Host: mosaic.woltje.com' -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7332/
req d -H 'X-authentik-username: tester-probe' http://127.0.0.1:7332/
req e -H 'x-mosaic-tester: 1' http://127.0.0.1:7332/
req f -H 'X-authentik-username: tester-probe' http://127.0.0.1:7333/
req g -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7333/
}
case "${1:-}" in
p4) p4 ;;
p5) p5 ;;
p6) p6 ;;
*) echo "usage: p456.sh p4|p5|p6" >&2; exit 2 ;;
esac
@@ -1,9 +0,0 @@
# P5 2026-10-20T22:00:17Z; HEAD 4385b9ec183a2da4136ccd639d2a6b041eb9c68f
# git diff --quiet 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh: exit 0
# git status --porcelain (exit 0): []
# scripts/stack-release.sh HEAD blob 3b068b1546d4020cffde5d73929d9130afe91911 NEW blob 3b068b1546d4020cffde5d73929d9130afe91911
# scripts/webui-service.sh HEAD blob 0a32547c54ac233e75a21f4817ae230855dbc088 NEW blob 0a32547c54ac233e75a21f4817ae230855dbc088
# scripts/bus-service.sh HEAD blob f71250204dfeceb51beeea5941ebbf3a33ce4b92 NEW blob f71250204dfeceb51beeea5941ebbf3a33ce4b92
# scripts/stack-copy.sh HEAD blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f NEW blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f
P5 PASS
rc 0
@@ -1,235 +0,0 @@
// Merged window B+C, S1, S2 and D3 (repin-merged-bc.md "S1 and S2"): a read-only snapshot of every
// table in a bus store, so the 3h to 3i migration and a restore can be checked without the bus.
// OLD's broker doesn't take --tester on usage, names or threads.
//
// usage: node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]
// --held the running bus holds the store: a plain readOnly open, which adds no file to a WAL
// store that already has -wal and -shm (as stack-release.sh does for a held store).
// Refuses store-not-held when either is absent
// --stopped the bus is stopped: refuses store-unclean when -wal or -shm is present, else opens
// with immutable=1 and readOnly, which creates no file and leaves the store's mtime
// and sha256 alone
// --after also counts each timed table's rows with `at` after STAMP (mos-0838 D3, before a
// --restore). STAMP is a backup's STAMP file, in now_ms form (stack-release.sh:43,
// 2026-10-20T00:11:14.123Z). The count is a text compare, as the tool's own heldcount
// does, so every `at` in every timed table must have that form: a table with any
// other value refuses at-format and names the table (sage-1125)
// --backup also compares every table with a stack-release backup's bus.sqlite, which opens as
// --stopped opens a store: immutable=1 and readOnly, refusing backup-unclean when -wal
// or -shm is present, so nothing writes into the backup set
//
// It prints one JSON object: the schema version, then for every table in sqlite_master (type
// table, less sqlite_*) the row count, the max seq and a sha256 of the rows (in seq order, else in
// column order). `untimed` names the tables with no `at` column. For usage_records it adds per-run
// input and output sums, counted as `mosaic usage` counts them (usage.mjs turnRecords). messages
// and message_threads hold bodies and names, so they print counts only, no sha256 (sage-1125). It
// prints counts, hashes and table and meta key names only: no message body, no instance, project
// or thread name, no credential and no file content. It reads no config: the store path is always
// given.
//
// With --backup, each table gets `vsBackup` and `stopTables` names every table other than events
// whose rows differ from the backup's (sage-1123): a row with `at` after STAMP, a row past the
// backup's max seq, a changed row at or below it, a changed untimed table, or a table only one
// side has. meta counts only when a key other than schema_version and schema_digest changed (the
// 3h to 3i migration sets those two). Events are left to the tool's own --events N.
import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { existsSync, lstatSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { pathToFileURL } from 'node:url';
// usage_records is hashed on these columns in this order, never SELECT * (mos-0888 F1), as
// 3h-3i.sql:9 copies it. Every other table is hashed on its own columns in name order, so a
// rebuild that reorders columns keeps the hash. usage_records with any other column set refuses.
const USAGE_COLUMNS = ['seq', 'at', 'business', 'run', 'instance', 'provider', 'model', 'account', 'turn', 'source',
'phase', 'status', 'code', 'cursor', 'input', 'output', 'cache_write', 'cache_read'];
const COUNTS_ONLY = new Set(['messages', 'message_threads']);
const SCHEMA_KEYS = ['schema_digest', 'schema_version'];
const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
function refuse(code, detail) {
process.stderr.write(`snapshot: ${code}: ${detail}\n`);
process.exit(1);
}
function args(argv) {
const o = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--store' || a === '--after' || a === '--backup') {
if (i + 1 >= argv.length) refuse('usage', `${a} needs a value`);
o[a.slice(2)] = argv[++i];
} else if (a === '--held' || a === '--stopped') {
if (o.mode) refuse('usage', 'give one of --held and --stopped');
o.mode = a.slice(2);
} else refuse('usage', `unknown argument ${a}`);
}
if (!o.store || !o.mode) refuse('usage', 'node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]');
if (!isAbsolute(o.store)) refuse('usage', '--store must be an absolute path');
if (o.backup !== undefined && !isAbsolute(o.backup)) refuse('usage', '--backup must be an absolute path');
if (o.after !== undefined && !(STAMP.test(o.after) && Number.isFinite(Date.parse(o.after))))
refuse('usage', '--after must be a STAMP like 2026-10-20T00:11:14.123Z');
return o;
}
function file(path, code) {
if (!existsSync(path) || !lstatSync(path).isFile()) refuse(code, `${path} is not a file`);
}
function immutable(path, code) {
for (const f of [path + '-wal', path + '-shm'])
if (existsSync(f)) refuse(code, `${f} is present; an immutable read would skip the WAL`);
return new DatabaseSync(pathToFileURL(path).href + '?immutable=1', { readOnly: true });
}
function open({ store, mode }) {
file(store, 'store-missing');
if (mode === 'stopped') return immutable(store, 'store-unclean');
// A plain readOnly open of a WAL store with no -wal and -shm creates them, so a store that
// nothing holds refuses here instead of gaining two files.
for (const f of [store + '-wal', store + '-shm'])
if (!existsSync(f)) refuse('store-not-held', `${f} is absent; use --stopped for a store the bus doesn't hold`);
return new DatabaseSync(store, { readOnly: true });
}
const q = (name) => `"${name.replaceAll('"', '""')}"`;
const value = (v) => (typeof v === 'bigint' ? String(v) : v instanceof Uint8Array ? { hex: Buffer.from(v).toString('hex') } : v);
// The same records `mosaic usage` counts: per (run, turn, source) the latest row, an end row
// winning; a turn is known only when that row is an end row with status known.
function byRun(rows) {
const turns = new Map();
for (const row of rows) {
const key = JSON.stringify([row.business, row.run, row.turn, row.source]);
if (row.phase === 'end' || !turns.has(key)) turns.set(key, row);
}
const runs = new Map();
for (const row of turns.values()) {
const key = JSON.stringify([row.business, row.run]);
if (!runs.has(key)) runs.set(key, { business: row.business, run: row.run, input: 0n, output: 0n, unknown: new Set() });
const r = runs.get(key);
if (row.phase === 'end' && row.status === 'known') {
r.input += BigInt(row.input);
r.output += BigInt(row.output);
} else r.unknown.add(row.turn);
}
return [...runs.values()].map(({ unknown, input, output, ...r }) => ({
...r, input: String(input), output: String(output), unknownTurns: unknown.size,
}));
}
// meta is compared by key: the names of the keys whose value differs, never the values.
function metaKeys(db) {
return new Map(db.prepare('SELECT key, value FROM meta').all().map((r) => [String(r.key), String(r.value)]));
}
// One table: rows, max seq and sha256; rows after `after`; with `priorMax` (the backup's max
// seq) a second hash of the rows at or below it and the count of rows above it. The hashes stay
// inside: a COUNTS_ONLY table's `t` gets none.
function read(db, table, { after, priorMax } = {}) {
const cols = db.prepare('SELECT name FROM pragma_table_info(?)').all(table).map((c) => c.name);
if (!cols.length) refuse('table-missing', table);
let hashed = [...cols].sort();
if (table === 'usage_records') {
if (JSON.stringify(hashed) !== JSON.stringify([...USAGE_COLUMNS].sort()))
refuse('columns-changed', `usage_records has columns ${hashed.join(',')}`);
hashed = USAGE_COLUMNS;
}
const seq = cols.includes('seq');
const at = cols.includes('at');
const stmt = db.prepare(`SELECT ${hashed.map(q).join(',')} FROM ${q(table)} ORDER BY ${seq ? 'seq' : hashed.map(q).join(',')}`);
stmt.setReadBigInts(true);
const full = createHash('sha256');
const prior = createHash('sha256');
const limit = priorMax === undefined ? undefined : BigInt(priorMax ?? 0);
let rows = 0;
let rowsAfter = 0;
let badAt = 0;
let newRows = 0;
const kept = [];
for (const row of stmt.iterate()) {
rows++;
const line = JSON.stringify(hashed.map((c) => value(row[c]))) + '\n';
full.update(line);
if (seq && limit !== undefined) {
if (row.seq <= limit) prior.update(line);
else newRows++;
}
if (after !== undefined && at) {
if (typeof row.at !== 'string' || !STAMP.test(row.at)) badAt++;
else if (row.at > after) rowsAfter++;
}
if (table === 'usage_records') kept.push(row);
}
if (badAt) refuse('at-format', `${table} has ${badAt} row(s) whose at is not in the STAMP form, so a text compare with --after would miscount`);
const sha256 = full.digest('hex');
const t = { rows, maxSeq: seq ? (db.prepare(`SELECT max(seq) AS m FROM ${q(table)}`).get().m ?? null) : null };
if (!COUNTS_ONLY.has(table)) t.sha256 = sha256;
if (after !== undefined && at) t.rowsAfter = rowsAfter;
if (table === 'usage_records') t.byRun = byRun(kept);
return { t, sha256, cols: hashed, seq, at, newRows, priorSha: seq && limit !== undefined ? prior.digest('hex') : null };
}
// Tables only: no index, view or trigger, and none of SQLite's own sqlite_* tables.
const tablesOf = (db) => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name")
.all().map((r) => r.name);
const o = args(process.argv.slice(2));
if (o.backup !== undefined) file(o.backup, 'backup-missing');
const db = open(o);
const bdb = o.backup === undefined ? null : immutable(o.backup, 'backup-unclean');
const out = {};
try {
db.exec('BEGIN');
out.schema = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
const names = tablesOf(db);
const base = new Map();
if (bdb) {
out.backupSchema = bdb.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
for (const name of tablesOf(bdb)) base.set(name, read(bdb, name));
}
out.tables = {};
out.untimed = [];
const stop = [];
for (const name of names) {
const b = base.get(name);
const r = read(db, name, { after: o.after, priorMax: b ? b.t.maxSeq : undefined });
if (!r.at) out.untimed.push(name);
if (bdb) {
const v = { backupRows: b ? b.t.rows : null };
let changed;
if (!b) changed = true;
else if (name === 'meta') {
const now = metaKeys(db);
const then = metaKeys(bdb);
v.keysChanged = [...new Set([...now.keys(), ...then.keys()])].filter((k) => now.get(k) !== then.get(k)).sort();
changed = v.keysChanged.some((k) => !SCHEMA_KEYS.includes(k));
} else if (r.seq) {
v.backupMaxSeq = b.t.maxSeq;
v.newRows = r.newRows;
v.priorSame = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.priorSha === b.sha256;
changed = r.newRows > 0 || !v.priorSame;
} else {
v.same = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.sha256 === b.sha256;
changed = !v.same;
}
if ((r.t.rowsAfter ?? 0) > 0) changed = true;
r.t.vsBackup = v;
if (changed && name !== 'events') stop.push(name);
}
out.tables[name] = r.t;
}
if (bdb) {
out.onlyInBackup = [...base.keys()].filter((n) => !names.includes(n));
out.stopTables = [...stop, ...out.onlyInBackup].sort();
}
if (o.after !== undefined) {
out.after = o.after;
out.eventsAfter = out.tables.events?.rowsAfter ?? null;
}
} finally {
try { db.exec('ROLLBACK'); } catch {}
db.close();
bdb?.close();
}
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
# Re-pin 4429a104f window step wrapper (copied from Darkwing's window-rows-177-185/w.sh): w.sh FILE TITLE CMD... runs CMD in REPO under a clean env (no ANTHROPIC_*, CLAUDE_*,
# MOSAIC_* or pi variables), appends the title, command, output, exit code and date -u stamps to window/FILE,
# prints the output, and exits with CMD's exit code.
set -uo pipefail
W=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
f="$W/$1"; title="$2"; shift 2
E=(env -i HOME="$HOME" PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" LANG=C.UTF-8
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}")
t0=$(date -u +%FT%T.%3NZ)
{ echo; echo "## $title"; echo "# start $t0"; echo "# command: (cd \$REPO && <clean env> $*)"; } >> "$f"
out=$(cd /mnt/storage/src/mosaic-stack && "${E[@]}" "$@" 2>&1); rc=$?
printf '%s\n' "$out" >> "$f"
echo "# exit $rc, end $(date -u +%FT%T.%3NZ)" >> "$f"
printf '%s\n# exit %s\n' "$out" "$rc"
exit $rc
@@ -1,9 +0,0 @@
npm warn deprecated [email protected]: Use your platform's native DOMException instead
added 128 packages in 2s
npm warn install-scripts 3 packages had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts @google/[email protected] (preinstall: echo 'preinstall: no-op')
npm warn install-scripts [email protected] (postinstall: node install.js)
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
@@ -1 +0,0 @@
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
@@ -1,16 +0,0 @@
start 2026-10-20T21:45:57Z
stack-copy rc=0
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
oauth4webapi 3.8.8
IMPORT_OK
LOCK_MATCH
163M /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
/dev/nvme1n1p5 458G 405G 30G 94% /home
copy 4429a104f: roles 6, projects 1, arbiters 2
HOLDS chief instance.name yes
HOLDS pm instance.name no
LOAD_OK
end 2026-10-20T21:45:59Z
@@ -1,109 +0,0 @@
// W10 of repin-4429a104f.md (e1272d1a), adapted from Darkwing's window-rows-177-185/w10-browser.mjs: the tester listener
// on 127.0.0.1:7333 through the webui browser harness (packages/webui/tests/browser.mjs, same blob at HEAD and NEW). Reads only: before any page
// script runs, fetch and XMLHttpRequest are wrapped so every call is logged and any method other than
// GET or HEAD is refused in the page, and the script submits no form and clicks no button. The chat
// check reads the #/chat list only and never opens the W9 thread page (the launcher reads lastOpen).
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
const OUT = '/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f';
const BASE = 'http://127.0.0.1:7333/';
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
const GUARD = `(() => {
const log = window.__w10 = [];
const ok = m => m === 'GET' || m === 'HEAD';
const f = window.fetch;
window.fetch = (input, init = {}) => {
const method = String(init.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
const url = String(input instanceof Request ? input.url : input);
log.push({ method, url, refused: !ok(method) });
return ok(method) ? f(input, init) : Promise.reject(new TypeError('w10 refused ' + method));
};
const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__m = String(method).toUpperCase(); this.__u = String(url); return open.call(this, method, url, ...rest); };
XMLHttpRequest.prototype.send = function (...a) { log.push({ method: this.__m, url: this.__u, xhr: true, refused: !ok(this.__m) }); if (!ok(this.__m)) throw new TypeError('w10 refused ' + this.__m); return send.apply(this, a); };
})();`;
const b = await browser({ machine: false });
const result = {};
let failed = false;
const check = (name, pass, detail) => { result[name] = { pass, ...(detail === undefined ? {} : { detail }) }; if (!pass) failed = true; };
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
await b.viewport(1280, 1600);
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
await b.navigate(BASE + '#/settings');
const ready = await wait('!!document.querySelector("#tester-banner") && !!document.querySelector("#routes-form") && !!document.querySelector(".tester-name-form")');
check('settings loaded', ready);
const s = await b.evaluate(`(() => {
const q = x => document.querySelector(x);
return {
banner: q('#tester-banner')?.textContent.trim() ?? null,
h1: q('#bus-view h1')?.textContent.trim() ?? null,
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
convFormHidden: q('#conv-form')?.hidden ?? null,
panels: [...document.querySelectorAll('#bus-view h2')].map(h => h.textContent.trim()),
instancesTable: !!q('#instances-table'),
nameForms: document.querySelectorAll('.tester-name-form').length,
routesForm: !!q('#routes-form'),
routesSubmitEnabled: q('#routes-form button[type=submit]')?.disabled === false,
forbidden: [...document.querySelectorAll('#launch-settings-form, .assistant-form, #token-limits-table [data-limit-reset], #send-section')].map(e => e.id || e.className || e.tagName),
tokenLimitsPanel: !!q('#token-limits-title') || !!q('#token-limits-table'),
};
})()`);
result.settings = s;
check('tester banner', /tester/i.test(s.banner ?? ''), s.banner);
check('instance, routes and name editors present', s.instancesTable && s.routesForm && s.nameForms > 0);
check('only tester forms (conv-form is index.html:21, global and hidden)', s.convFormHidden === true && s.forms.every(f => f === 'conv-form' || f === 'routes-form' || /tester-name-form/.test(f) || /^instance-(add|change)-form$/.test(f)), s.forms);
check('no launch settings, assistant, limit reset or send', s.forbidden.length === 0, s.forbidden);
check('no Token limits panel (plan W10 item 2, amended)', s.tokenLimitsPanel === false);
await b.screenshot(`${OUT}/w10-settings.png`);
await b.navigate(BASE + '#/');
// The Board is index.html:22 #board-view; bus.js hides #bus-view on it.
const board = await wait('document.querySelector("#bus-view")?.hidden === true && !!document.querySelector("#board-view #waiting-title")');
await new Promise(r => setTimeout(r, 1500));
const bd = await b.evaluate(`(() => { const v = document.querySelector('#board-view'); return {
busViewHidden: document.querySelector('#bus-view')?.hidden ?? null,
banner: !!document.querySelector('#tester-banner'),
headings: [...v.querySelectorAll('h2')].map(h => h.textContent.replace(/\\s+/g, ' ').trim()),
unavailable: /unavailable|refused|not authorized/i.test(v.textContent),
length: v.textContent.length,
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
}; })()`);
result.board = bd;
check('board reads', board && bd.busViewHidden === true && !bd.unavailable && bd.headings.length > 0, bd);
await b.screenshot(`${OUT}/w10-board.png`);
await b.navigate(BASE + '#/chat');
// chat.js threads(): ul.chat-thread-list with <a href="#/chat/threads/<id>">. Read only; no link is followed.
const chatReady = await wait('!!document.querySelector("ul.chat-thread-list") || /No threads yet/.test(document.body.textContent)');
const c = await b.evaluate(`(() => {
const list = document.querySelector('ul.chat-thread-list');
return {
hash: location.hash,
banner: !!document.querySelector('#tester-banner'),
threads: list ? [...list.querySelectorAll('li')].map(li => ({ href: li.querySelector('a')?.getAttribute('href') ?? null, name: li.querySelector('a')?.textContent.trim() ?? null, instance: li.querySelector('.muted')?.textContent.trim() ?? null })) : [],
conversationOpen: !!document.querySelector('.chat-conversation'),
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
};
})()`);
result.chat = c;
check('chat lists the W9 thread', chatReady && c.threads.some(t => t.href === '#/chat/threads/' + W9_THREAD), c.threads);
check('W9 thread page not opened', c.hash === '#/chat' && c.conversationOpen === false, c.hash);
check('tester banner on chat', c.banner);
await b.screenshot(`${OUT}/w10-chat.png`);
const log = await b.evaluate('window.__w10');
result.requests = log;
check('no write through the listener', log.every(r => !r.refused && (r.method === 'GET' || r.method === 'HEAD')), log.filter(r => r.refused));
check('no request to the W9 thread messages or open', !log.some(r => r.url.includes(W9_THREAD)), log.filter(r => r.url.includes(W9_THREAD)));
} catch (e) {
failed = true;
result.error = String(e?.message ?? e);
} finally {
await b.close();
}
console.log(JSON.stringify(result, null, 2));
console.log(failed ? 'W10 browser: FAIL' : 'W10 browser: pass');
process.exit(failed ? 1 : 0);
@@ -1,30 +0,0 @@
// W10 diagnosis after the two failed checks of w10-browser.mjs (sign-out form listed; chat list missing the W9 thread).
// Read only, with the same GET/HEAD guard as w10-browser.mjs. Records each form's hidden flag on Settings, the /api/config
// sso value the page sees, and every ul.chat-thread-list on #/chat (chat.js:93 renders one per project). No screenshots.
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
import { readFileSync } from 'node:fs';
const src = readFileSync(new URL('./w10-browser.mjs', import.meta.url), 'utf8');
const GUARD = eval(src.slice(src.indexOf('const GUARD = ') + 14, src.indexOf('`;', src.indexOf('const GUARD = ')) + 1));
const BASE = 'http://127.0.0.1:7333/';
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
const b = await browser({ machine: false });
const out = {};
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
await b.navigate(BASE + '#/settings');
await wait('!!document.querySelector("#routes-form")');
out.config = await b.evaluate(`fetch('/api/config').then(r=>r.json()).then(j=>({sso:j.sso??null}))`);
out.forms = await b.evaluate(`[...document.querySelectorAll('form')].map(f=>({id:f.id||f.className,hidden:f.hidden,action:f.getAttribute('action'),method:f.getAttribute('method')}))`);
await b.navigate(BASE + '#/chat');
await wait('document.querySelectorAll("ul.chat-thread-list").length >= 1');
await new Promise(r => setTimeout(r, 1000));
out.chat = await b.evaluate(`({hash:location.hash,open:!!document.querySelector('.chat-conversation'),projects:[...document.querySelectorAll('.chat-projects section.panel')].map(s=>({project:s.querySelector('h2')?.textContent.trim(),threads:[...s.querySelectorAll('ul.chat-thread-list li a')].map(a=>a.getAttribute('href'))}))})`);
out.requests = await b.evaluate('window.__w10');
} finally { await b.close(); }
const signOut = out.forms.find(f => f.id === 'sign-out');
const listed = out.chat.projects.some(p => p.threads.includes('#/chat/threads/' + W9_THREAD));
const writes = out.requests.filter(r => r.refused || !['GET', 'HEAD'].includes(r.method));
console.log(JSON.stringify(out, null, 2));
console.log(`sign-out hidden ${signOut?.hidden}, sso ${out.config.sso}; W9 thread listed ${listed}; thread page open ${out.chat.open}; writes ${writes.length}`);
process.exit(signOut?.hidden === true && listed && !out.chat.open && writes.length === 0 ? 0 : 1);
@@ -1,277 +0,0 @@
# W9 go mos-1114, start 2026-10-20T22:05:51Z
# plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
log (last five lines):
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
check:
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
result: pass
bus status:
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
socket: present
writer.lock: pid 2950981
boot: organization file loaded at 2026-10-20T22:02:08.125Z
unit flags: match the pointer
drift: 6 commit(s) on refactor since the pointer's copy
# exit 0
PASS markdown-preview.js is served and equals the copy's
PASS /api/config on loopback: send=false edit=false
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
PASS loopback save refused: HTTP 403 error=read-only sent=false
CONSOLE_PASS
# exit 0
# journal since W5: proxy proof off 1, Sign-in 0
# Console pid 2950925 cwd /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
# exit 0
[
{
"business": "mosaic-stack",
"instance": "chief",
"run": "r097436c91816",
"launchedBy": "jason",
"harness": "claude-code",
"model": "claude-opus-5-5",
"provider": "anthropic",
"modelKey": "opus",
"pid": 2951160,
"startTime": "63491912",
"runnerPid": 2951161,
"runnerStartTime": "63491912",
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
"startedAt": "2026-10-20T22:02:08.941Z",
"live": true
}
]
# exit 0
# one running chief from this start: run r097436c91816
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
# exit 0
sent c5b43f04 to chief
chief (RESULT, re c5b43f04) c3e236d7:
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
# exit 0
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
# exit 0
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
# exit 0
# project window-20261020-2205, thread 446201de-e8b5-4df3-9356-d0d5422cc899
sent b4b66ced to window-20261020-2205/w9
assistant (RESULT, re b4b66ced) cfa7ea99:
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
# exit 0
# reply received 2026-10-20T22:06:05Z
[
{
"seq": 570,
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
"at": "2026-10-20T22:05:58.283Z",
"business": "mosaic-stack",
"from_role": "tester",
"from_run": "tester-cli",
"to_role": "assistant",
"class": "REQUEST",
"in_reply_to": null,
"decision": null,
"corrects": null,
"body": "[mos test] reply with one line, then stop",
"to_name": null
},
{
"seq": 571,
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
"at": "2026-10-20T22:06:04.247Z",
"business": "mosaic-stack",
"from_role": "assistant",
"from_run": "r497f1c817d4d",
"to_role": "tester",
"class": "RESULT",
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
"decision": null,
"corrects": null,
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
"to_name": null
}
]
# exit 0
# thread read: 2 messages: tester->assistant REQUEST, assistant->tester RESULT
window-20261020-0801/w9 assistant d0ba7a66
window-20261020-2205/w9 assistant 446201de
# exit 0
[]
# exit 0
{
"total": {
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
},
"byRun": [
{
"run": "r8fba7e598156",
"input": 0,
"output": 0,
"cacheWrite": 0,
"cacheRead": 0,
"unknownTurns": 1
},
{
"run": "r4a96e2142e4d",
"input": 2,
"output": 52,
"cacheWrite": 11750,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "rc78c9700c8e9",
"input": 14,
"output": 1519,
"cacheWrite": 18187,
"cacheRead": 89559,
"unknownTurns": 0
},
{
"run": "r293f33bcbc63",
"input": 2,
"output": 44,
"cacheWrite": 18595,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "r34520a581318",
"input": 6,
"output": 323,
"cacheWrite": 26349,
"cacheRead": 14307,
"unknownTurns": 0
},
{
"run": "r7e99838f9f93",
"input": 2,
"output": 44,
"cacheWrite": 9858,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "rc964611b2092",
"input": 4,
"output": 120,
"cacheWrite": 26138,
"cacheRead": 23870,
"unknownTurns": 0
},
{
"run": "rbb708846b605",
"input": 12,
"output": 2089,
"cacheWrite": 17285,
"cacheRead": 73823,
"unknownTurns": 0
},
{
"run": "rc36b17ef3788",
"input": 16,
"output": 5221,
"cacheWrite": 36717,
"cacheRead": 202573,
"unknownTurns": 0
},
{
"run": "r097436c91816",
"input": 2,
"output": 131,
"cacheWrite": 11915,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "r497f1c817d4d",
"input": 2,
"output": 80,
"cacheWrite": 9856,
"cacheRead": 0,
"unknownTurns": 0
}
],
"byInstance": [
{
"instance": "chief",
"input": 36,
"output": 4114,
"cacheWrite": 85486,
"cacheRead": 177689,
"unknownTurns": 1
},
{
"instance": "coder",
"input": 2,
"output": 44,
"cacheWrite": 18595,
"cacheRead": 0,
"unknownTurns": 0
},
{
"instance": "assistant",
"input": 4,
"output": 124,
"cacheWrite": 19714,
"cacheRead": 0,
"unknownTurns": 0
},
{
"instance": "pm",
"input": 20,
"output": 5341,
"cacheWrite": 62855,
"cacheRead": 226443,
"unknownTurns": 0
}
],
"byProvider": [
{
"provider": "anthropic",
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
],
"byModel": [
{
"provider": "anthropic",
"model": "claude-opus-5-5",
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
],
"byAccount": [
{
"account": null,
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
]
}
# exit 0
# human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread d0ba7a66, so no human thread exists to read
# W9 checks and turns end 2026-10-20T22:06:06Z; the assistant run wait and S2 are w9b.sh
@@ -1,93 +0,0 @@
#!/usr/bin/env bash
# Re-pin 4429a104f, W9 checks and the turns (repin-4429a104f.md at f41729178, sha256 e1272d1a; the W9 list of
# repin-rows-177-185.md plus the journal Sign-in line and the Console cwd), on Mos's go mos-1114.
# Adapted from Darkwing's window-rows-177-185/w9.sh. Every call goes through w.sh into W.txt; every bus call carries
# --tester. A failed check stops the script. The wait for the assistant run and S2 are in w9b.sh, so the interim
# report to Mos goes out between the two (report rule, waits over 5 minutes).
# usage: w9.sh
set -uo pipefail
REPO=/mnt/storage/src/mosaic-stack
WIN=$REPO/agents/sage/work/window-repin-4429a104f
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
W5_STAMP="2026-10-20 22:02:06 UTC"
W5_MS=$(date -d "2026-10-20T22:02:06Z" +%s)000
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
CC=/mnt/storage/scratch/sage-pin-de352dfaf/console-check.sh
CC_SHA=d068fe02da897b700110a162d13d9671fe5994f053c45aff99113fade737094c
CONSOLE_PID=2950925
PRIOR_TID=d0ba7a66-aa3f-4918-9518-94132bce6b28
MOSAIC=$HOME/.npm-global/bin/mosaic
C_NEW=(node "$HOME/.local/share/mosaic-stack/copies/$NEW/packages/cli/src/cli.mjs")
BZ=(--business mosaic-stack)
W=$WIN/W.txt
note() { echo "# $*" >> "$W"; echo "# $*"; }
stop() { note "STOP: $*"; exit 1; }
sha() { sha256sum < "$1" | cut -c1-64; }
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
note "W9 go mos-1114, start $(date -u +%FT%TZ)"
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
[ "$(sha $CC)" = "$CC_SHA" ] || stop "console-check.sh is not d068fe02 (SCRIPTS.sha256)"
grep -q "/copies/$NEW/scripts/mosaic" "$MOSAIC" || stop "the wrapper is not pinned to NEW"
note "plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked"
# checks
step .w9-status.txt "W9 status" scripts/stack-release.sh status mosaic-stack || stop "W9 status exit"
grep -q "\"copy\":\"$NEW\"" $WIN/.w9-status.txt && grep -q '"pm":true' $WIN/.w9-status.txt && grep -q '"schema":"3i"' $WIN/.w9-status.txt || stop "W9 status: pointer not on NEW, pm true, 3i"
grep -q 'unit flags: match the pointer' $WIN/.w9-status.txt || stop "W9 status: unit flags"
step .w9-cc.txt "W9 console-check NEW ($CC, d068fe02)" bash $CC $NEW || stop "console-check exit"
grep -qx CONSOLE_PASS $WIN/.w9-cc.txt || stop "console-check not CONSOLE_PASS"
J=$(journalctl --user -u mosaic-webui.service --since "$W5_STAMP" --no-pager)
N=$(printf '%s\n' "$J" | grep -c 'Proxy: proxy proof off'); S=$(printf '%s\n' "$J" | grep -c 'Sign-in:')
{ echo; echo "## W9 webui journal since $W5_STAMP: 'Proxy: proxy proof off' and 'Sign-in:' counts"; echo "proxy proof off $N, Sign-in $S"; } >> "$W"
note "journal since W5: proxy proof off $N, Sign-in $S"
[ "$N" = 1 ] || stop "proxy proof off count is $N, not 1"
[ "$S" = 0 ] || stop "Sign-in count is $S, not 0"
systemctl --user show -p MainPID --value mosaic-webui.service | grep -qx "$CONSOLE_PID" || stop "Console MainPID is not $CONSOLE_PID"
CWD=$(readlink /proc/$CONSOLE_PID/cwd)
{ echo; echo "## W9 Console pid $CONSOLE_PID cwd"; ls -l /proc/$CONSOLE_PID/cwd | sed 's/^.* -> /-> /'; } >> "$W"
note "Console pid $CONSOLE_PID cwd $CWD"
[ "$CWD" = "$HOME/.local/share/mosaic-stack/copies/$NEW" ] || stop "Console cwd is not the NEW copy"
step .w9-launches.txt "W9 launches list" $MOSAIC launches list || stop "launches list"
step .w9-launches.json "W9 launches list --json" $MOSAIC launches list --json || stop "launches list --json"
sed -i '$d' $WIN/.w9-launches.json
CHIEF=$(node -e '
const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")),since=Number(process.argv[2]);
const c=l.filter(x=>x.business==="mosaic-stack"&&x.instance==="chief"&&x.live===true);
if(c.length!==1||!(Date.parse(c[0].startedAt)>=since)){console.error(`running chiefs ${c.length}`);process.exit(1)}
process.stdout.write(c[0].run)' $WIN/.w9-launches.json "$W5_MS") || stop "launches: not one running chief from this start"
note "one running chief from this start: run $CHIEF"
step .w9-agents.txt "W9 agents --tester" $MOSAIC agents "${BZ[@]}" --tester || stop "agents"
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w9-agents.txt && stop "the chief is working"
# turns
step .w9-talk-chief.txt "W9 talk chief --tester" $MOSAIC talk chief "[mos test] re-pin $NEW: reply with one line, no staffing request" "${BZ[@]}" --tester || stop "talk chief"
PROJ=window-$(date -u +%Y%m%d-%H%M)
step .w9-project.txt "W9 project create $PROJ --tester" $MOSAIC project create $PROJ "${BZ[@]}" --tester || stop "project create"
step .w9-thread.txt "W9 thread create $PROJ w9 --tester" $MOSAIC thread create $PROJ w9 "${BZ[@]}" --tester || stop "thread create"
TID=$(sed -n "s/^created $PROJ\/w9 id \([0-9a-f-]*\) instance .*$/\1/p" $WIN/.w9-thread.txt)
[ -n "$TID" ] || stop "no thread id"
note "project $PROJ, thread $TID"
echo "$TID" > $WIN/.w9-tid.txt
step .w9-talk-thread.txt "W9 talk --thread --wait 180 --tester" $MOSAIC talk --thread "$TID" "[mos test] reply with one line, then stop" --wait 180 "${BZ[@]}" --tester || stop "talk --thread"
note "reply received $(date -u +%FT%TZ)"
date -u +%s > $WIN/.w9-reply-at.txt
step .w9-thread-read.json "W9 thread read --json --tester" $MOSAIC thread read "$TID" --json "${BZ[@]}" --tester || stop "thread read"
sed -i '$d' $WIN/.w9-thread-read.json
node -e '
const t=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
console.log(`# thread read: ${t.length} messages: ${t.map(m=>`${m.from_role}->${m.to_role} ${m.class}`).join(", ")}`);
process.exit(t.length===2&&t[0].from_role==="tester"&&t[1].from_role==="assistant"?0:1)' $WIN/.w9-thread-read.json | tee -a "$W" || stop "thread read is not the tester message plus one assistant reply"
step .w9-threads.txt "W9 threads --tester" $MOSAIC threads "${BZ[@]}" --tester || stop "threads"
grep -q "$TID\|$PROJ" $WIN/.w9-threads.txt || stop "threads does not list the W9 thread"
step .w9-names.json "W9 C_NEW names --tester --json" "${C_NEW[@]}" names "${BZ[@]}" --tester --json || stop "names"
step .w9-usage.json "W9 usage --tester --json" $MOSAIC usage "${BZ[@]}" --tester --json || stop "usage"
if grep -q "${PRIOR_TID:0:8}" $WIN/.w9-threads.txt; then
note "human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread ${PRIOR_TID:0:8}, so no human thread exists to read"
else
note "human-thread probe: S1's one thread is not in threads --tester; probe it by hand before S2"
fi
note "W9 checks and turns end $(date -u +%FT%TZ); the assistant run wait and S2 are w9b.sh"
@@ -1,185 +0,0 @@
# W9b start 2026-10-20T22:06:27Z: waiting for the launcher to end r497f1c817d4d (thread 446201de-e8b5-4df3-9356-d0d5422cc899), no stop by hand
# r497f1c817d4d not live in launches list --json by 2026-10-20T22:21:07Z (5 s poll)
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
# exit 0
## W9b launches log lines for r497f1c817d4d (at, event, instance, thread, run, reason, exitCode, signal only)
at=2026-10-20T22:06:00.766Z event=launch instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
at=2026-10-20T22:21:05.149Z event=thread-idle instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
at=2026-10-20T22:21:05.171Z event=end instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d reason=stopped exitCode=0 signal=null
## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming r497f1c817d4d or thread-idle
2026-10-20T17:21:05.171444-05:00 dragon-lin mosaic[2950923]: mosaic-bus: run r497f1c817d4d (assistant) ended: stopped
(grep exit 0)
# SNAP sha256 639fd28a checked before S2 2026-10-20T22:21:07Z
{
"schema": "3i",
"tables": {
"decision_events": {
"rows": 21,
"maxSeq": 21,
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
},
"decisions": {
"rows": 21,
"maxSeq": 21,
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
},
"deliveries": {
"rows": 1740,
"maxSeq": 1740,
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
},
"events": {
"rows": 1543,
"maxSeq": 1543,
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
},
"instance_names": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"message_threads": {
"rows": 4,
"maxSeq": null
},
"messages": {
"rows": 571,
"maxSeq": 571
},
"meta": {
"rows": 3,
"maxSeq": null,
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
},
"projects": {
"rows": 2,
"maxSeq": 2,
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
},
"role_claims": {
"rows": 101,
"maxSeq": 101,
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
},
"task_comment_cursors": {
"rows": 220,
"maxSeq": 220,
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
},
"task_snapshots": {
"rows": 408,
"maxSeq": 408,
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
},
"thread_claims": {
"rows": 4,
"maxSeq": 4,
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
},
"thread_opens": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"threads": {
"rows": 2,
"maxSeq": 2,
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
},
"usage_records": {
"rows": 34,
"maxSeq": 34,
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
"byRun": [
{
"business": "mosaic-stack",
"run": "r8fba7e598156",
"input": "0",
"output": "0",
"unknownTurns": 1
},
{
"business": "mosaic-stack",
"run": "r4a96e2142e4d",
"input": "2",
"output": "52",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc78c9700c8e9",
"input": "14",
"output": "1519",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r293f33bcbc63",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r34520a581318",
"input": "6",
"output": "323",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r7e99838f9f93",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc964611b2092",
"input": "4",
"output": "120",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rbb708846b605",
"input": "12",
"output": "2089",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc36b17ef3788",
"input": "16",
"output": "5221",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r097436c91816",
"input": "2",
"output": "131",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r497f1c817d4d",
"input": "2",
"output": "80",
"unknownTurns": 0
}
]
}
},
"untimed": [
"message_threads",
"meta",
"task_comment_cursors"
]
}
# exit 0
# S2.json sha256 1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173
# W9b end 2026-10-20T22:21:07Z
rc=0
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Re-pin 4429a104f, W9 tail (adapted from Darkwing's window-rows-177-185/w9b.sh, under mos-0948's ruling for that window: the launcher
# ends the assistant run at threadIdleSeconds 900 after the 22:06:05Z reply, about 22:21:05Z). Nothing is stopped by hand. Report if live at 22:27Z.
# usage: w9b.sh
set -uo pipefail
REPO=/mnt/storage/src/mosaic-stack
WIN=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
RUN=r497f1c817d4d
TID=446201de-e8b5-4df3-9356-d0d5422cc899
DATA=/home/jwoltje/.mosaic-dev
SNAP=$WIN/snapshot.mjs
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
MOSAIC=$HOME/.npm-global/bin/mosaic
DEADLINE=$(date -d "2026-10-20T22:27:00Z" +%s)
MARK18=$(date -d "2026-10-20T22:22:00Z" +%s)
W=$WIN/W.txt
note() { echo "# $*" >> "$W"; echo "# $*"; }
stop() { note "STOP: $*"; exit 1; }
sha() { sha256sum < "$1" | cut -c1-64; }
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
# launches log lines for the run: at, event, instance, thread, run, reason, exitCode, signal only
runlog() { node -e '
const keep=["at","event","instance","thread","run","reason","exitCode","signal"];
for(const s of require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n")){const o=JSON.parse(s);if(o.run!==process.argv[2])continue;
console.log(keep.filter(k=>k in o).map(k=>`${k}=${o[k]}`).join(" "))}' "$DATA/launches/mosaic-stack.jsonl" "$RUN"; }
note "W9b start $(date -u +%FT%TZ): waiting for the launcher to end $RUN (thread $TID), no stop by hand"
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
END=; M18=
while :; do
$MOSAIC launches list --json > $WIN/.w9b-poll.json 2>&1 || stop "launches poll"
LIVE=$(node -e 'const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(l.filter(x=>x.run===process.argv[2]&&x.live===true).map(x=>x.run).join(" "))' $WIN/.w9b-poll.json $RUN)
[ -z "$LIVE" ] && { END=$(date -u +%FT%TZ); break; }
NOW=$(date +%s)
if [ -z "$M18" ] && [ $NOW -ge $MARK18 ]; then M18=1; note "22:22Z mark $(date -u +%FT%TZ): $RUN still live; launches log lines for it:"; runlog | tee -a "$W"; fi
[ $NOW -ge $DEADLINE ] && break
sleep 5
done
if [ -z "$END" ]; then
note "STOP: $RUN still live at $(date -u +%FT%TZ) (22:27Z mark); a stop is Mos's call. S2 waits."
runlog | tee -a "$W"
exit 4
fi
note "$RUN not live in launches list --json by $END (5 s poll)"
step .w9b-launches-after.txt "W9b launches list after the assistant run" $MOSAIC launches list
{ echo; echo "## W9b launches log lines for $RUN (at, event, instance, thread, run, reason, exitCode, signal only)"; runlog; } | tee -a "$W"
{ echo; echo "## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming $RUN or thread-idle"; journalctl --user -u [email protected] -u mosaic-webui.service --since "2026-10-20 22:05:00 UTC" --no-pager -o short-iso-precise | grep -E "$RUN|thread-idle|threadIdle"; echo "(grep exit ${PIPESTATUS[1]})"; } | tee -a "$W"
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S2"
note "SNAP sha256 639fd28a checked before S2 $(date -u +%FT%TZ)"
step S2.json "S2 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held || stop "S2"
sed -i '$d' $WIN/S2.json
note "S2.json sha256 $(sha $WIN/S2.json)"
note "W9b end $(date -u +%FT%TZ)"
@@ -1,43 +0,0 @@
# W0b-W4 start 2026-10-20T22:00:49Z
# plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked 2026-10-20T22:00:49Z
# W0 copy present, w0.txt LOAD_OK
# W0b: /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z, sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93, recorded in backups.txt
rbb708846b605 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T19:29:59.045Z running
rc36b17ef3788 mosaic-stack/pm claude-code claude-opus-5-5 by chief since 2026-10-20T20:20:56.252Z running
# exit 0
# W1 start stamp 2026-10-20T22:00:49Z
# SNAP sha256 639fd28a checked before S1
# S1.json sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
{"copy":"490e2771d3d057ab76991e2c7859cd251857e64c","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T192956Z-490e2771d3d0","at":"2026-10-20T19:30:00Z"}
log (last five lines):
{"at":"2026-10-20T01:41:05Z","op":"pm-on","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","backup":null,"activation":"20261020T014103Z-89af38a83afa","result":"ok","pm":"on"}
{"at":"2026-10-20T07:58:00Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T075800Z-490e2771d3d0","result":"started"}
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
check:
bus: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
Console: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
result: pass
bus status:
host: mosaic-stack, pid 3229540, running, since 2026-10-20T19:29:57.662Z, notifier off
socket: present
writer.lock: pid 3229657
boot: organization file loaded at 2026-10-20T19:29:57.661Z
unit flags: match the pointer
drift: 109 commit(s) on refactor since the pointer's copy
# exit 0
# W3-plan.txt sha256 45de68d42b4cf88640fe0986d071dba4fa076b7db86ff4ed34de4bb6fa2915c6
DropInPaths=
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
# exit 0
DropInPaths=
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
# exit 0
# W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart
# W4 bus Environment: 2 assignments; quote or backslash: none (values not recorded)
# W0b-W4 end 2026-10-20T22:00:50Z
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Re-pin 4429a104f, W0b-W4 (repin-4429a104f.md at f41729178, sha256 e1272d1a...32b9; mos-1105, mos-1107 go). W0 ran before P2.
# Adapted from Darkwing's window-rows-177-185/wsteps.sh.
# Nothing is stopped. Each step goes through w.sh into W.txt; a failed check stops the script.
# usage: wsteps.sh
set -uo pipefail
REPO=/mnt/storage/src/mosaic-stack
WIN=$REPO/agents/sage/work/window-repin-4429a104f
OLD=490e2771d3d057ab76991e2c7859cd251857e64c
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
BC=$REPO/agents/sage/work/window-plan/repin-de352dfaf/business-check.mjs
BC_SHA=eece4d4671e1ce15ba81e1654c641925ac4988b343687b062897392285a8a1a7
BIZ=$HOME/.config/mosaic-dev/businesses/mosaic-stack.json
BIZ_SHA=1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93
DATA=/home/jwoltje/.mosaic-dev
SNAP=$WIN/snapshot.mjs
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
MOSAIC=$HOME/.npm-global/bin/mosaic
W=$WIN/W.txt
note() { echo "# $*" >> "$W"; echo "# $*"; }
stop() { note "STOP: $*"; exit 1; }
sha() { sha256sum < "$1" | cut -c1-64; }
note "W0b-W4 start $(date -u +%FT%TZ)"
[ "$(git -C $REPO show f41729178:agents/sage/work/window-plan/repin-4429a104f.md | sha256sum | cut -c1-64)" = "$PLAN_SHA" ] || stop "plan at f41729178 is not e1272d1a"
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file in REPO is not e1272d1a"
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh || stop "P5: the four scripts differ from NEW"
[ -z "$(git -C $REPO status --porcelain -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)" ] || stop "P5: the four scripts are dirty"
[ "$(sha $BC)" = "$BC_SHA" ] || stop "business-check.mjs sha"
note "plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked $(date -u +%FT%TZ)"
# W0 ran at 21:45:57Z (w0.txt): the copy must still be there with LOAD_OK recorded.
[ -f "$HOME/.local/share/mosaic-stack/copies/$NEW/scripts/mosaic" ] || stop "W0 copy missing"
grep -qx LOAD_OK $WIN/w0.txt || stop "w0.txt has no LOAD_OK"
note "W0 copy present, w0.txt LOAD_OK"
# W0b
[ "$(sha $BIZ)" = "$BIZ_SHA" ] || stop "business file sha256 is not P1's 1e49bdf0"
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
COPY=$BIZ.pre-window-$STAMP
[ ! -e "$COPY" ] || stop "$COPY exists"
cp -p "$BIZ" "$COPY" || stop "W0b cp"
[ "$(sha "$COPY")" = "$BIZ_SHA" ] || stop "W0b copy sha256"
echo "W0b $(date -u +%FT%TZ) business file copy $COPY sha256 $(sha "$COPY") (source $BIZ sha256 $(sha "$BIZ"))" >> $WIN/backups.txt
note "W0b: $COPY, sha256 $(sha "$COPY"), recorded in backups.txt"
# W1
$WIN/w.sh W.txt "W1 agents --tester" $MOSAIC agents --business mosaic-stack --tester > $WIN/.w1-agents.txt || stop "W1 agents"
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w1-agents.txt && stop "W1: the chief is working"
$WIN/w.sh W.txt "W1 launches list" $MOSAIC launches list || stop "W1 launches list"
note "W1 start stamp $(date -u +%FT%TZ)"
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S1"
note "SNAP sha256 639fd28a checked before S1"
$WIN/w.sh W.txt "S1 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held > $WIN/S1.json || stop "S1"
sed -i '$d' $WIN/S1.json # w.sh prints "# exit N" last; S1.json keeps the snapshot only
note "S1.json sha256 $(sha $WIN/S1.json)"
# W2
$WIN/w.sh W.txt "W2 status" scripts/stack-release.sh status mosaic-stack || stop "W2 status"
# W3
$WIN/w.sh W.txt "W3 activate NEW --pm --plan" scripts/stack-release.sh activate $NEW mosaic-stack --pm --plan > $WIN/W3-plan.txt || stop "W3 plan exit"
sed -i '$d' $WIN/W3-plan.txt
note "W3-plan.txt sha256 $(sha $WIN/W3-plan.txt)"
# W4
$WIN/w.sh W.txt "W4 bus unit (Environment omitted from the transcript)" systemctl --user show [email protected] -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 bus"
$WIN/w.sh W.txt "W4 webui unit" systemctl --user show mosaic-webui.service -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 webui"
BUSL=$(systemctl --user show [email protected] -p ExecStart -p DropInPaths -p WorkingDirectory)
WEBL=$(systemctl --user show mosaic-webui.service -p ExecStart -p DropInPaths -p WorkingDirectory)
for u in BUSL WEBL; do v=${!u}; grep -qx 'DropInPaths=' <<< "$v" || stop "W4 $u DropInPaths not empty"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$OLD/" || stop "W4 $u ExecStart does not name the OLD copy"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$NEW" && stop "W4 $u ExecStart names NEW"; grep '^ExecStart=' <<< "$v" | grep -q "[\"'\\\\]" && stop "W4 $u ExecStart has a quote or backslash"; done
grep '^ExecStart=' <<< "$BUSL" | grep -q -- ' --pm' || stop "W4 bus ExecStart has no --pm"
note "W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart"
ENVL=$(systemctl --user show [email protected] -p Environment)
note "W4 bus Environment: $(tr ' ' '\n' <<< "${ENVL#Environment=}" | grep -c =) assignments; quote or backslash: $(grep -q "[\"'\\\\]" <<< "$ENVL" && echo FOUND || echo none) (values not recorded)"
note "W0b-W4 end $(date -u +%FT%TZ)"
-1
View File
@@ -1365,4 +1365,3 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
2026-10-20T20:40:15Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 1 review, candidate 8862feccc (static) | CHANGES: B1 base drift, row 189 ace27dc5e conflicts in stack-release.sh, rebase + full gate rerun; N1-N7 notes; Gitea 29634, queue rev 2362 (uncommitted, left for Sage)
2026-10-20T21:29:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 2 review, candidate 537a02392 (static) | CHANGES: B1 base drift, row 192 4429a104f conflicts in mosaic-config.mjs and webui-service.sh (async load); round 2 content fine; Gitea 29685, queue rev 2447 (uncommitted, left for Sage)
2026-10-20T22:16:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 3 review, candidate df65a3d4c on darkwing/row190-r3 | APPROVE, Gitea 29713, queue rev 2504 (left for Sage's queue-commit); static review, no slot used
+11 -120
View File
@@ -15,7 +15,7 @@ and changes nothing.
| Command | Purpose | Notes |
|---|---|---|
| `scripts/bootstrap.sh [--instance NAME]` | Create `~/.config/mosaic-dev/config.json` if absent; with `--instance NAME`, `~/.config/mosaic-NAME/config.json` and the claimed root `~/.mosaic-NAME` | Idempotent; existing config validated, never rewritten. See [Stack instances](#stack-instances---instance-name-row-190) |
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
@@ -270,9 +270,9 @@ scripts/mosaic follow --thread <thread> [--tester]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh uninstall | status <business>
```
The human commands read and resolve through the broker's human transport
@@ -321,89 +321,12 @@ stdin and stdout it exits 4 with the usage.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
## Stack instances (`--instance NAME`, row 190)
Several stacks can run on one host, each with its own config, data root,
units, Console port and CLI wrapper. The default instance is today's stack
and keeps every name it had. A named instance is created once:
```
scripts/bootstrap.sh --instance NAME
```
NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4
(`instance-name-invalid`), and `--instance dev` is the default. Bootstrap
writes `~/.config/mosaic-NAME/config.json` (0644) and claims `~/.mosaic-NAME`
with the marker `instance.json`, `{"instanceVersion":1,"name":"NAME"}`. It
creates either directory 0700; one that already exists keeps its mode. It refuses with exit 3 and writes
nothing when the root exists, is not empty and holds no marker naming NAME.
A second run changes nothing. `~` is the home of the OS user that runs the
instance.
`--instance NAME` is taken, once and in any position, by `bootstrap.sh`,
`bus-service.sh`, `webui-service.sh` (all but `copy`), `discord-service.sh`,
`stack-copy.sh` (`wrapper` and `check`) and `stack-release.sh`. Copies serve
every instance, so both `copy` verbs refuse it with exit 4. Inside a process the config
travels as `MOSAIC_CONFIG`. Selection, checked before any read or write:
| Given | Config | Names |
|---|---|---|
| neither | `~/.config/mosaic-dev/config.json` | the default's |
| `MOSAIC_CONFIG` only | that file | the default's |
| `--instance NAME` only | `~/.config/mosaic-NAME/config.json` | NAME's |
| both | must be one file (a symlink to it agrees), else exit 4 `instance-config-mismatch` | NAME's |
Every selection checks the data root's marker. A named instance whose root
lacks its own marker refuses `instance-root-unclaimed`; a root whose marker
names another instance refuses `instance-root-mismatch`, also when a config
reaches it with no flag. Both exit 3 before anything is read or changed. A
missing config exits 3 with the bootstrap command that creates it.
| | Default | Instance NAME |
|---|---|---|
| bus unit | `[email protected]` | `[email protected]` |
| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` |
| Discord unit | `[email protected]` | `[email protected]` |
| CLI wrapper | `$BIN/mosaic` (Q81 marker) | `$BIN/mosaic-NAME`, marker `# mosaic-cli wrapper, instance NAME (row 190)`, exports `MOSAIC_CONFIG` |
| bus session env | `%h/.config/mosaic-dev/secrets/claude-session.env` | `~/.config/mosaic-NAME/secrets/claude-session.env` |
| Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` |
A named instance's units add `Environment=MOSAIC_CONFIG=<its config>` and
` [NAME]` after the Description, and the Console unit's comment on the
variables it sets names `MOSAIC_CONFIG`. The default's renders are
byte-identical to the templates' renders before row 190. `stack-copy.sh
wrapper --instance NAME` creates `$BIN/mosaic-NAME` or replaces one that
carries NAME's marker, and refuses any other file. `check --instance NAME`
needs `--business ID`, and fails with `CLI: <copy> (MOSAIC_CONFIG mismatch)`
when the wrapper does not export NAME's config.
For every instance, a Console `--port` or `--tester-port` that another
installed Console unit in the unit directory already takes refuses
`port-claimed`, naming that unit: `webui-service.sh install` exits 1, and
`stack-release.sh activate` and `rollback` exit 3 before anything stops
(`--plan` lists it). A unit with no `--port` holds 7332.
`stack-release.sh --instance NAME` reads only NAME's pointer, log, store and
backups. Its busy check sees only NAME's bus units. Its drop-in scan adds
`mosaic-bus-NAME@*.service.d` and the shared `mosaic-bus-.service.d`. Its
rollback set holds NAME's two unit files, their own `.d` directories and
`$BIN/mosaic-NAME`; it never captures or restores the shared prefix
directories (`mosaic-bus-.service.d`, `mosaic-webui-.service.d`,
`mosaic-discord-.service.d`). The live-session guard refuses a path inside
any named instance's config directory or `~/.mosaic-NAME`, even when that
instance's config is unreadable.
All instances run under one OS user for now. That user's processes can
read and control every instance; a separate user per instance is row 195.
A real second instance on this host is a Q173 window, and tunnel or Traefik
exposure of a named Console waits for Jason after Q175 SSO.
## Stack copies (`scripts/stack-copy.sh`)
```
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]
scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID]
```
One read-only tree supplies the bus, Console and human CLI (row 85,
@@ -445,11 +368,11 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
common options: --copies DIR, --bin DIR, --instance NAME
common options: --copies DIR, --bin DIR
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
```
@@ -565,36 +488,6 @@ rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
`activate ... --sso` (row 200, #1698) starts the Console with Q175 sign-in
(row 192). It drops `--public-host`, `--auth-header`, `--allow-user` and any
proof pair from the pointer's Console flags and the unit, and puts `--sso` after
the other flags, before `--send` and `--tester-port`. It may name the live copy
once, so a copy already at row 192 or later turns sign-in on without a copy
change. Later activates, `pm-on` and rollbacks to a copy with `--sso` carry it.
`--sso` with a proof pair or `--no-proof`, or on any other verb, is a usage
error. Before anything stops, a run whose Console flags hold `--sso` refuses
`sso-target` for a target Console without it, `sso-config` when the target's own
`checkConsole` refuses the config's `console` key (or there is none, in which
case, from another copy, it names the first step: activate the target without
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
that is missing, a symlink, not a regular file, not owned by this uid, has group
or other bits, resolves inside the repository or the copies, or is empty. It
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
prints one `sso:` line: `on` with what it drops and carries, `carried`, or
`dropped` on a rollback.
A rollback from an `--sso` pointer to a copy whose Console lacks `--sso` takes
the Console flags from the rollback set of the activation that moved the stack
onto the pointer's copy from the target, past any same-copy re-pin, checked
against the set's `SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
When that set is missing, fails its sums, holds no readable argv or already ran
`--sso`, the run refuses `sso-rollback-flags`. Any activate or rollback also
refuses `config-console-key` before anything stops when the config holds a
`console` key the target's `scripts/mosaic-config.mjs validate` refuses (a copy
before row 192 refuses the key). The message says to remove the key first. So a
window re-pins to row 192 or later before it writes the key, and a rollback past
row 192 removes the key first.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
@@ -709,7 +602,6 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error.
```bash
scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
# render, install, uninstall and status also take --instance NAME (row 190); copy refuses it (exit 4)
scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit
scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
@@ -787,9 +679,8 @@ Each value is held to a plain character set the unit can carry (no space,
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
with a letter or digit; anything else is refused. With `--auth-header
X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik
uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable; a named instance's sets only
`MOSAIC_CONFIG` (row 190). Serve's exit 2 (usage, config, or a
uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
port already in use) is not restarted.
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
@@ -814,7 +705,7 @@ scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding>
scripts/discord.sh reload <binding>
scripts/discord-service.sh [--instance NAME] render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
```
One seat's conversation reachable from listed channels of one Discord
+11 -86
View File
@@ -98,55 +98,6 @@ every `scripts/test-*.sh`.
---
## C2-01b survey escapes, list item lines and attachment embeds
### Problem
Webby's row 212 review (webby-0041, `agents/webby/work/queue-212/REVIEW-r1.md`, approve on bdf18272a)
left three notes that change C2-01's code. Row 212 lands as approved, and these come after it.
- N1. The comment at `packages/library/src/survey.mjs:76` is false. `JSON.stringify` leaves C1 and bidi
controls raw, so a folder name can carry them into the survey output.
- N2. A front matter link reports its key's line, not its list item's (`reader.mjs:156`, `:162-165`). Two
links under `relationships:` both point at the key, and so do their R4, R5 and R6 problems.
- N3. `links.mjs:2` reads `![[x]]` embeds as links, so an embedded image that is unmapped gives R4 "a link
to no file". Design 1.3 says nothing about embeds.
### Owner and reviewer
- Owner: Gosalyn.
- Reviewer: Webby.
- After: C2-01.
### Files owned
`packages/library/src/survey.mjs`, `src/reader.mjs`, `src/links.mjs`, `packages/library/README.md` where
it describes these, and `packages/library/tests/`.
### What ships
- N1. The survey prints U+0080 to U+009F, U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069 in any
name as `\uXXXX` escapes, and the comment says what the code does.
- N2. A link in a front matter block list reports its list item's line. A link in a scalar or flow value
keeps the key's line.
- N3 (sage-1517, Gosalyn's proposal). An embed whose target has an extension other than `.md` is an
attachment. It is neither resolved nor indexed, and it never gives R4. The survey still counts it as an
embed. An embed with no extension or with `.md` resolves as a link, so an unresolved one is R4.
Tests: one fixture folder name per escaped range prints escaped. Two links under one block list key report
two different lines, and their problems carry those lines. `![[cover.png]]` gives no problem and counts as
an embed, `![[missing-note]]` gives R4, and `![[note.md]]` resolves.
### Out of scope
The index and queries (C2-05a), and webby-0041 N4 and N5, which go to C2-12 and C2-10b.
### Gate
Webby approves the manifest. The library tests are green on Sage's gate, along with every
`scripts/test-*.sh`.
---
## C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file
### Problem
@@ -431,8 +382,6 @@ The setup sheet in plain words:
- That a short link to a name that also exists in another of her repos can point somewhere else for the
agents, and that a full-path link avoids this.
- What changes for Bean and Chet: short cited items, no passages (design 4).
- That a list of links in a note's properties uses the quoted form Obsidian's property editor writes,
`appears_in: ["[[A]]", "[[B]]"]`. The unquoted `[[[A]], [[B]]]` is R3 (webby-0041 N4).
The survey run sheet gives the exact `library survey` command with C2-01's real flags, and states what
the output holds: names and counts only.
@@ -741,11 +690,9 @@ Design 7.4. The chat view renders a cited reply through `renderItems`: each item
with a scope badge (canon, playground or web) and "changed since" when the file's last commit is newer
than the cited one. Each notice shows its fixed line. A status line reads `library.status`: the served
commit and its age, `library-diverged`, a fetch error, and the count of R-problems with a link to the
list. A web cite's `url` renders as a link only when its scheme is `https`, and as plain text otherwise.
`renderItems` doesn't check the scheme (`packages/library/src/render.mjs:14-16`, webby-0041 N5).
list.
Tests: webui tests for items, scope badges, notices, "changed since" and the status line, and a web cite
with an `http:` and a `javascript:` url that each render as text.
Tests: webui tests for items, scope badges, notices, "changed since" and the status line.
### Out of scope
@@ -789,12 +736,8 @@ precheck prints only variable names, never values (webby-0034 N2, webby-0035).
`/srv/mosaic`, never from the release, because `mosaic` can write the release.
Inputs. `--profile factory|library` (default `library`), `--user`, `--home`, `--business`, `--copy ID`,
`--node PATH`, with `factory` only `--pm` and `--claude PATH`, and the Console's serve flags after `--`.
Row 195 calls it with those names. `library` refuses `--pm` and `--claude`.
`--node PATH`, and the Console's serve flags after `--`. Row 195 calls it with those names.
- `--node` must be an absolute path to a root-owned regular file outside `<home>`.
- `--claude` follows the `--node` rule: an absolute path to a root-owned regular file outside `<home>`, so
nothing running as `<user>` can replace the binary the bus runs (sage-1517, darkwing on gosalyn-0057).
Row 195 installs claude under a root-owned prefix to match.
- The copy id must be exactly 40 lowercase hex characters naming a directory that holds
`scripts/mosaic`, directly under `<home>/.local/share/mosaic-stack/copies`. That is the rule
`copy_root` uses (`scripts/stack-copy.sh:6`, `:20`). Anything else refuses before the render.
@@ -817,11 +760,9 @@ Row 195 calls it with those names. `library` refuses `--pm` and `--claude`.
- It refuses `--checkout`. Her host has no canonical checkout, and the queue store refuses a read
from the copy, so the queue panel isn't part of Case 2 (webby-0036 N4).
- It refuses `--board`. Case 2 runs no control board, and design 9.3 says why the default stays.
The factory profile (gosalyn-0057 items 1-3, sage-1517) takes `--port N` in 1..1023 and `--business ID`,
both required, and an optional `--tester-port N` in 1024..65535, never equal to `--port`, because
Case 1's acceptance uses the tester. .53 has no proxy, so `factory` refuses `--public-host`,
`--auth-header`, `--allow-user`, `--proof-header`, `--proof-file` and `--send`. It refuses `--checkout`
and `--board` as `library` does, and any other flag.
The factory profile takes the same set plus an optional `--tester-port N` in 1024..65535, never equal
to `--port`, because Case 1's acceptance uses the tester. Row 195 names any other difference in its
own brief.
The render. It renders two system units from the checkout's existing templates and changes neither:
`[email protected]` from `packages/cli/systemd/mosaic-bus.service.in`, enabled as
@@ -836,13 +777,7 @@ The render. It renders two system units from the checkout's existing templates a
node's directory is already in it, so with `--node /usr/bin/node` a node in `/usr/local/bin` would
come first and the bus would run it.
- `@FLAGS@` is the checked serve flags, in the order given.
- `@PM_FLAGS@` is ` --pm` when `factory` is given `--pm`, as `scripts/bus-service.sh:69-70` renders it,
and empty otherwise.
- `@CLAUDE_ENV@` is the line `Environment=MOSAIC_CLAUDE_BIN=<--claude>` when `factory` is given
`--claude`, and empty otherwise.
- Row 190 (round 3, in review) adds `@CONFIG_ENV@` and `@SESSION_ENV@` to the templates. They aren't on
refactor, and the render refuses an unknown token, so whichever of rows 190 and 230 lands second adds
both to this script with tests. A comment at the script's token list says so (gosalyn-0057 item 4).
- `@PM_FLAGS@` and `@CLAUDE_ENV@` render empty.
- `%h` becomes the literal `--home`, because in a system unit `%h` is root's home.
- After the render it refuses any `@[A-Z_]+@` or `%h` left in either unit.
@@ -882,8 +817,7 @@ none, and it refuses one. It also refuses:
(`scripts/webui-service.sh:266-273`).
- on a re-install, an installed unit that differs from the render in anything but the copy id. So the
node path and serve flags must equal the installed ones. The copy id is the one value that may
change, so a re-pin or a rollback to another copy re-renders both units together (row 195). With
`factory`, `--pm` and `--claude` must also match the installed bus unit.
change, so a re-pin or a rollback to another copy re-renders both units together (row 195).
It writes both units under `/etc/systemd/system/` as `root:root` 0644. It never uses `systemctl link`,
and it refuses a symlink at either unit path. It opens no file under `<home>`. It only stats the copy
@@ -915,9 +849,7 @@ checks below. C2-13b adds the library checks to the same file. It refuses unless
`mosaic` process bind the port, a session included, and makes the Console's capability pointless
(design 9.3, mos-1100).
- When some instance's harness is claude-code, one marker prompt run through the host's claude with
`--system-prompt-file` shows that it replaces the default prompt. The claude version is printed. The
host's claude is the installed bus unit's `MOSAIC_CLAUDE_BIN` when the unit sets one, as `factory`
with `--claude` does, and `claude` on PATH otherwise. With
`--system-prompt-file` shows that it replaces the default prompt. The claude version is printed. With
the design's model path, Bean and Chet run pi, so this check is skipped unless she adds a claude-code
specialist.
- When an instance's provider is `zai`, `<configDir>/secrets/claude-session.env` exists, is a regular
@@ -939,9 +871,6 @@ It also prints free memory and verifies that HOME is `<home>`.
- both units hold the four hardening lines once, in order, and only the Console holds the two
capability lines
- a check of the script for `systemctl link` finds nothing
- with `factory`, `--pm` renders an `ExecStart=` ending in `bus start <business> --pm` with one `--pm`,
and `--claude` renders exactly one `Environment=MOSAIC_CLAUDE_BIN=` line with that path. Without them
both tokens render empty
These cases refuse:
- an installed unit edited to `User=root`
@@ -959,17 +888,13 @@ These cases refuse:
- a copy id that is short, uppercase, holds `/` or `..`, or names no directory
- with `library`: `--port 7332`, a missing proof pair, a proof file owned by root, at 0644, a symlink, or
under the data root, `--tester-port`, `--checkout`, `--board`, a `--business` different from the
unit's, `--pm`, `--claude`, and an unknown flag. With `factory`: `--tester-port` equal to `--port`, a
missing `--port` or `--business`, each of `--public-host`, `--auth-header`, `--allow-user`,
`--proof-header`, `--proof-file` and `--send`, a `--claude` that is relative, not root-owned or under
`<home>`, and a re-install with a different `--pm` or `--claude`.
unit's, and an unknown flag. With `factory`: `--tester-port` equal to `--port`.
The precheck runs against a scratch data root. Each refusal has a case. A fake `node` printing v22
refuses, a fake `bwrap` that exits 1 refuses, and an unprivileged port start at or below the Console's
port refuses. The claude check runs against a fake claude that appends rather than replaces, and that
case refuses. With no claude-code instance the claude check doesn't run, and a test shows a missing
claude passes. When the bus unit sets `MOSAIC_CLAUDE_BIN`, the check runs that binary: a replacing fake
there passes while an appending fake sits first on PATH, and the swap refuses. The key file refuses when missing, at 0644, a symlink, with `ZAI_API_KEY=` empty, and
claude passes. The key file refuses when missing, at 0644, a symlink, with `ZAI_API_KEY=` empty, and
with `ZAI_API_KEY=x` followed by `ZAI_API_KEY=`. A search of the test output for the fixture key value
finds nothing. `factory` passes with the tester on and refuses a business file with a library project.
`library` refuses with the tester on.
+494 -45
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
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_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, unhireableAction } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -29,6 +29,9 @@ export const ACTIONS = Object.freeze([
'organization.change',
// Row 141 (#1635): routed under PRD 1.5 REQ-MSG-2.
'instance.name',
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
'role.hire',
'role.fire',
'credential.mint',
'spend',
'deploy',
@@ -64,6 +67,9 @@ const ROUTED = new Set([
'policy.change',
'prd.approve',
'decision.resolve.technical',
// 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',
]);
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
@@ -97,10 +103,42 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task
// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs.
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop']);
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
'instance.launch', 'instance.end', 'instance.request']);
// 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']);
// Row 194 (#1692, D4): the human lifecycle verbs, one entry per verb and instances.set op. Each calls
// #mayHuman after #human, and the hook refuses a verb missing here. Row 193 maps each entry to the
// human roles that may use it.
export const LIFECYCLE_HUMAN_VERBS = Object.freeze(['instance.launch', 'instance.end', 'instance.regenerate', 'veto.set',
'instances.set add', 'instances.set change', 'instances.set delete']);
// Row 194 (D1.1): the requests the launcher carries out, and the events that move each one. All are
// action.allowed events, which event.emit refuses for any action but routine.
const LIFECYCLE_REQUESTS = ['lifecycle.launch', 'lifecycle.regenerate', 'lifecycle.delete', 'lifecycle.end'];
const LIFECYCLE_STEPS = ['lifecycle.taken', 'lifecycle.done', 'lifecycle.cancelled'];
// A launch, regenerate or delete request older than this never runs. A terminate's signal has no
// limit: the launcher checks each run's start time before it signals.
export const LIFECYCLE_REQUEST_MS = 60_000;
const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
// required. Each carries the reason the log records.
const HIRE_FIRE_ARGS = Object.freeze({
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
'role.fire': [['name', 'reason'], ['name', 'reason']],
});
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
// What instance.request and the host ops answer about a request.
const lifecycleView = (r) => ({
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
...('revision' in r ? { revision: r.revision, retry: r.retry === true } : {}),
...('runs' in r ? { runs: r.runs } : {}),
...(r.kill === true ? { kill: true } : {}),
done: r.done ? { run: r.done.run ?? null, transaction: r.done.transaction ?? null, refusal: r.done.refusal ?? null } : null,
cancelled: r.cancelled ? { by: r.cancelled.by } : null,
});
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
'task.changed.external',
@@ -145,6 +183,7 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants'];
// Row 165 (#1662): the keys of the human-only instances.set (business instance-settings.mjs).
const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch'];
const VETO_KEYS = ['revision', 'op', 'name'];
// Row 123 (#1617): role.end never reaches the caller's own run. END_PROTECTED (vocabulary.mjs, row
// 155): only the chief of staff, or a decision the human resolved through the CLI, ends or revokes
// an instance of one (#protectedEnd, Q169). A raise to revoke one is a hard wait.
@@ -180,6 +219,36 @@ export const THREAD_PAGE_MAX = 200;
// A project or thread name: what the user calls it, one line.
const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
// The checks a business document passes at boot and at every reload.
function checkBusiness(name, b) {
// Row 177: a human named `tester` would share the tester's threads.
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
fail('invalid-business');
for (const role of Object.keys(b.roles)) {
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
const a = b.roles[role].authority;
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)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
fail('invalid-arbiter');
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
for (const d of Object.values(b.definitions ?? {})) {
const m = d?.messaging;
if (m === undefined || m === null) continue;
if (
!object(m) ||
!['sendTo', 'receiveFrom'].every(
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
)
)
fail('invalid-messaging');
}
}
export class Broker {
#store;
#businesses;
@@ -199,11 +268,13 @@ export class Broker {
#usage;
#limits;
#harnessOn;
#testerOn;
#roleFile;
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, testerOn = null, roleFile = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
@@ -218,6 +289,15 @@ export class Broker {
// module-shaped name passes and the launcher's own harness-off check is the only one.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
this.#harnessOn = harnessOn ?? (() => true);
// Row 194 (#1692, D4.1, B8): testerOn(business) is true when the organization file as it stands
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
// tester lifecycle call and at the take. Without it the broker's own copy decides.
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
// 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.
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
this.#roleFile = roleFile;
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
for (const table of [
@@ -240,34 +320,17 @@ export class Broker {
this.#clock = Math.max(this.#clock, ms);
}
}
for (const [name, b] of Object.entries(this.#businesses)) {
// Row 177: a human named `tester` would share the tester's threads.
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
fail('invalid-business');
for (const role of Object.keys(b.roles)) {
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
const a = b.roles[role].authority;
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)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
fail('invalid-arbiter');
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
for (const d of Object.values(b.definitions ?? {})) {
const m = d?.messaging;
if (m === undefined || m === null) continue;
if (
!object(m) ||
!['sendTo', 'receiveFrom'].every(
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
)
)
fail('invalid-messaging');
}
}
for (const [name, b] of Object.entries(this.#businesses)) checkBusiness(name, b);
}
// Row 194 (#1692, D3): swap in one business's document, loaded from the file as a restart would
// load it. It is synchronous, so no request sees half of it. A new human is row 193's ground.
reloadBusiness(name, document) {
if (!Object.hasOwn(this.#businesses, name)) fail('unknown-business');
if (!object(document)) fail('invalid-business');
const next = structuredClone(document);
checkBusiness(name, next);
if (next.human !== this.#businesses[name].human) fail('invalid-business');
this.#businesses[name] = next;
}
#now() {
this.#clock = Math.max(Date.now(), this.#clock + 1);
@@ -304,6 +367,92 @@ export class Broker {
owner,
);
}
// Row 194 (#1692): the runs that hold a claim for an instance, one per thread for a thread instance.
#running(business, instance) {
if (this.#threaded(business, instance)) return this.#threadHolders(business, instance).map((h) => h.holder_run);
const h = this.#holder(business, instance);
return h?.op === 'claim' ? [h.holder_run] : [];
}
// Row 194 (#1692, D1.1): a lifecycle request and how far it got, or null. The request is the
// action.allowed event the verb wrote. Its taken, done and cancelled events name it by id.
#lifecycle(business, request) {
const row = this.#store.get(
"SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?,?)",
business, request, ...LIFECYCLE_REQUESTS);
if (!row) return null;
const steps = this.#store.all(
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?) ORDER BY seq",
business, request, ...LIFECYCLE_STEPS).map((e) => JSON.parse(e.body));
const step = (action) => steps.find((x) => x.action === action) ?? null;
const done = step('lifecycle.done'), cancelled = step('lifecycle.cancelled'), taken = step('lifecycle.taken') !== null;
return { ...JSON.parse(row.body), at: row.at, instance: row.subject, taken, done, cancelled,
state: done || cancelled ? 'closed' : taken ? 'taken' : 'open' };
}
// The requests not yet closed, oldest first, of one instance or of the whole business.
#openRequests(business, instance = null) {
return this.#store.all(
`SELECT json_extract(r.body,'$.request') request FROM events r WHERE r.business=? AND r.kind='action.allowed'
AND json_extract(r.body,'$.action') IN (?,?,?,?) AND (? IS NULL OR r.subject=?)
AND NOT EXISTS (SELECT 1 FROM events c WHERE c.business=r.business AND c.kind='action.allowed'
AND json_extract(c.body,'$.request')=json_extract(r.body,'$.request') AND json_extract(c.body,'$.action') IN ('lifecycle.done','lifecycle.cancelled'))
ORDER BY r.seq`,
business, ...LIFECYCLE_REQUESTS, instance, instance).map((r) => this.#lifecycle(business, r.request));
}
#expired(r) {
return Math.max(Date.now(), this.#clock) - Date.parse(r.at) >= LIFECYCLE_REQUEST_MS;
}
// V5 instance-starting: a launch, regenerate or delete of I that is taken, or open and still young
// enough to be taken. An open request past its 60 s can only close as request-expired.
#starting(business, instance) {
return this.#openRequests(business, instance).some((r) => r.action !== 'lifecycle.end' && (r.state === 'taken' || !this.#expired(r)));
}
// The verb's request event, with a human.input for the human's own, as launch.revoke writes. A
// tester request records `tester` and has no human input.
#lifecycleRecord(s, action, instance, extra = {}) {
const request = randomUUID();
const eid = this.#event(s, 'action.allowed', { action, request, via: s.tester ? 'tester' : s.via, by: this.#owner(s), ...extra }, instance);
if (!s.tester) this.#event(s, 'human.input', { kind: 'admin', event: eid });
return request;
}
// A step the launcher's host ops write, or a terminate's cancel. No session acts in it.
#lifecycleStep(business, action, body) {
this.#event({ business, role: null, run: null }, 'action.allowed', { action, ...body });
}
// D4.1: a tester lifecycle call, and the take of a tester request, need bus.tester exactly true in
// the file as it stands.
#testerFile(business) {
if (this.#testerOn(business) !== true) fail('tester-off');
}
// V2 and V4 at the verb. The take repeats the revoked and retired checks.
#launchable(s, instance, thread) {
const b = this.#business(s.business);
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
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');
}
// 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(
`SELECT 1 FROM events f WHERE f.business=? AND f.kind='action.allowed' AND f.subject=? AND json_extract(f.body,'$.action')='fire.stopped'
AND NOT EXISTS (SELECT 1 FROM events d WHERE d.business=f.business AND d.kind='action.allowed' AND d.subject=f.subject
AND json_extract(d.body,'$.action')='fire.done' AND d.seq>f.seq) LIMIT 1`,
business, instance);
}
// D1.1 item 2: why the take closes a request instead of going, or null. A terminate's signal
// always goes. Its claims were revoked at the verb.
#takeRefusal(business, r) {
if (r.action === 'lifecycle.end') return null;
if (this.#expired(r)) return 'request-expired';
if (r.action !== 'lifecycle.delete') {
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', business)?.state === 'revoked') return 'launch-revoked';
if ((this.#business(business).retired ?? []).includes(r.instance)) return 'instance-retired';
if (this.#firing(business, r.instance)) return 'instance-firing';
}
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
return null;
}
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
#own(s) {
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
@@ -376,6 +525,12 @@ export class Broker {
if (s.reader) fail('read-only');
if (!s.human || s.via !== 'cli' || s.outsideAgent !== true) fail('human-required');
}
// Row 194 (#1692, D4): until row 193, a lifecycle verb passes for the business's one human, the
// owner. Row 193 replaces this body and nothing else.
#mayHuman(s, verb, target) {
if (!LIFECYCLE_HUMAN_VERBS.includes(verb) || typeof target !== 'string') fail('human-required');
if (s.human !== this.#business(s.business).human) fail('human-required');
}
#cap(s) {
const cap = randomBytes(32).toString('hex');
this.#sessions.set(cap, Object.freeze(s));
@@ -772,6 +927,7 @@ export class Broker {
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
)
fail('launch-revoked');
if (action === 'role.launch' && this.#firing(s.business, target)) fail('instance-firing');
const cls = this.#classification(s, action, target);
if (cls === 'within-role' && !decision) return { class: cls };
if (!decision) fail('decision-required');
@@ -1069,6 +1225,70 @@ export class Broker {
this.#store.transaction(() => this.#claimEnd(session, h, 'release', null, null, 'land-relaunch'));
return { released: true };
}
// Row 194 (#1692, D1.1): the launcher's lifecycle pass. Trusted launcher API, not socket verbs.
// lifecycleRequests lists the requests not yet closed, oldest first.
lifecycleRequests(business) {
this.#business(identifier(business));
return this.#openRequests(business).map(lifecycleView);
}
// The take, in one transaction: an open request that passes #takeRefusal is taken and answers go,
// one that fails it closes with the code. A taken request is never taken again.
lifecycleTake(record) {
keys(record, ['business', 'request'], ['business', 'request']);
this.#business(identifier(record.business));
identifier(record.request);
return this.#store.transaction(() => {
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
if (r.state !== 'open') return { go: false, refusal: 'request-closed', ...lifecycleView(r) };
const refusal = this.#takeRefusal(record.business, r);
this.#lifecycleStep(record.business, refusal ? 'lifecycle.done' : 'lifecycle.taken', { request: r.request, ...(refusal ? { refusal } : {}) });
return { go: !refusal, refusal, ...lifecycleView(this.#lifecycle(record.business, r.request)) };
});
}
// V5 step 2 for a taken delete: the running and starting checks again, then the writer's delete
// against the file as it stands, with the human's revision. The bus reloads after the commit, as
// after every write. A retry of a retired name commits nothing and goes to the rename, with the
// transaction of the delete whose rename failed (row 194 S3), which names the rename's target.
lifecycleDelete(record) {
keys(record, ['business', 'request'], ['business', 'request']);
this.#business(identifier(record.business));
const r = this.#lifecycle(record.business, identifier(record.request)) ?? fail('unknown-request');
if (r.action !== 'lifecycle.delete' || r.state !== 'taken') fail('request-not-taken');
if (r.retry === true) {
const failed = this.#store.all(
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' AND json_extract(body,'$.request')<>? ORDER BY seq DESC",
record.business, r.instance, r.request).map((x) => this.#lifecycle(record.business, x.request))
.find((x) => x.done?.refusal === 'workspace-rename-failed');
return { transaction: failed?.done?.transaction ?? null, retry: true };
}
if (this.#running(record.business, r.instance).length > 0) fail('instance-running');
if (this.#openRequests(record.business, r.instance).some((o) => o.request !== r.request && o.action !== 'lifecycle.end' &&
(o.state === 'taken' || !this.#expired(o)))) fail('instance-starting');
if (!this.#organization) fail('not-configured');
let out;
try {
out = this.#organization(record.business, 'instances.set', { revision: r.revision, instance: r.instance, op: 'delete' }, r.by);
} catch (e) {
this.#organizationFail(e, 'instances.set');
}
return { transaction: out.transaction, retry: false };
}
// The close of a taken request, with the run it started, the delete's transaction or the code
// that stopped it.
lifecycleDone(record) {
keys(record, ['business', 'request', 'run', 'transaction', 'refusal'], ['business', 'request']);
this.#business(identifier(record.business));
identifier(record.request);
for (const k of ['run', 'transaction']) if (k in record) identifier(record[k]);
if ('refusal' in record && !LIFECYCLE_CODE.test(record.refusal)) fail('invalid-request');
return this.#store.transaction(() => {
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
if (r.state !== 'taken') fail(r.state === 'open' ? 'request-not-taken' : 'request-closed');
const { business, ...body } = record;
this.#lifecycleStep(business, 'lifecycle.done', body);
return lifecycleView(this.#lifecycle(business, r.request));
});
}
// The merge decisions landHeld would count, each with its raised context and closing event. A
// null role is every non-thread instance's (row 156): raised outside a thread by a role that
// isn't a thread instance.
@@ -1245,7 +1465,9 @@ export class Broker {
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (PROJECT_FILE_VERBS.includes(request.verb))
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -1262,13 +1484,42 @@ export class Broker {
else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
// organization.set and the chief of staff's tool never reach it (sage-0918).
else if (request.verb === "instances.set") keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
else if (request.verb === "instances.set") {
keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
if (!["add", "change", "delete"].includes(args.op)) fail("invalid-request");
// Row 194 (D4.1, B6): the tester never deletes.
if (s.tester && args.op === "delete") fail("tester-refused");
if (!s.tester) {
this.#human(s);
this.#mayHuman(s, `instances.set ${args.op}`, args.instance);
}
// A delete is a launcher request (V5): the checks here write nothing, and the launcher's
// pass commits the file and renames the workspace after the take.
if (args.op === "delete") return this.#deleteRequest(s, args);
// Row 194 (D3 item 4): the write now goes live at once, so a live cap's authority must not
// change under its run.
if (args.op === "change" && "definition" in args && typeof args.instance === "string" &&
Object.hasOwn(this.#business(s.business).roles, args.instance) && this.#running(s.business, args.instance).length > 0)
fail("instance-running");
}
// Row 65 (mos-0840): routes.set, the Console's routing table, is human-only and outside
// ORGANIZATION_KEYS too. Through organization.set the chief of staff changes only the model
// key of an instance, except the assistant, whose whole vars it may set (Q169); that write
// runs the same route checks.
else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]);
else keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (#1692, D2.4, V6): only the human writes launch.keep and launch.noHire. The tester
// is refused by the gate above, as veto.set isn't in TESTER_ORGANIZATION_VERBS.
else if (request.verb === "veto.set") {
keys(args, VETO_KEYS, VETO_KEYS);
this.#human(s);
this.#mayHuman(s, "veto.set", args.name);
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
// the next launch. The human's limits.set still can.
const keep = this.#business(s.business).launch?.keep ?? [];
if (role && object(args.instances) && Object.keys(args.instances).some(i => keep.includes(i))) fail("instance-kept");
}
let actor = s.tester ? "tester" : s.human;
// This branch runs outside the store transaction below, so the grant is checked before the writer
// and consumed only once the writer has committed: a refused change logs action.refused alone,
@@ -1283,20 +1534,14 @@ export class Broker {
? { revision: this.#organization(s.business, "limits", {}, actor).revision }
: this.#organization(s.business, role ? "limits.set" : request.verb, args, actor);
} catch (e) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
// The routing table names a model the table lacks; the older verbs keep invalid-request.
if (request.verb === "routes.set" && e.code === "unknown-model") fail(e.code);
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
this.#organizationFail(e, request.verb);
}
if (role && !revisionRead) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
// Row 194 (#1692, D1): the read gains each instance's running runs, from the bus claims. An
// answer with no instances map passes through as it did before.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object")
for (const [name, entry] of Object.entries(result.instances)) entry.running = this.#running(s.business, name);
return result;
}
return this.#store.transaction(() => {
@@ -1316,6 +1561,153 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
// transaction, so the grant is checked before it and the use logged only after it, as
// organization.set's grant is.
#chiefLifecycle(s, verb, a) {
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
this.#agent(s);
const [allowed, required] = HIRE_FIRE_ARGS[action];
keys(a, allowed, required);
this.#hireFireArgs(action, a);
const args = a;
const b = this.#business(s.business);
if (b.launch?.by !== s.role) fail('role-not-chief');
if (!this.#organization) fail('not-configured');
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
// (decision-required), and these verbs take none.
const authority = { target: args.name };
this.#checkAuthority(s, action, authority);
// D2.4: a kept name is neither hired nor fired.
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
const actor = b.roles[s.role].definition + '.' + s.role;
const context = { instance: s.role, run: s.run, reason: args.reason };
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
const { reason: _, ...written } = args;
const write = (op) => {
try {
return this.#organization(s.business, op, written, actor, context);
} catch (e) {
this.#organizationFail(e, verb);
}
};
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
}
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
if (b.launch?.by === args.name) fail('organization-instance-referenced');
// The last listed name refuses before step A stops anything, as the writer would at step B.
const listed = b.launch?.instances ?? [];
if (listed.length === 1 && listed[0] === args.name) fail('invalid-request');
// Step A (webby-0011 B2): the store side first, so a delisted agent never keeps a live process.
// V3's revoke, cancel and end, with kill: true for the launcher's escalation.
const stopped = this.#store.transaction(() => {
const held = this.#threaded(s.business, args.name)
? this.#threadHolders(s.business, args.name)
: [this.#holder(s.business, args.name)].filter((h) => h?.op === 'claim');
for (const h of held) this.#claimEnd(s, h, 'revoke', null, args.reason);
const runs = held.map((h) => h.holder_run);
for (const [key, r] of this.#runs)
if (r.business === s.business && r.role === args.name && !this.#ended.has(key) && !runs.includes(r.run)) runs.push(r.run);
const open = this.#openRequests(s.business, args.name).filter((r) => r.action !== 'lifecycle.end');
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: s.role });
// The request the launcher's pass takes to signal the runs.
const request = runs.length === 0 ? null : randomUUID();
if (request)
this.#event(s, 'action.allowed', { action: 'lifecycle.end', request, via: 'chief', by: s.role, runs, reason: args.reason, kill: true }, args.name);
this.#event(s, 'action.allowed', { action: 'fire.stopped', request }, args.name);
return { request, runs, cancelled, taken };
});
// Step B: the delist. An instance already delisted counts as done.
const result = write('instance.fire');
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
identifier(args.name);
if (args.name === 'human' || args.name === 'tester') fail('invalid-request');
string(args.reason, 500);
if (action === 'role.fire') return;
identifier(args.definition);
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
}
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
#hireable(s, definition) {
if (!this.#roleFile) fail('not-configured');
const b = this.#business(s.business);
const load = (name) => {
try {
return this.#roleFile(name);
} catch (e) {
if (e.name === 'BusinessError') fail('definition-not-hireable');
throw e;
}
};
const def = load(definition);
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
// Row 194: the writer's own copy of the hire and fire checks, against the file as it stands.
if ((verb === "instance.hire" || verb === "instance.fire") && ["definition-not-hireable", "instance-kept"].includes(e.code)) fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
// The routing table names a model the table lacks; the older verbs keep invalid-request.
if (verb === "routes.set" && e.code === "unknown-model") fail(e.code);
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
}
// Row 194 (#1692, V5): a delete's checks at the verb, in the design's order, then its request.
// The writer's instances.check runs deleteRefusal then the last-listed check, with no write. Its
// named refusals come first. The last-listed one (a BusinessError) waits until the broker's
// running and starting checks have passed. A retired name is a retry of the workspace rename, and
// only after a rename that failed.
#deleteRequest(s, args) {
keys(args, ["revision", "instance", "op"], ["revision", "instance", "op"]);
identifier(args.instance);
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) fail("invalid-request");
const b = this.#business(s.business);
if ((b.retired ?? []).includes(args.instance) && !Object.hasOwn(b.roles, args.instance)) {
const last = this.#store.get(
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' ORDER BY seq DESC LIMIT 1",
s.business, args.instance);
if (this.#lifecycle(s.business, last?.request ?? "")?.done?.refusal !== "workspace-rename-failed") fail("organization-instance-retired");
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision, retry: true })) };
}
let lastListed = null, revision = null;
try {
({ revision } = this.#organization(s.business, "instances.check", { instance: args.instance }, s.human));
} catch (e) {
if (e.name !== "BusinessError") this.#organizationFail(e, "instances.set");
lastListed = e;
}
if (this.#running(s.business, args.instance).length > 0) fail("instance-running");
if (this.#starting(s.business, args.instance)) fail("instance-starting");
if (lastListed) fail("invalid-request");
if (revision !== args.revision) fail("revision-conflict");
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision })) };
}
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
// needs the decision held for the run's role; its end needs that begin, since the landing may
@@ -1610,6 +2002,9 @@ export class Broker {
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
if (a.role === s.role) fail('role-end-refused');
this.#protectedEnd(s, a.role);
// Row 194 (#1692, D2.4): a kept instance's run ends only by a decision the human resolved,
// the human's own end or the limits. End then launch would be a replace.
if ((this.#business(s.business).launch?.keep ?? []).includes(a.role)) fail('instance-kept');
} else {
identifier(a.decision);
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
@@ -2285,6 +2680,60 @@ export class Broker {
this.#claimEnd(s, h, 'revoke', null, s.tester ? 'stopped by the tester' : 'stopped by the human', s.tester ? 'tester' : s.human);
return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) };
}
// Row 194 (#1692, D1.1, V2, V4): launch and regenerate are requests the launcher's pass takes.
// The tester launches (D4.1) and never regenerates: instance.regenerate isn't in TESTER_VERBS.
case 'instance.launch':
case 'instance.regenerate': {
if (!s.tester) this.#human(s);
keys(a, ['instance'], ['instance']);
identifier(a.instance);
if (s.tester) this.#testerFile(s.business);
else this.#mayHuman(s, verb, a.instance);
this.#launchable(s, a.instance, verb === 'instance.launch' ? 'not-thread-launch' : 'regenerate-thread');
const action = verb === 'instance.launch' ? 'lifecycle.launch' : 'lifecycle.regenerate';
return { request: this.#lifecycleRecord(s, action, a.instance) };
}
// V3: every claim of I is revoked, and every open request of I cancelled, in this transaction.
// lifecycle.end names the runs for the launcher's pass to signal, the bound runs that hold no
// claim included. A tester ends only its own threads of a thread instance, as assistant.stop
// does, and cancels only its own requests.
case 'instance.end': {
if (!s.tester) this.#human(s);
keys(a, ['instance', 'reason'], ['instance']);
identifier(a.instance);
if ('reason' in a) string(a.reason, 500);
if (s.tester) this.#testerFile(s.business);
else this.#mayHuman(s, verb, a.instance);
if (!Object.hasOwn(b.roles, a.instance)) fail('unknown-role');
const owner = s.tester ? 'tester' : null;
const threaded = this.#threaded(s.business, a.instance);
const held = threaded
? this.#threadHolders(s.business, a.instance, owner)
: [this.#holder(s.business, a.instance)].filter((h) => h?.op === 'claim');
const reason = a.reason ?? (s.tester ? 'ended by the tester' : 'ended by the human');
for (const h of held) this.#claimEnd(s, h, 'revoke', null, reason, this.#owner(s));
const runs = held.map((h) => h.holder_run);
for (const [key, r] of this.#runs)
if (r.business === s.business && r.role === a.instance && !this.#ended.has(key) && !runs.includes(r.run) &&
(!owner || !r.thread || this.#store.get('SELECT owner FROM threads WHERE id=?', r.thread)?.owner === owner))
runs.push(r.run);
const open = this.#openRequests(s.business, a.instance).filter((r) => r.action !== 'lifecycle.end' && (!owner || r.by === owner));
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
// A taken request is past cancelling. Its run reaches the registry, and the lifecycle.end
// lets the pass signal it there.
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
if (runs.length === 0 && cancelled.length === 0 && taken.length === 0) fail('not-held');
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: this.#owner(s) });
return { request: this.#lifecycleRecord(s, 'lifecycle.end', a.instance, { runs, reason }), runs, cancelled, taken };
}
// D1.1 item 5: the read the CLI and the Console poll. A tester reads only its own requests.
case 'instance.request': {
if (!s.tester) this.#human(s);
keys(a, ['request'], ['request']);
const r = this.#lifecycle(s.business, identifier(a.request));
if (!r || (s.tester && r.by !== 'tester')) fail('unknown-request');
return lifecycleView(r);
}
case 'names': {
keys(a, []);
if (!s.human && !s.tester) this.#agent(s);
+9
View File
@@ -20,6 +20,10 @@ const LAUNCH_OPS = new Set([
'mayLaunch',
'landHolders',
'landRelease',
'lifecycleRequests',
'lifecycleTake',
'lifecycleDelete',
'lifecycleDone',
]);
function launchOp(m) {
if (m.op === 'identity') return runtime.identity(m.cap);
@@ -44,6 +48,11 @@ function launchOp(m) {
// role run started before its decision was resolved.
if (m.op === 'landHolders') return runtime.landHolders(m.business);
if (m.op === 'landRelease') return runtime.landRelease(m.record);
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
if (m.op === 'lifecycleRequests') return runtime.lifecycleRequests(m.business);
if (m.op === 'lifecycleTake') return runtime.lifecycleTake(m.record);
if (m.op === 'lifecycleDelete') return runtime.lifecycleDelete(m.record);
if (m.op === 'lifecycleDone') return runtime.lifecycleDone(m.record);
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
+72 -6
View File
@@ -11,6 +11,11 @@ import { readSessions } from '../../seat/src/session.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { busBusiness } from './business.mjs';
import { webService } from './web.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
@@ -103,6 +108,40 @@ export async function startBroker({
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
for (const id of Object.keys(businesses)) await limits.refresh(id);
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
// would load it. The credential references, the project roots the Credentials store protects
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
// whose references, roots or bots differ from the boot copy isn't reloaded, and the write
// answers restart: true. Create and hire add neither,
// because credentialed definitions are refused.
// A role not in the boot copy matches only with no references and no bot.
const fixedRole = r => canonicalJson({ credentials: r?.credentials ?? {}, tracker: r?.tracker ?? null });
const fixedAtStart = b => ({
roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])),
sync: canonicalJson(b.tracker?.sync ?? null),
roots: canonicalJson(Object.values(b.projects ?? {}).map(p => p.root).sort()),
});
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
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 resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = busBusiness(b, resolved);
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
}
broker.reloadBusiness(id, next);
return true;
} catch (e) {
reloadLog({ event: 'reload-failed', business: id, message: e.message });
return false;
}
}
const broker = new Broker({
usage,
limits,
@@ -118,19 +157,32 @@ export async function startBroker({
},
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
// plugins.set reloads the business's limit checks in the background.
organization:settings?(id,verb,args,actor)=>{
organization:settings?(id,verb,args,actor,context)=>{
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='limits.set')return withTokens(id,settings.set(id,args,actor));
if(verb==='plugins')return plugins.read(id);
if(verb==='instances')return instances.read(id);
if(verb==='instances.set')return instances.set(id,args,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
const result=plugins.set(id,args,actor);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
let result;
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
else if(verb==='instances.set')result=instances.set(id,args,actor);
else if(verb==='routes.set')result=settings.setRoutes(id,args,actor);
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// 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 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
return result;
return verb==='instances.set'?{...result,restart:!live}:result;
}:null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
// Row 65: a new launch binds only on a harness its business switches on, read from the file
@@ -143,6 +195,15 @@ export async function startBroker({
return false;
}
},
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
testerOn: (id) => {
try {
const b = settings ? settings.current(id) : businesses[id];
return b?.vars?.['bus.tester'] === true;
} catch {
return false;
}
},
});
// Process identities must be supplied for every managed run when using the human transport.
const launchRecords = [];
@@ -241,6 +302,11 @@ export async function startBroker({
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
landRelease: (r) => broker.landRelease(r),
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
lifecycleRequests: (business) => broker.lifecycleRequests(business),
lifecycleTake: (r) => broker.lifecycleTake(r),
lifecycleDelete: (r) => broker.lifecycleDelete(r),
lifecycleDone: (r) => broker.lifecycleDone(r),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
launches: bound,
+413 -5
View File
@@ -7,9 +7,13 @@ import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
@@ -27,7 +31,7 @@ const roles = {
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none },
};
const demo = () => ({
const demo = (extra = {}) => ({
demo: {
id: 'demo',
human: 'jason',
@@ -38,12 +42,13 @@ const demo = () => ({
// Row 65: runtime launches bind only on a harness the business switches on.
plugins: { harness: ['pi'] },
vars: { 'bus.tester': true },
...extra,
},
});
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
function setup(t) {
function setup(t, extra = {}, { roleFile = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
const store = new Store(root);
t.after(() => {
@@ -51,13 +56,16 @@ function setup(t) {
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const writer = { error: null };
const writer = { error: null, during: null };
const b = new Broker({
store,
businesses: demo(),
businesses: demo(extra),
roleFile,
organization: (...args) => {
organization.push(args);
if (writer.during) writer.during(args);
if (writer.error) throw writer.error;
if (args[1] === 'instance.hire' || args[1] === 'instance.fire') return { revision: 'b'.repeat(64), transaction: 't-' + organization.length };
// The human's limits read answers more than the revision; organization.revision must not.
return args[1] === 'limits'
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
@@ -645,3 +653,403 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
);
assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/);
});
// Row 194 (#1692, D2.4): the per-agent forbid. The chief can't end a kept instance's run with a
// reason or change its model. A decision the human resolved, and the human's own write, still can.
const KEPT = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['coder'] } };
test('row 194 T10: the chief\'s reason end of a kept instance refuses instance-kept, holds the claim and spends nothing', (t) => {
const { call, lastClaim, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'role.revoke', { role: 'coder', reason: 'replace it' }), /instance-kept/);
assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
assert.equal(allowed('role.end').length, 0);
// An instance not on the list still ends with a reason.
assert.deepEqual(call('chief', 'role.revoke', { role: 'pm', reason: 'stuck' }), { revoked: true });
});
test('row 194 T10 (Q2): a role.revoke citing a decision the human resolved still ends a kept run', (t) => {
const { call, lastClaim } = setup(t, KEPT);
const d = call('chief', 'decision.raise', {
action: 'role.revoke', target: 'coder', question: 'Stop the coder?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'chief-run' });
});
test('row 194 T10 (B7): the chief\'s organization.set of a kept instance\'s model refuses before the writer; the human\'s limits.set does not', (t) => {
const { call, organization, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { coder: 'm' } }), /instance-kept/);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm', coder: 'm' } }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(allowed('organization.change').length, 0);
call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm' } });
call('human', 'limits.set', { revision: PATCH.revision, instances: { coder: 'm' } });
assert.deepEqual(organization.map(([, verb, args]) => [verb, Object.keys(args.instances)]), [['limits.set', ['pm']], ['limits.set', ['coder']]]);
});
test('row 194 T13: organization.set and limits.set refuse the launch, retired, keep and noHire keys before the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const extra of [{ launch: { keep: [] } }, { retired: [] }, { keep: ['pm'] }, { noHire: ['coder'] }])
for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']])
assert.throws(() => call(who, verb, { ...PATCH, ...extra }), /invalid-request/, `${who} ${Object.keys(extra)[0]}`);
assert.equal(organization.length, 0);
});
test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse, and nothing reaches the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const op of ['keep', 'unkeep', 'nohire', 'hire'])
assert.throws(() => call('chief', 'veto.set', { revision: PATCH.revision, op, name: 'coder' }), /human-required/, op);
for (const op of ['add', 'change', 'delete'])
assert.throws(() => call('chief', 'instances.set', { revision: PATCH.revision, instance: 'coder', op, definition: 'coder' }), /human-required/, op);
assert.equal(organization.length, 0);
// The human's veto reaches the writer with the human as actor.
call('human', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' });
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.error = new Error('disk');
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.error = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = () => store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busBusiness(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = hireSetup(t);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
for (const instance of ['coder', 'pm'])
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
'stopped-' + instance, new Date().toISOString(), instance, JSON.stringify({ action: 'fire.stopped', request: null }));
for (const request of [launch, regenerate]) {
const { go, refusal } = b.lifecycleTake({ business: 'demo', request });
assert.deepEqual({ go, refusal }, { go: false, refusal: 'instance-firing' });
}
});
+42 -1
View File
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
import { Broker, LIFECYCLE_HUMAN_VERBS, ORGANIZATION_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
@@ -75,6 +75,8 @@ test('row 165 runtime: an add and a change reach the organization file with the
assert.equal(view.definitions['merge-gate'].accepted, true);
const added = bus.broker.request(human, { verb: 'instances.set', args: { ...ADD, revision: view.revision } });
assert.equal(added.instances['sec-1'].launched, true);
// Row 194 (D3 item 3): this boot copy's coder has no credential references and the file's has, so
// the bus doesn't reload and asks for a restart. reload.test.mjs boots from the file.
assert.equal(added.restart, true);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
const first = log().at(-1);
@@ -94,3 +96,42 @@ test('row 165 runtime: an add and a change reach the organization file with the
assert.equal(readFileSync(file, 'utf8'), before);
assert.equal(log().length, entries, 'a refused write logs nothing');
});
// Row 194 (#1692, D4, D4.1): the human lifecycle verbs so far, veto.set and instances.set's three ops.
test('row 194 T12: agent and reader sessions refuse veto.set; the tester gets add and change but not delete or veto.set; the human passes the hook for each listed verb', (t) => {
const root = mkdtempSync(join(tmpdir(), 'instances-verb-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const calls = [];
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([verb, args.op, actor]), verb === 'instances.check' ? { revision: ADD.revision } : { ok: true }) });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const caps = { reader: b.bindReader({ business: 'demo' }) };
for (const role of ['chief', 'pm']) caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
const VETO = { revision: ADD.revision, op: 'keep', name: 'pm' };
for (const [who, cap] of Object.entries(caps)) {
assert.throws(() => b.request(cap, { verb: 'veto.set', args: VETO }), /human-required/, who);
assert.throws(() => b.request(cap, { verb: 'instances.set', args: { ...ADD, op: 'delete' } }), /human-required/, who);
}
assert.throws(() => b.request(tester, { verb: 'veto.set', args: VETO }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }), /tester-refused/);
assert.equal(TESTER_ORGANIZATION_VERBS.includes('veto.set'), false);
assert.equal(calls.length, 0);
// veto.set takes exactly its three keys, and instances.set only its three ops.
for (const args of [{ ...VETO, actor: 'forged' }, { revision: VETO.revision, op: 'keep' }, { ...VETO, holder: 'mos' }])
assert.throws(() => b.request(human, { verb: 'veto.set', args }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, op: 'remove' } }), /invalid-request/);
assert.equal(calls.length, 0);
// The hook refuses a verb missing from LIFECYCLE_HUMAN_VERBS, so each call below passing shows the
// hook got that exact entry.
assert.deepEqual([...LIFECYCLE_HUMAN_VERBS].sort(), ['instance.end', 'instance.launch', 'instance.regenerate', 'instances.set add', 'instances.set change', 'instances.set delete', 'veto.set']);
b.request(human, { verb: 'veto.set', args: VETO });
b.request(human, { verb: 'instances.set', args: ADD });
b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
// S2c: the delete is a request after the writer's check, and writes nothing yet.
assert.ok(b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }).request);
b.request(tester, { verb: 'instances.set', args: ADD });
b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
assert.deepEqual(calls, [['veto.set', 'keep', 'jason'], ['instances.set', 'add', 'jason'], ['instances.set', 'change', 'jason'],
['instances.check', undefined, 'jason'], ['instances.set', 'add', 'tester'], ['instances.set', 'change', 'tester']]);
});
+373
View File
@@ -0,0 +1,373 @@
// Row 194 (#1692, D1.1, V2-V5, D4.1): the broker half of the lifecycle verbs. Launch, regenerate
// and delete are requests the launcher's pass takes through the host ops. Terminate revokes and
// cancels at the verb and names the runs the pass signals. The launcher half is in cli lifecycle.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, 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 { Broker, LIFECYCLE_REQUEST_MS, TESTER_VERBS } 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 R = 'a'.repeat(64);
const doc = (extra = {}) => ({
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
vars: { 'bus.tester': true },
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
roles: {
chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none },
assistant: { definition: 'assistant', ...none },
},
retired: ['old'],
...extra,
});
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
function setup(t, { organization = null, testerOn = undefined } = {}) {
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const tester = { on: true };
const b = new Broker({ store, businesses: { demo: doc(), other: OTHER }, organization,
testerOn: testerOn === undefined ? () => tester.on : testerOn });
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: b.bindTester({ business: 'demo' }),
reader: b.bindReader({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
const ask = (who, verb, args = {}) => code(() => call(who, verb, args));
let runs = 0;
const bind = (role, { claim = true, thread, name = `${role}-${++runs}` } = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: name, harness: 'pi', ...(thread ? { thread } : {}) });
if (claim) call(name, 'role.claim');
return name;
};
const lifecycle = () => store.get("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') LIKE 'lifecycle.%'").n;
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
const take = (request) => b.lifecycleTake({ business: 'demo', request });
const done = (request, extra = {}) => b.lifecycleDone({ business: 'demo', request, ...extra });
const state = (request) => call('human', 'instance.request', { request });
return { b, store, caps, call, code, ask, bind, lifecycle, inputs, take, done, state, tester };
}
// Moves the clock past a request's 60 s, for the rest of the test.
const later = (t) => { const now = Date.now(); t.mock.method(Date, 'now', () => now + LIFECYCLE_REQUEST_MS + 1000); };
test('row 194 T12: agent and reader sessions refuse the lifecycle verbs before any effect; the tester gets launch, end and the request read, never regenerate', (t) => {
const f = setup(t);
f.bind('chief');
f.bind('pm');
const verbs = [['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
['instance.end', { instance: 'coder' }], ['instance.request', { request: 'r1' }]];
for (const [verb, args] of verbs) {
assert.equal(f.ask('reader', verb, args), 'read-only', `reader ${verb}`);
for (const who of ['chief-1', 'pm-2']) assert.equal(f.ask(who, verb, args), 'human-required', `${who} ${verb}`);
}
assert.equal(f.ask('tester', 'instance.regenerate', { instance: 'coder' }), 'tester-refused');
assert.equal(f.lifecycle(), 0);
for (const verb of ['instance.launch', 'instance.end', 'instance.request']) assert.ok(TESTER_VERBS.includes(verb), verb);
assert.equal(TESTER_VERBS.includes('instance.regenerate'), false);
// Each human verb passes the D4 hook with its LIFECYCLE_HUMAN_VERBS entry.
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
f.call('human', 'instance.regenerate', { instance: 'pm' });
assert.deepEqual(f.call('human', 'instance.end', { instance: 'coder' }).cancelled, [request]);
});
test('row 194 V2 and V4 at the verb: the request is open, by the human, with a human.input; thread, retired, unknown and revoked refuse with no event', (t) => {
const f = setup(t);
const before = f.inputs();
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
const r = f.state(request);
assert.deepEqual([r.action, r.instance, r.by, r.state, r.done, r.cancelled], ['lifecycle.launch', 'coder', 'jason', 'open', null, null]);
assert.equal(f.inputs(), before + 1);
const raw = JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body);
assert.deepEqual(raw, { action: 'lifecycle.launch', request, via: 'cli', by: 'jason' });
const count = f.lifecycle();
assert.equal(f.ask('human', 'instance.launch', { instance: 'assistant' }), 'not-thread-launch');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'assistant' }), 'regenerate-thread');
assert.equal(f.ask('human', 'instance.launch', { instance: 'old' }), 'instance-retired');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'old' }), 'instance-retired');
assert.equal(f.ask('human', 'instance.launch', { instance: 'ghost' }), 'unknown-role');
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder', extra: 1 }), 'invalid-request');
assert.equal(f.ask('human', 'instance.launch', { instance: '../x' }), 'invalid-request');
f.call('human', 'launch.revoke');
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder' }), 'launch-revoked');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'coder' }), 'launch-revoked');
assert.equal(f.lifecycle(), count);
assert.equal(f.ask('human', 'instance.request', { request: 'no-such' }), 'unknown-request');
});
test('row 194 D1.1 take: a request is taken once, closes once, and lifecycleRequests lists only what is not closed', (t) => {
const f = setup(t);
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
const second = f.call('human', 'instance.regenerate', { instance: 'pm' }).request;
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => [r.request, r.state]), [[request, 'open'], [second, 'open']]);
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-not-taken');
const go = f.take(request);
assert.deepEqual([go.go, go.refusal, go.state, go.action, go.instance, go.by], [true, null, 'taken', 'lifecycle.launch', 'coder', 'jason']);
const again = f.take(request);
assert.deepEqual([again.go, again.refusal, again.state], [false, 'request-closed', 'taken']);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 1);
assert.equal(f.code(() => f.done(request, { refusal: 'Not A Code' })), 'invalid-request');
assert.equal(f.code(() => f.done(request, { run: '../x' })), 'invalid-request');
assert.equal(f.code(() => f.done(request, { extra: 1 })), 'invalid-request');
const closed = f.done(request, { run: 'run-x' });
assert.deepEqual([closed.state, closed.done], ['closed', { run: 'run-x', transaction: null, refusal: null }]);
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-closed');
assert.deepEqual(f.take(request).refusal, 'request-closed');
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => r.request), [second]);
assert.equal(f.code(() => f.take('no-such')), 'unknown-request');
assert.equal(f.code(() => f.b.lifecycleRequests('ghost')), 'unknown-business');
// A request from one business isn't another's.
assert.equal(f.code(() => f.b.lifecycleTake({ business: 'other', request: second })), 'unknown-request');
});
test('row 194 D1.1 take: a launch.revoke after the request closes it launch-revoked, and a request past 60 s closes request-expired', (t) => {
const f = setup(t);
const revoked = f.call('human', 'instance.launch', { instance: 'coder' }).request;
const regen = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
f.call('human', 'launch.revoke');
for (const request of [revoked, regen]) {
const r = f.take(request);
assert.deepEqual([r.go, r.refusal, r.state, r.done.refusal], [false, 'launch-revoked', 'closed', 'launch-revoked']);
}
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 0);
f.call('human', 'launch.restore');
const old = f.call('human', 'instance.launch', { instance: 'coder' }).request;
later(t);
const r = f.take(old);
assert.deepEqual([r.go, r.refusal, r.state], [false, 'request-expired', 'closed']);
assert.deepEqual(f.b.lifecycleRequests('demo'), []);
});
test('row 194 T12a: the tester launches and ends on its own business with actor tester; bus.tester off in the file refuses the call and the take tester-off', (t) => {
const f = setup(t);
const inputs = f.inputs();
const { request } = f.call('tester', 'instance.launch', { instance: 'coder' });
const r = f.call('tester', 'instance.request', { request });
assert.deepEqual([r.by, r.state], ['tester', 'open']);
assert.equal(JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body).via, 'tester');
assert.equal(f.inputs(), inputs, 'a tester request is not human input');
const human = f.call('human', 'instance.launch', { instance: 'pm' }).request;
// A tester reads only its own requests.
assert.equal(f.ask('tester', 'instance.request', { request: human }), 'unknown-request');
// The cap is bound to demo: an instance only `other` declares is unknown here.
assert.equal(f.ask('tester', 'instance.launch', { instance: 'only-there' }), 'unknown-role');
// bus.tester switched off in the file, with no restart.
f.tester.on = false;
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
assert.equal(f.ask('tester', 'instance.end', { instance: 'coder' }), 'tester-off');
const off = f.take(request);
assert.deepEqual([off.go, off.refusal], [false, 'tester-off']);
assert.equal(f.take(human).go, true, 'the human request is not the tester switch');
// Anything but exactly true is off.
f.tester.on = 'true';
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
f.tester.on = true;
const later2 = f.call('tester', 'instance.launch', { instance: 'coder' }).request;
const ended = f.call('tester', 'instance.end', { instance: 'coder' });
assert.deepEqual(ended.cancelled, [later2]);
assert.equal(f.state(ended.request).by, 'tester');
assert.equal(f.state(later2).cancelled.by, 'tester');
});
test('row 194 V3 terminate: claims revoked, open requests cancelled, bound runs named, taken requests listed; nothing at all answers not-held', (t) => {
const f = setup(t);
const held = f.bind('coder');
const unclaimed = f.bind('coder', { claim: false });
const open = f.call('human', 'instance.launch', { instance: 'coder' }).request;
const taken = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
f.take(taken);
const other = f.call('human', 'instance.launch', { instance: 'pm' }).request;
const out = f.call('human', 'instance.end', { instance: 'coder', reason: 'stuck on a loop' });
assert.deepEqual([out.runs, out.cancelled, out.taken], [[held, unclaimed], [open], [taken]]);
const claim = f.store.get("SELECT * FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
assert.deepEqual([claim.op, claim.holder_run, claim.by, claim.reason], ['revoke', held, 'jason', 'stuck on a loop']);
assert.deepEqual([f.state(open).state, f.state(open).cancelled], ['closed', { by: 'jason' }]);
assert.equal(f.state(taken).state, 'taken', 'a taken request is past cancelling');
assert.equal(f.state(other).state, 'open', 'another instance keeps its request');
// The end is a request the pass takes, with the runs to signal. It never expires.
const end = f.state(out.request);
assert.deepEqual([end.action, end.runs, end.state], ['lifecycle.end', [held, unclaimed], 'open']);
later(t);
assert.equal(f.take(out.request).go, true);
assert.equal(f.take(open).refusal, 'request-closed');
// The runs stay named while bound and not ended.
f.done(taken, { run: 'coder-new' });
for (const run of [held, unclaimed]) f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
assert.equal(f.ask('human', 'instance.end', { instance: 'coder' }), 'not-held');
assert.equal(f.ask('human', 'instance.end', { instance: 'old' }), 'unknown-role');
assert.equal(f.ask('human', 'instance.end', { instance: 'coder', reason: '' }), 'invalid-request');
});
test('row 194 V3 terminate of a thread instance: the human ends every thread, the tester only its own', (t) => {
const f = setup(t);
const hp = f.call('human', 'project.create', { name: 'human work' }).id;
const tp = f.call('tester', 'project.create', { name: 'test work' }).id;
const H = f.call('human', 'thread.create', { project: hp, name: 'mine' }).id;
const T = f.call('tester', 'thread.create', { project: tp, name: 'probe' }).id;
const h = f.bind('assistant', { thread: H });
const tr = f.bind('assistant', { thread: T });
const tester = f.call('tester', 'instance.end', { instance: 'assistant' });
assert.deepEqual(tester.runs, [tr]);
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'claim');
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', T).op, 'revoke');
const human = f.call('human', 'instance.end', { instance: 'assistant' });
assert.deepEqual(human.runs, [h, tr]);
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'revoke');
});
// The writer stand-in: instances.check answers per instance, instances.set commits a transaction.
function writer(answers) {
const calls = [];
const organization = (id, verb, args, actor) => {
calls.push([verb, args, actor]);
if (verb === 'instances.check') {
const a = answers[args.instance] ?? { revision: R };
if (a instanceof Error) throw a;
return a;
}
if (verb === 'instances.set') return { transaction: `tx-${calls.length}`, restart: false };
return { ok: true };
};
return { calls, organization };
}
const refused = (code) => Object.assign(new Error(code), { code });
const lastListed = () => Object.assign(new Error('coder is the last name in launch.instances'), { name: 'BusinessError' });
test('row 194 T6 V5 order at the verb: the writer\'s named refusals, then instance-running, instance-starting, the last-listed refusal and the revision', (t) => {
const w = writer({ chief: refused('organization-instance-referenced'), pm: lastListed(), coder: lastListed() });
const f = setup(t, { organization: w.organization });
const del = (instance, revision = R) => f.ask('human', 'instances.set', { revision, instance, op: 'delete' });
// A named refusal comes before the broker's checks, even with a claim held.
f.bind('chief');
assert.equal(del('chief'), 'organization-instance-referenced');
// The last-listed refusal waits until running and starting have passed.
const run = f.bind('coder');
assert.equal(del('coder'), 'instance-running');
f.call('human', 'instance.end', { instance: 'coder' });
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
const starting = f.call('human', 'instance.launch', { instance: 'coder' }).request;
assert.equal(del('coder'), 'instance-starting');
f.take(starting);
assert.equal(del('coder'), 'instance-starting', 'a taken request with no done is still starting');
f.done(starting, { refusal: 'launch-refused' });
assert.equal(del('coder'), 'invalid-request');
assert.equal(del('pm'), 'invalid-request');
assert.equal(del('assistant', 'b'.repeat(64)), 'revision-conflict');
assert.equal(del('assistant', 'not-a-revision'), 'invalid-request');
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete', definition: 'x' }), 'invalid-request');
assert.ok(w.calls.every(([verb]) => verb === 'instances.check'), 'the checks write nothing');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.delete'").n, 0);
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete' });
const r = f.state(request);
assert.deepEqual([r.action, r.instance, r.by, r.revision, r.retry, r.state], ['lifecycle.delete', 'assistant', 'jason', R, false, 'open']);
assert.deepEqual(w.calls.at(-1), ['instances.check', { instance: 'assistant' }, 'jason']);
// An open delete is a starting request too: a second delete waits for the first.
assert.equal(del('assistant'), 'instance-starting');
});
test('row 194 V5: an open launch past its 60 s is not starting, as it can only close request-expired', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
f.call('human', 'instance.launch', { instance: 'coder' });
later(t);
assert.ok(f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' }).request);
});
test('row 194 V5 after the take: lifecycleDelete re-checks running and starting, then commits through the writer with the human as actor', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' });
const del = () => f.b.lifecycleDelete({ business: 'demo', request });
assert.equal(f.code(del), 'request-not-taken');
assert.equal(f.take(request).go, true);
// A launch recorded after the delete's verb and a claim taken since both stop it.
const launch = f.call('human', 'instance.launch', { instance: 'coder' }).request;
assert.equal(f.code(del), 'instance-starting');
f.call('human', 'instance.end', { instance: 'coder' });
const run = f.bind('coder');
assert.equal(f.code(del), 'instance-running');
f.call('human', 'instance.end', { instance: 'coder' });
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
assert.equal(f.state(launch).cancelled.by, 'jason');
const out = del();
assert.deepEqual(w.calls.at(-1), ['instances.set', { revision: R, instance: 'coder', op: 'delete' }, 'jason']);
assert.deepEqual(out, { transaction: out.transaction, retry: false });
assert.match(out.transaction, /^tx-/);
assert.equal(f.done(request, { transaction: out.transaction }).done.transaction, out.transaction);
const launchReq = f.call('human', 'instance.launch', { instance: 'pm' }).request;
assert.equal(f.code(() => f.b.lifecycleDelete({ business: 'demo', request: launchReq })), 'request-not-taken');
});
test('row 194 V5 retry: a retired name deletes again only after workspace-rename-failed, and that request commits nothing', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'old', op: 'delete' }), 'organization-instance-retired');
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' });
f.take(request);
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' } });
delete next.roles.pm;
f.b.reloadBusiness('demo', next);
const writes = w.calls.length;
const retry = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }).request;
assert.deepEqual([f.state(retry).retry, f.state(retry).revision], [true, R]);
f.take(retry);
// S3: the retry answers the failed delete's transaction, which names the rename's target.
assert.ok(transaction);
assert.deepEqual(f.b.lifecycleDelete({ business: 'demo', request: retry }), { transaction, retry: true });
assert.equal(w.calls.length, writes, 'a retry neither checks nor writes the file');
f.done(retry);
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }), 'organization-instance-retired');
});
test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit the file with retired, log the human and reload so the name no longer binds', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { busBusiness } = await import('../src/business.mjs');
const { loadBusiness } = await import('../../business/src/business.mjs');
const { resolveInstance } = await import('../../business/src/resolve.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'lifecycle-runtime-'));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busBusiness(b, resolved) }, 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 ask = (verb, args) => bus.broker.request(human, { verb, args });
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
const revision = () => ask('instances').revision;
ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
const before = readFileSync(file, 'utf8');
// The real writer's refusals reach the verb, and none of them writes.
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'coder', op: 'delete' })), 'organization-credentials-out-of-scope');
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'pm', op: 'delete' })), 'organization-instance-referenced');
assert.equal(code(() => ask('instances.set', { revision: 'b'.repeat(64), instance: 'sec-1', op: 'delete' })), 'revision-conflict');
assert.equal(readFileSync(file, 'utf8'), before);
const { request } = ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'delete' });
assert.equal(readFileSync(file, 'utf8'), before, 'the verb only records the request');
assert.deepEqual(bus.lifecycleRequests('demo').map((r) => [r.request, r.action, r.instance]), [[request, 'lifecycle.delete', 'sec-1']]);
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
const after = JSON.parse(readFileSync(file, 'utf8'));
assert.equal(Object.hasOwn(after.roles, 'sec-1'), false);
assert.deepEqual(after.retired, ['sec-1']);
assert.equal(after.launch.instances.includes('sec-1'), false);
const log = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
assert.deepEqual([log.actor, log.state, log.transaction], ['jason', 'committed', transaction]);
assert.equal(bus.lifecycleDone({ business: 'demo', request, transaction }).state, 'closed');
assert.equal(code(() => bus.broker.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' })), 'unknown-role');
assert.equal(code(() => ask('instance.launch', { instance: 'sec-1' })), 'instance-retired');
});
+126
View File
@@ -0,0 +1,126 @@
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
// restart would load it, so a created instance binds with no restart. A definition change on a
// running instance refuses, and a file whose credential references changed isn't reloaded.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { busBusiness } from '../src/business.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
// The bus form of the business file, built as cli bootConfig builds it.
function busForm(root, rolesDir, id = 'demo') {
const b = loadBusiness(id, { dir: root, rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
return busBusiness(b, resolved);
}
async function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'reload-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi', 'claude-code'] };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const reloads = [];
const reloadBusiness = bus.broker.reloadBusiness.bind(bus.broker);
bus.broker.reloadBusiness = (id, next) => { reloads.push(structuredClone(next)); reloadBusiness(id, next); };
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const revision = () => ask('instances').revision;
return { root, rolesDir, file, bus, reloads, ask, revision };
}
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
let runs = 0;
const bind = (bus, role) => bus.broker.bindLaunch({ business: 'demo', role, run: `run-${++runs}`, harness: 'pi' });
test('T14 a create goes live with no restart: the new instance binds at once and the answer says restart false', async (t) => {
const f = await setup(t);
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
assert.equal(added.restart, false);
assert.equal(f.reloads.length, 1);
assert.equal(f.reloads[0].roles['sec-1'].definition, 'security-reviewer');
assert.ok(bind(f.bus, 'sec-1'));
});
test('T14 every committed verb reloads once, limits.set, routes.set, plugins.set and veto.set included, and a refused one does not', async (t) => {
const f = await setup(t);
f.ask('limits.set', { revision: f.revision(), limits: { tokens: { instances: { coder: { limit: 100 } } } } });
assert.equal(f.reloads.length, 1);
assert.equal(f.reloads[0].launch.limits.tokens.instances.coder.limit, 100);
f.ask('routes.set', { revision: f.revision(), routes: { coder: { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'low', fallback: null } } });
assert.equal(f.reloads.length, 2);
assert.equal(f.reloads[1].roles.coder.vars.harness, 'claude-code');
f.ask('plugins.set', { revision: f.revision(), plugins: { harness: ['claude-code', 'pi'] } });
assert.equal(f.reloads.length, 3);
assert.deepEqual(f.reloads[2].plugins.harness, ['claude-code', 'pi']);
f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' });
assert.equal(f.reloads.length, 4);
assert.deepEqual(f.reloads[3].launch.keep, ['coder']);
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'ghost', op: 'change', vars: {} })), 'organization-unknown-instance');
assert.equal(code(() => f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' })), 'organization-no-change');
assert.equal(f.reloads.length, 4);
});
test('T14 a definition change on a running instance refuses instance-running; a vars change still goes live', async (t) => {
const f = await setup(t);
f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
const cap = bind(f.bus, 'sec-1');
f.bus.broker.request(cap, { verb: 'role.claim' });
const before = readFileSync(f.file, 'utf8');
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', definition: 'merge-gate' })), 'instance-running');
assert.equal(readFileSync(f.file, 'utf8'), before);
const changed = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', vars: { model: 'claude-sonnet-5-5' } });
assert.equal(changed.restart, false);
assert.equal(f.reloads.at(-1).roles['sec-1'].vars.model, 'claude-sonnet-5-5');
});
test('T14 a file whose credential references differ from the boot copy is not reloaded and the write answers restart true', async (t) => {
const f = await setup(t);
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
const moved = join(f.root, 'secrets', 'moved-gitea.token');
writeFileSync(moved, 'placeholder-not-a-token', { mode: 0o600 });
doc.roles.coder.credentials.gitea.file = moved;
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
assert.equal(added.restart, true);
assert.equal(f.reloads.length, 0);
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
});
test('T14 a file whose project root differs from the boot copy is not reloaded, because the Credentials store protects the boot roots', async (t) => {
const f = await setup(t);
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
const moved = join(f.root, 'project-moved');
mkdirSync(moved);
doc.projects.stack.root = moved;
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
assert.equal(added.restart, true);
assert.equal(f.reloads.length, 0);
});
test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => {
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 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 } });
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
});
@@ -21,6 +21,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
// Row 177 (#1675): the tester's own threads.
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
'instance.launch', 'instance.end', 'instance.request',
]);
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
+4 -1
View File
@@ -156,7 +156,10 @@ file through the broker's `organization.set`, limited to the keys the human's
call them (row 132). Under Jason's ruling Q169 (row 155, #1649) the chief of
staff is the one agent that ends the assistant, which ends every assistant
thread run, and it configures the assistant through `assistants` (Assistant
settings, below) and names instances through `set_name`. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
settings, below) and names instances through `set_name`. PRD 1.7 REQ-COS-3
(row 194) grants it `role.hire` and `role.fire` within role: it hires and
fires with a reason and no decision, through the broker's `instance.hire` and
`instance.fire`, which the harness's `hire` and `fire` tools call. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
it is the only role `launch.by` can name. The PM holds no `role.launch`
(REQ-LAUNCH-1); it asks the chief of staff for a session.
-190
View File
@@ -1,190 +0,0 @@
// The access file, <configDir>/access.json (federation design section 3.5,
// FED R1, #414). It names the extra users who may sign in to this instance
// and the grants that let them reach a business's threads. The validator is
// pure: providers, peers, businesses and the owner's sign-in subjects come in
// as arguments. Any failed rule refuses the whole file, which leaves every
// user with no grant (fail closed).
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
// no instance. covers() relies on this order.
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
const LOCAL_HOME = "local";
const ALL = "*";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const SUB_MAX = 255;
export function accessFilePath(dir = configDir()) {
return join(dir, "access.json");
}
function requireArray(value, where) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
return value;
}
function requireText(value, where) {
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
}
return value;
}
function subjectKey(provider, sub) {
return JSON.stringify([provider, sub]);
}
function checkContext(context) {
const where = "access context";
requireObject(context, where);
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
requireObject(context.businesses, `${where}.businesses`);
const businesses = new Map();
for (const [id, business] of Object.entries(context.businesses)) {
const at = `${where}.businesses.${id}`;
requireId(id, `${where}.businesses key`);
requireObject(business, at);
requireId(business.human, `${at}.human`);
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
businesses.set(id, { human: business.human, instances: new Set(instances) });
}
const owners = new Set();
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
const at = `${where}.ssoSubjects[${i}]`;
requireObject(s, at);
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
});
return { providers, peers, businesses, owners };
}
function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
subjects.forEach((s, i) => {
const sat = `${at}.subjects[${i}]`;
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
seenSubjects.add(key);
});
}
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
function checkScope(value, where, check) {
const list = requireArray(value, where);
if (list.length === 0) refuse(`${where} must not be empty`);
if (list.includes(ALL)) {
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
return;
}
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
// Check a parsed access document against the instance's context. Returns the
// document frozen. Every refusal is a BusinessError naming the key path. An
// expired grant is not refused: covers() treats it as absent.
export function checkAccess(document, context) {
const ctx = checkContext(context);
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
return deepFreeze(structuredClone(document));
}
// Read and check the access file. One descriptor, opened without following a
// link, serves the stat checks and the read (readJsonFile). The file holds
// authority, so it must belong to this uid and not be writable by group or
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return checkAccess(document, context);
}
// Does a checked grant cover a thread? `target` is the thread's business, bus
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
// read and any grant covers it, while `collaborator` asks to drive the instance
// and a viewer grant never covers it. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
return true;
}
+28 -3
View File
@@ -9,6 +9,7 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
@@ -18,7 +19,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "retired"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -118,7 +119,7 @@ function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -142,7 +143,29 @@ function checkLaunch(launch, roles, definitions, file) {
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) };
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
// chief can't fire, replace or end. noHire names definitions the chief can't hire, by the id
// pattern only, so a stale entry is harmless. Both are optional and only the human writes them.
const keep = launch.keep === undefined ? undefined : requireDistinctList(launch.keep, `${where}.keep`, (name) => {
requireId(name, `${where}.keep entry`);
if (!Object.hasOwn(roles, name)) refuse(`${where}.keep names ${name}, which the business file doesn't declare`);
if (END_PROTECTED.includes(roles[name].definition)) refuse(`${where}.keep names ${name}, whose definition ${roles[name].definition} can't be kept`);
});
const noHire = launch.noHire === undefined ? undefined
: requireDistinctList(launch.noHire, `${where}.noHire`, (name) => requireId(name, `${where}.noHire entry`));
return {
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
};
}
// Row 194 (#1692, V5): instance names a delete retired. None may be declared again, so a later
// create or hire can't reuse a name whose decisions outlive it.
function checkRetired(retired, roles, file) {
return requireDistinctList(retired, `${file} retired`, (name) => {
requireId(name, `${file} retired entry`);
if (Object.hasOwn(roles, name)) refuse(`${file} retired names ${name}, which roles still declares`);
});
}
// Validate a parsed business document read from `file`. `rolesDir` is where
@@ -205,9 +228,11 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(retired ? { retired } : {}),
});
}
+2 -3
View File
@@ -4,12 +4,11 @@
export { BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
+159 -14
View File
@@ -1,8 +1,9 @@
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
// holder, credentials and tracker stay hand edits, and there is no delete. Every write goes
// through the organization writer, which logs the actor and the old and new values.
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
// lists and the chief's hire and fire. Every write goes through the organization writer, which
// logs the actor and the old and new values.
import { existsSync, readdirSync } from "node:fs";
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
@@ -10,11 +11,59 @@ import { checkRoutes } from "./launch-settings.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
// The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
if (END_PROTECTED.includes(entry.definition)) return { code: "organization-definition-refused" };
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
if (references.length > 0) {
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
}
if (document.launch?.keep?.includes(instance)) {
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
}
const definition = loadRole(rolesDir, entry.definition);
if (definition.credentials.length > 0 || entry.tracker !== undefined || entry.credentials !== undefined) {
return { code: "organization-credentials-out-of-scope", message: `${instance} holds credentials or a tracker bot; deleting it is a hand edit` };
}
return null;
}
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
// A business whose only listed instance is this one refuses with a BusinessError.
export function deletedDocument(document, instance) {
const next = structuredClone(document);
delete next.roles[instance];
if (next.launch) {
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
next.launch.instances = next.launch.instances.filter(name => name !== instance);
const tokens = next.launch.limits?.tokens?.instances;
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
}
next.retired = [...(next.retired ?? []), instance];
return next;
}
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
@@ -64,14 +113,19 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
launched: launch ? launch.instances.includes(name) : false,
launcher: launch ? launch.by === name : false,
protected: END_PROTECTED.includes(entry.definition),
kept: (launch?.keep ?? []).includes(name),
}]));
return { business, revision, launch: launch !== null, instances, definitions: definitions() };
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
business, revision, launch: launch !== null, instances, definitions: definitions(),
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
};
}
// args: { revision, instance, op: "add" | "change", definition?, vars?, launch?: boolean }.
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
// the instance's vars whole. The answer's restart is true when the running bus must restart
// before the change counts: the broker holds the roles it loaded at start (broker.mjs bindLaunch).
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
// business after the write and sets restart from that reload (runtime.mjs).
function set(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
throw new OrganizationError("invalid-request");
@@ -80,7 +134,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
throw new OrganizationError("invalid-request");
}
const { instance, op } = args;
if (!id(instance) || (op !== "add" && op !== "change")) throw new OrganizationError("invalid-request");
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
// The broker refuses a business whose roles name human or tester (broker.mjs, the load check),
// so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1).
if (instance === "human" || instance === "tester") {
@@ -91,15 +145,18 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (op === "delete") {
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
return commit(business, old, planDelete(old, instance), { revision: args.revision, actor });
}
const next = structuredClone(old);
const exists = Object.hasOwn(next.roles, instance);
let redefined = false;
if (op === "add") {
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
if (exists) throw new OrganizationError("organization-instance-exists");
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
definitionFor(rolesDir, args.definition);
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
redefined = true;
} else {
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
if (!exists) throw new OrganizationError("organization-unknown-instance");
@@ -115,8 +172,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
}
entry.definition = args.definition;
redefined = true;
}
}
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
}
if (Object.hasOwn(args, "launch")) {
@@ -125,6 +181,95 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
else if (!listed.includes(instance)) listed.push(instance);
}
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance) {
const refusal = deleteRefusal(document, rolesDir, instance);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
function veto(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
const { op, name } = args;
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
const list = next.launch[key] ?? [];
if (op === "keep") {
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
}
const add = op === "keep" || op === "nohire";
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
function hire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name, definition } = args;
if (!id(name) || !id(definition) || name === "human" || name === "tester") throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
}
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
definitionFor(rolesDir, definition);
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
const next = structuredClone(old);
if (Object.hasOwn(old.roles, name)) {
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
throw new OrganizationError("organization-instance-exists");
}
} else {
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
}
next.launch.instances.push(name);
return commit(business, old, next, { revision, actor, context });
}
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
function fire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name } = args;
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
if (!old.launch.instances.includes(name)) return { ...read(business), transaction: null, delisted: false };
if (old.launch.instances.length === 1) {
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
return { ...commit(business, old, next, { revision, actor, context }), delisted: true };
}
// The checks every write runs, then the write.
function commit(business, old, next, { revision, actor, context }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
// The checks mosaic business validate runs, before the writer publishes anything, then the
@@ -151,9 +296,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
}
}
}
const receipt = writer.write(business, next, { revision: args.revision, actor });
return { ...read(business), transaction: receipt.transaction, restart: redefined };
const receipt = writer.write(business, next, { revision, actor, context });
return { ...read(business), transaction: receipt.transaction, restart: false };
}
return { writer, read, set };
return { writer, read, set, checkDelete, veto, hire, fire };
}
+4 -2
View File
@@ -174,7 +174,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
function write(id, document, { revision, actor }) {
// context, when given, is logged with the change: row 194's hire and fire carry the chief's run
// and the decision there (D2.2).
function write(id, document, { revision, actor, context }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
@@ -200,7 +202,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
+29 -6
View File
@@ -4,11 +4,11 @@
// ceilings. Version 1 files still load: they carry no authority, so every
// vocabulary action is gated for them.
import { lstatSync } from "node:fs";
import { existsSync, lstatSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { refuse } from "./errors.mjs";
import {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, unhireableAction,
} from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
@@ -17,7 +17,7 @@ import {
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
function checkTools(tools) {
@@ -75,6 +75,25 @@ function checkSkills(skills) {
});
}
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
export { unhireableAction };
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
// is validated without its own "hireable" check, so a list can't recurse.
function checkHireable(hireable, file) {
const dir = dirname(file);
return requireDistinctList(hireable, "role hireable", (name) => {
requireId(name, "role hireable entry");
const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
const outside = unhireableAction(named);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
});
}
function checkGiteaScopes(scopes) {
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
@@ -134,8 +153,9 @@ function checkContract(contract, file) {
// Validate a parsed role document read from `file`. Returns a frozen role:
// { roleVersion, name, title, contract, contractPath, tools, network,
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
// messaging: { sendTo, receiveFrom } | null, skills: [name] }.
export function validateRoleDocument(document, file) {
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
export function validateRoleDocument(document, file, options = {}) {
requireObject(document, "role");
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
@@ -147,7 +167,7 @@ export function validateRoleDocument(document, file) {
const network = document.network === undefined ? "none" : checkNetwork(document.network);
return deepFreeze({
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [],
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
});
}
for (const key of V2_REQUIRED) {
@@ -165,6 +185,9 @@ export function validateRoleDocument(document, file) {
credentials: checkCredentials(document.credentials),
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
skills: document.skills === undefined ? [] : checkSkills(document.skills),
hireable: document.hireable === undefined ? []
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
: checkHireable(document.hireable, file),
});
}
+33 -2
View File
@@ -35,6 +35,10 @@ export const ACTIONS = Object.freeze([
// Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of
// staff set names once its role file grants that within-role.
"instance.name",
// Row 194 (#1692): the chief of staff hires (adds and lists a role instance) and fires (ends its
// runs and delists it). Hard waits under PRD 1.6 (mos-1017), routed under PRD 1.7 REQ-COS-3.
"role.hire",
"role.fire",
"credential.mint",
"spend",
"deploy",
@@ -54,7 +58,7 @@ export const ACTIONS = Object.freeze([
// action joins this list only by the reviewed commit that names it routed.
// git.merge.protected, policy.change, prd.approve and role.revoke left the
// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed
// under REQ-MSG-2 (1.5).
// under REQ-MSG-2 (1.5). role.hire and role.fire left the hard waits in 1.7 (REQ-COS-3).
export const ROUTED = Object.freeze([
"task.create",
"task.assign",
@@ -78,15 +82,42 @@ export const ROUTED = Object.freeze([
"policy.change",
"prd.approve",
"decision.resolve.technical",
// 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",
]);
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
// cited spec or not. A role file may not list these under withinRole or
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected and
// account.create, credential.mint, message.external, git.push.protected,
// deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
// Row 194 (#1692, D2.3): the chief of staff hires only a definition whose every withinRole and
// crossRole action is on this list. An allowlist, so an action added to ACTIONS later is never
// hireable until a reviewed commit adds it here. git.push.remote (row 199) is not on it.
export const HIRE_ALLOWED = Object.freeze([
"message.send",
"record.write",
"review.request",
"task.update.assigned",
"task.scope.change",
"git.push.working",
]);
// Row 194 (#1692, D2.3): the first action of a definition's authority that isn't on
// HIRE_ALLOWED, or null when every one is. role.mjs's hireable check and the broker's hire check
// use it. It lives here because the session sandbox mounts this file and not role.mjs
// (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs.
export function unhireableAction(role) {
for (const key of ["withinRole", "crossRole"]) {
const outside = role.authority[key].find((action) => !HIRE_ALLOWED.includes(action));
if (outside !== undefined) return outside;
}
return null;
}
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
// accepts for tasks.
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
@@ -1,81 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { BusinessError } from "../src/errors.mjs";
import * as access from "../src/access.mjs";
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
// interface edits one place.
const CONTEXT = {
providers: ["authentik"],
peers: ["factory"],
businesses: { "creative-hub": { human: "melanie", instances: ["researcher", "writer"] } },
ssoSubjects: [],
};
const check = (doc) => access.checkAccess(doc, CONTEXT);
const PROJECT = "0b6f4a52-6f1e-4c7a-9a0e-3f1d2c4b5a69";
const NOW = new Date("2026-10-20T19:30:00Z");
function file(level) {
const grant = { id: "g-1", user: "jason", business: "creative-hub", projects: ["*"], instances: ["researcher"] };
if (level !== undefined) grant.level = level;
return {
accessVersion: 1,
revision: 1,
users: [{ id: "jason", home: "local", subjects: [{ provider: "authentik", sub: "s-1" }] }],
grants: [grant],
};
}
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
test("a grant's level is collaborator or viewer", () => {
for (const level of ["collaborator", "viewer"]) {
assert.doesNotThrow(() => check(file(level)), level);
}
});
test("any other level refuses with the key path named", () => {
for (const level of ["owner", "admin", "member", "Viewer", "viewer ", "", null, 1, ["viewer"], undefined]) {
refuses(() => check(file(level)), /grants\[0\]\.level/);
}
});
test("covers tells a viewer grant from a collaborator grant", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
const collaborator = file("collaborator").grants[0];
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
// collaborator (design 9).
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
});
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
});
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const read = { level: "viewer" };
assert.equal(access.covers(viewer, target, NOW, read), true);
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
});
-300
View File
@@ -1,300 +0,0 @@
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
// section 3.5, and covers(). Every test works in a scratch directory.
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { BusinessError } from "../src/errors.mjs";
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
import { tmp, writeJson } from "./helpers.mjs";
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
const P2 = "11111111-2222-4333-8444-555555555555";
function context(over = {}) {
return {
providers: ["authentik"],
peers: ["factory"],
businesses: {
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
acme: { human: "jason", instances: ["coder"] },
},
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
...over,
};
}
function doc() {
return {
accessVersion: 1,
revision: 3,
users: [
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
],
grants: [
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
],
};
}
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
assert.equal(error.exitCode, exitCode);
assert.match(error.message, pattern);
return true;
});
}
function mutated(change) {
const d = doc();
change(d);
return d;
}
test("a valid document passes and comes back frozen", () => {
const checked = checkAccess(doc(), context());
assert.deepEqual(checked, doc());
assert.ok(Object.isFrozen(checked));
assert.ok(Object.isFrozen(checked.grants[0].projects));
});
test("checkAccess does not freeze or alias the caller's document", () => {
const d = doc();
const checked = checkAccess(d, context());
assert.ok(!Object.isFrozen(d));
assert.notEqual(checked.grants, d.grants);
});
test("LEVELS is the single allowed level set, collaborator and viewer (row 193a)", () => {
assert.deepEqual([...LEVELS], ["collaborator", "viewer"]);
assert.ok(Object.isFrozen(LEVELS));
});
test("accessFilePath is access.json in the config directory", () => {
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
const dir = tmp();
const saved = process.env.MOSAIC_CONFIG;
process.env.MOSAIC_CONFIG = join(dir, "config.json");
try {
assert.equal(accessFilePath(), join(dir, "access.json"));
} finally {
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
else process.env.MOSAIC_CONFIG = saved;
}
});
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
for (const bad of [0, -1, 1.5, "3", undefined]) {
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
}
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
});
test("users: unknown keys and the id rule", () => {
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
});
test("users: human, tester and any business's human are refused", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
// The human of a business the grant doesn't name is refused too.
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
});
test("users: home is a given peer or local", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
});
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
// A pair twice in one user, and across two users.
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
// The owner's own sign-in subject can't be an extra user.
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
// The same sub under another provider is a different pair.
const twoProviders = context({ providers: ["authentik", "google"] });
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
});
test("grants: unknown keys, id, user and business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
});
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
});
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
});
test("grants: any level other than collaborator or viewer refuses, naming grants[i].level", () => {
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
}
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
});
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
}
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
});
test("the context is required and checked", () => {
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
for (const key of ["providers", "peers", "ssoSubjects"]) {
const c = context();
delete c[key];
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
}
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
});
// loadAccess: the file checks, then checkAccess.
test("loadAccess reads a valid file and checks it", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
assert.deepEqual(loadAccess(file, context()), doc());
});
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
const dir = tmp();
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
const real = writeJson(join(dir, "real.json"), doc());
const link = join(dir, "access.json");
symlinkSync(real, link);
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
const sub = join(dir, "d");
mkdirSync(sub);
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
});
test("loadAccess refuses another owner and group or other write", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
for (const mode of [0o620, 0o602, 0o666]) {
chmodSync(file, mode);
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
}
chmodSync(file, 0o644);
assert.ok(loadAccess(file, context()));
});
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
const dir = tmp();
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
});
// covers.
const NOW = new Date("2026-10-20T12:00:00Z");
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
// Any level reads: the least level is viewer (row 193a).
const READ = { level: "viewer" };
test("covers: * and listed ids match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
assert.equal(covers(g2, target(), NOW, READ), true);
});
test("covers: an unlisted project, instance or business doesn't match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
});
test("covers: an expired grant doesn't match, at and after the instant", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
// No expires never expires.
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
});
test("covers: level is the least level asked for (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
const v = { ...g, level: "viewer" };
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
assert.equal(covers(g, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
});
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
assert.throws(() => covers(v, target(), NOW), TypeError);
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
for (const bad of ["owner", "Viewer", "", null, 1]) {
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
}
});
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
for (const bad of ["owner", "Collaborator", undefined]) {
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
}
});
test("covers: now must be a valid Date", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
});
+33
View File
@@ -234,3 +234,36 @@ test("the shipped example refuses as written and validates once filled in", () =
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
assert.equal(id, 15);
});
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
const plain = check();
assert.equal(Object.hasOwn(plain.launch, "keep"), false);
assert.equal(Object.hasOwn(plain.launch, "noHire"), false);
assert.equal(Object.hasOwn(plain, "retired"), false);
const business = check((d) => {
d.launch.keep = ["coder", "chief"];
d.launch.noHire = ["reviewer", "no-such-role"];
d.retired = ["old-coder"];
});
assert.deepEqual(business.launch.keep, ["coder", "chief"]);
// A stale noHire entry is harmless: only the id pattern is checked.
assert.deepEqual(business.launch.noHire, ["reviewer", "no-such-role"]);
assert.deepEqual(business.retired, ["old-coder"]);
assert.ok(Object.isFrozen(business.launch.keep) && Object.isFrozen(business.retired));
assert.deepEqual(check((d) => { d.launch.keep = []; d.launch.noHire = []; d.retired = []; }).launch.keep, []);
});
test("row 194 each bad keep, noHire or retired entry refuses", () => {
refuses(() => check((d) => { d.launch.keep = ["ghost"]; }), /launch\.keep names ghost, which the business file doesn't declare/);
refuses(() => check((d) => { d.launch.keep = ["coder", "coder"]; }), /duplicate entry in .*launch\.keep/);
refuses(() => check((d) => { d.launch.keep = "coder"; }), /launch\.keep must be an array/);
refuses(() => check((d) => { d.roles.assistant = { definition: "assistant" }; d.launch.keep = ["assistant"]; }),
/launch\.keep names assistant, whose definition assistant can't be kept/);
refuses(() => check((d) => { d.launch.noHire = ["Bad Name"]; }), /launch\.noHire entry/);
refuses(() => check((d) => { d.launch.noHire = ["coder", "coder"]; }), /duplicate entry in .*launch\.noHire/);
refuses(() => check((d) => { d.retired = ["coder"]; }), /retired names coder, which roles still declares/);
refuses(() => check((d) => { d.retired = ["x", "x"]; }), /duplicate entry in .*retired/);
refuses(() => check((d) => { d.retired = ["../x"]; }), /retired entry/);
refuses(() => check((d) => { d.retired = {}; }), /retired must be an array/);
});
@@ -14,12 +14,15 @@ import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from ".
// Decision 104's list plus role.launch (decision 139) plus role.end and
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
// (row 141, REQ-MSG-2, sage-0589). A role file that differs from it fails.
// (row 141, REQ-MSG-2, sage-0589), plus role.hire and role.fire (row 194, PRD 1.7 REQ-COS-3,
// sage-1471, mos-1089). A role file that differs from it fails.
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]), crossRole: Object.freeze([]) }),
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
hireable: Object.freeze([]),
});
// The fixture business's chief-of-staff instance, which is also its launcher.
@@ -85,9 +88,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
assert.deepEqual(record.credentials, {});
});
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write and instance.name; every other action is gated", () => {
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => {
const record = resolved(loadFixture());
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"];
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"];
for (const action of ACTIONS) {
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
}
@@ -139,6 +142,8 @@ test("the contract names no duty to talk to the human and the pm contract no lau
assert.doesNotMatch(chief, /main (agent|point of interaction)/i);
assert.doesNotMatch(chief, /\bJason\b/);
assert.match(chief, /never talk to the user/);
// Row 194 (#1692, D3 item 5, N3): a hire launches with no restart once the human sets its route.
assert.match(chief, /A hire is listed for `launch` at once, with no restart, but it\s+runs only after the human sets its route \(harness and thinking\)/);
const pm = readFileSync(join(REPO_ROLES, "pm.md"), "utf8");
assert.doesNotMatch(pm, /^- Launch /m);
assert.match(pm, /You don't launch sessions/);
@@ -33,6 +33,11 @@ test("REQ-ROLE-2: END_PROTECTED includes the assistant's thread definition", ()
assert.ok(Object.isFrozen(END_PROTECTED));
});
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire left the hard waits and are routed.
test("REQ-COS-3: role.hire and role.fire are routed, not hard waits", () => {
for (const action of ["role.hire", "role.fire"]) assert.ok(ROUTED.includes(action) && !GATED_ONLY.includes(action), action);
});
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);

Some files were not shown because too many files have changed in this diff Show More