Compare commits

...
Author SHA1 Message Date
jason.woltje 666f4d6864 fix(cli): build review fixes for the parity verbs (#1873, P1-3)
- autonomy set: an empty --authority-resolver clears the resolver as the
  panel does; --reverse-window-hours takes 1 to 168, as the page and bus do.
- The default instance gets a one-line refusal (exit 3) naming the named
  instance and --dev-local-owner, not a stack trace.
- tasks create|edit|archive: a value starting with -- is a usage error;
  --business is checked before the agent guard, and the guard also reads
  process.env.
- Drop the unused consoleRefusal export.
- Docs: --business is required, resolver clearing, window range, and the
  named-instance requirement.
2026-11-11 20:47:35 -06:00
jason.woltje 226e28d1e6 fix(cli): autonomy set takes the bus's 64-hex revision (#1873, P1-3)
The panel's revision is the bus's sha256 of the organization, so a
whole-number --revision could never match a real Console. The fake in
cli-parity now answers hex revisions, and a whole number is a usage error.
2026-11-11 20:47:35 -06:00
jason.woltje 550bd084d0 feat(cli): hire, autonomy and task-board verbs for WebUI parity (#1873, P1-3)
mosaic org role-templates|hire|change|fire and mosaic org autonomy
[set|review] send the hire form's and the autonomy panel's own requests
to the running Console's org route, so the Console runs the same owner,
send, origin, body and revision checks and the same bus verbs. They
refuse inside an agent run after their usage checks.

mosaic tasks create|edit|archive pass the board's parseTaskWrite and go
to the bus as the person (tasks.create|edit|archive).

Docs: cli README, docs/TOOLS.md, webui README Console actions table.
2026-11-11 20:47:34 -06:00
jason.woltje 8827905ba0 fix(org): restore names bus recover for a stale lock; receipt taken at mount; restore-then-dashboard test (#1873)
Build review P3-1, P3-3 and P3-4.
2026-11-11 20:14:46 -06:00
jason.woltje a559957085 feat(org): restore starts the bus; pages follow restart and quick setup (#1873)
P1-4: org restore starts the bus as mosaic bus start ID --dev-local-owner
--detach would, and its receipt names the bus. Where that start is refused
(deployed or stamped instance, agent run) restore refuses before changing
anything; --no-bus keeps the old restore. The Console Restore action
restores and starts the bus with one receipt, and offers Restart bus when
the start fails after a good restore.

P1-5: Quick Setup refreshes the sidebar organization list, and the
organization dashboard redraws on the config event Restart bus already
dispatches, so the bus line and System types follow without a reload.
2026-11-11 20:03:35 -06:00
jason.woltje 6d0e9f89be fix(bus): sweep ungranted copy slots at start, clean a failed stage (#1873)
Security review P2: a hard crash after the stage left the staged token
on disk. sweepCopySlots runs at start, before Credentials loads, and
removes each copy service slot whose binding holds no grant, plus any
staged temp file. It never touches a role's slots, a slot a loaded
reference names, or an organization whose document cannot be read. A
stage write that fails removes its temp file, and its slot when the
rename has already happened.

Build review P3s: push is held to the binding's approved level, not the
token's. Tests now cover the boot-copy pre-checks, the format check
running before the stage, and stageCopy's reference refusal.
2026-11-11 17:13:04 -06:00
jason.woltje d5aa963893 feat(bus): copy credential issue CB2, Gitea verified live (#1873)
An owner-issued Gitea token for an agent copy is staged inactive in the
self-only store, proved live against the copy's Gitea (token scopes by
semantic comparison with all and admin refused, the copy's dedicated
per-org/copy/service account, every reachable repository, expiry), then
committed under the writer's generation, revision, decision, copyId,
incarnation and definition-pin recheck. The grant record holds only an
opaque grant id and rotate date. Vikunja refuses
credential-scope-unverified. Copies still cannot launch (CB3).
2026-11-11 17:13:04 -06:00
jason.woltje 75f6d5533a fix(proxy): review fixes for the Console proxy setup page (#1873)
Security review:
- Release verification cache keys on ctime as well as mtime, in
  nanoseconds. A same-size rewrite with mtime set back now re-hashes and
  reports proxy-install-invalid.

Build review:
- The setup page hides the account panels on any failed accounts read, not
  only proxy-down, and brings them back once the proxy answers.
- The organizations table shows a dash, not a made-up 0, when a bus does
  not answer or sends no model list (orgCatalogue in proxy-admin.mjs).
- A fence clear the bus did not answer keeps the confirmed restart for a
  retry.
- A fence with no readable time names `mosaic proxy fence clear
  --proxy-restarted` on the page, in the refusal table and in the README.
- CLI `init --generate-key` goes through the setup controller and writes
  the same init receipt as the page.
- Status carries the last ten receipts, so the page shows them after a
  reload. An initialized proxy keeps the init form folded.
- README: proxy-admin-busy is per process; the cross-process locks that
  serialize the writes are named.
2026-11-11 16:32:31 -06:00
jason.woltje f0e11cb342 feat(proxy): Console proxy setup page and CLI parity (#1873)
One Proxy setup panel on the Model proxy settings page takes an owner from
nothing to a running proxy: status with one next-step button per state, the
pinned fetch with hash checks, progress and a receipt, init with a
bus-generated key (a pasted key only over loopback or HTTPS, with a
confirmation), unit render/install/start/stop/restart with health, a fence
clear that the bus re-proves against a confirmed restart, and a stale init
lock clear that names its blockers. Own proxy per organization shows as not
built.

New Console routes under /api/proxy/admin/ use the existing owner proxy
checks; agents, testers and grant holders get human-required. New bus verbs
proxy.init {generate:true} and proxy.fence.clear. Receipts go to
<dataRoot>/state/proxy-admin.jsonl with labels and key ids only. Every new
refusal code has a refusal-table entry.

CLI: mosaic proxy status, unit render|status|install|start|stop|restart,
init --generate-key, fence clear --restart, init-lock status|clear; proxy
fetch keeps its stdout and adds progress and the receipt.

Page fix found in the end-to-end run: a stopped proxy hides the account
panels until the service answers. (The catalogue switch label fix found in
the same run landed upstream as D3 P0-4.)
2026-11-11 16:32:31 -06:00
jason.woltje 35c125fc1f fix(cli): agent-guard re-check: delete revision error first, real-org archive and delete proof (#1873)
org delete checks --if-revision before --confirm again, so a bad revision
without --confirm says "invalid revision" as it did on BASE.

The archive and delete "bus untouched" check moves from the status test,
whose fixture has no owner file, to the Restart bus test, which has a real
organization and a live bus. An agent run's archive and delete exit 3 and
leave the bus pid and the organization unchanged; the same archive from a
human shell stops the host, and restore makes the organization active again.
2026-11-11 16:15:51 -06:00
jason.woltje 294149c20e fix(cli): agent-guard review round: org archive and delete refuse, usage errors first (#1873)
Build review finding 2: org archive and org delete stop the organization's
bus, so they now refuse inside an agent run like bus stop, after their usage
checks and before any config is read. Finding 3 (also security P3): the
--pm/--no-launch and Console LAN flag checks now come before the refusal,
so a usage error exits 4 as the docs say.
2026-11-11 16:15:51 -06:00
jason.woltje 6f18f3d015 fix(cli): bus and Console lifecycle verbs refuse inside an agent run (#1873)
D2 security re-check P1 (agent-guard). mosaic bus start|stop|restart and
mosaic console start (with or without --dev-local-owner), stop and restart
refuse inside an agent run with refuseInsideAgent's refusal (exit 3), as
bus recover does. The check reads the given environment and this process's
own, so a sanitized io.env does not hide a marker; bus recover and console
restart now check both too. consoleStart refuses for every Console. Status
verbs stay open. No new marker list and no new human gate.

The Console's Restart bus runs org-bus.mjs in the Console process; its new
host goes through the guarded CLI entry with the Console's own environment.
Restart Console relaunches under the supervisor without the CLI.
2026-11-11 16:15:51 -06:00
jason.woltje a5f227e9fc fix(console): Stop bus review round: trail and stopped flag (#1873)
A stop that may already have signalled the host (a timeout or an
unexpected error) is trailed as failed, not refused; only exit 3 comes
before the signal. The response's stopped flag follows what busStop
reports, so a host that exited on its own between the read and the stop
is not reported as stopped by this press.
2026-11-11 16:10:48 -06:00
jason.woltje ce066123b0 feat(console): Stop bus stops one organization's bus cleanly (#1873)
POST /api/orgs/ID/stop/bus runs busStop the way mosaic bus stop does: a
graceful SIGTERM close that leaves no host.lock or writer.lock, so an
offline schema migration can open the store straight after. It follows
restartBus for authority, same-origin checks, trail and receipt. A bus
already stopped answers done with state stopped and changes nothing; a
host identity the Console cannot prove refuses with every file unchanged.

The organization header gains a Stop bus button beside Restart bus, one
press, sharing its receipt and busy state. The restart icon now lives
inside its button, so a wide receipt no longer pulls it off the button.
2026-11-11 15:59:32 -06:00
jason.woltje 5dbd7e72d3 fix(org): copy binding review round, CB1 (#1873)
Security review P2s:
- A binding proposal keeps only its structured request fields. The
  free-text reason is never written to the private proposal or covered
  by its digest.
- A root copy's receipts are checked at prepare, at check, and again
  under the organization lock before the write and before publication.
  A copy removed, replaced or unreadable since boot refuses with
  instance-fired, copy-stale or root-policy-unreadable.

Build review P2:
- A root copy's admitted projects come from the project unions,
  recomputed from the current document and the boot's root agents. A
  project that excludes the copy, or whose union refuses, is never in
  reach; a request naming it refuses with credential-scope-exceeded.

Build review P3s: tests for proposal-time shadowing, the loader's
copyBindings validation, a pending record that covers every service,
a stricter path check on the refusal table, and refusal codes on every
bare rejects in the binding tests.
2026-11-11 14:27:08 -06:00
jason.woltje d23673cd3e feat(org): copy credential binding CB0 and CB1 (#1873)
CB0: registry incarnation minted at reserve (legacy entries get one at
the first binding proposal, under the registry lock); copyId, source
and definition pin, binding generation; a pure resolver where
effective = role intersect approved and only a bound, matching,
unshadowed record resolves.

CB1: copyBindings in the organization file (ids, scope names, project
ids and integers only). applyCopyBindingDecision is its only writer,
behind a module-private capability every other commit is fenced
against. A binding is an owner-only subtype of the hire decision:
only the chief raises it, no autonomy level, resolver agent, member
right, tester or reader resolves it. Digest, expiry, single
consumption, revision, generation, incarnation and definition pin are
rechecked before any effect; a bind writes pending, an unbind the
unbound tombstone, receipts are paths only. Nothing writes bound, so
nothing launches on a binding yet (CB2/CB3). The org service and the
Console update path keep copyBindings server-held. Eight new refusal
codes with next steps.
2026-11-11 14:27:08 -06:00
jason.woltje ebe2af86de fix(console): D1 security re-check fixes for the dev-owner start (#1873)
The agent-run refusal on a dev-local-owner start now checks the starting
process's own environment as well as the environment given for the
Console, so a caller cannot pass a clean env to get past it.

The start lock now names its holder (pid and start time). A detached
start that times out and kills its supervisor's process group clears the
lock only when it names that supervisor and the supervisor is gone, so a
forced kill no longer blocks every later start.
2026-11-11 14:16:09 -06:00
jason.woltje 1947a056fb fix(console): D1 review fixes for the detached dev-owner supervisor (#1873)
Build review P2: the supervisor ignores a lost reply pipe and keeps
supervising. Security P2: a stop signal during start is held until the
start settles, and the CLI's start timeout signals the supervisor's
process group. Security P1 (part): console start --dev-local-owner
refuses inside an agent run. Tests cover review, confirm and reverse on
the org autonomy route; TOOLS.md and the webui README are corrected.
2026-11-11 14:16:09 -06:00
jason.woltje 7ffe932250 feat(console): dev-local-owner Console writes autonomy and restarts when detached (#1873)
A --dev-local-owner Console's organization pages read writable:false because
the per-org options had no autonomy writer and the route fell back to the
request transport, which org options never set. The org options now carry an
autonomy writer on the organization's console transport, fenced to the four
owner autonomy verbs. The route prefers it and keeps requestWrite otherwise.

mosaic console start --dev-local-owner --detach now runs the Console under a
detached supervisor, so Restart Console (exit 75) relaunches it on the same
address. A non-dev detached Console is unchanged. A deployed --sso --send
Console has no organization routes and keeps its root autonomy transport;
a test pins both.
2026-11-11 14:16:09 -06:00
jason.woltje 900daf5213 fix(org): D2 security review: live identity refuses, clear only inspected entries, no revive from an agent run
- busRecover: a live pid with the recorded start time is the recorded process, so it refuses
  ("bus host identity is uncertain") whatever its argv. It used to read a mismatching argv as a dead
  host. The pinned recovery test now covers a reused pid (live, other start time) as the wrong
  process, and a new test covers the refusal.
- busRecover clears through setAside: each inspected entry is renamed aside and checked by device
  and inode; a swapped entry puts everything back and refuses before any receipt or deletion, and
  the deletion names only the aside entries.
- mosaic bus recover refuses inside an agent run (refuseInsideAgent), like mosaic org reload. The
  Console-start revive does nothing when the Console's own environment carries an agent marker.
- Tests: org-recovery (identity, agent refusal), org-revive (setAside swap), bus-revive (markers);
  the other reviveBuses tests pass an explicit env.
2026-11-11 14:03:18 -06:00
jason.woltje f9dae1ebad fix(org): D2 build review: pin pm:false, wait for the start-up revive, say when a clear was followed by a failed start
- The Console-start revive asks busRevive for pm:false; reviveStartOptions holds the rule (never
  wider than the dead host's record) and is tested on its own. A dropped --pm is logged with the
  way back.
- A Restart bus press waits for that organization's start-up revive (revivePending) before it reads
  the bus, so it no longer races it for the lifecycle lock; a press that still meets org-locked gets
  409 bus-busy, nothing changed.
- busRevive marks an error from busStart after a successful clear (error.recovered); the trail, the
  Console log and the press response then say "failed", not "refused". A non-CliError logs its code.
- Tests: bus-revive.test.mjs (new), org-admin and org-revive cases for the above.
2026-11-11 14:03:18 -06:00
jason.woltje f063158875 fix(org): Console start reattaches to org buses and revives a provably dead host (P0-3)
A Console unit stop (KillMode=mixed) SIGKILLs the bus hosts the Console
started, leaving host.json, host.lock and the socket behind, and the
dev Console's Restart bus refused 409 on that stale state.

- busRevive (org-bus): busRecover, then busStart detached on the
  options the dead host recorded. busRecover still refuses a live host
  pid, a lock that is not the dead host's, a live writer pid, or a
  socket that answers or is uncertain.
- reviveBuses (webui): when a dev-local-owner Console starts, a bus that
  answers is left (reattached); a bus with stale state goes to busRevive
  with pm:false, trailed as bus.revive before anything changes.
- Restart bus (org-admin): for the exact dev owner only, a stale-state
  refusal goes to busRevive; a signed-in owner keeps the refusal.
- The receipt reads "Dead bus host cleared; bus started".
2026-11-11 14:03:18 -06:00
jason.woltje ff20efb819 fix(autonomy): review round for #1873 account coverage
A replaced installed harness answers null for countsAccount until the
bus restarts and reads the new module. A runtime test pins the
startBroker wiring for claude-code (covered) and pi (refused). The
plugin README says countsAccount also decides authority coverage and
is read once at start.
2026-11-11 13:46:22 -06:00
jason.woltje cfe7535533 fix(autonomy): account quota coverage reads the harness module's countsAccount, not a name (#1873)
authorityBudgetCovered named claude-code to decide whether an account
quota covers every future launch, which fails the row 65 scan. The bus
host now passes each harness module's own countsAccount answer, read
once with its levels in harnessLevels, through autonomyHost to the
broker. Without the lookup no account quota counts (fail closed); the
provider/model branch is unchanged.
2026-11-11 13:39:44 -06:00
jason.woltje b1fc96f4ae fix(routes): name Disable only when it makes the save apply (P0-4 review)
The catalogue refusals no longer tell every user to press Disable model
proxy catalogue. The server text names the proxy setup step. The Routing
page adds the Disable step only when cliproxy is listed and every moved
model comes from another listed provider, and sends a tester to the
owner. The switch mismatch message no longer claims a setting changed.

Tests: a fallback thinking-only change saves with no catalogue; a move
between model-proxy models refuses before and after cliproxy is removed.
2026-11-11 13:35:56 -06:00
jason.woltje 967025f749 fix(routes): fixture routes, thinking and limits save without a model catalogue (P0-4)
A fixture-only organization reads a ready catalogue with no model proxy
client: the catalogue asks the proxy only when a module calls it.
A thinking-only change, and a limits patch that keeps harness, model and
entry, no longer asks the catalogue. A change that truly needs it still
refuses, and the WebUI names the next step: keep the model and change
thinking, or press Disable model proxy catalogue. The Routing switch
follows whether cliproxy is listed and removes or adds only cliproxy.
2026-11-11 13:35:55 -06:00
jason.woltje 70504ddddb fix(webui): build the inbox hire form after the read; pass through only pre-insert hire codes (#1873)
D4 review fix round.

P2: the org inbox took the hire form's HTML before its workspace read, so typing or a
submit result that landed during a slow read was painted over with the older draft.
inboxPage now takes a function and calls it while building the page after the read.
Two browser cases hold the inbox read: typing during it, and a refused start that
resolves during it, both survive the repaint (both fail on the previous commit).

P3: a hire start passes a bus code through only when the broker raises it before it
records the decision (hire-decisions prepare: the model route codes,
definition-not-hireable, definition-shared, no-chief-of-staff). Every other code,
including revision-conflict-revoked, request-timeout, invalid-envelope,
incomplete-request and codes the resolve or apply steps can raise, stays
outcome-unknown. Messages are worded for hire, fire and change alike.
2026-11-11 13:01:54 -06:00
jason.woltje c3293ef4ef feat(webui): hire form on the org inbox; a definite hire refusal keeps its code (#1873)
D4, readiness P0-1 and P1-2.

On an org Console, #/inbox now carries the hire, fire and change form
below "Answered today". It posts to the current organization's bus
through the same decision endpoint and verb (hire.start.console); the
organization field is read-only. Inputs carry focus keys, so a poll
repaint keeps the draft, focus and caret.

A hire start the bus definitely refused now keeps its own code. The
model route codes (model-provider-unavailable and its kin) get a next
step. Any other definite code is named, and the message never claims
that nothing was recorded. A lost or unreadable reply is still
outcome-unknown. The templates, confirmation and resolve steps are
unchanged.
2026-11-11 12:45:12 -06:00
jason.woltje 88a94ddd34 test(bus): pin the autonomy budget helper import (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje 2d4b07e516 fix(org): retain committed inverse status through completion IO failure (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje e3232b814f fix(org): enforce autonomy budgets and durable inverse boundaries (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje 0ef6326084 feat(org): add per-class autonomy and guarded reversals (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje ce4fe048bb fix(discord): the connector's STOP refusal names the journal by label (#1873)
Re-check 3 found connector.start() still quoted the journal directory in
its STOP refusal, and the CLI passes a DiscordError through unchanged.
It now uses journalLabel like the CLI and journal refusals. The connector
test asserts the path is absent: 22/1 on the old source, 23/0 after.
2026-11-11 10:22:14 -06:00
jason.woltje 63e2a574af fix(discord): a native fs error never prints its path: CLI choke point and journal labels (#1873)
The discord CLI's top-level handler prints a native Node error that is not
already a labelled refusal as 'cannot <syscall> <label> (<CODE>)'. The journal
and approvals readers and writers name the file by label at the source, so
conversation, control-board and webui callers get the label too. The session
directory check moves to engine-pi.mjs as sessionContinues(). Exit codes and
refusal codes are unchanged.
2026-11-11 10:11:11 -06:00
jason.woltje d9023e28db fix(discord): check-then-read gaps name the file by label, never the path (#1873)
A token file, context file, tool root, data root or setspark key file
that changes between its check and its read failed with a native error
whose message carried the host path. Each read now names the file by
label and code (quietRead): same error object, type and code, so exit
codes, refusal codes and check order are unchanged.
2026-11-11 09:50:14 -06:00
jason.woltje c3fe6b77ad fix(discord): journal.mjs takes BINDING_NAME from errors.mjs, not binding.mjs (#1873)
The journal label import pulled binding.mjs and its config readers into
every journal.mjs importer. The queue test repo copies only journal.mjs
and errors.mjs, so its genesis failed with ERR_MODULE_NOT_FOUND (webui
reads.test.mjs red, 4 tests). BINDING_NAME now lives in errors.mjs and
binding.mjs re-exports it, so its export is unchanged.
2026-11-11 08:53:31 -06:00
jason.woltje 809b1b0100 test(control-board): board API body carries the seen marks label, not the path (#1873)
The board API returns a scan refusal in its JSON error body. The test
starts a board on port 0 over a seen.json that is not an object and
checks the body names seen.json by label, with no sentinel or scratch
path. It fails on BASE, where the body quotes the host path.
2026-11-11 08:53:31 -06:00
jason.woltje 4a9d9d4b89 fix(business): old-path re-export for system-config-problem.mjs (#1873)
rename-b-identity pins one packages/business/src module per moved
organization module; the review round's new module needs its shim.
2026-11-11 08:53:31 -06:00
jason.woltje 87db40e0cd fix(cli,seat,discord,organization,control-board,harness,plugins,webui): review round: launcher reasons, session, discord reads, mosaic-config and keys by label (#1873)
Review "changes 8" fix round for org-redact6.

- launcher: a harness module's throw names its system code or class,
  never its message; preflight, loader, bundle, checkout, sandbox and
  route text is relayed with host paths replaced (relay()).
- claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the
  repository's node_modules, never a path; persona and skill bundle
  errors and sandboxProblem name their file or the sandbox by label.
- seat session: thread store, legacy store, overlap, busy lock and
  newRun refusals name the place under the data root.
- discord: unknown keys, binding names and turn ids are described when
  not plain; approvals, journal and repository read errors name the file
  and code; git drops stderr lines that carry a host path and a spawn
  failure names its code only.
- seat, control-board: registration fields and seen keys are described
  when not plain; invalid seat name and layout state the constraint.
- organization: the registry names the config directory and registry
  file by label; --project is quoted only when it is a plain name;
  systemConfigProblem moves to its own module.
- scripts/mosaic-config.mjs: the config by where it came from, the data
  root and its marker by role, keys and arguments only when plain, and
  error codes in place of raw messages. Exit codes unchanged.
- webui sso-config: an unknown console key is named only when plain.

Sentinel tests in each package's path-redaction-labels file fail on
BASE; pins that quoted a path now assert the label and its absence.
2026-11-11 08:53:31 -06:00
jason.woltje f3f33a6171 fix(organization,cli,discord,seat,control-board): refusals name the field or file by label, never a host path (#1873)
redact6. A refusal names the field, the file or the instance. It never quotes a
config value, a host path, or a raw e.message or stderr that carries one.

- organization: checkCredentialRef names the field and each forbidden root by
  label; OrgError keeps .path only for a document field ($...); the registry,
  fence and lock refusals use the quiet helpers (call and code).
- cli: loadSystem never relays mosaic-config stderr: instance refusals keep
  their code and names, everything else reads as a field and its constraint
  against a labelled config. Project-key, access warning, halt gate, sessions
  file, launch socket, harness relays and the workspace rename name labels.
  Launch-log reasons read a Node system error as its call and code.
- scripts/mosaic-config: configuration not found names where the config came from.
- discord, seat, control-board: config, token, context, tool-root, journal,
  binding, registration and seen-marks refusals name labels.

Exit codes, refusal codes and check order are unchanged. Each new or changed
test fails on BASE be698e602.
2026-11-11 08:53:31 -06:00
jason.woltje 5320e3d2c0 test(cli): the lifecycle fixture host answers definitionScan (#1873)
A4's hire writes an org:hire-<uuid> definition, so the chief's in-session
launch of its hire now reaches the launcher's held-secret check. The fixture's
op table lacked definitionScan and refused invalid-host-request; process.mjs
answers it.
2026-11-11 08:49:15 -06:00
jason.woltje 16cdf5cdc1 fix(organization): an approved hire and the boot copy definitions run the definition check (#1873)
The hire service takes the bus's definition check as scanDefinition and runs
it on the stored proposal entry before preview, check or apply goes further,
and on the approved entry again just before the writers. An approval whose
definition holds a credential the store came to hold after the raise refuses
definition-holds-credential before the decision is consumed; nothing is
written. Without the hook the entry still goes through assertClean whole.

Bus start and credentials.issue also check the boot copy snapshot's
definitions (A4 root agents file definitions, re-applied on every reload),
which no organization file holds.
2026-11-11 08:49:15 -06:00
jason.woltje 1a45a7274b fix(organization): credentials issue and rotate refuse a value already in an organization definition (#1873)
credentials.issue runs a check after it normalizes the value and before the store, the
organization file or memory changes: the value must not appear in the definitions of any
organization the bus serves (instructions, personality and every other field or key). A hit
refuses definition-holds-credential naming the organization, definition and key, never the
value or the credential. An organization file the check cannot read refuses
definition-scan-unreadable (new TI entry). The check runs on the bus loop inside issue, so a
launch admission can no longer pass and then see its definition text become held before spawn.
The final admission re-scan stays as the backstop for values held outside the verb.
2026-11-11 08:49:15 -06:00
jason.woltje 17eecb2d65 fix(organization): final launch admission re-checks the org definition against held credentials (#1873)
Review round (P2): a credential issued between the launcher's definition scan and
spawn could make text already in the definition a held value; a same-date rotate
leaves the organization file's revision unchanged, so the revision fence missed it.
The bus's final request.admission for an instance, the last bus op before spawn,
now runs the held-secret check on the org: definition the instance names, on the
bus loop that issue runs on, and refuses definition-holds-credential.

Self-found: check() takes the scanned entry from the same read as resolveInstance,
before any await can let another check replace the shared organization.
2026-11-11 08:49:15 -06:00
jason.woltje 8fec494694 fix(organization): bus start and launch check org definitions against the held credentials (#1873)
T1 follow-up. A definition's text becomes an agent's contract, but a load ran the
held-secret check with an empty store. Bus start now checks every definitions entry
(the boot copy's org: entries and the file's own) with the live store, and the
launch check asks the bus the same question for the org: definition it just read,
before a contract exists. A hit refuses as definition-holds-credential, a new code
in the refusal table; the refusal names the definition and key, never the text or
the credential. bus start records the refusal in the boot status.
2026-11-11 08:49:15 -06:00
jason.woltje 4a122699a0 fix: preserve tester session identity through cleanup (#1873) 2026-11-11 08:22:31 -06:00
jason.woltje f382699d47 fix: bound tester supervisor cleanup and verify fast sessions (#1873) 2026-11-11 05:13:47 -06:00
jason.woltje 5e93356cfa fix(authority): close chief grant and root fire review gaps (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 89205a6163 feat(authority): resolve hire, fire and role changes through pinned human decisions (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 6342940634 test(cli): clarify cleanup timing limits and namespace failures (#1873) 2026-11-11 03:49:22 -06:00
jason.woltje cfd7e612d3 test(cli): bound supervisor cleanup with a fixed process inventory (#1873) 2026-11-11 03:20:27 -06:00
jason.woltje afe131444c test(cli): retain default service template coverage (#1873) 2026-11-11 02:34:50 -06:00
jason.woltje 0b52249cc3 test(cli): measure legacy and query inner timers at the supervisor (#1873) 2026-11-11 02:11:48 -06:00
jason.woltje b7c9eee603 fix(organization): definition writes go through the bus with the held-secret check (#1873)
Review P1: the CLI wrote definitions with a no-op secret check. setDefinition
now refuses definition-secret-check-required when a write that adds or changes
an entry has no assertClean. New bus verbs definition.import|invent|edit|remove
run the writes for the business's human at the cli with the credential
store's assertClean; the tester, agents and readers refuse. The CLI's
definition writes call the bus and map its codes to messages that never
quote the definition.
2026-11-11 01:15:12 -06:00
jason.woltje 1d07df0d61 feat(organization): org definitions, template hash and diff reads, definition verbs (#1873)
Roles round 2, slice T1 (roles-and-rails design sections 4, 5 and 13).

- The org file's definitions key: each entry {rev, from?, role, instructions,
  personality?} passes checkComposition; a bad entry refuses the whole file,
  naming the definition and key, never a value.
- org:<name> instance references resolve to the file's definition; a missing
  one refuses definition-missing, naming the instance. Template names are
  unchanged.
- Template identity is the name plus a sha256 over the template JSON and its
  contract; templateHashes reads the current hash for each template.
- The writer's setDefinition is the only path that changes definitions: it
  checks the composition, bumps rev, refuses agent actors, and refuses
  definition-in-use for a definition an instance names (A4 adds the change
  decision). write() refuses organization-definitions-protected.
- Definition verbs import, edit, invent and remove need org.hire
  (definition.*.console in VERB_RIGHTS) and refuse every agent caller.
- The diff read lists the primitives that changed and a line diff of the
  instructions. It applies nothing.
- CLI: mosaic org definitions templates|list|show|diff|import|invent|edit|remove.
2026-11-11 01:15:12 -06:00
jason.woltje be698e602b test(cli): json-redaction pins follow the label refusals
The review round changed the config-command relay and the org document
refusal to name the file by label. cli/tests/json-redaction.test.mjs still
required the host path in those two CLI refusals. They now require the
label and the path's absence. The direct scripts/mosaic-config.mjs checks
and the loadSystem check (PL1, deferred) still require the path.
2026-11-11 01:04:15 -06:00
jason.woltje d085b16136 fix(organization,cli): review round: raw fs and git errors, console entry, config, document and --vars refusals name labels
Sol review (org-redact5, changes 6):
- org-service: Node filesystem errors become "cannot <syscall> <label> (<CODE>)";
  code, syscall, deleteStep and deleteReceipt stay. git failures name the verb
  and exit code, never git's stderr.
- org-console: consoleStop names "another checkout", not the entry path. Exit 3.
- config-command: the system config is named by where it came from. Exit 3.
- cli org: documents and raw fs errors are named by kind and code. Exit 2.
- instances: --vars file named by flag and kind. Exit 4.
- hosts: a refused command is named by the basename refusedCommand matched.
- role.mjs (T1): the change is back to the readJsonFile label only.
Sentinel tests for each; pins in last-values, rename-b-commands, org-recovery.
2026-11-11 01:04:15 -06:00
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00
jason.woltje e588fb32cb test(cli): band the P10 inner timer at the supervisor, not around cleanup (#1873)
The row299 root-side pair (mos-1409) spans supervisor start-up and
clean_session, which scans every /proc entry on the host. With about
1,850 processes that adds 0.6 to 1.6 s, so the unchanged inner probe read
2.5 to 3.9 s and failed the [1.25, 2.75) band, while the 1 s inner-timer
mutant read 2.06 to 2.38 s and passed. Widening the bound alone would
have kept that mutant alive.

A second observer pair, also confined to the private test copy, brackets
only the inner timer: begin just before the supervisor starts its sleep,
end at the start of finish(). It writes only for the SO_PEERCRED peer and
at most one pair. The unchanged band now applies to that interval
(2.01 s on base, 1.00 s and 3.00 s for the 1 s and 3 s mutants, so both
fail). The root pair remains required and must contain the timer pair;
probe-inner-timeout already places it before the outer bound.
2026-11-11 00:30:10 -06:00
jason.woltje e3577e9d31 fix(case2): give the case2 fixture host the request-host ops (#1873)
test-case2.sh failed 8 of 21 steps on base from S3b on. Bisect names
f23a998ee (#797, "close project archive admission races") as the first
bad commit: it made every launch call request.admission and updated each
in-process launcher fixture except scripts/case2/launch.mjs. The fixture
host refused the op with invalid-host-request, so no launch started; the
S6/S7 "unauthenticated" and S8/S10 reds followed from the missing launch
capability, not from a separate session change.

The fixture now spreads requestHostOps from packages/cli/tests, the shared
map every other fixture uses, so it tracks the production protocol.
2026-11-11 00:30:10 -06:00
jason.woltje 99ce579e3c fix(plugins): keep imports on the verified package pin (#1873) 2026-11-11 00:11:13 -06:00
jason.woltje b44b14c7df feat(plugins): install pinned instance packages and declared tools (#1873) 2026-11-11 00:11:13 -06:00
jason.woltje 85858938fc docs: design credential bindings for agent copies 2026-11-10 23:52:29 -06:00
jason.woltje 6415e81ee7 docs(run-sheet): check config directory ancestors before accounts init (#1873)
mosaic accounts init refuses accounts-unsafe since #1919 when a folder above the
config directory is writable by another user. Step 3 now gives the read-only check.
2026-11-10 23:43:55 -06:00
jason.woltje 15225c5e1c test(webui): verify applied light overrides and the U5 shot recipe (#1873) 2026-11-10 23:40:06 -06:00
jason.woltje cb9580f6fb fix(webui): explain conflicts that reopen a read-only task (#1873) 2026-11-10 23:40:06 -06:00
jason.woltje c0e08f81ea fix(webui): reopen Archive conflicts on the current task (#1873) 2026-11-10 23:40:06 -06:00
jason.woltje f7813339b5 test(refusals): detect escaped malformed identifiers (#1873) 2026-11-10 23:16:26 -06:00
jason.woltje 3e82bc16c3 fix(refusals): restore validated identifiers already shown by the system (#1873) 2026-11-10 23:16:26 -06:00
jason.woltje 57052c53dd fix(organization): old-path re-export for config-dir-trust.mjs (#1873)
rename-b-identity requires one packages/business/src shim per
packages/organization/src module.
2026-11-10 22:26:46 -06:00
jason.woltje 930182b54a fix(organization): accounts config directory checks every ancestor (#1873)
assertAccountsOwner refused a symlink or non-directory on the config path,
but checked owner and mode only on the leaf. An ancestor owned by another
user, or writable by others without the sticky bit, let that user rename the
directory away and put their own accounts.json in its place. Since M3 that
file decides root authority on a deployed Console.

One shared helper, config-dir-trust.mjs, now holds A2's rule: every ancestor
belongs to root or this uid and is group or other writable only with the
sticky bit; configDir itself is this uid's and not group or other writable.
The accounts store (load, init, lock, locked write, mosaic accounts) and the
root agent file's writer both call it, each with its own refusal code. A2's
messages are unchanged.

Accounts refusals no longer quote a path: the config directory and accounts
file uid messages name the rule and the uid only (redaction rule, #1916).
A missing config directory now refuses accounts-unsafe instead of a raw
ENOENT.
2026-11-10 22:21:06 -06:00
jason.woltje d0cfebe286 fix(cli): M3 re-check: provider denials answer 403; A2 test owner carries rootOwner (#1873)
The rehearsal provider matches routes on the parsed pathname, so a
query string no longer turns authorize or token into a 404. Authorize
and token answer 403 to any method before the GET check, and only
discovery and the JWKS answer 405 to another method. R20 now sends a
POST to token and a GET to authorize with a query, and expects 403 for
both.

The upstream A2 agent-levels write test stubbed an owner session
without rootOwner. M3 sets the hook's owner flag from rootOwner, as
sso.mjs grants it, so the stub now carries rootOwner: true, as
system-types-http does. No check changes.
2026-11-10 21:38:17 -06:00
jason.woltje 9acc378555 fix(cli): M3 review round: re-pin migration, rehearsal provider (#1873)
An installed unit must equal the render but for the copy id, so a
re-pin that adds --sso refused render-refused. case2-unit gains --check
(every render check, nothing written) and --check-new (a first install's
checks). stack-upgrade runs both checks before anything stops, and
--next-args reads root's <instance>.args.next: equal before -- but for
--copy, different after it. After the stage it moves the installed unit
files into a new <instance>.replaced.XXXXXX directory and renders a
first install. A failure moves them back unchanged and restarts the old
copy, and confirm swaps the args file. The run sheet gives the commands,
the rollback past the migration included.

A stamped rehearsal now starts an isolated provider on 127.0.0.1 port 0
with a certificate and keys made for the run. It serves discovery and a
JWKS for the live provider ids and answers 403 to authorize and token.
Only the scratch Console trusts its certificate. The gate requires each
provider ready (sso-ready); provider-discovery requires discovery and
JWKS fetches and no code or token request. The provider stops with the
run, and by itself once the run's shell is gone.

The system-path test helper asserts the log's from copy again. The
pinned-copy sign-in requests are bounded, and cleanup waits for each
Console to exit.
2026-11-10 21:38:17 -06:00
jason.woltje db822c5b9d feat(cli): M3 reconciliation: accounts units, stamped renders, rehearsal (#1873)
stack-release refuses a user-unit activate, rollback or pm-on when the
instance shows any accounts sign (stamped target, accounts.json,
console.sso, .deployed), before anything stops. Root renders the system
units with case2-unit.sh, and the op runs with --units external.

case2-unit renders a stamped copy's units only with --sso, console.sso,
an accounts.json that loads and a valid .deployed, and names each
missing step in run-sheet order before any write. Per Jason's ruling of
2026-11-10, a stamped library unit takes --sso and refuses proxy-preview
admission and its proof. The unstamped and dev paths are unchanged.

rehearse runs a stamped copy against rehearsal.invalid with scratch
slot placeholders, a copy of the live accounts.json and the copy's own
.deployed. It refuses sso-not-prepared and accounts-not-initialized
before any rehearsal directory exists, and sso-isolated before the host
starts.

The tests that turned SSO on through the user unit now pin the refusal
and reach the same end state through the system path. The copy tests
start the Console on real scratch prerequisites and pin each refusal.
The run sheet, TOOLS.md and the accounts design record the ruling and
the migration order.
2026-11-10 21:38:17 -06:00
jason.woltje 554bc5116d feat(cli): require dedicated host uid for accounts system units (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 6db8fb2dce feat(cli): enforce stamped-copy and deployed-instance boundaries (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 85122dd2bc feat(webui): separate account sessions from root authority (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 82dcba2d45 fix(agents): A3 hidden checkouts cover every left-out project and refuse links, final admission binds the organization revision (#1873) 2026-11-10 20:27:33 -06:00
jason.woltje 29f7c3af85 fix(agents): A3 excluded checkouts hidden, launch boundary rechecks unions, reload collisions and changed defines refuse (#1873)
- A checkout an earlier launch left for a project the instance may no longer
  work in is covered by an empty tmpfs inside the sandbox; the host copy stays.
- The launcher rechecks every prepared project against the reloaded document
  after preparation, and the bus's final admission takes the instance and
  checks the broker's current unions.
- A project define whose vars changed since boot refuses its copy until the
  next start.
- Instance-name collisions are recomputed at each reload from the frozen root
  claims and the current project defines; every claimant refuses.
2026-11-10 20:27:33 -06:00
jason.woltje 17a48217f6 fix(agents): A3 project unions reach every agent, copies never collide or shadow (#1873)
Review fix round for slice A3.

- Every project's union is recomputed from the current organization file
  at each load. A refused union refuses the project's own copies and every
  inherited root or org agent there: no mount at launch (checkout-skipped,
  project-refused), no thread and no room, each naming the project and why.
- A project exclude removes that project's mount from the excluded agent;
  a narrow reaches the instance's resolved limits, so its real bundle is
  narrowed. A narrow in any project narrows the instance for every launch,
  since one launch mounts every project it works in.
- Two copies whose instance names collide both refuse, naming each
  claimant; the view lists the claimants. An organization instance of the
  same name replaces a root or project copy, at boot and after it, and the
  copy is gone rather than refused.
- Rooms hold only the thread instances that work in the room's project.
- The grid finds an agent's copy by name and project from the bus view,
  never by rebuilding the instance name.
- Real-boot regressions: inherited agents in a refused union, exclude and
  narrow in the bundle, an org override after boot, two organizations each
  running a copy, a root file change while running, and a grid fed by a
  booted bus.
2026-11-10 20:27:33 -06:00
jason.woltje 9d00f9ad54 feat(agents): A3 root and project agent copies at bus boot (#1873)
Each root agent an organization does not exclude, and each agent a project
defines, becomes a copy when the organization's bus boots. The root file is
read once, through loadRootAgents, at the cli boot step; the snapshot is fixed
until the next boot. A bound copy joins the organization document's roles and
goes through validateBusinessDocument and checkRoles with the organization's
own credential map. A copy whose role needs a credential service is unbound
and refuses at launch by name and service (credential-unbound); a copy the
organization refuses is copy-refused; every agent in a project whose union
refuses is project-refused, naming the project. A project agent launches
only with its own project's mounts and takes a thread only in its project
(project-agent-elsewhere). Refusal events carry the code alone. The bus's
instances read carries the copies with their running runs, and the Agents
grid shows each copy's real status and reason.

channel-proof re-pins webui bus.mjs for four STATUS entries; no proof or
transport change. packages/business gets the agent-copies.mjs re-export.
2026-11-10 20:27:33 -06:00
jason.woltje a3e82efe23 fix(redaction): close independent review findings (#1873) 2026-11-10 19:56:05 -06:00
jason.woltje c8ff8ea128 fix(redaction): omit remaining configuration values from refusals (#1873) 2026-11-10 19:56:05 -06:00
jason.woltje ddf4f895cd fix(rails): close review guidance and coverage gaps (#1873) 2026-11-10 19:42:36 -06:00
jason.woltje 7035ce61b3 feat(harness): expose caller rails and refusal guidance (#1873) 2026-11-10 18:51:15 -06:00
jason.woltje 54bf239e32 test(webui): v1 business golden carries RR's resolve rights (#1873) 2026-11-10 18:31:23 -06:00
jason.woltje a4eed1e219 fix(agents): A2 review round: config check, name rule, drafts and test gaps (#1873)
The CLI agent verbs validate the instance config through loadSystem before
any read or write, and print a fixed line for errors they do not own. The
root writer refuses a config directory with an ancestor another user can
write without the sticky bit. The project HTTP route checks agent names
with the organization ID_PATTERN and answers 400 naming the key.

The Settings level forms take their own class, so the role-instance
handlers no longer catch them, and keep both drafts in module state with
the revision each began at. The organization writer tests now reach the
file's own uid check and test the assistant protection with an assistant
in the fixture.
2026-11-10 18:25:54 -06:00
jason.woltje 1e1bbaa6dd fix(webui): A2 agent level grids scroll inside their card on phones (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 78bda9d6a4 test(organization): A2 refusal patterns follow var-redact's key-only text (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 6b39af7888 feat(agents): A2 root and project agent level writers, CLI and Settings forms (#1873)
Root writer for <configDir>/agents.json (owner only, revision-checked, atomic, A1-validated,
receipts name agents and keys only), the project.agents writer verb on the organization
writer, `mosaic agents root` and `mosaic project agents` verbs, and owner-only Settings >
Agents edit forms on GET/POST hooks gated like /api/agents/levels.
2026-11-10 18:25:54 -06:00
jason.woltje 96c900d210 fix: preserve read error codes and launcher fixtures (#1873) 2026-11-10 17:54:33 -06:00
jason.woltje 307fe928ac fix: redact remaining file and config refusal echoes (#1873) 2026-11-10 17:06:54 -06:00
jason.woltje e9a079fcc4 fix(organization): validate narrowed arbiter resolve authority (#1873) 2026-11-10 16:51:03 -06:00
jason.woltje 7de9cfccc3 feat(organization): enforce arbiter domain resolve rights (#1873) 2026-11-10 16:33:21 -06:00
jason.woltje c3c192677d test(webui): the layout reload test loads the page through navigate (#1873)
The mark from the previous commit kept the wait off the old page, but
the wait could still start on it and be killed by the reload
("Inspected target navigated or closed", once in ten runs). navigate()
waits for the load; a query makes the same URL load again.
2026-11-10 16:01:01 -06:00
jason.woltje 95ee19cc71 fix(webui): a task dialog closed during the conflict read stays closed (#1873)
Review fix. dialog.close() clears `open` at once but queues the close
event that empties the dialog. A conflict read that resumed in between
still found the old form connected and reopened the dialog, and the
queued event then emptied it: an open modal with nothing in it. The
check is now the dialog's own `open` state and that it still holds the
form. A browser test closes the dialog and releases the read's body in
one task; it fails on the previous commit.
2026-11-10 16:01:01 -06:00
jason.woltje 07dba7f2f5 test(webui): the layout reload test waits for the new page (#1873)
The wait after location.reload() could pass on the board that was
unloading, and the next read then found no board (seen once in ten
runs under load). A mark set before the reload goes with the old page,
so the wait now passes only on the reloaded one.
2026-11-10 16:01:01 -06:00
jason.woltje ceee80d5b7 fix(webui): task board conflict shows the refusal only with the current task (#1873)
A product race, not only a test race. On task-conflict the dialog wrote
the refusal into the old form, turned its buttons back on and only then
read the current task. For the length of that read the form showed the
refusal beside the old task and a Save went out against the old digest.
The conflict test read the title in that window and failed under load.

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

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

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

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

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

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

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

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

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

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

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

Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
2026-11-09 19:25:44 -06:00
jason.woltje feb98d5529 test(cli): compare file bytes in the org tester-init refusal check
Sol review of 221c1e9d3 (P2): the snapshot held only path names, so a
rewrite of .mosaic-org.json before the refusal passed. It now holds names,
types and file bytes; the reviewer's rewrite mutant fails it.
2026-11-09 19:17:28 -06:00
jason.woltje 221c1e9d35 Fix tester project tests for organization refusal 2026-11-09 19:05:09 -06:00
jason.woltje 544918a715 docs: record U1 and SYS1 landings (#1873) 2026-11-09 19:00:25 -06:00
jason.woltje 2c6556ab1e fix(sys1): count project system types in the restore guard and admit the CLI verbs
Sol review sys1-review-r1 (changes 3):
- P1: project_system_types joins stack-release.sh COUNTED and the T45 LISTED pin; the
  post-backup refusal and in-run recount tests now cover it.
- P2: the CLI channel admits project.system-types and project.system-types.set; a new test
  drives both through run() and the real channel module (1/1 red without the fix).
- P2: the #811 migration-reader pin moves to 19 steps ending 3t-3u.
2026-11-09 18:46:36 -06:00
jason.woltje f4bfd3a744 feat(plugins,organization): system types as a plug-in kind (REQ-SYS-1, #1873)
Adds the system-type kind with five modules (coding, creative-writing,
marketing, montage, business-management), an org-level enabled list
validated fail-closed, project selections limited to the org's list,
read verbs, and bus migration 3t-3u. REQ-SYS-2 toolsets are designed and
deferred: they need a role authority contract.
2026-11-09 18:46:35 -06:00
jason.woltje d35447005a Isolate invalid workspace reads and cancel timed-out org work 2026-11-09 18:22:27 -06:00
jason.woltje 2109458b3f feat(webui,cli): U1 cross-organization read model and console verbs (#1873)
GET /api/workspace on the dev-local-owner Console: one snapshot of
projects, sessions, inbox, tasks and per-org metrics across every
organization the owner belongs to. Membership reuses the org list's
admission (organizations.members); each org is read over the same
per-org options the org pages use, with one deadline per org (4000 ms)
and a state (ok, down, starting, timeout, invalid) with a time. Every
row carries its org id; decide commands carry --org. Granted users
and the tester get 403 owner-only. Polling at 10 s, no push.

mosaic console restart and mosaic org reload <id> call the Console's
F3 routes (POST /api/console/restart, POST /api/orgs/<id>/restart/bus)
so gating, proof and trail are the Console's own; both refuse inside
an agent run first.

The cross-org assistant read is designed and stopped: it needs a new
broker read verb or role authority.
2026-11-09 17:54:06 -06:00
jason.woltje 1616d1dd6f docs: BUILD-LOG and session line for PRD 1.20 and defect l (#1873, #1886) 2026-11-09 17:38:26 -06:00
jason.woltje 7a97cfc4f3 fix(bus): make the private store root at start when a reference names the store (#1886)
Adding an agent with a store credential on an instance with no
<configDir>/secrets/agents left the bus unable to start
(credential-file): Credentials refuses a missing store root, and only
issue() made it, which needs a running bus. Start now makes the root
(0700) the same way issue() does, only when some reference has
store: true. Credentials' own refusal (credential-store T1) is unchanged,
and a root with loose modes or a symlink still refuses start.

Tests: two new credential-verbs tests; the creation test fails on
483eccb3d with credential-file. All 171 bus test files run one at a
time: 1713 pass, 0 fail.
2026-11-09 17:10:09 -06:00
jason.woltje 03fe321b12 docs(prd): land version 1.20, the unified workspace (#1873)
Jason approved draft 2 on 2026-11-09 at 22:57Z: "The file looks fine
other than that." The exception is the Montage module name, deferred
since 19:26Z. Amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3
and adds 17 requirements (101 to 118): REQ-WS-1..8, REQ-AGENT-1..3,
REQ-SYS-1..2, REQ-ACCT-1..2, REQ-SETUP-1..2. requirements.json and the
tasks and bus count pins follow.

Tests: 68 pass, 1 fail. The failure is stack-copy.test.mjs (20/1), which
fails the same way on origin/refactor 483eccb3d: npm's pinned install
can't find node-gyp/bin/node-gyp.js in the test Node installation.
2026-11-09 17:04:31 -06:00
jason.woltje 483eccb3dd docs: BUILD-LOG and session line for organizations F3 (#1873) 2026-11-09 16:45:56 -06:00
jason.woltje a8f9f09573 Merge remote-tracking branch 'origin/refactor' into org-f3-merge 2026-11-09 14:07:52 -06:00
jason.woltje 0a73279d62 fix(webui): F3 review round: instances trail line, honest dev-mode README
The instances route now passes the owner trail note, so an organization
instance change appends the actions.jsonl line the README promises (P3).

The README claimed no agent reaches the F3 controls. On a dev local owner
Console that is false: every local request acts as the owner, which the
pinned startup warning already says. The README now states it and names
sign-in as the boundary; a local-agent peer refusal is a follow-up (P1).
2026-11-09 14:07:52 -06:00
jason.woltje 73df1a687f docs: BUILD-LOG for organizations landings #1876-#1881 (#1873) 2026-11-09 14:00:54 -06:00
jason.woltje 999d173fd4 Merge refactor fdd6658a6 into F3 (E2E5 fixes)
app.js busDown conflict: refactor's order (org list read before
bus-status, E2E5 review P2) with F3's Restart the bus attachment on
the Bus unavailable banner.
2026-11-09 13:54:46 -06:00
jason.woltje fdd6658a65 fix(webui): refresh the Settings bus control on every org list read
E2E5 review P2. An open Settings page kept Done after the bus died
because only opening the organization reread bus health. display.js
now announces each fresh list read, org-create.js redraws the setup
steps when the selected org's bus state flips (choices kept, no draft
guard), and app.js reads the list before bus-status so a failed
bus-status read no longer skips it. Regression: healthy, Refresh down
shows Start, Refresh up hides it; red on 8a8edc44b.
2026-11-09 13:53:19 -06:00
jason.woltje e98909d907 Merge refactor 2bed0aebe into F3 (console restart, owner settings writes)
Conflicts kept both sides: rename stage B organization paths and the
relative-launch re-exec with F3 imports, F2 onboarding script and the
north-star fence exemption with F3 console-restart.js and ORG_OWNER_WRITE,
and the /api/config fields from both.
2026-11-09 13:41:20 -06:00
jason.woltje 1b6cac2bdf feat(webui): restart the Console, the org bus and agents from the WebUI (F3)
Self-service operation from the WebUI, for a dev instance first.

- Settings > Console > Restart Console. A foreground `mosaic console
  start` now supervises the webui: the child exits 75 after answering a
  restart and the supervisor relaunches it with the same argv. Org buses
  keep running. /api/config carries a per-process boot id, so the open
  page polls until a new process answers and then reloads. A detached
  Console has no supervisor and answers 409.
- Restart bus on every "Restart needed" receipt and on the bus-down
  banner: POST /api/orgs/<id>/restart/bus restarts a running bus or
  starts a stopped one, then shows a done receipt with the time.
- Settings > Routing: after a save, the page lists how the change
  reaches each agent (next start, session end, thread idle) and offers
  "Restart <agent> now" for a running one. A session is terminated with
  the reason "route change" and launched again; a thread is terminated.
- Organization settings: the signed-in owner's settings, routing,
  agent, plug-in and Model proxy writes now reach the selected org's
  bus instead of answering 403. Org mode exists only under
  --dev-local-owner. Proxy setup stays a terminal step; a dev-only note
  names the two commands.

Every control needs the signed-in owner session, the page's exact
Origin and Sec-Fetch-Site: same-origin. The tester listener, granted
users, API tokens, agents and bus roles are refused before any work.
Each use is written to <dataRoot>/console/actions.jsonl (0600, no
symlinks) with who, when, what and the outcome. The Console and bus
restart trails fail closed. No change to credential handling, the
broker, the bus tenant boundary or v2 schemas.
2026-11-09 13:37:28 -06:00
jason.woltje 8a8edc44b6 Merge refactor 2bed0aebe into E2E5 fixes 2026-11-09 13:30:24 -06:00
jason.woltje d6eff452e3 fix(webui): recover archived organizations and console controls 2026-11-09 13:29:23 -06:00
jason.woltje 2bed0aebeb Merge refactor bad760ce0 into U0 samples 2026-11-09 13:26:39 -06:00
jason.woltje af674e14c1 docs(design): keep the WebUI four-tile logo in the U0 samples; record U0 approval (#1873)
Jason approved the U0 screens on 11-09 with one change: keep the temporary
logo the site already uses. The samples drew the design system's Relay
placeholder; they now draw the WebUI's four-tile mark with the same fills
as processing.css. The direction doc records the approval verbatim, the
deferred Montage name, and the open-question defaults phase U takes.
2026-11-09 13:26:39 -06:00
jason.woltje bad760ce08 Merge refactor fbf2f6fc8 (rename stage B) into F2
Conflict in packages/webui/src/serve.mjs imports: kept F2's
THREAD_DEFINITION and onboarding imports on stage B's organization path.
The F2 assistant test edit followed the move to packages/organization;
onboarding-browser now imports the organization service directly.
2026-11-09 13:17:33 -06:00
jason.woltje 5937a977f8 webui: F2 review fixes, interview retry, fresh approval capability, primary start
Sol's F2 review: a refused interview start threw on the null
currentTarget after the await and left Start disabled (P2); the panel
cached /api/config for the life of the page, so Approve outlived the
capability (P3). Hold the button before the await; reread config on
each offer. Lead P3: on Quick Setup done, Start onboarding interview
gets the accent fill; Open organization stays secondary.
onboarding-retry-browser is red 0/2 on the old source, 2/0 now.
2026-11-09 13:17:10 -06:00
jason.woltje fbf2f6fc89 Merge refactor c9c409ac4 into rename stage B
Conflict in packages/webui/src/cli.mjs: kept the relative-launch re-exec
from #1876 with stage B's organization import paths.
2026-11-09 13:13:13 -06:00
jason.woltje e19db4009c organization: keep the old stderr prefix on config diagnostics in both spellings
Sol's stage B review (P2): mosaic org config validate|resolve printed
'mosaic org config: ' on refusals and warnings, so a caller moving from
mosaic business validate|resolve lost the byte-level diagnostic contract
in rename design r2. Now only the usage line follows the spelling; every
other stderr line keeps 'mosaic business: '. The B2 test compares a
refusal, a warning and a system config problem unnormalized; it fails
4 of 9 on the old source.
2026-11-09 13:12:53 -06:00
jason.woltje 205154e8d7 roles: the assistant's prose names write_record (F2, #1873)
roles/assistant.json grants record.write and the harness exposes it
as write_record, but the prose said 'You have no write tool', which
could make a model refuse the onboarding draft. The grant is
unchanged; spec/ stays human-only.
2026-11-09 13:04:35 -06:00
jason.woltje cc9f6edccc Merge org-f2 (onboarding interview, F2 #1873) into refactor 2026-11-09 13:03:49 -06:00
jason.woltje c9c409ac4e webui: keep a long north-star heading in the overview preview
The word-boundary cut removed everything after '# ' when the first
500 characters were one long heading, leaving '#…'. Keep the hard cut
when the soft cut would leave only a markdown marker. Review P3 on
F1 (#1873).
2026-11-09 13:02:17 -06:00
jason.woltje e12713f68e webui: render the project north star as markdown on the overview (F1, #1873)
The overview's Project direction panel printed north-star.md source as one
paragraph. It now mounts the same DOM-building markdown renderer the
requirements view uses, trims the preview to 500 characters at a word
boundary, and links to the full document in Docs. No raw HTML from the
document reaches the page; the fallback is a text node.

Quick Setup leaving no records was not reproduced on 00ef0bd30: all six
steps report done and the records step writes where the project view
reads. org-quick-live gains a records case that checks the six setup
POSTs, the records repository on disk and the live north-star read with
no button press. The manual records button stays for older projects.

Tests: projects-north-star-browser 1/0 (red before the change),
org-quick-live 4/0 twice on a fresh scratch instance.
2026-11-09 13:02:17 -06:00
jason.woltje 7fe301a172 feat(webui): onboarding interview after Quick Setup and on the project page (F2)
- Quick Setup's done state offers Start onboarding interview next to Open
  organization. It opens a conversation with the assistant and sends the
  template's first message; Open organization skips it.
- The project overview offers the interview while the north star is the
  init template, then shows the assistant's draft with Approve as north
  star and the equivalent CLI command.
- GET /api/onboarding/interview serves the template and the assistant
  instance. POST /api/north-star/approve runs approveNorthStarDraft with
  the save's boundary (header user, exact Origin, same-origin Fetch
  Metadata, strict JSON). In an organization it is the one project write
  the fence lets through; the tester and granted users never reach it.
  /api/config says northStarApprove where the route can pass.
- GET /api/project-file adds `committed`, the file's last commit time; `at`
  stays the read time.
2026-11-09 12:58:32 -06:00
jason.woltje 00ae2c718c feat(project,cli): onboarding interview template and north-star approval (F2)
REQ-SETUP-2 (PRD 1.20 draft), unified workspace direction ruling 14 and
phase F2.

- packages/project/templates/onboarding-interview.json: the interview's
  conversation name, four questions, draft paths and first message.
  loadInterviewTemplate(url) reads it and validateInterviewTemplate checks
  it, so an organization file or a plug-in can pass a replacement later.
- approveNorthStarDraft: agents never write spec/, so the assistant
  proposes docs/north-star-draft.md with its existing record.write grant
  and the human approves. The approval copies the exact reviewed draft
  commit into spec/north-star.md through the human save verb, pinned to
  the reviewed spec commit. A rewritten draft or a moved spec writes
  nothing. No new verb, grant or role change.
- mosaic project north-star <project> [--approve <draft> --base <spec>]
  shows the spec and the draft and approves as the human; mosaic project
  interview <project> prints the first message; mosaic thread create
  takes --instance.
2026-11-09 12:58:29 -06:00
jason.woltje 7917913606 webui: skip Node's -- delimiter when finding the Console script
Node leaves the -- option delimiter out of process.execArgv, so
'node -- /abs/cli.mjs' picked '--' as the script and re-executed an
already absolute launch, running preloads twice. Review P3 on #1873
reqget-2.
2026-11-09 12:57:06 -06:00
jason.woltje 744753d7eb fix: normalize relative console launch path 2026-11-09 12:44:35 -06:00
jason.woltje ee7dbd2332 Merge rename stage B (org-rename-b) into refactor 2026-11-09 12:38:57 -06:00
jason.woltje 1cc09a1cad docs(design): match U0 org dashboard sessions to the main dashboard count (#1873)
The org dashboard listed an idle chief as a session, so Distinctly
Digital showed four sessions there and three on the main dashboard.
The chief has no running session; it stays on the agents screen.
2026-11-09 12:38:03 -06:00
jason.woltje 36c8260d7d docs(design): add U0 unified workspace samples (#1873)
Eight static screens for PRD 1.20 phase U, for Jason's approval
before U1-U5 are built: sidebar with collapsible Organizations and
org marks, cross-org dashboard, org dashboard with docs, KPIs,
metrics, sessions and a settings index, projects across orgs, one
inbox, Kanban and list tasks with tracker badges, side-by-side panes,
and agents at root, org and project level.

Mock data only: three orgs (Home, Distinctly Digital, Forge), ten
projects, no real names or credentials. workspace.css imports
tokens.css and marks each extension EXT 1-10. render.mjs serves the
samples on 127.0.0.1 port 0 and renders 32 PNGs at 1440x900 and
390x844, light and dark, failing on script errors, failed requests,
sideways scroll or unloaded fonts. No WebUI code changes.
2026-11-09 12:36:28 -06:00
jason.woltje edec4e1d04 docs: PRD 1.20 draft 2, chief of staff hires at every level (#1873) 2026-11-09 12:14:00 -06:00
jason.woltje 13c75b699f cli, organization: org config and org instances commands, old business spelling kept (rename r2 stage B2)
mosaic org config validate|resolve runs runConfigCommand
(packages/organization/src/config-command.mjs), the callable service the
old mosaic business validate|resolve executable now calls too. The service
returns the exit code, never exits the process and does nothing on import.
The spelling sets only the stderr prefix and the usage text; status and
stdout are byte-identical, and the v1 output keeps the business key.

mosaic org instances|instance|veto takes the same arguments as
mosaic business instances|instance|veto and runs the same proof-bearing
instances command. It is dispatched before orgCommand, so it never builds
the dev-local-owner context. mosaic org validate FILE is unchanged.

Help, the UI labels for agent actions and instance reasons, and the
current docs now use the org spelling and the packages/organization paths.
Two webui test regexes follow the label text. packages/cli/tests/
rename-b-commands.test.mjs compares the two spellings golden for golden
on status, stdout, stderr and authorization outcomes, and covers a draft
file named like an installed ID.
2026-11-09 12:00:29 -06:00
jason.woltje 00ef0bd301 docs: record unified workspace direction and PRD 1.20 draft (#1873) 2026-11-09 11:55:44 -06:00
jason.woltje 3c2d8631e5 docs: record owner rulings for organizations 2026-11-09 11:55:31 -06:00
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00
jason.woltje d6ab5047af Merge rename stage A (org-rename-stage-a) into refactor 2026-11-09 11:15:59 -06:00
jason.woltje 311ae46e3d Merge test-health fixes (org-test-health-fix) into refactor 2026-11-09 11:14:55 -06:00
jason.woltje e08f0fa1f4 test(webui): match the E2E round 4 Queue heading and record-init write
The refused Queue read keeps Queue as its heading, and the owner's
Initialize project records button is a new POST in bus.js. Both older
tests predated those intended changes.
2026-11-09 11:14:08 -06:00
jason.woltje 3581f7548a Merge E2E round 4 fixes (org-e2e4-fix) into refactor
# Conflicts:
#	packages/webui/src/public/chat.js
#	packages/webui/src/public/projects.js
2026-11-08 23:25:02 -06:00
jason.woltje 1b8e7976a6 bus: PRD reads refuse when org project records exist at both roots
readCandidate called readPrdMaterial with the org data root directly, so
prd.submit and the Console PRD draft kept working while project.files refused
the same conflict. It now resolves through recordArgs like the file adapters.
Fixes the P2 from the E2E4 review.
2026-11-08 23:24:19 -06:00
jason.woltje c27ff53649 webui: reread request panels, files and summary on every refresh
The refresh shortcuts (unchanged view, transcript-only change) skipped the request
summary and file rereads, so a request's phase change showed only after a full
repaint. One refreshRequestData helper now runs on every paint path; a quiet reread
keeps the present content until a changed result arrives.

s6b's error matrix waited for the 'Reading' placeholder to go, which a quiet
reread no longer shows, so it could read the previous scenario. It now waits for
the expected text.
2026-11-08 23:22:43 -06:00
jason.woltje d070ed6d4a dev fixture: take a stale record atomically so a concurrent init keeps a live record
A stale-record init now renames the record aside, checks it is still the one it
observed, and puts it back otherwise. Two inits racing over the same stale record
can no longer delete the record of a live endpoint the other one just started.
Fixes the P2 from the test-health review.
2026-11-08 23:07:48 -06:00
jason.woltje 87b7a34f1c Merge rename part A2: --org selectors with --business aliases
# Conflicts:
#	packages/cli/tests/org-console.test.mjs
#	packages/webui/src/cli.mjs
2026-11-08 23:04:19 -06:00
jason.woltje a36d45af10 Merge rename part A1: pin v1 organization contracts 2026-11-08 23:04:19 -06:00
jason.woltje 61e4e76520 Fix organization setup records and console regressions 2026-11-08 22:55:55 -06:00
jason.woltje 20d798cd57 Merge live project updates (org-live-updates) into refactor 2026-11-08 22:43:23 -06:00
jason.woltje a8bcf94b8c Scope requirement view state to its request and keep closed panels closed
A refresh that drops the displayed request no longer hands its open
requirement and focus to another request's requirement with the same
local id: the restore key now carries the request id. Keyed disclosures
also remember a closed state, so desktop provenance the user closed
stays closed through a repaint. Adds Sol's two R3 probes as tests.
2026-11-08 22:43:16 -06:00
jason.woltje 9a52eecffd selector: org-console test asserts the Console child's exact argv
The detached Console is a selector producer: the test reads the child's
/proc cmdline and asserts serve --port 0 --business alpha --dev-local-owner.
2026-11-08 22:35:24 -06:00
jason.woltje 4fa6995c13 selector: --org and --business in land-work
land-work takes --org as --business in one scope. A repeated, conflicting,
missing, empty or flag-shaped value exits 4 before the config is read. The
id keeps its old grammar (none): any other text names businesses/<ID>.json
and a missing file exits 3. A value of --subject or --attribution is never
read as a selector.
2026-11-08 22:35:24 -06:00
jason.woltje 381039fe5f selector: --org and --business in stack-copy check
check takes --org as --business in one scope: each spelling once, both only
with the same value. A repeated, conflicting, missing, empty or flag-shaped
value exits 4 before any unit is read; an id outside check's own grammar
still exits 1. copy and wrapper refuse either spelling with usage, as
before. The sourced-library path used by stack-release.sh does not read
selector.sh; the command path refuses if it cannot.
2026-11-08 22:35:24 -06:00
jason.woltje 1105de280b selector: stack-release rehearsal fixture copies org-selector.mjs
The rehearsal builds fake copies from an explicit file list. f198cfd47 made
packages/cli/src/access.mjs import org-selector.mjs, which the list lacked,
so every rehearsal that ran the copied CLI failed with "Cannot find module"
(17 of 226 tests) from f198cfd47 until this commit. Real copies were not
affected: stack-copy.sh COPY_PATHS takes packages/cli/src whole.
2026-11-08 22:35:24 -06:00
jason.woltje af29832a05 Keep request selection and requirement state by identity across refreshes
Sol's re-check of c0b2f7d86 found two failures. A refresh that changed only an
unselected request kept the page and so kept the request selector's old closure;
selecting that request then drew its old PRD. Retained handlers now draw from the
newest accepted read for their container.

Requirement disclosures and focus were saved by position, so a requirement
inserted above the focused one handed its open state and focus to a neighbour.
Requirement and provenance details carry a restore key; open state and summary
focus are restored by that key and a key that no longer exists is dropped. The
outer chat capture uses the same helper.

org-live-fix-r3-browser.test.mjs holds Sol's two probes: 0/2 on c0b2f7d86, 2/0
here. Nineteen live-update, chat, project, request and s10-s15 files: 231/0.
2026-11-08 22:23:59 -06:00
jason.woltje 9e458878df selector: --org and --business in stack-upgrade's args-file scopes
read_args takes --org as --business in the unit flags before -- and in the
serve flags after it, with per-scope duplicate and conflict refusal, and
refuses a file whose serve flags name no selector or another business, as
case2-unit.sh would at the render after A2 stopped the units. The serve
flags are walked with case2-unit.sh's grammar so a value is never read as a
selector. The file is not rewritten; only --copy's value changes, as before.
A checkout without scripts/selector.sh refuses checkout-unreadable.
2026-11-08 22:05:44 -06:00
jason.woltje c0b2f7d86a Preserve live project views through overlapping refreshes 2026-11-08 21:57:48 -06:00
jason.woltje ac63a187e8 Merge walkthrough fixes and LAN dev bind (org-stage) into refactor
org-walkthrough-fix 60f3b3d78 (Sol APPROVE 0/0/0) and org-lan-bind 95a5f7072 (Sol APPROVE 0/0/1) via org-stage 265f1767c.
2026-11-08 21:56:43 -06:00
jason.woltje 226be2e935 selector: --org and --business in case1-accept's three scopes 2026-11-08 21:56:02 -06:00
jason.woltje dbb6909dcd selector: case2-precheck and case2-install refuse when selector.sh cannot be read
Neither script runs under set -e, so a failed source left selector_take
undefined and parsing carried on without the duplicate and conflict rules.
2026-11-08 21:56:02 -06:00
jason.woltje 4b7ddbcb43 selector: --org and --business in case2-unit's outer and serve scopes 2026-11-08 21:39:20 -06:00
jason.woltje 623388603b Merge organizations MVP (ui-org-r2) into refactor
BUILD-LOG.md: append-only union, refactor entries first, then the branch's.
docs/SESSIONS.md: append-only union, refactor entries first, then the branch's.
packages/bus/src/runtime.mjs: keep the organization usage timer and refactor's halted/invalid startup-recovery gate.
packages/harness/src/first-output.mjs: keep the branch's harness-neutral markers; refactor's empty-length rejection moves into the Pi monitor (packages/plugins/harness/pi/model-stream.mjs).
packages/harness/tests/runner.test.mjs: keep refactor's length-continuation cases and the branch's output-policy cases.
packages/plugins/harness/pi/index.mjs: keep the documented GLM limits and the branch's transport-based bundle.

Suites: 6356 pass, 32 fail; all 32 fail identically on both parents
(node_modules symlink EISDIR in pinned-copy and stack copy/release tests,
missing Python jsonschema in two conversation tests).
2026-11-08 21:38:42 -06:00
jason.woltje 7f882a905d selector: --org and --org-file in case2-install
Rename design r2, "Selector scopes and parser matrix", case2-install row. --org/--business
and --org-file/--business-file are two families, each its own scope: each spelling once, and
a second spelling must carry the same text (file paths compared as given). Missing values
are usage; a flag-shaped or invalid id keeps the business-id refusal; a flag-shaped file, a
repeated spelling and a conflict say why. All exit 4 before anything is read or written.
The persona-import call it spawns still passes --business.
2026-11-08 21:27:37 -06:00
jason.woltje 265f1767c0 Merge org-lan-bind into org-stage 2026-11-08 21:27:33 -06:00
jason.woltje 3baf6f2297 selector: --org and --business in case2 persona-import and precheck
Rename design r2, "Selector scopes and parser matrix". case2-persona-import.mjs takes --org
through selectorScope and case2-precheck.sh through scripts/selector.sh, one scope each. Both
keep their exit codes and their old business-id wording; a repeated or conflicting selector
says why. persona-import now checks the id while parsing, so stage refuses an invalid
selector before it reads anything.

The case2-install fake stack copy carries packages/business/src/selector.mjs, which the real
copy already does (COPY_PATHS takes packages/business/src).
2026-11-08 21:25:49 -06:00
jason.woltje 298081dbf3 Keep console views stable across refreshes 2026-11-08 21:24:12 -06:00
jason.woltje 259302a368 selector: --org and --business in the Console CLI and service parsers
cli.mjs serve and webui-service.sh each take --org as --business in their own
scope through the shared selector rules; the service unit still carries the
canonical --business. A flag-shaped or invalid value keeps each script's old
wording and exit code; a repeated or conflicting selector is a usage error.
2026-11-08 21:20:09 -06:00
jason.woltje f198cfd47b selector: --org and --business in each CLI parser's own scope
Rename design r2, "Selector scopes and parser matrix". packages/business/src/selector.mjs
and scripts/selector.sh carry one rule set: in a parser's scope each spelling appears once,
and a second spelling must carry exactly the same value; a repeated spelling, a missing or
flag-shaped value, an invalid id and unequal values refuse. The file family
(--business-file/--org-file) compares paths as given.

Every CLI parser that took --business now takes --org through orgSelector, and the old
global --org rewrite in main() is gone, so a value another option consumes is never read as
a selector. The id is checked while parsing (OrgError org-invalid, exit 3), so grants admin
set no longer reads the token and proxy init no longer prompts for the key before an
invalid id refuses. access gains the same local id check.
2026-11-08 21:14:11 -06:00
jason.woltje 95a5f7072d Document the console LAN flags and console status 2026-11-08 21:13:56 -06:00
jason.woltje 665a5148d4 Open the dev owner Console to allowed LAN names
mosaic console start --dev-local-owner --dev-insecure-bind ADDR
--dev-allow-host NAME[,NAME...] binds 0.0.0.0 or an IPv4 address and
answers only NAME:<bound port> plus the loopback names. Both flags are
refused without the dev owner, the bind without an allowed name, and a
specific ADDR that is not itself a name. Start and console status print
the no-sign-in warning; the state file records bind and allowHosts.

serve.mjs takes devAllowHosts only with the dev owner; SSO, preview and
tester listeners stay loopback. Writes need Origin http://<same Host>.
Chromium sends no Fetch Metadata from a LAN http origin, so for an
allowed name a write with that exact Origin and no Sec-Fetch-Site counts
as same-origin; an explicit header is still judged by the routes.
chat.js builds its operation keys without crypto.randomUUID, which an
insecure context lacks.
2026-11-08 21:13:53 -06:00
jason.woltje 60f3b3d78f Offer New organization in the header menu, picker and empty state
Once organizations existed, Quick Setup was reachable only through
Settings > Organizations > type an ID > New organization. This tree
has no header organization switcher, so the owner of a Console with
organization setup now gets "New organization" in the header avatar
menu, under the Organization picker on Settings > Organization, and
next to the Settings link in the no-organization empty state. Each
opens #/settings/organizations?new=1, which focuses the ID field and
then drops the flag. The form itself is unchanged.

The new browser test covers the menu at 1400 and 390px, the picker
and the empty state. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:46 -06:00
jason.woltje 4b40c7f57b Keep project crumbs evenly spaced for short project names
"Projects / work /  / Conversations / init" had no missing label.
Every project crumb link had min-width:44px, so a short name such as
"work" was padded with blank space after its text, and the separators
around it read as an empty crumb. The 44px target width now comes
from an ::after overlay centred on the link, so the layout box fits
the text on every project route.

The new browser test measures text-to-separator gaps and hit width
on the overview, conversations and conversation routes at 1440 and
390px. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:40 -06:00
jason.woltje a1491a0163 Give organization Consoles a request read so request status resolves
The organization options in cli.mjs carried no requestRead, so
/api/orgs/<id>/config never offered requests and the project and
conversation pages counted "requests not offered" as a failed read:
"Request status unavailable." on a conversation whose message went
through. The owner now reads request status over the organization's
own bus socket, as the root Console does. Request writes stay off,
per the README Console actions inventory.

The new browser test runs the real cli.mjs serve --dev-local-owner
against a real organization record with only the bus transports
shimmed. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:35 -06:00
jason.woltje 500418e710 test: pin remaining v1 organization contracts 2026-11-08 20:53:16 -06:00
jason.woltje c2a6fad2a8 dev fixture: --ttl for the endpoint, and init replaces a stale record (P19 #882)
The endpoint ended after MOSAIC_FIXTURE_TTL_S, but init passed it only PATH, so
the lifetime was always the 14400 s default. Its record stayed after it ended,
and init refused fixture-exists until `dev fixture stop`.

init now takes --ttl SECONDS (0 = no expiry; otherwise 60 to 2147483, the
longest delay a Node timer holds; the default stays 14400), passes it to the
endpoint, and records ttlSeconds and expiresAt. /health and status report them.
A record whose pid is no longer the endpoint is stale: init removes it and says
which pid it replaced. A live endpoint still refuses fixture-exists.

A running bus keeps the endpoint it read at start, so after a replacement
the bus needs a restart; --ttl 0 avoids the expiry in the first place.
2026-11-08 20:53:03 -06:00
jason.woltje 7d1ab7cd96 test: pin v1 resolver and command contracts 2026-11-08 20:45:50 -06:00
jason.woltje 041e163d3c tester-cli: load the bus client only in the nonce child (P19 #876)
The tester-cli test makes about 20 Console reads and chat calls in a
row. Each one spawns TESTER_CLI, which re-executes itself so the nonce
is in its initial environment; both processes statically imported the
client and the broker module graph behind it, though the parent only
spawns the child. The child now imports Client and readProcess after
the re-exec branch, so the parent is a bare node start. Interleaved
over 20 calls at load 19: 258 ms per call before, 192 ms after. The
proof, nonce and argv are unchanged.

What remains is legitimate serial work: about 20 round trips of two
node processes each, about 3 s idle and 10 s at load 20 on 16 cores
(before this change). 30 s left under 3x room on a busy machine, so the
test's hang guard is now 60 s.
2026-11-08 20:45:23 -06:00
jason.woltje 588253e524 test: stop dev-local-owner buses before removing their root (P19 #875)
The Console-and-bus test in packages/webui/tests/dev-local-owner.test.mjs
registered rmSync(root) as its first t.after hook. Hooks run in
registration order, so root went first, both `bus stop` calls then failed,
and the SIGTERM fallback reached only the outer `bus start`. Each run left
the beta and acme --internal-host processes and their brokers running.

One hook now kills the Console, stops both buses while root exists, waits
for every recorded process (outer CLIs, hosts from their receipts, and the
hosts' children, each matched by start time) and fails if any survives,
SIGKILLing the survivors first so the failure does not leak. Root is
removed last. A mutant that skips the stops fails with six survivors.
packages/bus/tests/dev-local-owner.test.mjs already stopped its bus and
leaves nothing behind.
2026-11-08 20:39:31 -06:00
jason.woltje 93a73a7375 test: leave a linked node_modules out of the copy fixtures
On a deploy tree node_modules is a symlink. .gitignore's node_modules/
matches only a directory, so git ls-files -co --exclude-standard lists
the link as an untracked file and the fixtures' cpSync throws EISDIR
on it (8 failures in pinned-copy.test.mjs). stack-copy.sh never takes
node_modules from git (it archives COPY_PATHS and runs npm ci), so the
fixtures now exclude **/node_modules from the listing. The copy code is
unchanged.
2026-11-08 20:34:53 -06:00
jason.woltje 6ca7f3b656 Merge branch 'org-quick-project' into ui-org-r2 2026-11-08 15:53:52 -06:00
jason.woltje c47fcc750d Merge branch 'org-polish-2' into ui-org-r2 2026-11-08 15:53:52 -06:00
jason.woltje 86c1546a4f Merge branch 'org-testhang' into ui-org-r2 2026-11-08 15:53:52 -06:00
jason.woltje 1b175a0511 Merge branch 'org-recover-r2' into ui-org-r2 2026-11-08 15:53:52 -06:00
jason.woltje 6694b4b6d6 Fix polish review findings F1-F3 (P19 #868)
F1: the org list shows a broken org as invalid only when the failure
came after the owner check passed (ownerVerified). A broken root or
marker on someone else's org stays hidden.

F2: /api/orgs answers a registry read failure with a fixed message.
The parser text could quote private registry bytes.

F3: selected-org pages keep the list error, paint one replaceable
header banner, and Retry always re-reads the list. The Board shows the
registry message once.

New tests org-list-privacy and org-list-refresh-browser fail on
0e9b2c505 for F1, F2 and F3 and pass here. Serial batch over all 134
webui files plus business org-* and cli org: only failures were the
known pinned-copy EISDIR (8) and an org-switch assertion on the old
pass-through text, updated here.
2026-11-08 15:53:42 -06:00
jason.woltje 0e9b2c505d Org polish: lock-free reads, visible org errors, no-board label (P19 #867, #868, #869)
#867: organization reads no longer take the registry writer lock; they
retry while the lock exists or the registry changed during the read.
#868: a broken organization is listed as invalid with a path-masked
reason instead of disappearing; the Board, header and picker show it,
and /api/orgs 503 carries the reason. Orgs the principal may not see and
archived orgs still give an empty list.
#869: a Console with no board says "No board", not "Board: null".
#866 (save-path validation at bus admission) is left out: it refused
documents the bus accepted before and needs a migration story first.

Every bus, business, cli and webui file run serially; failures are only
the known EISDIR node_modules-link files.
2026-11-08 15:27:19 -06:00
jason.woltje fe042f072b fix(webui): create project during quick setup 2026-11-08 15:26:03 -06:00
jason.woltje f9f34bd249 Fix testhang review findings F1-F4 (P19 #865)
serve.mjs keeps the bound address as well as the port, so the settings,
queue and business routes answer a request that arrives after close().
browser.mjs keeps an exited leader's group tracked until it is killed at
exit, and close() signals the group only while the leader is alive. The
late-request test is event-based with one owned timeout. New test: the
chromium group dies when the main process dies and close() never runs.

Webui browser and server files (92, pinned-copy excluded): 741 pass, 0 fail.
2026-11-08 15:22:48 -06:00
jason.woltje 11f95842ef Answer requests that arrive after close starts; kill chromium by process group (P19 #865)
- serve.mjs read server.address().port in the Host checks. Once close() starts that is
  null, so a request on a kept-alive connection threw and was never answered, and the test
  file waited forever. The bound port is now stored on 'listening'.
- Killing only chromium's main process left its renderer and GPU children writing into the
  profile, so the profile rm failed with ENOTEMPTY. Chromium now runs in its own process
  group; close() and the exit hook kill the group, and the rm retries.
- teardown.test.mjs: a request sent after close() starts gets a 200.
2026-11-08 15:00:28 -06:00
jason.woltje d68e7b0976 Fix recovery review findings: live Console identity, crashed clear fence, atomic quarantine (P19 #857, #862)
- A Console is stale only when its pid is gone or its start time differs. A live Console
  started from another entry path is never quarantined; stop refuses to signal it and
  names the path it was started from.
- The lock-clear fence records the clearer's pid and start time. A fence left by a dead
  clearer is moved aside and recovery continues; a live or unreadable fence refuses.
  lock show reports a fence.
- Console state is moved aside with one atomic rename after an inode re-check.
2026-11-08 14:52:42 -06:00
jason.woltje 56a21e4e33 Bound test teardown so org browser runs cannot hang (P19 #865)
Combined node --test runs hung in the org browser files. Two causes:

- org-quick: a page's GET /api/config reached the server on a
  kept-alive connection after close(server) had stopped the listener.
  The handler threw on server.address().port, the request was never
  answered, and server.close() waited for it forever. close() in
  fixture.mjs now cuts every connection and fails the hook after 5 s
  with a message.
- org-delete: t.after hooks run in registration order, so the data
  directory was removed while the server still wrote there. The rm
  failed with ENOTEMPTY, node:test skipped the later hooks, and the
  open server and chromium kept the file running. The three org files
  and project-archive-style now register the removal last.

browser.mjs: close() kills a browser that does not answer
Browser.close within 5 s, a call to an exited browser fails at once,
and an unclosed chromium no longer holds the event loop and is killed
when the test process exits.

teardown.test.mjs reproduces each case: 0/4 pass on the old helpers,
4/4 on the new ones.
2026-11-08 14:38:58 -06:00
jason.woltje e9a7adab11 Recover stale Console state, dead org locks and wedged bus hosts
console start and stop move a console.json whose recorded process is gone
aside instead of refusing (P19 #857). mosaic org lock show|clear reports a
lifecycle lock holder and clears it only when the holder is dead.
mosaic bus recover marks a bus host stopped when its process is gone and its
socket does not answer, and writes a recovery receipt (P19 #862).
2026-11-08 14:11:13 -06:00
jason.woltje 606911834e Merge organization archive and fixes (quick-setup 57d5675ae)
# Conflicts:
#	packages/cli/src/cli.mjs
2026-11-07 23:06:53 -06:00
jason.woltje 57d5675ae3 Name the final delete button for what it does
The panel says the organization moves to the deleted folder, so the
final button no longer says it deletes permanently.
2026-11-07 23:06:32 -06:00
jason.woltje 312c62fd4f Fence organization archive against in-flight startup 2026-11-07 23:05:43 -06:00
jason.woltje ec143b2b9c Add organization archive and restore 2026-11-07 22:49:49 -06:00
jason.woltje 53e0f189a2 Merge quick setup review fixes (quick-setup-fix 653b3e2e7) 2026-11-07 22:37:13 -06:00
jason.woltje 009024ea45 Merge organization quick setup and delete (quick-setup 614559a94) 2026-11-07 22:37:13 -06:00
jason.woltje 614559a94c Fix organization deletion review findings 2026-11-07 22:36:33 -06:00
jason.woltje 047825460f Delete an organization after two confirmations
mosaic org delete ID --confirm ID --if-revision N, and POST /api/orgs/:id/delete,
mark the organization as deleting, stop its bus, move its files into
<dataRoot>/deleted/<ID>-<stamp>/ with a receipt, then drop it from the registry
and append deleted.log. A failure stops at its step and a rerun resumes. Bus
start refuses an organization that is being deleted. The settings page asks
twice: a panel that lists the effects, then typing the ID before Delete
permanently is enabled. Purge and remote revocation are follow-ups.
2026-11-07 22:22:05 -06:00
jason.woltje 3272fd9c6f Let the import scanner accept the plug-in loader's own module import
Quick Setup made organization code import the plug-in loader, which loads
packages/plugins/<kind>/<name>/index.mjs by a computed path. The scanner now
allows exactly that form in the loader file and still refuses it anywhere else.
2026-11-07 22:22:05 -06:00
jason.woltje 653b3e2e75 Stop quick setup on an unreadable reply and reset controls for a new draft
A 200 or 201 with a body that does not name the expected step as done now
stops at that step as an unknown outcome, with no further writes. Starting a
new draft re-enables the preset, thinking and run controls and clears the
risk checkbox. Browser regression covers both (Sol review P1, P2).
2026-11-07 22:03:45 -06:00
jason.woltje c09570d696 Add plugin-based organization quick setup 2026-11-07 21:43:03 -06:00
jason.woltje 632a4f16b9 Serve the dev fixture only to organizations that use it
The dev fixture guard was instance-wide: once a fixture record existed,
every organization's bus start refused when a provider credential was in
the environment. One instance could not hold a fixture organization and a
real-model organization side by side.

Bus start now reads the organization file. An organization that does not
route a launch to the fixture launches as configured. A fixture
organization's launch environment drops provider credentials before the
guard runs.
2026-11-07 21:22:13 -06:00
jason.woltje bc728e5927 Keep organization setup writable in organization mode
Settings marked every page except appearance and business read only when
send was off. Organization mode always turns send off, so the read-only
style hid New organization and Save organization. Organizations now stays
writable when the server allows org setup. The browser test asserts both
buttons are visible, not only clickable.
2026-11-07 20:47:48 -06:00
jason.woltje 2969d2a014 Preserve writer busy refusal on registry contention 2026-11-07 15:28:50 -06:00
jason.woltje f10562dba6 Lock legacy writers before registry initialization 2026-11-07 15:26:19 -06:00
jason.woltje 09c2e8f1f9 Record document digest when activating an organization 2026-11-07 15:22:40 -06:00
jason.woltje d759df57d2 Register organizations fix round session 2026-11-07 15:12:44 -06:00
jason.woltje 10516313e9 Open newly created organizations in their own context 2026-11-07 15:11:43 -06:00
jason.woltje a55310559b Honor broker document holds across service writes 2026-11-07 15:00:09 -06:00
jason.woltje cb79be0440 Populate scoped repository picker and exercise browser setup 2026-11-07 14:56:49 -06:00
jason.woltje c1f9220662 Verify scoped Console and concurrent broker writes 2026-11-07 14:56:16 -06:00
jason.woltje e342af7697 Coordinate organization document writers 2026-11-07 14:47:27 -06:00
jason.woltje fa2a61a0e7 Enable scoped chat project creation 2026-11-07 14:47:12 -06:00
jason.woltje 84a088cfec Preserve organization route and guard draft navigation 2026-11-07 14:43:19 -06:00
jason.woltje 381b7835f6 Keep organization admin requests global 2026-11-07 14:41:52 -06:00
jason.woltje f1ce9a98d3 Read notifier from selected organization root 2026-11-07 14:40:18 -06:00
jason.woltje 8f800c1361 Verify shared proxy and organization integration 2026-11-07 13:50:39 -06:00
jason.woltje 62261ca1fc Update tester fixtures for organization transport 2026-11-07 13:18:34 -06:00
jason.woltje ea46b21a5d Update pinned copy fixture for organization buses 2026-11-07 13:17:09 -06:00
jason.woltje 6feb8a68bb Update two-instance bus fixture for organizations 2026-11-07 13:15:52 -06:00
jason.woltje c40a8f0d6c Route owner decisions to selected organization 2026-11-07 13:14:39 -06:00
jason.woltje dce084f8eb Expose organization setup availability 2026-11-07 13:10:19 -06:00
jason.woltje 30d5372c0f Protect organization setup drafts on switch 2026-11-07 13:07:43 -06:00
jason.woltje 8dd9b37fe0 Expose organization boot status 2026-11-07 13:07:43 -06:00
jason.woltje f6259e02c9 Keep notifier reads outside CLI module graph 2026-11-07 13:07:43 -06:00
jason.woltje c5db52b6cc Read E2E notifier from organization root 2026-11-07 12:56:06 -06:00
jason.woltje d5d00e28df Use recorded fixture setup in organizations E2E 2026-11-07 12:54:54 -06:00
jason.woltje f819c55302 Use organization roots in channel proof fixtures 2026-11-07 12:49:24 -06:00
jason.woltje 43b911dd94 Show the recorded notifications choice during setup 2026-11-07 12:47:20 -06:00
jason.woltje 9750609f4c Restore scoped dev-owner launch checks 2026-11-07 12:45:13 -06:00
jason.woltje af9e367719 Keep fixture harness selection in its plugin and run pi offline 2026-11-07 12:39:33 -06:00
jason.woltje 934cc08e77 Register theme and organization setup routes 2026-11-07 12:37:47 -06:00
jason.woltje ed081626b5 Bind organization identity into bus boot 2026-11-07 12:36:27 -06:00
jason.woltje 0e0255de0d Prepare fixture organizations with explicit notifications choice 2026-11-07 12:35:31 -06:00
jason.woltje af6fad2fbd Merge commit '67c62de9b' into ui-org
# Conflicts:
#	packages/webui/src/public/bus.js
#	packages/webui/src/serve.mjs
2026-11-07 12:33:39 -06:00
jason.woltje be032405d1 S5 Console multi-org (round 3 candidate) 2026-11-07 12:33:02 -06:00
jason.woltje 7583e03a39 Merge commit '487a2e7fa' into ui-org
# Conflicts:
#	scripts/mosaic
2026-11-07 12:32:10 -06:00
jason.woltje 3ecdf4d75e Merge commit '7fdb48827' into ui-org 2026-11-07 12:31:52 -06:00
jason.woltje 12997c78c8 Merge commit 'b52adc841' into ui-org
# Conflicts:
#	packages/cli/src/cli.mjs
#	scripts/mosaic
2026-11-07 12:31:16 -06:00
jason.woltje 4b8bbc06a0 Merge commit '2d8522887' into ui-org 2026-11-07 12:29:14 -06:00
jason.woltje d9d1af2e0f Merge commit '24e240d44' into ui-org
# Conflicts:
#	scripts/mosaic
2026-11-07 12:28:36 -06:00
jason.woltje 7fdb488270 Themes as installed plugins, chosen per organization (S4, plan C5)
Add a reviewed `theme` plugin kind (interfaceVersion 1, export `theme`, switch
"list"). A module returns data only: a displayName and a flat map of colour
custom properties for light, dim and dark. checkTheme validates names and
#rrggbb colours strictly and checks each text/background pair for contrast; a
bad module is refused and logged, never served.

The ten shipped palettes become the first ten modules under
packages/plugins/theme/<name>/. Modules are found by directory scan
(loader.discoverModules, loader.loadInstalled), not by a list in the Console.
loadModule and createLoader are unchanged.

The organization file takes `appearance: { theme, mode }`, validated in
packages/business/src/appearance.mjs; a missing appearance is valid.
GET /api/themes (themes-http.mjs) serves the installed, valid themes.

Browser: the Settings picker lists /api/themes. Precedence is a per-organization
browser override, then the organization's saved theme, then the built-in default
(Harbor, dark). Clearing the override restores the organization's choice. An
uninstalled or invalid saved theme renders the default with a visible warning
and keeps the saved value. A legacy global browser preference is not copied to
every organization. The organization is named in text, so colour is never the
only cue. A plain load writes no appearance record. The default look is
unchanged: computed styles match the base commit element for element.
2026-11-07 12:18:43 -06:00
jason.woltje 67c62de9ba Register the S6 organizations WebUI session 2026-11-07 11:57:46 -06:00
jason.woltje 905cea7576 Show bus host refusals and keep setup choices across retries
A failed bus start now carries the host's own refusal line, read from host.log; repository paths in messages are shown relative and URLs are kept. Setup cards keep the chosen mode, remote and risk box across a re-render, and a client-side refusal is shown on the card.
2026-11-07 11:57:11 -06:00
jason.woltje b52adc8411 Add the organizations CLI E2E on a scratch instance with a fixture worker turn 2026-11-07 11:47:07 -06:00
jason.woltje 2d8522887c Protect shared proxy organization keys 2026-11-07 11:42:56 -06:00
jason.woltje 7b542a3f06 Merge branch 'org/s5' into org/s6 2026-11-07 11:36:05 -06:00
jason.woltje 9df88fb251 Add Settings > Organizations page
Form and live JSON preview over one document, field errors with their
JSON path, the save receipt, and setup cards that name the CLI verb each
step equals. serve.mjs mounts org-admin.mjs with the existing origin and
CSRF fencing.
2026-11-07 11:36:01 -06:00
jason.woltje 2259ac3dd8 Add organization create and setup handlers for the WebUI
org-admin.mjs serves the S6 routes over the C2 organization service:
template, validation with JSON paths, create, edit with if-revision, and
guided setup steps that read their receipt back before acting and never
reinitialize. Receipts reach the browser with host paths relative to the
data and config directories.
2026-11-07 11:36:01 -06:00
jason.woltje 487a2e7fa0 Merge branch 'org/s1' into org/s5
# Conflicts:
#	scripts/mosaic
2026-11-07 11:32:05 -06:00
jason.woltje 56de6fbfba Route mosaic dev and run evidence, and pass the fixture to a dev-owner bus 2026-11-07 11:30:44 -06:00
jason.woltje 5dcc5bf77c Add mosaic run evidence
Read-only: finds a thread's launch and end records in the organization launch log and checks each run's session.json, bundle manifest digests and turn stderr files.
2026-11-07 11:30:44 -06:00
jason.woltje 3723cc56ec Add mosaic dev fixture init, status and stop
Starts the fixture provider's loopback endpoint for a named dev instance and records it under the data root. Refuses the default instance, a configured proxy, provider credentials in the environment, a non-loopback bind and a second fixture. fixtureLauncherOptions hands the endpoint to the launcher as its session proxy context.
2026-11-07 11:30:44 -06:00
jason.woltje 24e240d44e Scope buses to organization roots 2026-11-07 11:29:13 -06:00
jason.woltje 9758c732cd Merge branch 'org/s0' into org/s3 2026-11-07 11:21:39 -06:00
jason.woltje 0d0a9fbdc9 Add deterministic fixture model provider and loopback endpoint 2026-11-07 11:21:35 -06:00
jason.woltje 3456d93af0 Implement organization setup commands 2026-11-07 11:17:31 -06:00
jason.woltje abaec7dcc5 Add standalone proxy lock prototype 2026-11-07 11:16:00 -06:00
jason.woltje ecc426819e Disable shared proxy usage drains by default 2026-11-07 11:15:55 -06:00
jason.woltje d4400050d0 Add organization registry and service interfaces 2026-11-07 11:08:29 -06:00
jason.woltje c8d6d12fe3 Restore Vars spacing and name the Business file card in Settings 2026-11-07 10:38:09 -06:00
jason.woltje fb190721bf Move Business into Settings 2026-11-07 10:24:36 -06:00
jason.woltje d00eb351e6 fix(webui): keep long breadcrumbs, tabs and speaker names inside narrow layouts
S15 review fixes on top of a584c08ad.

- Breadcrumb: from 700px the project name stops at 40% and a long current
  label ellipsizes after it, so a long Conversation title no longer pushes
  the page wider than the window. Under 700px the current label is hidden
  and the project name shrinks as before.
- Details keeps its size beside a long Conversation title instead of
  squeezing to a vertical label (also present on 7c664fac9).
- The active tab is brought fully into the strip after counts load, after
  a refresh repaint and when a tab receives keyboard focus.
- Long speaker names wrap between words at narrow zoom; the time stays
  whole.

Tests: three new S15 browser tests, red on a584c08ad sources and green
here. S10-S15 each alone 3 times green; webui 952/0/0.
2026-11-07 09:13:12 -06:00
jason.woltje a584c08ade fix(webui): polish room and conversation layouts 2026-11-07 07:54:28 -06:00
jason.woltje 7c664fac91 fix(webui): finish scroll classification when Tab lands mid-settle
A Tab outside the transcript while a stale matching scroll was still being
classified deleted the settling pin, which cancelled the classification.
Tab moves no transcript, so no later scroll event decided it, follow stayed
on and the next growth pulled the reader to the tail.

The classification now has its own mark. Reader input drops only the pin
provenance that a repaint reads; the two-frame check still runs.

Room and Conversation 700x700 cases: bf6e92232 fails 2/2 in 3 of 3 runs
(5017 to 5267, 5135 to 5385); this commit passes 4/4 in 3 of 3.
2026-11-07 07:27:10 -06:00
jason.woltje bf6e92232e Preserve transcript pin through room repaint 2026-11-07 06:57:26 -06:00
jason.woltje 94c9f04f5b fix(webui): keep Room details at the right edge in short windows
In the short desktop Room layout the section title sat in the first grid column, so Room details ended mid-row. The title now spans the row and the members line sits right-aligned before the button, capped so long names ellipsize.
2026-11-07 06:37:13 -06:00
jason.woltje 7f31368a1e fix(webui): stop truncating short member chips in the Room composer
The chip's percentage max-width resolved against a content-sized picker
column, so even a single short name was cut off. Cap chips at 16em and
test a one-member Room for whole chip names.
2026-11-07 05:53:03 -06:00
jason.woltje d13afaac3d fix(webui): give the Room message field its column in short windows
fit-content() is not valid inside minmax(), so the browser dropped the
composer column rule and the field fell into a narrow implicit column.
Use fit-content(50%) for the picker and test the field's share of the row.
2026-11-07 05:42:13 -06:00
jason.woltje 2960e8e9a9 fix(webui): place every Room row in the short layout and bound long member lists
Review fixes for S13: the closed-room notice and library line sit above the
messages, the members summary keeps one line (full names in Room details),
the recipient picker is bounded with truncated chips, and the icon-only Room
details button keeps a 44px target. Adds long-member and closed-room tests at
1440x700 and 700x541.
2026-11-07 05:28:44 -06:00
jason.woltje e27263f455 fix(webui): keep the Room message box in view in short desktop windows
At 100% zoom in windows 541-760px tall the Room page was 60px taller than
the window and the Send to room button started cut off (S10's 160px log
minimum). A short-window Room grid moves the members line beside the
section title and puts the composer on one row; under 650px the header
compacts. Keyed on the CSS viewport only, so 2x displays get the same
layout; page-flow layouts are unchanged.
2026-11-07 04:59:16 -06:00
jason.woltje 0e34b30770 fix(webui): keep a short scroll up off the tail in zoomed transcripts
An upward reader scroll that left the newest message's end at the
viewport bottom still read as at tail, so new content pulled the reader
down past the composer pin. The message-end tail position now counts
only on a downward scroll. A channel-disabled Conversation pins the
newest message instead of a composer that cannot send. S11 and S12
visibility checks require the whole newest message whenever it fits
above the composer.
2026-11-07 04:22:46 -06:00
jason.woltje bb4c176e20 fix(webui): keep the composer in view in zoomed transcripts
In page flow (200% zoom, short or narrow windows) the transcript now
follows with the composer's bottom at the viewport bottom and the newest
message above it. Absent or disabled composers keep the S11 pin. Narrow
Room composers stack in one column. S11's visibility check accepts the
newest message's end when it is taller than half the viewport.
2026-11-07 04:06:17 -06:00
jason.woltje 62ba124f2c fix(webui): keep keyboard focus and reveals on screen in zoomed transcripts
In page flow (200% zoom), the S11 follow logic re-pinned the document on
any scroll without wheel, touch, pointer or scroll-key input, so Tab,
Shift+Tab, focus(), find-style reveals and dialog return focus were pulled
back to the tail with focus thousands of pixels off screen.

Re-pin only a downward scroll with no reader input while following (scroll
anchoring as content grows). Count Tab as reader input, expire the reader
flag after two frames, and resume following when a transcript is opened.
2026-11-07 03:51:59 -06:00
jason.woltje 388fa5d0ca fix(webui): follow transcripts in page flow 2026-11-07 03:21:51 -06:00
jason.woltje 2f4470eed2 fix(webui): keep short 100% windows on the pinned transcript layout
S10 review P1: the max-height:700px page-flow rule moved the composer
off screen on 1440x700 and 1366x650 windows at 100%. Page flow now
applies only at max-height:540px (200% tab zoom on desktop screens) or
below 320px wide. S10 review P2: the moved Room details control keeps
its 44px target.
2026-11-07 03:02:02 -06:00
jason.woltje 66d14c00da fix(webui): keep zoomed transcripts and titles readable 2026-11-07 02:44:37 -06:00
jason.woltje 8832267574 fix(webui): wrap narrow conversation status and keep Overview geometry off Requirements
At 200% zoom on a 390px viewport the compact conversation row held a decision status on one line, so Needs your input pushed the page 12px sideways. The status now takes its own line under the icon and text and wraps. The Overview column margin and split were scoped to Overview, so Requirements keeps its base geometry. Tests cover a real decision status and the Requirements position.
2026-11-07 02:07:29 -06:00
jason.woltje 8aef086838 Fix narrow project reflow and overview action alignment 2026-11-07 01:47:35 -06:00
jason.woltje 5db88945d2 Add project header action icons 2026-11-07 01:08:19 -06:00
jason.woltje 5d2e96bbcd fix(webui): keep a followed transcript at the newest message when the log resizes
The conversation and room logs pinned to the bottom only on mutation, so a
log that settled shorter after paint stopped 23px short on every load.
Re-pin on resize too, and cover the room log.
2026-11-07 00:54:53 -06:00
jason.woltje d0b4a1b082 docs: drop the S7 builder's session line from the uidev integration 2026-11-06 23:56:06 -06:00
jason.woltje d9500b72cc Merge registry-keep (488431976) into ui-int-s6 2026-11-06 23:56:06 -06:00
jason.woltje 92beda28d5 Merge S7 fidelity and fix round (b74c8c88c) into ui-int-s6 2026-11-06 23:56:06 -06:00
jason.woltje b74c8c88c1 Fix S7 claim liveness and project layout fidelity 2026-11-06 23:55:17 -06:00
jason.woltje 31de69f2f6 Improve conversation fidelity and claim labels 2026-11-06 23:31:20 -06:00
jason.woltje 488431976e Keep launch recovery evidence when broker end is unconfirmed 2026-11-06 23:12:02 -06:00
jason.woltje 3081b587e9 Merge commit '9649c46ee' into ui-int-s6
# Conflicts:
#	docs/SESSIONS.md
#	packages/webui/src/public/shell.css
#	packages/webui/tests/dev-local-owner.test.mjs
2026-11-06 21:30:23 -06:00
jason.woltje e4e8cd232b Merge commit '617d7e4a7' into ui-int-s6
# Conflicts:
#	docs/SESSIONS.md
2026-11-06 21:30:16 -06:00
jason.woltje 56de15a405 Merge commit 'e1abcc9ed' into ui-int-s6 2026-11-06 21:30:12 -06:00
jason.woltje 9649c46ee0 Keep project read failures visible 2026-11-06 21:29:20 -06:00
jason.woltje 617d7e4a7a Keep Routing when a route edit lands during a harness save
A harness or catalogue save clears routesView. A route draft made while
the save was in flight kept routesSection from adopting the fresh read,
so the next render dereferenced a null view and Routing disappeared
until a page reload. Adopt the fresh view whenever none is held; the
draft keeps its cells.

S6a review P2 1.
2026-11-06 21:25:32 -06:00
jason.woltje 35f687f9bf fix(webui): distinguish empty project states 2026-11-06 21:08:56 -06:00
jason.woltje 8a6a641b42 Enable harness selection in routing 2026-11-06 21:08:13 -06:00
jason.woltje e1abcc9ed8 Guard catalogue identity changes at existing keys 2026-11-06 20:58:25 -06:00
jason.woltje 8d8c479676 fix: guard legacy provider materialization 2026-11-06 20:38:46 -06:00
jason.woltje 43686b1451 merge: ui slice 5 polish and review fix into ui-dev 2026-11-06 20:07:16 -06:00
jason.woltje c2f655ff90 fix(webui): read again after a failed history read (ui slice 5 review) 2026-11-06 20:02:54 -06:00
jason.woltje 6e6eb16271 merge: model provider plugins (#1858, #1855) into ui-dev 2026-11-06 20:02:05 -06:00
jason.woltje b48396b68e fix(plugins): provider review fixes, admission binding, alias-safe models, no provider names in core (#1858) 2026-11-06 19:52:57 -06:00
jason.woltje 5915c99809 style(webui): polish round from the live audit (ui slice 5) 2026-11-06 19:33:00 -06:00
jason.woltje ccccac2ade feat(plugins): model providers as plugin modules, cliproxy catalogue on Routing (#1858) 2026-11-06 19:11:13 -06:00
jason.woltje 1d03ef5fbb fix(webui): slice 4 review fixes, conversation layout and mobile density 2026-11-06 19:05:12 -06:00
jason.woltje 08b7db413b fix(harness): move harness-specific output handling into the harness plugins (#1855) 2026-11-06 18:27:58 -06:00
jason.woltje f713bb7b81 style(webui): polish round for project, settings and agent pages (ui slice 4) 2026-11-06 18:26:59 -06:00
jason.woltje ad9b2dee42 fix(webui): keep model proxy on the root hash route 2026-11-06 17:08:15 -06:00
jason.woltje 03072cc08b fix(plugins): set documented output limit for glm-5.3-flash (#1852)
Register the documented 65536-token default output limit and retain the existing 128000-token context window for glm-5.3-flash. Other model registrations are unchanged.

Pi's isRecoverableLength (pi-ai dist/utils/overflow.js:163), used by agent-session.js:1659, now treats a length stop below 65536 output tokens as recoverable, including context-clamped requests or a lower provider cap. Pi then compacts and retries. It allows one recovery in a row: the flag resets on a new user message or a successful assistant message that is not a length stop, so tool rounds can re-arm it within one prompt.

The recovery happens inside one runner attempt. The turn marker reflects the final agent_end, and the failed and retried messages still count in that message's usage delta, so three runner attempts need not mean three provider calls.

The 128000 context and 16384 reserve are unchanged, so threshold compaction still starts above 111616 tokens. Output clamping now starts above about 58368 estimated input tokens instead of 107520. No new local refusal.

This commit can be dropped without affecting Part A.
2026-11-06 16:49:31 -06:00
jason.woltje 1d8f1713f8 docs: record #1852 review, fix round and gate 2026-11-06 16:49:31 -06:00
jason.woltje fcc0f01aa0 fix(webui): project pages review fixes (ui slice 3) 2026-11-06 16:34:20 -06:00
jason.woltje 3e03e123fe fix(harness): check processing and close usage on every length continuation path (#1852)
Review round fixes for the length continuation in Part A.

Before each continuation the runner now reads processing.status as well as
state. A halted, invalid or unreadable processing state starts no further
attempt; the message is left unread with no reply and no failure record, as
for a halt during any turn.

A failed state or processing read keeps the empty length classification, so
the sender gets no RESULT. The chief notice and the trail event name how many
attempts hit the limit.

usage.end for the message id now runs in a finally block, so it closes once
even when an attempt throws.

The library citation retry no longer starts continuations. They would run
outside the message's usage bracket, and the retry's own bracket is refused
(#1853). An empty length retry fails visibly instead, with no fixed RESULT.

Tests cover a halt between attempts, a failed continuation check on the plain
and library paths, a throwing attempt, a library continuation that succeeds,
and the retry path.
2026-11-06 16:34:04 -06:00
jason.woltje 3c689efa31 fix(harness): treat a length stop with no answer as a failed turn (#1852)
Pi could spend its output limit on thinking and the runner relayed an empty answer as a result. Preserve final stop metadata and allow two bounded continuations, accounting for the whole message in one transcript usage delta.

Record exhaustion on the message trail and notify the chief without an automatic reply cycle. Mount the shared notice prefix in the sandbox.
2026-11-06 16:12:30 -06:00
jason.woltje 71bc3a4294 merge: project pages (ui slice 3) into ui-dev
Merges ui-dev-projects (c907e4d0d) onto the Settings slice (65df511f3).
Conflicts resolved by keeping both sides: the slice 3 token block in
tokens.css, and in bus.js the Settings display redirect plus the
Projects route test. Whole webui suite on Node 26: 729 passed, 0 failed.
2026-11-06 15:35:05 -06:00
jason.woltje c907e4d0d0 feat(webui): project pages redesign (ui slice 3) 2026-11-06 15:29:46 -06:00
jason.woltje 65df511f38 fix(webui): settings review fixes (ui slice 2) 2026-11-06 15:25:14 -06:00
jason.woltje 3598839e63 docs: record #1847/#1848 fix row build, review and gate 2026-11-06 14:44:57 -06:00
jason.woltje a06d792ff9 feat(webui): settings page redesign (ui slice 2) 2026-11-06 14:43:04 -06:00
jason.woltje c04529c7bb fix(bus): defer halted startup recovery; latest launch only gates notices (#1848)
Review round 1 on 4048e0f60 found two defects.

Startup recovery ran unconditionally. A continuation retry now refuses
while processing is halted, so a broker restart with a waiting
continuation failed with processing-halted. Startup recovery now waits
while processing is halted or invalid, as the periodic pass does;
processing.resume schedules it again.

#continuationStarting matched any launch without an end, so an older
launch whose end was lost (#1851) hid every later run's end. Only the
target's latest lifecycle event counts now.

Both regression tests fail on 4048e0f60 and pass here.
2026-11-06 14:26:12 -06:00
jason.woltje 4048e0f60c fix(bus,harness): stop INFO echo replies and duplicate continuation notices (#1847, #1848)
Expose the parent message class on agent delivery so the runner can stop automatic replies to answers while preserving requests and ordinary relays. Scope continuation retry versions to the target, suppress notices during processing halts and unclaimed launches, and clarify the instance-running refusal.

The bus suite excludes project-files.test.mjs because its 1 MiB socket test timed out on both the candidate and the untouched 1186cf7b1 base.
2026-11-06 14:14:53 -06:00
jason.woltje 1186cf7b1e fix(webui): record request misses only from the request read (#797)
The chat.js fetch wrapper recorded a request-not-found from /api/requests/<id>/workspace as a missing request and then served a synthetic 404 to the request panel's rereads, failing 11 request-browser tests. Only the request's own read records a miss now. Adds a regression test that fails on 9d3eb9e11, and bounds the r3 held-404 test's check on its refresh reads, which raced Files' cached repaint under load.
2026-11-06 12:34:00 -06:00
jason.woltje 700259aa38 feat: allow agent launches in dev local owner mode 2026-11-06 12:16:36 -06:00
jason.woltje 545f77dd86 feat(webui): dark/light switch in the navbar (ui slice 1) 2026-11-06 12:12:25 -06:00
jason.woltje f12021c6d7 fix: dev local owner mode requires the named instance's data-root marker 2026-11-06 12:10:11 -06:00
jason.woltje 4638c07fcc style(webui): council fixes for type, surfaces, targets and focus (ui slice 1) 2026-11-06 11:52:47 -06:00
jason.woltje 7a67db9c41 style(webui): match the dashboard reference palette, type and header (ui slice 1) 2026-11-06 11:28:26 -06:00
jason.woltje b73e55b794 feat: dev-only local owner mode for named loopback instances 2026-11-06 11:23:36 -06:00
jason.woltje 9d3eb9e115 fix(webui): fence in-flight request misses on an observed state (#797)
A mosaic-request-state for an id now advances that id's own generation on every thread retaining it, so a 404 still in flight from before the observation cannot record the miss afterwards. Generations are pruned with the misses to retained rows. Adds a held-404 regression that fails on 16384bb12.
2026-11-06 11:15:15 -06:00
jason.woltje 67c1bcd891 feat(webui): dashboard shell, navbar, sidebar and logo (ui slice 1) 2026-11-06 11:14:54 -06:00
jason.woltje 16384bb120 fix(webui): retry missing requests on thread open and observed state (#797) 2026-11-06 11:06:04 -06:00
jason.woltje 97d670da94 fix(bus): name task evidence gaps and stop request polling (#797) 2026-11-06 10:49:33 -06:00
jason.woltje 6b84fe136e fix(webui): order same-key files settles by commit, not start 2026-11-06 09:35:25 -06:00
jason.woltje 86383a9d1e fix(webui): retain files content across redraws 2026-11-06 09:19:17 -06:00
jason.woltje 4cc3d05b3a Merge fix/write-record-object (a7417a1f2) into integrate/mvp3-ux-1106 2026-11-06 08:24:15 -06:00
jason.woltje 521b34bdd7 Merge feat/mvp3-project-archive-r9 (e75c33947) into integrate/mvp3-ux-1106 2026-11-06 07:50:10 -06:00
jason.woltje a7417a1f2e fix(project): accept JSON strings for records 2026-11-06 07:40:28 -06:00
jason.woltje c4dac3e6ee Merge fix/resume-chief (26f1218b9) into integrate/mvp3-ux-1106 2026-11-06 07:33:03 -06:00
jason.woltje e75c339470 fix(webui): withdraw request writes when a refresh resolves archived
A same-thread refresh kept the last resolved live controls until the session and history reads finished. When the fresh project lookup finds the project archived or the archive state unknown, withdraw the unguarded thread controls and remount the request panel fenced before those reads.
2026-11-06 07:15:31 -06:00
jason.woltje 26f1218b9c fix(bus): retry chief after processing resumes 2026-11-06 06:51:39 -06:00
jason.woltje 4c85d37842 Keep resolved archive state during thread refresh 2026-11-06 06:40:45 -06:00
jason.woltje 5bf07f35f0 fix(webui): retain archive state on failed refresh 2026-11-06 06:19:40 -06:00
jason.woltje 2511e79673 fix(webui): give archive controls the shared button treatment (#797) 2026-11-06 06:13:21 -06:00
jason.woltje 51892cf95a fix(webui): suppress writes on archived projects 2026-11-06 05:26:42 -06:00
jason.woltje 13f81354e8 Merge fix/ui797-supervisor-3t (cdf8dadc1) into integrate/mvp3-ux-1106
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
2026-11-06 05:03:52 -06:00
jason.woltje aa88548d64 Merge feat/ui797-stall-guard-int (8c568ea46) into integrate/mvp3-ux-1106
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
2026-11-06 05:03:42 -06:00
jason.woltje 8c3f48113f fix(webui): restrict writes when archive state is unknown 2026-11-06 04:48:14 -06:00
jason.woltje cdf8dadc1e fix: accept compatible supervisor store schemas (#797) 2026-11-06 04:10:29 -06:00
jason.woltje 37f38d8e10 docs: record archive integration verification 2026-11-06 04:02:29 -06:00
jason.woltje 8c568ea467 Merge integration head into #797 stall guard 2026-11-06 03:49:38 -06:00
jason.woltje 526d3e3b7d fix(webui): keep threads readable when archive lookup fails 2026-11-06 03:44:44 -06:00
jason.woltje 2cc938328c fix(webui): use integration routes for archived navigation 2026-11-06 03:41:23 -06:00
jason.woltje 6a655a5cbb Merge commit '5255e4e74171cb0f640a2fcd83c77806b5b6f3d6' into feat/mvp3-project-archive-r4
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
#	packages/bus/tests/request-continuation.test.mjs
#	packages/webui/src/public/chat.js
#	packages/webui/src/public/index.html
#	packages/webui/src/serve.mjs
#	packages/webui/tests/browser.test.mjs
2026-11-06 03:32:15 -06:00
jason.woltje 6b2c925b95 fix(harness): fail 1 on an unmapped child signal (#797)
A signal name with no number in os.constants.signals now exits 1 instead of a made-up 128. Known signals keep 128+n (SIGKILL 137, SIGTERM 143) and the content-free stderr receipt. Test covers all three through the real entrypoint.
2026-11-06 03:22:31 -06:00
jason.woltje a3f4551406 fix(harness): back off only running instances (#797) 2026-11-06 03:01:12 -06:00
jason.woltje 006381cfc4 fix(harness): retain model child signal receipts (#797) 2026-11-06 03:01:12 -06:00
jason.woltje 478df1cacd fix(harness): preserve adapter text output bytes (#797) 2026-11-06 03:01:12 -06:00
jason.woltje f23a998eed fix: close project archive admission races (#797) 2026-11-06 02:54:04 -06:00
jason.woltje 5255e4e741 Merge fix/mvp3-req-texts-797 (d9b65961f) into integrate/mvp3-ux-1106
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
#	packages/bus/tests/request-continuation.test.mjs
2026-11-06 02:50:32 -06:00
jason.woltje d9b65961fc fix(bus): hold every quote fallback to the room (#797)
The oversize and unresolved ask-PM notices were appended without
checking the room left by the lead, so a lead with 0-130 bytes left
pushed a 972-task request past exec capacity (E2BIG at 131,086).
quoteBlock now returns at most max(room, 0): the full notice, the
47-byte short line, or nothing. Tests sweep rooms -3..160 for both
steps and run a 972-task lifecycle that must still exec.

BUILD-LOG appends a correction to the r4 limits (40,774 / 88,234).
2026-11-06 02:21:19 -06:00
jason.woltje 3e754c96a0 fix(harness): back off refused launches (#797) 2026-11-06 02:05:32 -06:00
jason.woltje c7ddc7d31c fix(harness): mark first streamed model output (#797) 2026-11-06 02:02:43 -06:00
jason.woltje 1c33c342bc fix(archive): fence new execution and preserve archived reads (#797) 2026-11-06 01:51:08 -06:00
jason.woltje 5df42478b6 fix(cli): answer native dialogs in the tester browser (#797)
A click on the test Console's Resume button opened window.confirm and the
browser daemon hung: the mouse release, screenshots and the frame tree all
block until the dialog is answered, and ui-browser had no way to answer it.

A click that opens a dialog now returns {dialog:{type,message}}. The new
command `dialog accept|dismiss` answers it and records the type, message
and answer in the receipt. While a dialog is open other commands except
stop refuse (journaled), and no screenshot or URL check is attempted.
A real-Chromium confirm test covers accept, dismiss and the refusals.
2026-11-06 00:13:16 -06:00
jason.woltje 3e21d239b3 feat(bus): owner project archive and unarchive (#797)
Jason's owner report 2026-11-06: no way to archive or delete projects.
Archive is the reversible half; delete stays out of scope because project
and run records are write-once evidence.

- project.archive / project.unarchive: human-only (project.create's gate
  minus the tester carve-out; tester, user and agent refused). State is the
  latest row of a new append-only project_archives journal (3s->3t, additive;
  the rows are the audit trail), so projects itself never changes and nothing
  is deleted. Repeats refuse project-already-archived / project-not-archived.
- project.list hides archived projects until {includeArchived:true}, which
  flags each with archived:true; a user still lists granted projects only.
- While archived, thread.create, thread.send, room.open and room.send refuse
  project-archived; every existing record stays readable and unarchive
  restores everything.
- Console: new public/project-archive.js (+css) and project-archive-http.mjs
  routes (chatHTTP's write fence); chat.js gains two one-line hooks,
  index.html one script and one link tag; styling only through the shell's
  .project-archive-control / .project-archived-filter hooks.
- CLI: mosaic project archive|unarchive <name-or-id> and list --archived.
- stack-release COUNTED/LISTED gain project_archives (schema order); schema
  step-count pins and migration fixtures updated for 3t.

bus 1601/1601 (Node 24; Node 26 has the pre-existing project-file socket
stall), webui 582/582, plugins 132/132, cli 1191/1194 (two pre-existing
load flakes of the row253 C4 and land_work families; both pass isolated and
the base commit's full suite fails four of the same family).
2026-11-05 23:50:51 -06:00
jason.woltje 2297d532ea fix(bus): fit the whole requirement quote to the worker request (#797) 2026-11-05 23:37:55 -06:00
jason.woltje 5e36a395b4 Merge feat/ui797-support (22e3d2188) into integrate/mvp3-ux-1106
Support r6 APPROVE (independent Codex review). Gate on Node 26.12.1:
budget-supervisor 97/0, isolated-identity 8/0, ui-browser 13/0,
request-lifecycle-live 2/0. Append-only BUILD-LOG.md and docs/SESSIONS.md
conflicts keep both sides.
2026-11-05 23:36:40 -06:00
jason.woltje 3c55f5b25d fix(webui): tolerate a room whose project is not in the loaded list (#797)
The room context event read project.repo without a guard, so a room
outside the loaded project list threw and failed the room load.

Gate: full WebUI with the editor build required, 633/0 on the second
run. The first run failed I1 inbox in decide-browser.test.mjs once;
three isolated runs each on this tree and on 5a346825b passed 9/0.
2026-11-05 23:24:32 -06:00
jason.woltje 22e3d21889 fix(cli): require owner-named pin targets and fixed serve paths (#797)
A pin corroborates a ledger target only when its binding names its own
campaign and the owner campaign. serveBudget freezes the startup campaign
and ledger paths and retains the ledger with its startup target; a running
service accepts its exactly unchanged startup ledger. Without retained
evidence an owner business/config retarget now refuses as ambiguous.
2026-11-05 23:22:32 -06:00
jason.woltje 1eb3940874 Merge fix/w15-integration-r1 (b64773956) into integrate/mvp3-ux-1106 2026-11-05 23:18:51 -06:00
jason.woltje b64773956e fix(webui): keep remote draft, room context and drawer focus across polls (#797) 2026-11-05 23:18:04 -06:00
jason.woltje b3ee8a2bcd fix(bus): bound requirement quote by encoded bytes (#797)
The r2 review found that counting raw bytes let JSON and control
escaping grow one quote six-fold, so the runner's MOSAIC_REQUEST
exceeded the exec string limit (E2BIG). The continuation renderer now
applies the 16384-byte budget to encoded title and text bytes, cutting
on whole code points. Adds an execution-boundary regression.
2026-11-05 23:04:31 -06:00
jason.woltje 85e1ec5d15 fix(cli): refuse conflicting ledger targets before halt (#797)
A valid owner pin and a v2 ledger that disagreed on target still halted
the ledger's binding, so a business-only edit or a foreign ledger could
redirect the stop. The ledger target now needs owner corroboration;
otherwise the owner-verified pin replaces it, or the check refuses.
2026-11-05 22:55:43 -06:00
jason.woltje 5a346825b7 Merge integrate/w15-slices (70443895e) into integrate/mvp3-ux-1106 2026-11-05 22:40:07 -06:00
jason.woltje 27315d4961 Merge fix/mvp3-coder-stall-797 (8d27a82ea) into integrate/mvp3-ux-1106
Gate on Node 26.12.1, 31 files: 422 pass, 1 fail. The failure is the
row 66 unslop timing flake in packages/harness/tests/claude-session.test.mjs,
which fails about 1 run in 3 on the base too; the file rerun passed 14/0.
Append-only BUILD-LOG.md and docs/SESSIONS.md conflicts keep both sides.
2026-11-05 22:39:56 -06:00
jason.woltje 70443895e4 feat(webui): preserve drawer session context (#797) 2026-11-05 22:35:41 -06:00
jason.woltje fce831c053 fix(cli): confine browser mounts and keep halt on binding damage (#797) 2026-11-05 22:34:36 -06:00
jason.woltje 6eee6b64ce feat(webui): connect W15 slices (#797) 2026-11-05 22:29:12 -06:00
jason.woltje 50a8d1a43a fix(bus): contain requirement quotes and refresh continuation state (#797) 2026-11-05 22:10:51 -06:00
jason.woltje 8d27a82ea7 fix(harness): acknowledge Claude helper completion after stdout drains (#797) 2026-11-05 22:10:37 -06:00
jason.woltje 89b0de91f3 Merge feat/ux816-w15-project-remote-r2 (18df6e049) into integrate/w15-slices
# Conflicts:
#	packages/bus/src/broker.mjs
#	packages/bus/tests/console-decide.test.mjs
#	packages/bus/tests/request-console.test.mjs
#	packages/webui/src/cli.mjs
#	packages/webui/src/serve.mjs
2026-11-05 22:08:48 -06:00
jason.woltje 59602c9105 Merge feat/ux816-w15-files-r2 (551acdf3d) into integrate/w15-slices 2026-11-05 22:08:28 -06:00
jason.woltje 07ea16d390 Merge feat/ux816-w15-inbox (7a2dc5bad) into integrate/w15-slices
# Conflicts:
#	packages/webui/src/public/bus.js
2026-11-05 22:08:28 -06:00
jason.woltje 307dad8713 Merge feat/ux816-w15-requests (ea0b67654) into integrate/w15-slices 2026-11-05 22:08:13 -06:00
jason.woltje 316004ee4c Merge feat/ux816-w15-conversations (cecfcdd20) into integrate/w15-slices 2026-11-05 22:08:13 -06:00
jason.woltje d51bc4893a Merge feat/ux816-w15-shell-r3 (3fd82b084) into integrate/w15-slices 2026-11-05 22:08:06 -06:00
jason.woltje 18df6e049c feat(webui): enable owner project remote save (#797) 2026-11-05 22:04:07 -06:00
jason.woltje 905d313cdf fix(cli): isolate browser network and halt on ledger loss (#797) 2026-11-05 21:52:54 -06:00
jason.woltje 551acdf3d3 feat(webui): list verified draft workspace names (#797) 2026-11-05 21:52:26 -06:00
jason.woltje 3fd82b0840 feat(webui): W15 shell rail, drawer and Projects nav (#797) 2026-11-05 21:50:48 -06:00
jason.woltje 6c123ce671 fix(harness): report Claude helper boundary failures (#797) 2026-11-05 21:44:24 -06:00
jason.woltje aca8ce0599 Merge fix/mvp3-planning-797 (c47a67de8) into integrate/mvp3-ux-1106
Gate on the merged tree: business 307/0, bus 1596 pass with one
pre-existing hang (project-files.test.mjs, same on relay base 2f1cef4c1),
harness 243/1 on the row 66 unslop test, which also fails 1 in 3 runs
on the relay base (timing flake, pre-existing).
2026-11-05 21:44:04 -06:00
jason.woltje 92fe6c9303 feat(webui): add guarded project remote writer (#797) 2026-11-05 21:25:47 -06:00
jason.woltje ea0b676541 feat(webui): update request presentation (#797) 2026-11-05 21:23:14 -06:00
jason.woltje 19e6cb0f1e fix(cli): address ui797 support review findings (#797) 2026-11-05 21:21:50 -06:00
jason.woltje cecfcdd201 feat(webui): add project conversation surfaces (#797) 2026-11-05 21:18:46 -06:00
jason.woltje 6e5cf26e7a fix(bus): quote approved requirement texts into coder/reviewer continuations (#797)
D3: coder/reviewer sandboxes cannot read the product records repo and
list_requirements stays PM/CTO-only (row 90, #1584). The bus now quotes
the task-cited approved requirement texts (exact text, id, title, citing
task refs, snapshot id, product key, records commit) into the
implementation and review continuations, resolved host-side through the
same catalog resolver behind the scoped requirements verb, never from
model input. Quotes are bounded at 16384 bytes and framed as quoted
data; an unresolvable or revoked snapshot degrades to an ask-PM line
without blocking the task. No role, tool or schema widened; one duty
note each in roles/coder.md and roles/reviewer.md.
2026-11-05 21:18:26 -06:00
jason.woltje 8094901bce fix(harness): preserve failed Claude result diagnostics (#797) 2026-11-05 21:17:18 -06:00
jason.woltje 191061a9e7 feat(webui): add W15 shell candidate (#797) 2026-11-05 21:15:36 -06:00
jason.woltje 7a2dc5bad1 feat(webui): show decision origin and receipt evidence (#797) 2026-11-05 21:13:45 -06:00
jason.woltje 59801efe4d feat(webui): add safe reviewed file text view (#797) 2026-11-05 21:12:47 -06:00
jason.woltje bd8340b9b8 feat(cli): add isolated ui797 acceptance support (#797) 2026-11-05 20:53:44 -06:00
jason.woltje c47a67de8d fix(bus): keep the coverage refusal when its notice fails (#797)
Review M1 on 62bece4a9: a failed notice insert or route threw past
#refused, replacing request-evidence-required and skipping its audit.
The notice is now best-effort; its transaction still rolls back whole.
Tests: insert and route failures keep code, audit and lifecycle; retry
publishes once. Both red on 62bece4a9.
2026-11-05 20:47:27 -06:00
jason.woltje 1bd0077391 fix(harness): mark model activity before final answers (#797) 2026-11-05 20:45:03 -06:00
jason.woltje 2f1cef4c1d fix(harness): expose trusted assistant coordinator context (#797) 2026-11-05 20:39:48 -06:00
jason.woltje 62bece4a9d fix(lifecycle): explain incomplete planning coverage (#797) 2026-11-05 20:28:16 -06:00
jason.woltje 29a3cc6323 Fix MVP intake relay receipts and role guidance 2026-11-05 20:17:23 -06:00
mos 05fdfef9ea feat(skills): add reusable UX improvement workflow (#1845)
Capture task-grounded research, preserved evidence, wireframes before graphical
concepts and independent assessments with separate author/evaluator contexts.
Document frozen-method reuse, scoped feedback, null/zero arithmetic, coverage
and stopping rules without requiring a particular model, harness or subscription.

Personally authored by a scoped Codex agent. Fresh independent source and
forward-use review APPROVE; 14 package-focused checks pass. Retain the synthetic,
single-model limits and separate installation/activation authority.

Package manifest: b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140
2026-11-04 21:27:44 -06:00
mos bd8120318b fix(lifecycle): resume requests when evidence arrives (#1839)
Wake the existing PM when validated evidence arrives after its first notice,
including overlapping recovery across retained request history. Preserve durable
deduplication and current authority checks.

Put the exact verification record and verdict contract in the first scoped
review request. Carry linked owner-request provenance into runner headers and
expose the existing requirement-record example to planning tools.

Implementation: Codex Terra initial repair; Codex Sol lead R1 correction,
regressions and developer documentation. Independent R2 APPROVE, 115 focused
checks. Matching final local coverage proves 4,333 distinct required cases;
the intentional FEDR7 skip is covered by its standard scratch wrapper.

Reviewed patch: 18ff6455e7ab69bc55cef88845f86189ed5f67b114ad7996f714a35ca1a90239
Source manifest: 6daf0668101bbad030c45411304d0d049de5e4da32bdcb03c40095ed1c63128f
2026-11-04 21:08:42 -06:00
jason.woltje e9271841f3 fix(webui): enable factory chat and recover accurate states (#1843)
Enable CLI and WebUI channels in the Case 1 starter. Explain channel
refusals, preserve in-memory drafts, and distinguish failed reads from
successful empty data. Keep room refusal state through failed refreshes
and restore session controls after catalog recovery.

Integrates reviewed source commits 5558fe020 and a1ba9cd56. Independent
review R2 approved both recovery fixes; append-only records preserve
controller and implementation evidence.
2026-11-04 17:27:22 -06:00
jason.woltje a89139e5ea docs: record live WebUI channel recovery (#1843) 2026-11-04 16:49:06 -06:00
jason.woltje 3572bf9b32 docs: diagnose MVP 3 WebUI channel failure (#1843) 2026-11-04 16:39:00 -06:00
jason.woltje 51525a31d6 test(lifecycle): cover three delivery paths and crash recovery (#1839) 2026-11-04 14:47:45 -06:00
jason.woltje 81e54bf4d8 feat(bus): attach requests to existing approved products (#1839) 2026-11-04 13:52:36 -06:00
jason.woltje 76f8d3654f fix(bus): bind delivered review to task closure (#1839) 2026-11-04 13:31:15 -06:00
jason.woltje aa9b91d88b feat(bus): record reviewed delivery and let the owner fetch the artifact (#1839) 2026-11-04 13:31:14 -06:00
jason.woltje f7378f365c fix(release): read inline migration pairs in the release chain reader (#811) 2026-11-04 13:20:11 -06:00
jason.woltje 34e9af6167 feat(harness): let the PM ask the owner an intake question (#1839) 2026-11-04 12:42:34 -06:00
jason.woltje 48ec9721d1 feat(console): move the model proxy page into Settings (#803) 2026-11-04 12:32:59 -06:00
950 changed files with 84395 additions and 8290 deletions

No files matched your search

+12 -5
View File
@@ -42,9 +42,15 @@ green at every step. Not production software — a proven foundation.
rewritten, only pruned via `prune` with a receipt.
6. **Fail closed**: missing or invalid config/policy refuses the operation.
Never improvise around a refusal; diagnose it.
7. **Policy**: missions govern tasks (least-privilege intersection — a task
narrows, never widens). Role authority is declared in `roles/` and changes
only via reviewed commits.
7. **Policy**: missions govern tasks; a task narrows, never widens. The
rails, the core vocabulary of tools and actions, and the shipped role
templates in `roles/` change only by reviewed commit. An organization's
own roles are copies or compositions of known parts. One takes effect
only through a decision, resolved where the organization's owner-set
autonomy level sends it, by default a human with `org.hire`. The
decision lists every grant above the chief's own as a flagged line,
which only the owner approves. Only the chief raises hires, and a hired
agent cannot hire.
8. **Git**: commit only after applicable suites are green. Work on the
owner-authorized `refactor` branch; never force-push. Push remains an explicit
act. Do not merge into `next` or `main` without separate authorization.
@@ -104,7 +110,8 @@ Finish all independent work first, then ask one focused question only when:
Repository gates still apply. In particular, a successful implementation or a
broad request to “finish” does not by itself authorize push, merge, deployment,
release, production changes, policy/role expansion, or access to secrets. Perform
release, production changes, policy/role expansion, or access to secrets. An approved
hire decision is that authority for the one agent it names. Perform
such an action only when the established plan explicitly includes it. If blocked,
report the exact boundary, what is complete, the recommended resolution, and the
specific action that will resume; do not use “waiting for confirmation” as a
@@ -206,7 +213,7 @@ Full reference — usage, fields, exit codes, safety notes:
- `LAYERS.md` — implemented vs deferred layers
- `docs/SESSIONS.md` — session registry
- `adapters/README.md` — the harness adapter contract
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
- `roles/` — shipped role templates and the conductor policy
## Recovery rule
+232
View File
@@ -6115,3 +6115,235 @@ runs were joined. Queue remains with the lead after the linked-worktree refusal.
Local commit only; no push or live instance/credential-file access.
2026-11-04T17:21:41Z | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 lifecycle Task 6b before/after | Before: origin/refactor 103168614 includes Task 6a intake/staff tools and Task 5a create recovery; Task 4 source 76207fe48 cherry-picked as e41ec8159d23d52963d869abf22fc9d430831f44 with both append-only log sides retained. No checked task/review advancement or durable role continuation recovery existed. After: PM-only submit_prd and advance_request map to exact PRD submission and evidence-derived planning → implementation → review → delivery → complete transitions (changes return to implementation); task-create/assignment, latest project push/review/verdict and closure evidence are rechecked under the request revision and current snapshot. Eligible PM/CTO intake_status path reads expose only selected committed draft documents. Durable continuation intents are keyed by request/revision/target/step and composed atomically with outgoing messages; immutable attempt, manifest and exact launch-refusal receipts deduplicate retries. Message reads never acknowledge progress; validated lifecycle transitions and the chief's delivery effect in the originating assistant session do. Startup/event recovery plus a single-flight 30-second pass bounded to 64 requests catches missed wakeups. Successors receive the same unfinished key, with existing routed/effect receipts rechecked before resending. The launcher starts only the configured chief through its existing launchPm path; workers use that chief's real launch/end authority. Old product manifests drain their current turn, then the chief relaunches with request.admission and real ensureCheckouts. Exact refusals appear as RequestState.launch_refusal for intake-panel integration; no Console panel is present in this base. No synthetic capability, PM launch grant, hire through the empty chief hireable list, roles/*.json change, extra scheduler service, live-instance action, configuration/credential mount or new worker import was added. Six role duty documents updated. Migration 3q-3r is necessary solely to add the checked advanced lifecycle event to the closed request_events kind list; it preserves rows, sequence and all append-only guards, with historical fixture/version pins updated. Corrections: migration digest/pins and one bundle tool-list assertion were updated; notification scheduling was narrowed so reads do not cause unsolicited bootstrap retries; an accidental repository-root npm test invocation reported no test script and was corrected to packages/harness. One broad bus run hit the pre-existing #656 FIFO child niceness 16 vs 19 assertion (also recorded by Task 4); that test passed 21/21 in isolation and the final full bus rerun passed without a source change to it. Final GREEN, zero skips: new 43/43, focused compatibility 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36; extra sandbox import closure 11/11, git diff --check clean. Logs: /mnt/storage/scratch/tmp/task6b-final-new.log, task6b-final-focused.log, task6b-final-harness.log, task6b-final-bus.log, task6b-final-business.log, task6b-final-launcher.log and task6b-final-sandbox.log in the same directory; earlier failure/isolated logs retained. All test invocations used fresh TMPDIR directories from /mnt/storage/scratch/tmp/XXXXXX. Queue command refused this linked worktree, so no queue movement was attempted in the canonical checkout. One local implementation commit only; no push, merge, release, deployment or live-instance changes.
2026-11-04T17:57:52Z | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase before/after | Before: local 347791b55 on the superseded Task 4 cherry-pick e41ec8159; origin/refactor is b5a58b48b4c254f166135c24e22bd63ce4b5e0b9 with Task 4 c098fa022, #772 793f3b157, Task 3 and Task 5b. After: replay only 347791b55 on the explicitly requested new branch t3/lifecycle-task-6b-r2; e41ec8159 is dropped. Shipped Task 3 migration 3q-3r and all seventeen existing migration files are byte-identical to the base. New 3r-3s rebuild preserves all fifteen shipped event kinds (including question.asked, question.answered, attach.selected and every Task 4 PRD/mode kind) and adds advanced, for sixteen; schema.sql matches, SCHEMA_VERSION=3s, SCHEMA_SHA256=fde7a46505a964d0e3802aaee96a1e2d9fb8c7163fbafc2f86aca914ecc50f03. The migration test carries rows of every prior kind, preserves a sqlite_sequence high-water mark, index and every request/effect guard, then appends advanced at the next sequence. Current-version, offline-chain and synthetic future-step test pins updated through 3s (future fixture 3t). Conflicts resolved by keeping both behavior sets: broker retains Console owner/verb guards, request question/answer/attach/cancel handlers, Task 5b builtin binding and scoped requirements/git admission, plus 6b advance and continuation services; lifecycle exports the union of Task 3 and 6b operations; runtime retains Task 5b builtin binding and #772 pending-product readiness while adding 6b recovery; harness keeps Task 5b project/snapshot/op schemas and catalog prose while adding 6b tools, with duplicate schema properties removed; six role duty documents merged cleanly and retained; append-only logs keep both sides. Integration corrections: unanswered owner questions suspend new continuation dispatch, answer evidence resumes it, and planning continuations carry the stable create op required by Task 5b. A real Task 5b adapter test creates/assigns the task with that op, advances it and keeps cross-project git refused. First full bus run 1554/1555 found a Task 3 global-zero-effects assertion; it now asserts attach preserves the exact preexisting continuation receipts. Another bus run and isolated #656 control reproduced the existing nice 16 versus 19 race: the installed Node scheduling rule changes startup priority. The owned FIFO child now sets priority after fixture construction immediately before the measured operation; all priority/nonblocking assertions remain intact, no daemon or live instance changed. FIFO 21/21 and final full bus 1555/1555 pass. First WebUI run 537/540, two failures and one skip: missing local esbuild 0.28.2 and one browser refresh timeout; npm ci --ignore-scripts installed only the locked WebUI dependencies, the browser file passes 2/2 in isolation, and whole WebUI passes 540/540 with MOSAIC_REQUIRE_EDITOR_BUILD=1 and test-concurrency=4. An accidental root npm test invocation reported no script and ran no tests; corrected to the bus package. Final GREEN, zero skips/cancellations: new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540. Every test invocation used its own mktemp -d /mnt/storage/scratch/tmp/XXXXXX TMPDIR. Logs /mnt/storage/scratch/tmp/task6b-r2-new-final.log, task6b-r2-bus-final.log, task6b-r2-harness.log, task6b-r2-business.log, task6b-r2-launch.log, task6b-r2-webui-final.log; complete per-run counts in task6b-r2-runs.json and schema evidence in task6b-r2-schema-proof.json in the same directory. No unresolved conflict, role JSON authority change, shipped migration edit, credential/config mount, push, merge, deployment or live-instance action. Queue command again refused this linked worktree; canonical coordination is unchanged. Local rebase only, with no attribution trailers.
2026-11-04 | Codex implementation sub-agent | #803 Console Model proxy in Settings | Before: the owner's native Model proxy page was linked above the Console tree at /proxy. After: Settings links to /settings/model-proxy, whose page links back to Settings; /proxy redirects there only after the existing sign-in, granted-user and named-host fences. The proxy script, fixed API routes, human bus verbs, read and write authority, origin, Fetch Metadata, transport and bounded body checks are unchanged. No secret values are displayed. Tests first: expected red 3 failures (new Settings link, redirect and tester fence), 18 passes, zero skips; final targeted proxy HTTP 13/13, proxy browser 5/5, Settings browser 3/3, whole webui 558/558, zero failures, cancellations or skips. Each test command used a fresh TMPDIR under /mnt/storage/scratch/tmp/XXXXXX; logs /mnt/storage/scratch/tmp/console803-logs/{red,proxy-page,proxy-browser,views,webui}.log. Root dependencies were symlinked and webui dependencies linked per entry without installation. Queue next in this worktree refused the linked checkout; canonical queue next t3 returned nothing, with no queue movement. Local commit only; no push, live configuration or secrets access.
2026-11-04T18:34:22Z | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question before/after | Before: base 647626bdd exposes request.question on the bus but no PM harness tool. After: PM with message.send gets intake_question in both harnesses, forwarding only request, revision, op, text and optional bounded choices; the existing broker enforces exact shape, distinct choice keys, authority and replay. A stable caller-supplied op and exact retry use the broker journal, while bus refusals remain errors and lost/unknown outcomes are reported as unknown with no automatic retry. PM duty prose describes status reconciliation and that answers do not approve a PRD. Red evidence: request-tools red-real.log 19/25, with missing tool and unknown-outcome failures; earlier fixture corrections are retained in the logs. Final gates: harness 234/234, bus 1556/1556, business 307/307, all zero failures/skips/cancellations; tests include both harness manifests, unauthorized roles, invalid input, refusal propagation, unknown outcome, stable-op replay and owner Console answer through the real bus. Logs: /mnt/storage/scratch/tmp/W6Jjos/; each invocation used a fresh TMPDIR from /mnt/storage/scratch/tmp/XXXXXX and pipefail. No bus, role JSON, contract or migration change; local commit only, no push. Canonical queue next t3 returned nothing; coordination stays with the controller.
2026-11-04 | Codex implementation sub-agent, t3/release-811-migration-reader | Tracker #811 before/after | Before: on base 647626bdd1dbfe0fd5eebd1b0e717a2da8d2a644, the release admission and plan text readers omitted the inline 3p-to-3q and 3r-to-3s pairs, leaving 15 of the engine's 17 starting versions; the .53 Window C plan predicted store-newer for a 3r store. After: both readers recognize separate-line and inline pairs, and malformed key syntax is refused rather than silently skipped; the existing plan refusal for incomplete pairs remains. The regression imports the engine's private migration array through an in-memory test-only export, compares all 17 pairs and their order, exercises mixed layouts and malformed input, and failed on the unmodified script before passing. No store or migration changed. A full release run initially had 17 rehearsal failures because the requested base's rehearsal fixture omitted Task 5b's config imports; the test fixture now copies those dependencies, the affected rehearsal sweep passes, and the full release rerun passes. Final gates, zero skips: release 226/226, external release 25/25, stack-upgrade 34/34, focused rehearsal 21/21; syntax and diff checks passed. Each test run used a fresh TMPDIR from /mnt/storage/scratch/tmp/XXXXXX and fixture services, without Docker or live hosts. Logs: /mnt/storage/scratch/tmp/bqRed6/ (red.log, focused-final.log, stack-release.pre-fixture.log, rehearsal-group.log, stack-release.test.mjs.log, stack-release-external.test.mjs.log, stack-upgrade.test.mjs.log, static.log); interrupted preliminary runs also retained there. Canonical queue next for codex returned nothing; the linked worktree itself refused queue access. Local commit only, no push or live-instance action.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7b WIP | Implemented reviewed-delivery draft, 6b completion transition, owner attachment and legacy completion handling on t3/lifecycle-task-7b; no migration. Red-first logs and focused evidence under /mnt/storage/scratch/tmp/vWMFoL/. Brief trio 22/22, harness 228/228, business 307/307, seat 239/239, webui 558/558, zero skips on those runs. Whole bus 1566/1569: channel-proof freezes the changed webui transport bytes and two Console whitelist pins omit the mandated artifact verb. Each failing file reproduced alone and passed on 647626bdd. Stopped for lead ruling on the security proof baseline; tests and changes remain uncommitted, no push.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7b | Lead authorized the Console transport proof re-pin after the earlier stop: webui bus.mjs changes only the artifact response cap to 2 MiB, all other Console responses retain 1 MiB, and the focused transport test checks both boundaries and refusal above them. Added owner-only artifact read, reviewed blob and reviewer record.write receipt binding, atomic completion and chief intent, same-artifact duplicate handling, historical complete preservation, safe attachment and evidence panel. Real task.close through the tasks adapter and fake tracker now feeds an end-to-end delivery fixture; coder push_working and reviewer fetch_review use distinct local clones. The downloaded HTML was opened in offline Chromium and matched the reviewed blob and hash. No migration; schema remains 3s. GREEN zero skips: brief 24, bus 1570, harness 228, business 307, seat 239, webui 559; logs /mnt/storage/scratch/tmp/vWMFoL/final-*. Previous bus failure and stop remain recorded above; no push or deployment.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 Task 7b B1 fix round, before | On 1904c9775, review found delivery could combine the planned reviewer closure with a newer unclosed second reviewer verdict. Added two real task.close regressions: unclosed V2 must refuse without completion or chief intent, and closed planned V1 must remain deliverable after another reviewer’s V2. Both reproduced the defect before the fix; red log /mnt/storage/scratch/tmp/cLYQkO/red.log. Queue next refused linked-worktree use; canonical checkout untouched.
2026-11-04 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 Task 7b B1 fix round, after | Bound delivery to the exact push, planned review, approving verdict and task.closed receipt accepted by request progress. A newer unclosed second-reviewer V2 now refuses without completion or chief intent; the closed planned-reviewer V1 still delivers. Both real-adapter regressions red before and green after (logs /mnt/storage/scratch/tmp/cLYQkO/red.log and green.log). Required gates zero skips: bus 1572/1572, harness 228/228, seat 239/239, webui 559/559; logs /mnt/storage/scratch/tmp/cLYQkO/. The first whole webui attempt had one timeout in decide-browser I1 (558/559); that file passed 9/9 alone on both this branch and 647626bdd, and the whole rerun passed. No proof pin, Console whitelist, transport, policy or schema change; no push.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8, before | Isolated branch t3/request-lifecycle-task-8-76f8d365 at exact origin/refactor base 76f8d3654faa8f22a96c1857c01c764e9637b1bd. Build the repository-only three-path acceptance harness and process crash recovery with shipped authority, persistent fake external services, real typed tools/Git/Console, plus a guarded explicit-instance live runner. Task 7b complete/delivery/artifact behavior is authoritative. No policy expansion, shipped migration edits, canonical working-tree writes, installs, credentials/config reads, push or live acceptance. Queue next refused linked-worktree access; coordination remains with the lead. Root node_modules linked to canonical dependencies; webui node_modules is a real directory with per-entry symlinks. Gates use fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX and retained logs. Original fixture provenance is an owner-supplied incident quotation, not a runtime row or live receipt.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8, stopped on brief/base conflict | Task 8 requires attach_existing_and_revise_missing_requirement to attach to an authorized existing product, reuse a matching approved snapshot or revise when missing, and deliver without creating another project. At exact base 76f8d365 the shipped hostPreparation in packages/bus/src/request-host.mjs supplies only preparePlans/admitPlan; neither it nor runtime/broker supplies listProjects or prepareExisting. request-lifecycle projects() therefore returns [] for every production-host picker; attachment refuses request-project-refused. The existing-path unit fixtures inject both missing services. Even with those test-only injections, selection.confirmed explicitly leaves existing associations in discovery/request-attachment-unavailable; runBootstrap skips existing bindings, and no shipped transition adopts the existing approved snapshot. Reproduction: /mnt/storage/scratch/tmp/fayxaI/attach-contract.test.mjs starts the real file-backed runtime with shipped role definitions and scopedFixture (only SCOPES imported from world, no permissive AUTHORITY/world broker), bootstraps timer through actual owner selection and product PRD approval, proves its snapshot resolves, opens a second request and resolves existing through authenticated Console verbs. Expected picker includes timer; actual []. Retained log /mnt/storage/scratch/tmp/fayxaI/attach-contract-red.log: 0 pass, 1 assertion failure, 0 skips/cancellations; fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX with pipefail. This is a deterministic missing host capability, not a load failure. Per explicit assignment rule, no implementation chosen or production behavior changed; remaining harness/live-runner work and broad gates not run, no commit because acceptance is not green. Lead resolution needed: authorize Task 8 to implement the missing existing-product host preparation/attachment/snapshot-adoption path, or provide a revised base/brief that supplies it. No roles, ROUTED, security boundary, shipped migration, canonical tree, real credential/config or live host touched.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8a, after | Lead ruled the missing existing-project host path an in-scope gap at 76f8d365, superseding the stop without changing its record. Wired the shipped host inventory/preparation to current owner/business mapping, project config, admitted product registry and existing role/tracker/mode authority. Picker rows carry the organization mapping revision; only the server retains the displayed inventory, and the owner pick journals that revision. A host restart before picking requires a fresh read. The exact current mapping, source commit, existing paths, tracker, staff and mode form the confirmation; stale picker, pick or confirmation refuses. Attachment holds the organization lock and processing lease through validation and its atomic bootstrap-kind receipt, creating no resource. A validated active product snapshot with the same exact original intent is a conservative proven fit and is adopted without a new approval; other intent enters PM discovery/revision, and the new attached request cannot borrow the older active snapshot for task creation. This adds bounded request binding under existing authority, no role, action, capability, ROUTED, Console whitelist, transport proof or security boundary change. Existing bootstrap.completed and attach.selected payloads gain optional host-validated attachment fields; historical rows are unchanged, no new event kind/table or migration, schema remains 3s. Retry after temporary records-read refusal now resumes instead of getting stuck in an invalid transition. README documents freshness and the conservative fit rule. Red logs: 8a-red.log (6/6 fail), 8a-red-scope-http.log (borrowed snapshot accepted; HTTP fixture initially missed log callback), corrected 8a-red-http.log (revision absent), 8a-red-retry.log (working-file dirt was not a valid committed-read refusal), corrected 8a-red-retry-real.log (repair could not resume). Original retained diagnostic went red-to-green; focused compatibility 105/105, final new tests 8/8. Final required gates, zero failures/cancellations/skips: bus 1580/1580; harness npm test 234/234; business npm test 307/307; seat npm test 239/239; whole webui node --test tests/ with concurrency 4 and MOSAIC_REQUIRE_EDITOR_BUILD=1 563/563. All commands had fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX and pipefail; logs /mnt/storage/scratch/tmp/UMYC56/8a-final-{bus,harness,business,seat,webui}.log, 8a-green-final.log, 8a-static.log. Earlier green runs retained separately. Changed-module syntax, relative imports, documentation/reference review and git diff --check pass. Root dependency link and real webui directory of per-entry links only, no install. Pending before/stop SESSIONS and BUILD-LOG lines included as instructed. Local Task 8a commit only; Task 8b and lead-only live window remain.
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8b, before/after | Before: Task 8a local 81e54bf4d82f670c5c2aba947e650bc5d74a90f2 supplies the missing host path, but there was no three-path process acceptance fixture, standalone offline timer acceptance or guarded live runner. After: added the original owner-supplied quotation with its unchanged source IDs/SHA/provenance and an explicit distinction from newly generated runtime input/message IDs; real file-backed startBroker in a killable process; persistent parent FakeVikunja HTTP service with scoped fake credentials and valid isolated organization; shipped role definitions, real chief launch authority/limits, resolver/bundles/session manifests, independently spawned typed-tool callers, real record/PRD/task validation, Git socket/bundles, separate coder/reviewer workspaces and run identities, independent committed Node timer test execution, and signed-in fake-OP Console HTTP/browser actions. Scripted model responses supply agent-proposed 25/5 durations, start/pause/reset and a gesture-enabled cue only through the approved product PRD. The coder writes actual self-contained HTML, with request/snapshot provenance metadata; the reviewer fetches that exact commit, runs its test and writes the verification record before its verdict. Real close/deliver and chief → assistant → user messages complete the reviewed local delivery; no merge, remote publish or deployment is implied. Autonomous original-wording, guided question/reject/revise/exact approval, existing matching snapshot and missing-requirement revision, cancellation and mode change during approval all pass. Eight hard process crashes (selection resolution, mapping, intent/spec commit, PRD resolution, tracker POST, working push, review verdict, export before notification) restart over the same on-disk Store/repositories and surviving fake service; tests require unchanged historical rows, identical known IDs/commits and one product/task/artifact. A hidden remote POST outcome stays uncertain without a replacement POST or delivery claim and continues once proof is readable. Chromium controls time and verifies work/break transitions, pause/resume/reset, cue after a user gesture and no external requests while offline. Fixture success is integration/recovery evidence, not live model quality or deployment readiness.
Task 8b defects reproduced before fixes: owner answer/attach/cancel controls on assistant-origin requests tried to thread a human→PM message into the assistant session, correctly refused by SQLite; controls now use the PM inbox unless the origin session is itself PM-owned, leaving the original request/session binding and message graph intact. Cancellation in PRD review previously selected the closed bootstrap decision; it now withdraws only the request's exact current pending PRD decision, with existing owner, PM, project and messaging checks. Targeted production regressions 2/2 red then green (8b-red-controls.log, 8b-green-controls.log). No schema/migration, shipped role JSON, ROUTED, capability, Console whitelist, transport-proof or security-boundary change. Existing bootstrap.completed/attach.selected extension from 8a remains schema 3s. New live-runner preflight tests 2/2 red then green. Repository live runner requires an explicit named isolated development instance, matching marker/config, expected business owner, existing staff/source/tracker/host, owner process proof and explicit run authorization before submission; it never starts hosts, supplies mode/PRD approval, provisions accounts/boards/grants, retries an unknown submission, or defaults to .53/.54/production. It retains request revisions and review/task/download receipts; exact artifact hash/length are checked separately from live browser acceptance.
Task 8b verification corrections: early harness runs exposed fixture cleanup ordering, unchanged record writes, globally reused create ops, missing persisted launch proof fields, a reused commit with nothing to push, a cross-request timestamp assertion and a missing empty owner-audit file. Those were corrected with the same strict production APIs; logs 8b-autonomous-*.log, 8b-integration-{1,2,4}.log and 8b-paths-recovery-3.log remain. The first autonomous prototype run was interrupted after a cleanup hang; it is recorded as a cancellation, not a passing run. Final required two-file acceptance run 16/16, zero skips/cancellations (8b-integration-final.log). Full CLI first run: 1192 tests, 1139 pass, 52 fail, 1 skip (8b-cli.log). Every failing file reproduced alone: lifecycle, proxy-launch-order, session-clear, thread-launch and thread-revoke (isolated-*.test.mjs.log). Their trusted-host fixtures lacked the request-host operations already used by the landed launcher. A shared test-only forwarding table now calls the actual runtime methods, with no unconditional allow/ready result; all 60 affected tests pass (8b-launcher-fixture-green.log, 8b-thread-fixture-green.log). The ordinary CLI suite intentionally skips R7; the final full npm run enabled R7 under an isolated fake HOME and its existing group/port/socket cleanup wrapper, adapted outside the repo only to run the complete suite. No federation/proof policy or production script changed. Final CLI 1192/1192, zero skips, with R7 acceptance and clean child/listener cleanup (8b-cli-final.log).
Task 8b final whole-package gates, all zero failures/cancellations/skips: bus 1582; CLI 1192; tasks 132; project 50; harness npm test 234; business npm test 307; seat npm test 239; whole WebUI node --test tests/ with concurrency 4 and MOSAIC_REQUIRE_EDITOR_BUILD=1 564. Total 4300. Every test/gate invocation used fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX, pipefail and retained logs; no install. Root node_modules remains a link; WebUI node_modules remains a real directory of per-entry links. Gate matrix /mnt/storage/scratch/tmp/tupyBK/8b-gate-results.json; 16 case-labeled immutable evidence sets indexed by /mnt/storage/scratch/tmp/tupyBK/8b-acceptance-evidence-index.json, including artifact bytes, original quote, live fixture IDs, histories, manifests, coder/reviewer test logs and crash-lock proof. Syntax, import/Markdown references, whitespace and byte preservation of shipped authority/migrations passed (8b-static-final.log). Self-review performed; no sub-agent/delegate was started. Queue coordination remains with the lead after the linked-worktree refusal. Local second commit only; no push or canonical working-tree/live-instance/real-credential action.
Lead-only live window checks, not executed here: (1) explicitly authorize and select a named isolated development instance on .53 with its own data root/marker, installed source/board, scoped credentials, real PM/CTO/chief/coder/reviewer/assistant routes and signed-in owner Console; retain preflight and refuse any missing resource rather than provision it. (2) Run packages/cli/live/request-lifecycle.mjs with explicit --instance, --config, --business, --owner, --request-file and --evidence plus --authorize-isolated-run, from a genuine owner shell so the existing process proof applies; use --submit with an existing owned --thread, or observe the owner's existing --request. The owner selects/confirms the exact displayed autonomous plan once in Console. No user relay, manual task creation or worker shell command is part of the lifecycle. (3) Retain original request/message bytes, exact plan confirmation, approved snapshot/decision, tracker create/assignment, working commit, independent reviewer fetch/test/record/verdict, matching closure, delivery and chief/assistant notification; check identical project/snapshot/task/commit/review/verdict IDs across the chain. (4) Download through the owner-only Console URL, rerun observation with --request and --artifact, and require byte length/SHA to equal the reviewed delivery. Open that downloaded HTML with network disabled and verify the approved durations, start, pause/resume/reset, both transitions and the phase-end cue after Start, with no external requests/assets. Inspect actual independent model/session/test receipts before declaring live acceptance. (5) If live crash checks are authorized, kill only the owned isolated broker at the eight named checkpoints; preserve DB/WAL/SHM and repositories. Follow the existing operator rule: establish the exact writer PID is dead, retain its lock identity, and explicitly remove only the stale lock/socket before supported restart. There is no production automatic stale-lock reclaim and this task does not add one. Require unchanged historical rows, identical known IDs/commits and no duplicate project/task/artifact; an unprovable remote outcome must remain a visible wait. Full invocation and qualifications are in packages/cli/live/README.md. Stage 4 live acceptance remains with the lead.
2026-11-04 | Lead Codex, #815 / Gitea #1843 chat-channel recovery before | Owner /chat screenshots show failed read plus false empty state and Create project channel-disabled. Read-only .53 checks: plugins.channel absent; existing usage-source transcript and harness claude-code retained; Console journal has matching refusal; existing typed tester plugins API is authorized. Failure precedes project dispatch. Plan: typed tester channel-only correction with private config backup/readback and no restart; Codex source fix for factory channel declaration and accurate failure UI, tests/review before integration. No signed-owner workflow or schema/role/secret change.
2026-11-04 | Lead Codex, #815 / Gitea #1843 live configuration outcome | Operator enabled cli+webui through pinned 51525a31d tester plugin setter at 22:43:43Z, transaction e94c4db7-c3e3-4d31-825f-0b204a1ccf76; committed actor tester, only plugins.channel changed, other configuration deeply equal. Private backup/rollback report: /mnt/storage/scratch/mosaic-lead/reports/chat-channel-815.md. Lead independently verified configured channels, committed receipt and real tester WebUI GET /api/chat/projects HTTP 200. Services retained PIDs; no deployment/schema/credential/model/role change or owner workflow. Source regression fix remains active and issue open.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, before (recorded during implementation) | Started in T3-prepared branch t3/channel-disabled-chat-recovery at controller base 3572bf9b32f414283686e828155ae859a45c537e, source baseline 51525a31d. Scope: explicit Case 1 CLI/WebUI opt-in and Console channel-refusal/failed-read recovery; preserve global defaults, enforcement and no-tracker starter. Controller owns coordination and live repair; no live config, role, queue or transport changes.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, after | Case 1 explicitly opts into cli and webui; no-tracker starter and global defaults remain unchanged. Console renders a fixed disabled-channel explanation, retains in-memory drafts and distinguishes definite refusal from unknown outcome, disables known-refused creation/send actions, marks cached reads stale and does not claim empty on failed reads. TDD red logs and green gates under /mnt/storage/scratch/tmp/mosaic-815-logs/: final targeted chat/room HTTP and Chromium 100/100, full WebUI with MOSAIC_REQUIRE_EDITOR_BUILD=1 574/574, business 307/307, zero skips/cancellations. First full WebUI after root npm ci: 569 passed, 1 missing WebUI esbuild failure, 1 editor-build skip; WebUI lockfile install restored the pinned dependency. Concurrent run: 571/572 with one unrelated decide-browser I1 wait timeout; isolated decide-browser 9/9, subsequent full gate green. Superseded full gates were stopped when later regression corrections changed the source. No bus.mjs/proof pin, role, policy, auth, live host/config, queue, push or deployment change. Draft recovery tested through the Console Refresh action; full browser reload does not persist in-memory drafts.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 source fix round 1, stopped | At base 5558fe020 reproduced room channel state loss and sticky legacy-session controls in real Chromium, then made the scoped state corrections; focused red/green logs and the 47/47 covering-browser pass are under /mnt/storage/scratch/tmp/mosaic-815-r1/. The single authorized full WebUI run with MOSAIC_REQUIRE_EDITOR_BUILD=1 and concurrency 2 finished 575/576, zero skips/cancellations: unrelated thread-claims-browser row278 timed out waiting for its table; that entire file then passed 6/6 alone. The unchanged vendored-editor import warning remained, and the editor rebuild passed. No commit: the full applicable suite is not green, and the brief specified one full run. Controller owns the decision on further verification. Minor cached-room stale-label finding remains unchanged. No live, bus/proof, authentication, policy, role, schema, tracker, push, merge or PR change.
2026-11-04 | #815 / Gitea #1843 source fix round 1 controller verification | Existing authorization covered another verification run after the unchanged row278 browser timeout. Same command and test settings: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/; 576 passed, zero failures/cancellations/skips, required editor rebuild passed, duration 168128 ms. Source/test blob hashes unchanged from pre-run snapshot; no test weakening or unrelated source changes. Both earlier full failure and isolated 6/6 evidence retained. Log: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-controller-retry.log. Local commit now permitted; independent I1/I2 re-review next.
2026-11-04 | #815 / Gitea #1843 integration phase started | Independent scoped re-review APPROVE at a1ba9cd5669cf084ef69eb663b42234fbce38824: I1/I2 addressed; no new critical/important finding. Applied only the five reviewed source/test/template paths to refactor and appended worker records while preserving all existing controller log bytes. Entire tracked source outside BUILD-LOG/SESSIONS matches the reviewed candidate. Running final canonical WebUI gate and factory regression before commit/push. M1 cached-room stale label and M2 unchanged tooling/audit observations remain deferred/nonblocking. No deployment or owner acceptance.
2026-11-04 | #815 / Gitea #1843 integration phase verified | Canonical full WebUI: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/ passed 576/576, zero failures/cancellations/skips, required editor rebuild passed (171530 ms); factory regression passed 8/8, zero failures/cancellations/skips. Logs: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-canonical.log and factory-canonical.log. Entire source outside append-only logs still equals independently approved candidate a1ba9cd56. Prior full business 307/307 applies to identical business/template source. All existing controller and worker log bytes preserved. Committing and pushing the reviewed repair under the established plan; source deployment and owner PRD-to-artifact acceptance remain separate. Known in-memory full-reload draft loss, minor cached-room stale-label omission and unchanged tooling observations retained.
2026-11-04 | #816 / Gitea #1844 design phase started | Owner supplied current Chat/project/thread screenshots and familiar chat references, specifying Projects navigation, expandable project/session sidebar, customizable display, project KPI dashboard, in-project issues and north-star/PRD access, PRD agent alerts, and room broadcast. Begin design-only collaboration, then wireframes and generated images. Mobbin research follows owner connection; no application implementation, PRD/schema/policy changes or live operations. Brief: docs/plans/2026-11-04_projects-workspace-design-brief.md.
2026-11-04 | #816 / Gitea #1844 wireframe draft phase | Navigation and autonomous-conversation reports align on project tree, distinct dashboard/disclosure actions, breadcrumbs and conversational work cards. First design artifact at /mnt/storage/scratch/mosaic-lead/design/projects-816/projects-wireframe.html; previewed desktop/mobile and checked local navigation/audience interactions. Dashboard detail remains provisional while its designer finishes. No application code or live change; wireframe example data and proposed capabilities are labeled.
2026-11-04 | #816 / Gitea #1844 initial design phase delivered | Combined all three GPT reports into design-synthesis.md and reconciled v2 wireframes. Desktop/mobile preview and local state checks passed for navigation/disclosure, decisions/confirmation, local delivery, unavailable KPIs and room audience. Built-in image generation produced selected dashboard-v1.png, conversation-v2.png and room-v1.png; originals/variants and exact prompts retained, dimensions/SHA manifest recorded. Pack: /mnt/storage/scratch/mosaic-lead/design/projects-816/design-pack.md. This is a design draft for feedback; Mobbin research remains pending connection, not complete or approved implementation. Mandatory Git wrapper refused the #1844 update due to missing acting identity; prepared comment retained and Vikunja #816 updated without bypass. No application, PRD, schema, authority, credential or live change.
2026-11-04 | #816 / #797 owner full-send parallel phase started | Owner requested independent UX Lead and MVP 3 Lead sessions. Both launched in separate T3-prepared worktrees at e9271841f, read back running; UX confirmed Mobbin search. Disjoint source/artifact ownership, preserved read-only initial reference manifest, separate status files, root-only canonical integration. Design work cannot modify application source and does not block functional repairs. Current browser capture unavailable in UX lane; supplied screenshots remain usable. Repository-native worktree/storage rules take precedence over generic estate prefix; /var/src creation denied, disk-backed /mnt/storage task storage retained. No live change or acceptance claim.
2026-11-04 | #816 / #797 parallel phase evidence milestone | Independent visual baseline assessments verified against preserved references; method corrections and reviewer dissent retained without author disclosure. UX is finishing a new wireframe state package and independent review, with frozen manifest required before assessment. MVP reported an isolated source candidate with retained RED evidence and scoped gate; full-package checks and exact-candidate review remain in progress. Root made no application source, runtime, credential, deployment or acceptance change.
2026-11-04 | #818 reusable UX improvement skill phase chartered | Owner requested reusable personal/distributable skill. New isolated GPT author owns skills/ms-ux-improvement and package-focused behavioral checks; root owns independent review/integration. Existing frontend-design guidance remains intact; no mandatory subscription/provider dependencies. Candidate is not yet reviewed, installed, activated or published. Active UX and MVP lanes continue without overlapping paths.
2026-11-04 | #797 / Gitea #1839 R2 integration phase started | Exact patch18ff6455 verified, fresh R2 APPROVE and4333 distinct required local cases observed through retained normal-exit receipts; the one ordinary FEDR7 skip is covered by its dedicated standard wrapper. Apply only12 reviewed non-log paths on e9271841f, preserve/import candidate log suffixes and keep UX/skill source separate. Native Git transport currently refuses anonymous-credential-refused despite owner-confirmed Mos API identity; no helper/launcher bypass or live action authorized by this integration.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair, before | T3 isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; charter /mnt/storage/scratch/mosaic-lead/mvp3/functional-repair-plan.md. Reproduce late evidence, reviewer contract ordering, linked relay and requirement guidance before bounded fixes; preserve authorization and immutable receipts. Queue refused linked worktree; no canonical or queue access. Pinned npm ci only; no commit, forge, live or protected operations.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair, after | At e9271841f3af23bf2a72aa17d9d67a1ec5abe017 reproduced premature PM continuation stalls, missing first review JSON contract, missing linked request header, and absent typed requirement example. Added evidence-keyed durable wakeups, overlapping recovery handling, lifecycle review guidance, owner/thread/role-checked relay provenance and requirement guidance. Focused RED/GREEN, cross-owner and removed-role negatives, late-record refusal and both harness prompts retained under /mnt/storage/scratch/mosaic-lead/mvp3/repair-author/. Final bus/harness/tasks/project/business/seat and relevant CLI gate: 2610 passed, 0 failed/cancelled/skipped, concurrency 2, MOSAIC_REQUIRE_EDITOR_BUILD=1 and private /mnt/storage/scratch/mosaic-lead/mvp3/a9. Earlier long-path suite refused Unix sockets and was interrupted (2591 pass, 88 fail, 75 cancelled, 1 skip); shorter-path lifecycle and three launcher reproductions passed. Two later intermediate suites were interrupted for newly reproduced provenance/claim races; all logs retained. No commit, push, forge, canonical, configuration, role JSON, schema or live change; independent exact-candidate review and lead's broader gates remain.
2026-11-04 — MVP 3 independent lead candidate validation (#797/#1839): applying the byte-verified Terra candidate d8ef58ffc06b63e34d5e4e056898523fdb43643b933edcf3ed45135a2cc922b6 to the isolated lead worktree for broader local verification; canonical integration/live acceptance not performed. Author RED/GREEN retained; independent review pending.
2026-11-04 — MVP 3 R1 remediation (#797/#1839): lead confirmed retained-history128/257 wake race with RED tests and repaired recovery to retain a complete reinspection cycle in bounded batches after overlap. Reviewer scratch plus source regressions and quiescent control4/4GREEN. Source remains uncommitted/unintegrated. Earlier parent broad run was mixed-candidate, ended143 without root terminal summary, and is not a final gate; excludedCLI compactions/revoke retry under short authorized TMPDIR passed6/6. R2 candidate and matching full-package verification pending.
2026-11-04 | #797 / Gitea #1839 R2 canonical integration verified | All12 non-log files equal independently approved/tested source manifest6daf0668101bbad030c45411304d0d049de5e4da32bdcb03c40095ed1c63128f;8 JS syntax checks and whitespace check passed. Canonical prior log bytes preserved and candidate author/lead suffixes appended. Retained gate coverage4333 distinct cases plus independent115 focused checks applies to identical source; no duplicate broad test run. Commit/publish and deployment remain separate operations; no live acceptance claimed.
2026-11-04 | #818 / Gitea #1845 skill integration phase started | Independently approved candidate02, exact3-file manifestb93d7836 and14 package-focused checks; fresh forward-use artifacts and synthetic/single-model limits retained. Integrate only new skills/ms-ux-improvement plus append-only records. No application/source-family overlap, installation, activation or publication.
## 2026-11-05 — UX improvement skill author phase (Vikunja #818; related Gitea #1844)
Before: clean isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; no UX improvement skill. Scope: personally author skills/ms-ux-improvement, retain baseline/forward evidence at /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z. Queue next refused linked worktree (exit 2); direct owner charter governs without queue/forge writes. Package-focused checks only; no application suites, commit, activation or publication.
## 2026-11-05 — UX improvement skill author phase completed (Vikunja #818; related Gitea #1844)
After: personally authored three-file skills/ms-ux-improvement coordinator package, complementary to existing frontend execution guidance; final candidate-02 content manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140. Retained 19 bounded clean-context behavioral samples / 186 raw files: six unguided baselines, seven candidate-01 samples including ready-review control, six candidate-02 samples. Candidate-01's two unnecessary frozen-method recovery conditions and already-correct control remain recorded; narrowed wording and final tests show no extra gate, without causal/statistical improvement claim. Fresh package-focused skill-creator/resource validation passed for workspace, frozen snapshot and scratch copy; patch/whitespace, arithmetic, integrity, unchanged baseline and append-only ownership checks passed. Entry 637 words (2,210 total) and one-model synthetic/procedural-scope limits retained as author concerns. Exact source, patch, hashes, commands, raw evidence and independent-review brief: /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Author task complete; independent source approval pending. No application suites, commits, forge/queue writes, publication, installation, runtime activation or protected actions.
2026-11-04 | #818 / Gitea #1845 canonical skill integration verified | Exact3 reviewed Markdown files match source hashes; canonical skill-creator validator and whitespace checks pass. Independent review APPROVE/no findings,14 focused checks and a fresh bounded forward-use handoff retained. All prior canonical and author log bytes preserved. Package is available in source by explicit path; no managed/personal installation, activation, publication or user-acceptance claim. MVP activation remains pinned to parentbd8120318, independent of this skill commit.
## 2026-11-05 — MVP 3 relay fixes (#797 / #1839), author phase
Before: isolated checkout at 05fdfef9ea1fdddf58c725c04f229ca8559fc054, branch fix/mvp3-relay-797; live acceptance diagnosis identified configured-chief addressing, misleading relay receipts, bootstrap approval wording, and premature-staffing wording. Direct charter authorizes bounded implementation, tests and local commit only. Queue next refused linked worktree; no queue, forge, push or live action. F2 regression reproduced null receipt before repair; scratch evidence and final gate will be recorded in /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md.
## 2026-11-05 — MVP 3 relay fixes (#797 / #1839), author phase completed
After: assistant addresses the configured chief instance; role-send receipts derive provenance from the stored message and existing reply-chain checks; PM and chief guidance describes the existing bound bootstrap decision and request-based staffing before task creation. No role JSON, schema, policy or alias change. F2 whole-file regression was red (7 pass, 1 fail: null relay receipt) then green (8 pass), with unlinked, wrong-recipient and caller-supplied-ID negatives; threaded reply file passed 20/20. Final whole-suite gates: bus on Node 24.22.1 1596/1596; harness 242/242; business 307/307; 37 additional broker-importer files 512/512; zero failures, cancellations or skips. Original Node 26 bus attempt was interrupted after project-files stalled in a socket send: 1593 pass, one cancelled; that whole file passed 8/8 on Node 24.22.1 and the complete bus suite was rerun. Scratch TMPDIR, exact commands and logs: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. Local commit authorized; independent review and all publication or live actions remain outside this author phase.
## 2026-11-05 — MVP 3 relay R2 trusted coordinator context (#797 / #1839)
Before: isolated branch fix/mvp3-relay-797-r2 from 29a3cc63238307d8644447c67a0364a6b03dfa36. Independent R1 review's sole CHANGES finding: owner-started assistants could not discover an arbitrary configured chief key, especially offline. Direct R2 charter authorizes trusted generated routing context, offline swapped-key integration evidence, required tests, append-only records and a local commit; no push or live action. Queue read refused linked worktree, then canonical read returned nothing; no queue mutation.
After: assistant-only resolved coordinator context comes from validated launch.by and the coordinator's optional configured identity.name. The generated prompt names the exact instance key and role guidance uses it explicitly; no-launch context reports a blocker. Broker source, messaging policy, recipient checks and role JSON are unchanged. Config-to-bundle-to-broker integration covers owner-launched assistant, offline chief, violet/opal definition swap, descriptive name, ignored session override, correct stored recipient/provenance, forbidden CIO and definition-alias refusals. Required whole-file gate 321/321 and additional content/path/bundle checks 124/124 on Node 24.22.1; final touched bundle file 24/24, all zero failures/cancellations/skips. Existing digest machinery covers assistant coordinator and prompt bytes; no literal changed-source hash pin required an update. Original reviewer probes 12/13 because their F1 deepEqual asserts the diagnosed defect; preserved original and a scratch copy changing only that assertion to notDeepEqual passed 13/13, including F1. Initial added fixture runs had two setup mistakes (missing store directory, thread claims absent from agents); corrected. Initial related run 79/124 had missing local dependencies and one Claude style-hook assertion; npm ci from the unchanged lockfile followed by the same whole-group rerun passed 124/124. Scratch evidence: /mnt/storage/scratch/tmp/0T6C89; exact commands and limits: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md. Local commit only; independent exact-candidate review remains.
## 2026-11-05 — MVP 3 planning coverage implementation started (#797)
Before: pinned 05fdfef9ea1fdddf58c725c04f229ca8559fc054; live b1869a13 has five approved requirements but one singly cited task. Scope: preserve scalar task schema, surface exact uncovered IDs to PM, clarify planning versus push evidence, diagnose coder stop and report D3–D5. Local branch only; no push, merge, runtime writes or role authority changes. Queue refused in linked worktree; direct implementation charter governs this child.
## 2026-11-05 — MVP 3 planning coverage implementation ready for review (#797)
After: preserve the scalar task requirement schema and fail-closed coverage gate; incomplete advance now sends a deduplicated PM bus notice naming every uncovered id and explaining correction, task-limit reconciliation and why a push cannot clear coverage. PM contract, task_create description and planning continuation state the same rule. Five-requirement regression proves prose/push do not cover missing ids, retries do not flood, changed coverage updates the notice, and complete assigned coverage advances without pushes for the other tasks. Focused whole files: 52 pass/0 fail/0 skip; scoped/lifecycle/import/role regressions: 115 pass/0 fail/0 skip; dependency/path/closure files: 61 pass/0 fail/0 skip (228 total). Initial fixture run: 51 pass/1 fail (new test used an invalid PRD author; corrected to pm.pm). Initial path checks: 28 pass/23 fail from absent worktree node_modules; canonical dependency versions matched package.json, linked locally, then all 61 passed. Read-only .53 evidence identifies proxy-stall watchdog as coder stop cause; D2–D5 remain report-only follow-ups. Detailed commands, receipts, limitations and exact commit: /mnt/storage/scratch/mosaic-lead/mvp3/planning-gate-fix-r1-report.md. No push, merge, runtime write, role authority expansion or product-acceptance claim.
## 2026-11-05 — MVP 3 coder first-activity watchdog correction (#797), implementation handoff
Before: isolated branch fix/mvp3-coder-stall-797 starts at 05fdfef9ea1fdddf58c725c04f229ca8559fc054. Claude text-mode stdout appeared only at the final answer, so the 120-second first-output watchdog could stop productive thinking/tool work (D2 in planning-gate-fix-r1-report.md). Explicit child charter authorizes a local implementation/test/commit only; queue next codex refused this linked worktree, with no workaround or queue mutation.
After: Claude's private stream-json reader marks real upstream assistant events and emits only the successful final text, matching real CLI newline semantics byte for byte. Pi's existing extension also marks model text/thinking/tool deltas; mock remains a test-only immediate echo. Both reuse the existing exclusive 0600 proxy-first-output marker with fixed output\n content; init/status, synthetic CLI errors, tool results, stderr and workspace writes do not count. Runner supplies only its existing run directory; helper gets a read-only harness mount and bundle source digest. No timeout, schema, role authority, credential, proxy management or live state changed. Silent runs still stop with proxy-stall; interrupted first/resumed turns emit no partial answer or successful new session ID.
Verification: Node v24.22.1 with PATH pinned to /mnt/storage/scratch/tmp/z62zsJ/node24/node_modules/node-linux-x64/bin and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Final full 28-file command: 376 passed, 0 failed/cancelled/skipped/todo, test-concurrency=4 (coder-stall-final-r2.tap). Includes full launcher/model-stall, harness runner/bundle, seat session/sandbox, real Claude/Pi adapter, confinement, installer/copy, source-digest and import-closure checks. Delayed tool/thinking/header/assistant fixtures use 0.8-second injected watchdog and 1.4-second pre-answer work. Initial tests retained: missing test import and failed-result exit handling, real text-mode trailing-newline difference, then a combined-gate grandchild-reaping assertion race; all corrected and full final gate green. Whitespace and baseline log-prefix preservation checks pass. Report, exact commands, SHA256 manifest, scans and all TAP receipts: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md and coder-stall-* siblings. Local commit follows these green suites. Independent exact-commit review, CI/integration and deployment/live GLM acceptance remain with the lead; no push, stash, fleet operation or external message.
## 2026-11-05 — MVP 3 coder-stall review F1 correction (#797), local implementation handoff
Before: candidate 1bd0077391f1925db5e66796b98540e48f13bfc2 discarded complete error-typed result strings, losing text-mode stdout such as API Error: 400 review-local-api-error with exit 1. Restoring that output also required preventing the runner's generic stdout fallback from mistaking synthetic errors for model activity. Explicit correction charter authorizes one local follow-up commit on fix/mvp3-coder-stall-797; the linked-worktree queue refusal was diagnosed and left intact.
After: the private stream reader renders every complete final result string independently of success/failure, retains CLI nonzero exit codes, fails error-typed zero-exit turns, and withholds result text on stream-integrity/helper marker failures. The adapter's existing success-only session persistence remains unchanged. The Claude plugin declares adapter-owned activity in its existing bundle environment; the runner reads module metadata, ignores ambient declarations and keeps stdout fallback for Pi/worker/mock paths. No core harness-name conditional, watchdog timing/default, broker, roles, contracts, policy or live state change. Bundle tests pin the helper SHA-256 to 9c2cc28b7e719c1d49913401fffc952759d8633e50ae39ca36bfc50729b9415b.
Verification: before product edits, whole-file negative controls on 1bd007739 passed 68 and failed 7; the real installed Claude CLI HTTP 400 test failed on empty streaming stdout versus 38 text-mode bytes. It now passes with identical stdout bytes and exit 1, no activity marker and no new session ID, using only localhost, isolated HOME/configuration and a dummy key. Added subtype/is_error/nonzero controls, corrupt-after-result/oversized/truncated/helper marker-failure cases and real runner fallback controls. Final frozen gate: 414 passed, 0 failed/skipped/cancelled/todo across 31 whole files, one node --test invocation per file, at most two concurrently. Node v24.22.1 on the requested PATH; set -o pipefail and fresh scratch TMPDIR. The initial broad gate's sole architecture failure (412/413) is retained honestly; moved the declaration into the plugin and reran all 31 files green. Whitespace, source hashes and append-only prefix checks pass. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; per-file TAPs, exact commands, negative controls, source/pin scans and hashes: coder-stall-fix-r2-evidence/. Local commit follows verification; independent exact-commit review remains with the lead. No push, stash, forge write, real model call, contact with 10.1.1.53, deployment or live recovery.
## 2026-11-05 — MVP 3 coder-stall review F2 correction (#797), local implementation handoff
Before: 8094901bce2d46f952786eea6469643d8dac8868 propagated helper death/load errors without the fixed stream diagnostic; Node exit 1 or SIGKILL status 137 could also be ordinary complete CLI error codes. Explicit correction charter authorizes one new local commit on fix/mvp3-coder-stall-797. Queue next coder refused this linked worktree; no workaround or forge/queue mutation.
After: the adapter captures a private fd-3 completion handshake independently of answer stdout, CLI stderr and exit status. The helper's child does not inherit the completion descriptor. Complete CLI results retain their bytes and nonzero codes; invalid/absent completion emits exactly the fixed diagnostic and cannot save a new session. Helper stacks and shell crash text stay private. No completion file or writable mount, no broker/role/contract changes, and no watchdog timing/default changes. Helper SHA-256 and bundle pin: ce66fb2581dd789163f227030b71a26af04ce0d165fc5da6da6818eeac044ed0.
Verification: added adjacent SIGKILL, missing-helper, static-import and runtime-exception regressions plus complete CLI exit-1/137 byte-parity controls. Before product edits, whole-file baseline repeat had 20 pass/4 intended fail; the first run's additional short-watchdog assertion failure is retained honestly. Focused fixed file passes 24/24. Final requested matrix: 420 pass, 0 fail/skip/cancel/todo across 31 whole files, one sequential node --test invocation per file. Node v24.22.1, requested PATH first, set -o pipefail and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Real CLI localhost HTTP 400 retains identical 38 stdout bytes, exit 1 and empty stderr, no activity/session. Adapted direct and 13 managed hostile/fallback probes pass; managed helper death/missing-helper stderr is exact, with no activity/session and recorded descendants reaped. Five real bubblewrap invocations preserve read-only stream.mjs/session.json and distinguish helper SIGKILL from handled CLI errors at 1/137. Source/pin scans, whitespace and append-only prefix checks pass. Evidence and report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r3-report.md and coder-stall-fix-r3-evidence/. Local review candidate only; independent exact-commit review remains with the lead. No push, stash, amend/rewrite, forge write, real model call, contact with 10.1.1.53, deployment or live operation.
## 2026-11-06 — MVP 3 coder-stall r3 review F2 drain-window correction (#797), lead fix
Independent r3 review of 05fdfef9e..6c123ce67 returned CHANGES with one P2: stream.mjs wrote its fd-3 `complete` token before stdout had drained a large answer, so a helper killed in that window exited 137 with partial stdout and empty stderr, indistinguishable from a complete CLI error. Correction: the helper acknowledges only from the stdout write callback (an error there acknowledges `invalid`), and the token names the exit status (`complete <status>`). adapter.sh accepts only a token equal to `complete $HELPER_STATUS`; a bare or mismatched token gives the fixed diagnostic, and status 0 becomes 2. Complete CLI errors at 1/137 keep their bytes, status and empty stderr. Helper digest pin updated to ed1a052bdc1bf0bc38c28af684d7e67bc0dd47328e05f0c0a3b7e44cad625978. New regression in model-activity.test.mjs (8 MiB answer, unread stdout, helper SIGKILL) failed on 6c123ce67 (empty stderr) and passes after the fix. Whole-file matrix: 31 files, 421 pass, 0 fail, 0 skip, sequential, Node v24.22.1. Reviewer probes rerun against the fix: partial-drain reproducer passes; helper-failure 4/4; managed-hostile 13/13; confined bubblewrap probe passes; boundary probe passes after one fixture was adapted from the old bare `complete` token to `complete 19`, plus new mismatch and legacy-bare-token cases that now produce the diagnostic. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r4-evidence/. Local commit only; needs a fresh independent review. No push, stash, real model call or contact with 10.1.1.53.
2026-11-05 | #797 ui797 support implementation phase | Explicit lead charter from 05fdfef9ea1fdddf58c725c04f229ca8559fc054 on feat/ui797-support: local isolated OIDC/TLS, persistent CDP browser CLI and durable metered campaign supervisor only under packages/cli/live. Queue next refused linked worktree (exit 2); canonical queue/forge ownership remains with lead. Initial author tests identified explicit-config selection and browser evidence-count defects, corrected locally; real browser SSO and full focused gates in progress. No .53/live access, push, runtime/role/contract edits, attribution trailers or stash.
2026-11-05 | #797 ui797 support author candidate verified | Added local single-subject HTTPS OIDC and fixed TLS forwarder, persistent real-input Chromium CLI with private NSS trust, and external durable per-model metered-stop ledgers. Final focused run: 34 pass / 0 fail / 0 skip (identity 6, browser 5, budget 23); dependency regressions: 111 pass / 0 fail / 0 skip; supported processing CLI regressions: 4 pass / 0 fail / 0 skip. Initial configuration-selection, Basic credential decoding, browser readiness/count and shutdown issues were corrected; all final gates pass. Source remains confined to packages/cli/live plus append-only protocol records. Local commit authorized; independent exact-candidate review and supervised-service acceptance remain with lead. No .53/live activity, model spend, push or product-runtime/role/contract changes. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-evidence/; report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-report.md.
2026-11-06 | #797 ui797 support R2 review corrections verified | Before: local bd8340b9b accepted unrelated named instances, allowed Chromium WebSocket escape, trusted journal token totals, bypassed halt on lock failure, split model allowances across routes, omitted the absolute cutoff/100M GLM exception, and leaked browser fixtures. After: literal ui797/config/marker validation and persisted campaign revalidation; forced origin-only HTTP proxy (loopback bypass disabled, upgrades refused, HTTPS CONNECT restricted) with real HTTP/HTTPS Chromium negatives and independent zero-TCP/zero-upgrade forbidden-listener assertions; authoritative entry-token comparison/recomputed totals; preserved stale locks with durable halt on interruption/restart/storage failure; chartered proxy GLM/Claude/Codex bindings and one allowance per provider/model; inclusive 2026-11-07T06:00:00.000Z cutoff; exact GLM 100M/other models 10M maxima; awaited reverse-order fixture shutdown with owned-process checks and an intentional-failure whole-file regression. Initial CDP/response-policy browser containment and daemon close-event attempts failed and were corrected; terminal individual whole-file runs are identity 8, browser 8, budget 33 = 49 pass / 0 fail / 0 skip / 0 cancelled. All retained review probes rerun: wrong instance/deadline/split-route refuse, numeric corruption latches/halts, locked checks halt without reclaim, forbidden WebSocket hits 0, SIGTERM/SIGKILL survivors 0. The original general probe now exits 1 at its uncaught, correctly refused split-route initialization; a logging-only companion records every outcome. Final owned-process and 17440/17442/17443 listener sweeps are empty; unrelated existing sessions untouched. Source scope packages/cli/live only plus append-only logs. Queue refused linked worktree; no queue/forge write, push, stash, model call, .53/live contact or product-runtime/role/contract/dependency change. Local author candidate only; independent exact-head approval remains with lead. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-evidence/ and ui797-support-fix-r2-report.md.
2026-11-06 | #797 ui797 support R3 P1 corrections verified | Before: 19e6cb0f1 still emitted WebRTC STUN/TURN UDP despite its flag, and unavailable ledger directories bypassed halt and terminated serve. After: mandatory Bubblewrap network/PID namespaces with an isolated, up loopback; a single namespace TCP relay over a 0600 Unix socket to the existing origin-only proxy, preserving private NSS/TLS validation, control socket permissions and awaited browser-before-proxy cleanup. Real Chromium regressions from both origins create nine ICE agents against six independent IPv4/IPv6/127.0.0.2 STUN/TURN UDP listeners and three TCP listeners; zero datagrams/connections, existing WebSocket/HTTP/SSO/CA negatives retained. Campaign initialization now exclusively pins owner/config/binding/ledger evidence beside the campaign, independently of the ledger volume; missing/renamed/0500 storage still attempts supported CLI halt, explicitly reports nondurable latch/receipt, preserves evidence and never recreates an epoch. Serve survives failures and reasserts each tick/Resume, retaining its stop across storage recovery; invalid/foreign/changed targets refuse. Older intact ledger format preserved. Final sequential whole files: identity 8, browser 10, budget 43 = 61 pass / 0 fail / 0 skip / 0 cancelled. All R1/R2 probes rerun: zero forbidden TCP/UDP, all four storage variants count one halt, original latch preserved, stale-lock/corruption/cap/cutoff/identity negatives retained, SIGTERM/SIGKILL/startup-failure survivors zero. Original R1 general probe exits 1 at its correctly refused split-route setup. Independent final sweep: zero owned processes/listeners at fixed and 43 recorded endpoints; scratch sweep parser corrected before its terminal run. Exactly packages/cli/live plus this single entry and one SESSIONS line; linked-worktree queue refused, no queue/forge writes, push, stash, model call, .53 contact, host network/DNS/trust changes or unrelated-session cleanup. Local implementation ready for independent exact-head review; owner-service successful quiescence remains a later acceptance gate. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-evidence/ and ui797-support-fix-r3-report.md.
2026-11-06 UTC | #797 ui797 support R4 review corrections verified | Before: unchanged 905d313cd reproduces 13 host pathname D-Bus connections/11 AUTH EXTERNAL requests, four damaged-sidecar cases with zero halt attempts, and a coherent epoch edit accepting 4 of 8 real stored tokens against cap 5. After: empty Bubblewrap mount view with read-only system/browser dependencies, minimal dev/proc, private tmpfs tmp/run/home, browser-owned writable evidence/NSS mounts and explicit environment; actual Chromium D-Bus traffic zero on HTTP/HTTPS, diagnostic pathname/abstract/TCP attempts fail, UDP listener sees zero, synthetic 0600 file/config/runtime paths hidden. Private NSS trust and unchanged production Console OIDC sign-in pass. Version-2 ledger independently pins config bytes; sidecar read/parse/mode/deletion and semantic corruption stop with explicit evidenceErrors and counted supported halts, preserving damage. Serve retains verified target/config/epoch evidence and revalidates marker/root/store/config before targeting and immediately before halt. Normal-path independent campaign hashes/epochs must agree; real coherent-epoch-edit and zero pin hash halt without journal reset. Modern sidecar deletion stops; legacy version-1 requires intact independent config-byte proof and an unpinned legacy ledger refuses, without migration/reinitialization. README requires Bubblewrap/user/network/PID namespaces on every browser host and states identity is HTTPS 17443/oidc behind the Console TLS forwarder, never 17442. Sequential whole files budget 89, identity 8, browser 13, request-lifecycle-live 2 = 112 pass / 0 fail / 0 skip / 0 cancelled. Both unchanged reviewer probes first fail semantically on baseline, then all six controls pass; three further retained probes pass, 31 network records/12 denied CONNECTs and both WebRTC origins retain zero forbidden traffic. External-reader positive control, changed-path 64-hex pin search, ten syntax checks and whitespace pass. Development missing import and explicit PWD assertion corrected; evidence retained. Cleanup finds zero processes from this run, no fixed test listeners and empty/removed TMPDIR; 29 older other-session daemons untouched after overbroad sweep was narrowed to this run. Five packages/cli/live paths plus this entry and one SESSIONS line only; original log bytes preserved. Queue read refused linked worktree; no queue/forge writes, push, stash, model spend, real secrets, .53/live-service/host-trust changes or attribution trailers. Local implementation ready for independent exact-commit review; owner-service quiescence remains outside this author charter. Report/evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md and ui797-support-fix-r4-evidence/.
2026-11-06 UTC | #797 ui797 support R5 conflicting-ledger correction verified | Before: fce831c05 let a valid pin and a v2 ledger disagree on target and still halted the ledger's binding; the reviewer's unchanged target-conflict probe sent 8 halts to the wrong business/config (business-only corruption and a foreign ledger, check and serve startup). After: checkTarget runs corroborate() on every tick. A ledger target directs a halt only when the owner campaign, the owner pin, or the live binding of the owner campaign names the same target; otherwise the owner pin replaces it only when it matches the owner campaign hash, the live config/marker/root/store and the live config digest (evidence 'ledger-target-changed'); anything else refuses with campaign-instance-changed. Retained-target comparison stays as the second guard. Four new regressions (check, serve startup, running service, no corroboration) fail 4/4 on r4 source and pass after. Probe after fix: 0 wrong halts in all six cases. Sequential whole files budget 93, identity 8, browser 13, request-lifecycle-live 2 = 116 pass / 0 fail / 0 skip. No push, stash, .53, live service or model spend. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r5-fix-evidence/.
2026-11-06 UTC | #797 ui797 support R6 matching-damage and ledger-path correction verified | Before: 85e1ec5d1 rule (b) accepted a pin whose binding.business had been damaged to match a damaged ledger, and a foreign pin restored with the invoked ledger path; the reviewer's probe sent 8 halts to business B in check and serve startup. serveBudget re-read opts.ledger every tick, so a programmatic caller that mutated it moved stop/halt receipts to the replacement ledger. After: a pin corroborates a ledger target only when its binding names its own campaign's instance/config/business/owner and, while the owner campaign is readable, the owner's too. serveBudget freezes the startup campaign/ledger paths and retains the ledger path with the startup target; a running service accepts an exactly unchanged startup ledger as its own corroboration. Tradeoff (recorded): with no retained evidence, an owner business/config edit while the pin and ledger are intact now refuses (ambiguous target, indistinguishable from a forged pin+ledger pair) instead of halting the original; a running service still halts its retained startup target with reason campaign-changed; owner cap/epoch edits and sidecar-only damage still stop. Proof: 4 new regressions, 3 fail on 85e1ec5d1 and all pass after (the running matching-damage case already refused on r5); whole files budget-supervisor 97, isolated-identity 8, ui-browser 13, request-lifecycle-live 2, all pass/0 fail; reviewer probes with r6 expectations: matching-damage 6 cases 0 wrong halts, ledger-path 2 halts to the startup binding with stop/halt receipts only in the startup ledger, adversarial 104 cases verified (3 owner-retarget cases refuse in check/startup), r4 target-conflict exit 0. Evidence /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r6-evidence/. Local commit only; independent r6 review next.
## 2026-11-05 — MVP 3 D3 requirement texts in continuations implemented (#797)
Before: branch fix/mvp3-req-texts-797 off 62bece4a9da803d0734b435d157578ea5c8d2001 (planning-gate fix) in the T3 worktree; coder/reviewer could not read the product's approved requirement records (D3), and list_requirements remains PM/CTO-only by row 90 (#1584), asserted unchanged by packages/harness/tests/tools.test.mjs. Scope chosen by the lead: no role/tool widening; the bus itself quotes the task-cited approved texts into the implementation/review continuations it already sends.
After: broker #continuationQuote resolves, host-side and never from model input, this request's task.created citations (same product, exact approved snapshot) through the same catalog resolver behind the scoped requirements verb (#requirementsFor), bounding titles+texts at CONTINUATION_QUOTE_BUDGET=16384 bytes with a truncation+ask-PM line. request-continuation renders the block for push_working/review_verdict only, framed as quoted owner-approved data, never instructions; intents/evidence digests unchanged (quote lives in the message body only). request-lifecycle resolves the quote before its journal transactions (resume path; advance reuses the pre-transaction catalog.resolve result, covering the review→implementation changes loopback). Unresolvable or revoked snapshots and missing citations degrade to an ask-PM line without blocking or throwing. roles/coder.md and roles/reviewer.md gained one duty note each; no roles/*.json, tools.mjs gate, schema or authority change. New regressions: exact text/snapshot/records-commit/task citation with foreign-request, foreign-product and unknown-id isolation on a fresh send; reviewer quote; stale-drift and revoked-snapshot fallbacks without throw; budget truncation ordering. Whole files: bus+tasks 197 pass/0 fail/0 skip; harness+business 110 pass/0 fail/0 skip (Row 90 assertion green, coder/reviewer still lack list_requirements); adjacent recovery/limits/cli-lifecycle/launch 45 pass/0 fail/0 skip; 352 total, git diff --check clean. Initial runs failed three new tests ([draft] status not in snapshot rows; append-only request_snapshots/events triggers; successor reuses a waiting unroutable message) — corrected the tests, not the guards. Receipts: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-*.tap and req-texts-fix-r1-report.md. No push, merge, runtime write or product-acceptance claim.
## 2026-11-05 — MVP 3 D3 requirement continuation texts, implementation R2 (#797)
Before: exact R1 baseline 6e5cf26e7 on the owner-authorized fix/mvp3-req-texts-797 worktree. Independent review returned CHANGES: approved titles/bodies could forge PM/system/continuation lines, and catalog lookup could invalidate approval while resume dispatched obsolete implementation state. Corrected negative control ran the whole continuation file against unchanged production: 31 pass / 7 fail / 0 skip, including all four hostile coder/reviewer field cases and one persisted obsolete revision-7 coder message. Initial hostile fixtures included forbidden NUL and were rejected at PRD validation; removed that fixture character only, retained the initial receipt, then reproduced the actual containment failures before changing source.
After: every requirement field is rendered in one JSON record per line under explicit data markers, with quotes/backslashes/control characters and Unicode controls/formatters/line separators escaped. The 16,384-byte limit still counts raw UTF-8 titles plus texts before encoding; metadata and escaping add message bytes. Budget/codepoint/later-ID/truncation/ask-PM semantics remain. After async lookup, resume rereads revision/phase/snapshot, discards changed bindings and re-settles before any continuation write. Real-catalog regression commits a new owner spec at implementation revision 7, consumes/relaunches the coder and proves discovery revision 8 with no newly persisted obsolete coder message. Four hostile-field/consumer cases and two encoding/budget cases added; changed continuation file 38/38. All 225 bus/tasks/harness/business whole files ran sequentially with Node v24.22.1, per-file pipefail/tee and fresh /mnt/storage/scratch/tmp/XXXXXX TMPDIR: initial 2287 pass / 1 fail / 0 skip, solely the known claude-session.test.mjs:329 row-66 correction-notice flake on turn 3; unchanged whole-file rerun 10 pass / 0 fail / 0 skip. Terminal repository cases 2288/0/0; reviewer-suite author rerun 11/0/0 plus byte-identical real-catalog reproduction 1/0/0. Initial failure remains recorded, not relabeled. No other positive failure or skip. Intent/evidence/digest compatibility, deduplication and coder/reviewer list_requirements exclusion pass; protected role/tool/schema hashes unchanged. Changed-path hex-pin search has no pin, positive control finds four. One local commit authorized; no push, amend, rebase, force, stash, remote/live/model/secret/fleet/.53 action. Queue refused linked worktree, no bypass/move. Report and per-file evidence: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-fix-r2-report.md and req-texts-fix-r2-evidence/. Ready for independent exact-commit review; no R2 approval, CI, deployment or product acceptance claimed.
R3 encoded-budget correction (#797 D3, 2026-11-06, MVP3 lead): r2 review P2 found the raw 16,384-byte budget let JSON/control escaping grow one quote to ~98 KB, so stateBlock+continuation reached 142,978 bytes and the runner's MOSAIC_REQUEST exec failed E2BIG. request-continuation.mjs now applies QUOTE_BUDGET=16384 to JSON-encoded title+text bytes (same escaping as the rendered records), cutting on whole code points so no escape splits; later ids stay listed and the truncation/ask-PM notice is set by the render-time cut. broker CONTINUATION_QUOTE_BUDGET stays as a raw pre-bound and now also flags a title-only cut. Tests: two budget tests rewritten for encoded bytes, one control-escaping growth test, one execution-boundary regression (50-task state page with 250-character CJK titles + maximal escaping, runner stateBlock/turnRequest, /bin/sh spawn with MOSAIC_REQUEST). Negative control on 50a8d1a43: 3 of 4 new tests fail, boundary test E2BIG at 142,978 bytes. Fixed: request-continuation.test.mjs 40/0/0; bus+harness whole suites 1851 pass/0 fail/0 skip, Node v24.22.1; git diff --check clean. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-r2-fix-evidence/. Local commit only; no push, merge or live action.
R4 request-space correction (#797 D3, 2026-11-06, MVP3 lead): r3 review P2 found the quote serialized every cited task reference and record field outside the 16,384-byte title+text budget; 800 valid references plus the real 50-task state page made a 144,399-byte MOSAIC_REQUEST and exec failed E2BIG. request-continuation.mjs now derives the room from the host exec limit (REQUEST_ENV_MAX 131,056 = 131,072 minus "MOSAIC_REQUEST=" and NUL), the worst-case state page (STATE_BLOCK_MAX 40,724 for 50 tasks) and a 2,048-byte turn frame, leaving CONTINUATION_BODY_MAX 88,284. The quote gets that minus the lead. It charges the record skeleton for every requirement first, so every cited id stays listed; titles then texts fill min(16,384, remaining) with whole code-point cuts; task references fill what is left and the rest are counted in tasks_omitted; the truncation/ask-PM notice is kept. If even the skeleton does not fit, the block is replaced with a one-line ask-PM notice. Negative control: the three new tests against b3ee8a2bc's renderer fail 3 of 43 (800-reference case E2BIG at 144,399 bytes); with the fix the whole file passes 43/0/0. Bus+harness sweep 1853/1/0: the one failure is harness claude-session.test.mjs row 66 (scripted reply order), outside this diff; the whole file alone failed once and passed once on rerun, recorded as a pre-existing flake. Follow-up not fixed here: the lead (intent evidence with state.tasks and events) is itself unbounded, so a request with roughly 4,500+ tasks still exceeds the exec limit with no quote at all; that needs a file transport for MOSAIC_REQUEST or bounded intent evidence in its own row. Local commit only, awaiting independent r4 review.
Correction to the R4 request-space entry (#797 D3, 2026-11-06, MVP3 lead): the stated limits were off by 50 bytes. STATE_BLOCK_MAX is 1024 + 50 * (2 + 41 + 1 + 750 + 1) = 40,774, not 40,724, so CONTINUATION_BODY_MAX is 88,234, not 88,284. The code was always computed from the expression; only the prose was wrong. The "roughly 4,500+ tasks" follow-up threshold is also wrong for the real fixture: with two evidence event ids per task, the lead alone passes the 88,234-byte static body budget at about 972 cited tasks (92,363 bytes), and the bare request there is 130,955 bytes, 101 bytes under the 131,056 exec limit.
R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that both ask-PM fallbacks (oversize 131 bytes, unresolved 153 bytes) were appended without checking the room. A lead with 0-130 bytes left grew the body past CONTINUATION_BODY_MAX, and the real 972-task lifecycle case went from a 130,955-byte request that execs to 131,086 bytes and E2BIG. quoteBlock (now exported and outside createContinuations) holds every outcome to max(room, 0): the full notice when it fits, else the 47-byte "Ask the PM for the approved requirement texts." line, else nothing. That includes the final over-room check on a built block. New tests: a sweep of rooms -3..160 for push_working and review_verdict with both the unavailable and the oversize quote, checking the exact notice choice at each boundary; and the real 972-task lifecycle case (the shared manyCitedTasks helper, now also used by the 800-task test) asserting no fallback is appended and the request execs. Negative control in a scratch worktree at 2297d532e: both new tests fail, 972-task E2BIG at 131,086 bytes (the boundary sweep fails on the missing export there). With the fix the whole continuation file passes 45/0/0 on Node v26.12.1. The unbounded lead stays the recorded follow-up (file transport for MOSAIC_REQUEST or bounded intent evidence); in this fixture it reaches E2BIG on its own just above 972 tasks. Local commit only, awaiting independent r5 review.
2026-11-06 | #797 MVP3 project archive round 2 — implementation charter | Owner-directed correction of round 1 candidate 3e21d239b: close archived thread/session execution and product/request/PRD/task admissions, preserve accepted-work completion and history, restore archived navigation and the two CSS hooks. Local feat/mvp3-project-archive-r2 only; no push, merge, deployment, delete, role/contract changes or fleet changes. Cross-lane budget-supervisor schema correction remains for integration. Reviewer-probe regressions first reproduced 8 failures (58 passes); exact runs retained under /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-evidence/.
2026-11-06 | #797 MVP3 project archive round 2 — author verification complete | Archive admission now spans queued thread/session execution, product/repository intake, PRD activation, task requirements, bootstrap effects and request-bound manifests/continuations; accepted results, task/review/delivery receipts, cancellation, release/end and history remain usable. Archived navigation exposes saved threads/room history and Unarchive with only the two archive CSS hooks. Final full bus 1618/1618; WebUI 583/583; other path consumers 230/230; final affected lifecycle/launch files 67/67. CLI path gate 585/587: two dependency copy-fixture failures reproduced serially, then whole case2-install file 28/28 after removing an accidental yaml/yaml self-link; all other 31 files passed. Every earlier failed run and correction retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-evidence/. Store/schema remain 3t; cross-lane budget-supervisor correction deferred as directed. Local candidate for independent review only; no push, merge, deployment, delete, role/contract or fleet changes. Report: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md.
2026-11-06 | #797 MVP3 project archive round 3 — implementation and verification | Before: r2 head 1c33c342b had two P1s: stale final launch admission and an omitted origin-chat binding downstream. After: a single broker snapshot fences thread and manifest at launch; shared complete request-binding checks fence bootstrap effects, PRD activation and task/requirements admission while preserving accepted receipts and reads. Added permanent race/origin/shared-repository regressions and operator guidance. Node 26: exact-parent negative 5 behavioral failures / 1 control pass; final regressions 6/6, bus excluding socket file 1614/1614, WebUI 583/583, corrected whole release file 226/226. The separate project-files socket test timed out after five passes (exit 124, one cancelled wrapper); C8 had 569/587 after fixture timeouts were fixed, with 17 release-fixture failures subsequently corrected and the known parent adapter-exited-127 failure remaining. Initial failures, full commands and comparisons are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-evidence/ and the report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. No schema/store, supervisor, role, contract or policy changes; no push, merge, deployment or live acceptance. Independent limited re-review remains.
2026-11-06 | #797 MVP3 project archive r4 — integration and author verification | Before: r3 f23a998ee and integration 5255e4e74 conflicted; the reviewed resolution tree reproduced WebUI 625 pass / 15 fail. After: exact reviewed merge 6a655a5cb, archive route fix and readable unknown archive state with real broker refusal evidence. Node 26 final WebUI 644/644, bus excluding project-files 1642/1642, harness 277/277, CLI whole-file partitions 1198 pass / 1 FED R7 skip. Candidate and integration parent project-files each timed out after five passes with three socket cases unfinished; this is not a green whole-bus gate. Earlier focus and Claude row 66 failures, clean reruns, comparisons and exact commands are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md and project-archive-r4-evidence/. Local commits only; independent delta review remains, no push or deployment.
2026-11-06 | #797 MVP3 project archive r5 — WebUI implementation | Before: r4 head 37f38d8e1 exposed unguarded request and session writes when archive lookup failed, and mapped guarded attach's project-archived refusal to 503 outcome-unknown. After: the unknown-state thread keeps reads and guarded writes available with a warning, suppresses unguarded controls and handlers including library revert, and reports guarded attach as 409/refused; live and archived recovery are tested. Node 26: final WebUI 649 pass / 0 fail / 0 cancelled / 0 skipped; exact-r4 negative 5 behavioral failures, real authenticated archived-origin attach 409. An initial diagnostic full run had 647 pass / 2 fail while source was changing (session catalog recovery and known-archived clear cancellation); both pass on exact r4 and after correction. All iterations, comparisons, inventory and commands: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md and project-archive-r5-evidence/. Bus/harness/CLI not run for the WebUI-only change. Queue refused this linked worktree; no queue move, push, merge or deployment. Local implementation candidate for independent review.
2026-11-06 UTC | #797 stall-guard implementation started | Pinned 5df42478b6c1ef5e22e2eb6c3fa8605722ab5f77 on feat/ui797-stall-guard. Before: both pi print mode and Claude text mode emit the turn reply only at completion, so a live first turn can be stopped despite model output; a chief can also immediately repeat an instance-running launch refusal. Scope is event-based first output and bounded launch refusal handling with focused and package gates; no configuration, role, schema, proxy initialization or live-instance change. The queue command refused in the linked worktree; no canonical queue access.
2026-11-06 UTC | #797 F6 streamed first output | Managed pi JSON events and Claude stream-json partial events pass through one harness monitor; substantive text, thinking and tool-use events mark a fixed output token while stdout remains the final answer. Six real confined runner/adapter controls use a six-second guard: both streaming turns survived and both headers-only and keepalive-only cases stopped; both streaming tests failed on the pinned base and all four stall controls passed there. Harness suite 250 passed; CLI main partition 909 passed, one failed and one skipped, with its 53-test file rerun green; the failure also reproduced on the base. No guard threshold, proxy initialization, configuration, schema or role changes. Release and lane partitions are retained under /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-evidence/.
2026-11-06 UTC | #797 F7 launch refusal guard | The typed launch path now reports instance-running as an existing run to wait for, serializes calls per instance and delays a new call for 30 seconds after a refusal; a disconnected caller cannot start a queued launch later. There is no automatic retry, an unknown outcome remains distinct, other instances are independent, and success clears the delay. Five new tests failed on the F6 base and passed after the fix; the complete harness suite passed 255/255. The backoff lives for the requesting runner's lifetime and resets on its restart. No policy, role, schema, configuration or live change.
2026-11-06 UTC | #797 stall-guard review fix round | Before: managed stdout added or omitted bytes relative to adapter text mode, a signalled child lost its exit-137/stderr receipt, and no-route incurred the instance-running cooldown. After: adapter-specific byte rendering, a fixed signal-name diagnostic and signal exit status, and a 30-second cooldown only for instance-running; no-route can be retried after the route is set. New regression controls fail on 3e754c96a; real local-API adapter comparisons and owned-child death receipts match base behavior. Node 26 harness rerun 270/270; CLI whole-file coverage 1,197 pass and one existing federation skip after two whole-file reruns (one known timing flake and one interim stdout-fallback regression, both rerun green); isolated release 251/251 and gate-lane 61/61. The managed-session copies pass 20/20; all six real guard scenarios pass. Bus project-files independently reproduces the reviewed base hang under an external timeout. Initial namespace-only project-files attempt hit interpreter-unsafe; corrected ordinary-user run retained. Queue refused linked worktree; no push, deploy, model calls, live host, secret, policy, role, contract or schema changes. Exact evidence and remaining review boundary: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-r2-report.md.
2026-11-06 UTC | #797 stall-guard integration merge | Before: stall-guard tip 6b2c925b9 and integration 5255e4e74 each supplied a Claude activity route; managed Pi also had two potential marker producers. After: BASE's Claude stream helper is authoritative, the Pi monitor is retained and its extension duplicate is disabled for managed turns, runner metadata suppression and newline filtering coexist, instance-running-only cooldown remains, and known/unknown child-signal receipts survive on both paths. Claude empty-delta handling follows BASE's selected helper; helper SHA pin updated. Node 26 final harness 309 pass, CLI main/lane/release 885/61/251 pass with one FED R7 skip, separately verified guard E2Es 11 pass, bus excluding project-files 1615 pass. Bus project-files had 5 pass then timeout/cancellation on both candidate and exact BASE; gate is not green. One earlier harness run failed the known Claude row-66 timing assertion (exact BASE full harness 277/277 and two file runs 14/14; older-base reproduction recorded by prior review); final rerun passed. An initial signal test invoked the removed Claude monitor and was adapted. Queue refused linked worktree; canonical queue read returned nothing. Local merge candidate only, no push or deployment. Full counts, caveats and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md and stall-guard-int-evidence/.
2026-11-06 UTC | #797 supervisor schema guard implementation | Before: the 3s-only accounting guard refused the 3t archive store. After: the lead-authorized closed 3s/3t fallback preserves 3s compatibility and accepts 3t; store.mjs performs load-time file reads and in-memory schema/migration execution, so it is not imported into the supervisor. The real-Store current-version regression failed on BASE with test-store-schema-refused; synthetic 3s accepts, 3r/3u/missing refuse. DDL diff adds only project_archives objects. README documents owner diagnosis and restoration or campaign end without reset. Sequential Node 26 whole-file gates: budget 102/0, isolated identity 8/0, browser 13/0, lifecycle 2/0; no skips. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/; local candidate for independent review, no push or deployment.
2026-11-06 UTC | #797 resume chief implementation | Before: halted and launches-disabled receipts counted as the chief's launch attempt, so a resting request could not relaunch after resume at an unchanged prerequisite. After: continuation lookup excludes only those host-gate receipts while preserving visible refusals and earlier real attempt failures. Three real-bus/launcher regressions fail behaviorally on 13f81354e and pass with the fix, including bounded repeated ticks and a launch-failed retry guard. Node 26 touched file 12 pass; bus excluding project-files 1642 pass; serial CLI 1207 pass, 1 base-reproduced FED R7 skip. Initial concurrent CLI 1199 pass/8 fail/1 skip: four timing cases passed serially on base; four Claude wrapper failures reproduced on base and passed with the direct installed binary. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/resume-chief-evidence/; report: /mnt/storage/scratch/mosaic-lead/mvp3/resume-chief-report.md. One local commit for independent review; no push, merge, deployment or live host action.
2026-11-06 | #797 MVP3 project archive r6 — WebUI implementation | Before: r5 head 8c3f48113 exposed server-unguarded request, accepted-clear cancel, library Revert and project settings Save controls on a known-archived project. After: known archived state suppresses those controls and fences stale handlers while preserving readable history, settings, Unarchive and the guarded attach picker. Node 26 frozen WebUI 654 pass / 0 fail / 0 cancelled / 0 skipped; five new tests plus the changed navigation assertion fail on exact r5 production for expected behavior; real authenticated Store/Broker probes verify guarded send, resume, clear and attach refusals. The first whole-suite run was diagnostic because the final disposed-form guard was added during it; it also reported 654/0/0/0. Detailed inventory, iterations, commands and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r6-report.md and project-archive-r6-evidence/. Bus/harness/CLI suites not run for WebUI-only change. Queue refused this linked worktree; no queue move, push, merge, deployment or server guard change. Local candidate for independent review.
2026-11-06 | #797 MVP3 project archive r7 — WebUI implementation | Before: r6 head 51892cf95 lost known archived state during failed refresh, reopening repository Save and room Open and exposing guarded thread controls. After: settings, thread and room derive archive state from the displayed metadata through pending and failed reads; successful live reads restore controls, and refusal clearing remains intact. Node 26 final WebUI 658 pass / 0 fail / 0 cancelled / 0 skipped; four browser regressions fail on exact r6 for expected behavior. An earlier diagnostic whole suite had one 1440px remote-fixture timeout; candidate and r6 isolated reruns each passed 4/4, and the final whole suite passed that case. Room Send remains disabled on failed membership reads as before. Full iterations and comparison: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r7-report.md and project-archive-r7-evidence/. Bus/harness/CLI not run. Queue refused this linked worktree; no push, merge, deployment or server guard change. Local implementation candidate for independent review.
2026-11-06 UTC | #797 MVP3 project archive r8 — WebUI implementation | Before: r7 head 5bf07f35f temporarily withdrew live thread controls and showed archive-unavailable notices on every same-thread refresh without a failed read. After: a non-navigation redraw retains the resolved archive state only for the same thread with its project in retained metadata; missing retained metadata on failed reads remains unknown. Two delayed refresh ticks retain live request, session, share, cancel and revert controls; a thread switch still withdraws them and stays unknown after failure. Node 26: exact-r7 negative reproduces disabled session control, switch control passes; focused archive browser files 11/11; full WebUI 660 pass / 0 fail / 0 cancelled / 0 skipped, versus r7 658/0. The 1440px remote fixture passed. Bus/harness/CLI not run; project-files excluded. Queue refused linked worktree; no queue move, push, merge, deployment or server guard change. Local candidate for independent review. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-report.md.
2026-11-06 UTC | #797 MVP3 project archive r9 — WebUI fix for the r8 review P1 | Before: on r8 head 4c85d3784, a same-thread redraw kept the last resolved live controls. If the fresh lookup then found the project archived (listed or resolved) or unknown, the mounted request panel still offered mode, cancel, revoke, resend and answer, and the session, share, unshare, cancel and revert controls stayed enabled until the slower session and history reads finished. After: right after the fresh project lookup, a same-thread refresh that resolves archived or unknown withdraws those controls and remounts the request panel fenced, before the session read. The navigation and initial-load paths are unchanged. New browser test with the sessions read held open covers listed archived, resolved archived and unknown, and asserts no write. Node 26: negative control on exact r8 fails for behavior ("listed archived: [data-request-mode] offered while the sessions read is pending"); the r8 reviewer probe goes from 3/1 on r8 to 4/0; focused archive browser files 12/12; whole WebUI 661 pass / 0 fail / 0 cancelled / 0 skipped, versus r8 660/0. Bus/harness/CLI not run; project-files excluded. No push, merge, deployment or server guard change. Local candidate for independent review. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r9-evidence/.
2026-11-06 UTC | #797 write_record JSON object compatibility — implementation and verification | Before: JSON-encoded requirements and decisions were refused and project-record-invalid detail was lost before the agent. After: strict object parsing preserves object-form bytes, and only record.write project-record-invalid details reach the tool result; docs remain Markdown. Exact-base negative control: seven behavioral failures. Node 26 serial gates: touched file 7/7, project 50/50, harness 316/316, bus excluding project-files 1642/1642, CLI 1204 pass and one federation acceptance skip reproduced on base. An initial candidate focused run failed one test due to a duplicate code prefix; corrected and rerun green. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Linked-worktree queue refused; local candidate for independent review, no push or deployment.
2026-11-06 UTC | #797 Files polish — implementation and author verification | Before: caller and Files module each rendered a heading, and request redraws replaced settled content with a loading message. After: callers retain the single heading; cloned DOM content is cached per deduplicated request list or validated thread key, fenced against older mounts and cleared at sign-out. Exact-base negative control: 8 behavioral failures, 3 passes. Node 26 serial authorized browser gate: 91 pass, 0 fail, 0 cancelled, 0 skipped. An initial detached-container test timed out because its fixture awaited its own held request; the same timeout reproduced on base and the fixture was corrected. All evidence and limits: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-report.md. Local implementation candidate for independent review; no push, merge or deployment.
2026-11-06 UTC | #797 Files polish r2 — review r1 F1/F2 ordering fix | Before (86383a9d1): the per-key sequence fenced only cache writes, so an older same-key read in another live container painted stale files after a newer mount settled (F1), and a pending newer mount kept a completed result out of the cache, so a remount flashed "Reading files…" (F2). After: each mount takes a start order; a settle paints and caches only if no later-started mount for the key has committed, otherwise it shows the newer cached result; a pending mount never blocks an earlier completion. Reviewer probes ported as two tests; on 86383a9d1 they fail 2/13 for behavior, on the fix w15-files-browser 13/13 and the touched caller browser files 93/0/0/0 (Node 26 serial). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-r2-evidence/.
2026-11-06 UTC | #797 round A task evidence and request polling | Before: advance_request omitted missing per-task push/review chains, the coder continuation seeded the first task regardless of evidence, and missing request IDs were polled repeatedly. After: the broker names all incomplete task chains and supplies the same read-only result to continuation selection; scoped harness serialization exposes the refusal with the existing 300-character bound; thread-page readers suppress confirmed missing IDs until a new caught-up thread message. The owner authorized the narrow harness socket addition. Exact-base controls: five behavioral failures in the initial control and one in the bounded harness control. Node 26 serial named-file gate: 142 pass, 0 fail, 0 skip. An intermediate rerun was deliberately stopped after the owner clarified the bound; its log is retained. Queue refused this linked worktree. Local candidate for independent review; no push or deployment. Report and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-report.md and /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-evidence/.
2026-11-06 UTC | #797 round A fix r2 (Sol r1 P2 + two P3) | Before (97d670da9): a remembered request-not-found was retried only when the newest message on a caught-up thread changed, so a request opened with no new message stayed hidden; misses grew with paged history; an aborted read got a fabricated 404. After: misses are also cleared when the owner opens the thread (navigated) and when a mosaic-request-state for the id is seen anywhere on the page; timer refreshes and repaints still never retry. Misses are pruned to ids in the retained rows (200-row cap). An already-aborted read bypasses the memory and keeps the native AbortError. Remaining limit: the thread page reads only messages, so an event-only open with the thread left open and no navigation shows at the next message, navigation or reload. Closing that needs a server-visible thread signal, outside this change. New tests fail on 97d670da9 chat.js (2/2); webui chat-sessions-access, chat and browser files 40/0/0 (Node 26 serial). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r2-evidence/.
2026-11-06 UTC | #797 round A fix r3 (Sol r2 P2) | Before (16384bb12): a mosaic-request-state for an id advanced the thread's epoch only when a remembered miss was deleted, so a 404 still in flight from before the observation could record the miss afterwards and timer refreshes kept fabricating 404s for an open request. After: each observed state advances that id's own generation on every thread that retains it; an in-flight 404 records a miss only if both the thread epoch and the id generation are unchanged. Other ids' slow misses still settle. Generations are pruned to retained rows with the misses. New held-404 test fails on 16384bb12 chat.js at recovery; webui chat-sessions-access, chat and browser files 41/0/0 (Node 26 serial). Lead ruling: no third review round. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r3-evidence/.
2026-11-06 UTC | #797 round A fix r4 (Lead push gate red on 9d3eb9e11) | Before (9d3eb9e11): the chat.js fetch wrapper recorded a request-not-found from /api/requests/<id>/workspace as a missing request, then served a synthetic 404 to every later read of that id, including request panel rereads; request-browser.test.mjs failed 11/24 (its stub has no workspace route), base 6b84fe136 24/0. After: only the request's own read records a miss; a remembered miss still answers its workspace read in the page. New regression test fails on 9d3eb9e11 chat.js and passes on the fix. The r3 held-404 test raced Files' cached repaint under concurrency 3 (678/1; alone 5/5 pass); it now waits, bounded, for the refresh's reads to reach the server and still fails on 16384bb12. request-browser 24/0; whole webui suite except project-files 679/0/0/0 (Node 26, concurrency 3). Correction: the r1-r3 author gate ran only chat-sessions-access, chat and browser and omitted request-browser.test.mjs, which also exercises chat.js; that omission let this regression reach the push gate. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-push-gate/r4-*.
2026-11-06 UTC | #1847/#1848 fix row (Round B halt, key roundb-2-halt) | Before (1186cf7b1): the runner auto-replied a RESULT to every non-RESULT message, so chief INFO/RESULT replies to assistant RESULTs looped (thread 40df75c5, 54 messages, 1,036,184 assistant tokens); continuation notices keyed on business-wide launch/end/claim seqs, so every unrelated lifecycle event, including kill-switch ends during a halt, re-noticed the chief and drove 13 instance-running refusals (1,198,607 tokens). After: the broker adds in_reply_to_class to agent inbox rows and the runner sends no automatic reply to an agent's non-REQUEST reply to a RESULT (REQUEST, ordinary INFO and human/tester/user messages unchanged); continuation versions use only the target's own lifecycle events and claims, no notice while processing is halted or while the target's latest launch has not ended; the instance-running refusal tells the chief not to relaunch or relay. Builder 4048e0f60 (Codex Terra xhigh); Sol xhigh review r1 CHANGES: a halted broker restart failed on the new retry refusal (P1) and an older unmatched launch hid later ends, worsening #1851 (P2). Lead fix c04529c7b: startup request recovery waits while processing is halted or invalid, as the periodic pass does, and resume reschedules it; only the target's latest lifecycle event gates notices. Each regression test fails on its base. Gate on c04529c7b (Node 26.12.1): touched 123/0, bus except project-files 1648/0, harness 318/0, webui 678/1 then 679/0 on rerun (decide-browser I1 timed out under full-suite load; file alone 9/9 twice; no webui diff vs base). project-files.test.mjs hangs on base and candidate alike. Residual: a latest launch whose end is lost still suppresses notices until host recovery (#1851). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/step14/round-b/fix-1847-1848-gate/.
2026-11-06 UTC | #1852 length-stop implementation | Before: an empty Pi length stop could become an empty RESULT, and the GLM registration inherited a 16,384 output cap. Candidate: marker metadata identifies the failed turn, bounded continuations share one usage transcript delta, exhaustion writes a message-trail event and a chief notice, and GLM limits are a separate plugin change. The lead accepted the larger limit's Pi compact-and-retry effect and the later usage limit check. The first bus gate exposed a missing sandbox binding for the shared notice constant; the binding was added, and the three affected launch cases passed in their file. A Claude row-66 assertion failed intermittently in candidate runs, while an untouched base full harness run passed; no base reproduction is claimed. The existing library citation retry's :retry usage calls remain a follow-up. Local work only; no push or deployment. Evidence: /mnt/storage/scratch/tmp/mDnSad/.
2026-11-06 UTC | #1852 verification | Node 26 final harness 332/0, bus excluding project-files 1648/0, launcher and sandbox 69/0, and additional grep-selected tests 252/0. An overlapping bus run had library-sync-busy and a 30-second timeout; both affected files then passed 27/0 and the full bus rerun passed. The candidate's Claude row-66 test failed in an earlier full and isolated run, then the candidate and exact-base full harness suites passed 332/0 and 318/0 respectively; the base failure was not reproduced. Evidence: /mnt/storage/scratch/tmp/mDnSad/.
2026-11-06 UTC | #1852 Sol review and lead fix round | Sol xhigh review of 3c689efa3/44c3b19fd: CHANGES, four findings. A processing halt between attempts still started a continuation, since state and usage.end pass the halt gate; a failed continuation check replied with an empty RESULT; a throwing attempt skipped usage.end; library retry continuations ran after the message's usage.end. Lead fix 3e03e123f: each continuation first reads state and processing.status, and a halted, invalid or unreadable processing state starts nothing and leaves the message unread with no reply; a failed check keeps the failed-turn classification and sends no RESULT; usage.end for the message id runs in a finally; the library citation retry starts no continuations, and an empty length retry fails visibly with no fixed RESULT (the retry's own bracket is refused, #1853). Each new runner test fails on 3c689efa3. Part B re-cut with the compaction effect qualified (gated as 542dbe030 directly on 3e03e123f; on the branch it follows this docs commit with the same code): pi allows one recovery in a row, re-armed by a new prompt or a successful reply, inside one runner attempt; output clamping now starts above about 58368 estimated input tokens instead of 107520; threshold compaction is unchanged above 111616. Corrections to the builder entries above: the one usage bracket did not cover the library retry continuations or a throwing attempt; the Claude row-66 failure does reproduce on base 3598839e6 (1 of 8 isolated runs, candidate 3 of 8), so it is a pre-existing flake.
2026-11-06 UTC | #1852 gate | Node 26.12.1 on 542dbe030 (A+fix+B): touched 143/0, bus except project-files 1648/0, harness 337/0, webui 679/0 (first run 676/2/1: proxy-architecture could not load esbuild because the gate worktree lacked packages/webui/node_modules, and decide-browser I1 timed out; both passed 10/10 once the modules were linked, then the whole suite passed), grep-selected readers of the changed files 375/1. On 3e03e123f (A+fix, no B): touched 142/0, harness 335/1 (row-66 flake; file rerun 14/0), readers 375/1. The one reader failure, plugins harness.test row 65, also fails on base 3598839e6: first-output.mjs:42 (c7ddc7d31) and runner.mjs:406 (478df1cac) name a harness in core source; out of this row's scope. Evidence: /mnt/storage/scratch/tmp/PKGwA7/ and /mnt/storage/scratch/tmp/g0DYZH/.
2026-11-06 UTC | UI slice 4 polish — implementation phase (recorded retrospectively) | Before: ui-dev base ad9b2dee4 had low project headers, clipped composer layouts, incomplete conversation work-card summaries and opaque or inappropriate agent launch controls. The assigned scope is documented in /mnt/storage/scratch/mosaic-lead/uidev/brief-s4-polish.md. The linked-worktree queue refused; canonical checkout access was excluded by the assignment. After and verification are recorded in the following phase entry.
2026-11-06 UTC | UI slice 4 polish — implementation and verification | After: project headers, mobile details controls, phase stepper, linked PRD/files summaries and viewport composers were refined; thread instances no longer offer Launch, lifecycle refusals show codes and hints, and the unused proxy document was removed. Node 26 full WebUI suite: 758 pass, 0 fail, 0 cancelled, 0 skipped. Baseline and repaint/count/layout negative controls, intermediate failures and screenshot comparisons are recorded in /mnt/storage/scratch/mosaic-lead/uidev/s4-report.md and adjacent logs. Runtime source and tests were unchanged during the final suite; these append-only phase notes were recorded during and after it. Queue refused the linked worktree; no push, merge or deployment. Local candidate for independent review.
2026-11-06 UTC | UI slice 6b — implementation phase | Before: ui-dev base 43686b145 shows unavailable project states for absent records and message correlation IDs with no lifecycle record, and the audit records several remaining presentation defects. The assignment and protected boundaries are in /mnt/storage/scratch/mosaic-lead/uidev/brief-s6b.md. The queue refused this linked worktree. Verification and outcome are recorded in the following phase entry.
2026-11-06 UTC | UI slice 6b — implementation and verification | After: missing project records have a named refusal and empty presentation; message correlation IDs with no intake record no longer imply failed request reads, while real failures remain visible. Conversation, holder, tab, chip and Assistant sizing polish is included. Node 26 final gates: project 51 pass / 0 fail / 0 cancelled / 0 skipped; WebUI 786 / 0 / 0 / 0, bounded to four CPUs after two load-sensitive timeouts passed independently. The earlier full run and its failures, fixture screenshots and request-ID trace are in /mnt/storage/scratch/mosaic-lead/uidev/s6b-report.md. Queue refused the linked worktree; no push, merge or deployment. Local candidate for independent review.
2026-11-07 UTC | Organizations S1 — implementation phase | Before: ui-dev c8d6d12fe used the instance root for one bus and the Console had no scoped lifecycle commands. The S1 stream charter and boundaries are in /mnt/storage/scratch/mosaic-lead/uidev/org-release/PLAN.md. The queue refused this linked worktree; canonical checkout access was excluded. Verification and unresolved integration boundaries follow in the stream report.
2026-11-07 UTC | Organizations S1 — implementation evidence | Organization-root bus and launcher paths, guarded lifecycle and Console launch/stop were implemented locally. Focused Node 26 tests and host regressions passed; the fresh-organization notifier choice, shared-proxy S2 integration, and remaining instance-root consumers are not complete. Exact commands, inventory, failures and boundaries: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. No push or deployment. Gitea issue creation was excluded by the no-network and no-external-communication limits.
2026-11-09 UTC | Unified workspace (#1873) — direction recorded | Before: the 11-07 rulings had one Console switch between organizations from Settings, tasks only in each org queue, and an agent bound to one organization (REQ-USER-1). After: Jason's 11-09 direction and answers recorded in docs/plans/2026-11-09_unified-workspace-owner-direction.md (all orgs visible at once, per-org task stores with a Vikunja option and an aggregated Kanban, root/org/project agents with one cross-org assistant, system type plug-ins, self-service restart and model switch). PRD 1.20 draft 1 written for his exact-text approval; the PRD itself is unchanged. The unpushed 11-07 rulings record (ad8140065) lands with it.
2026-11-09 UTC | Organizations friction round, landings #1876-#1881 (#1873) | Before: refactor 00ef0bd30. A Console launched by a relative path showed Request status unavailable, the overview printed the north star as raw markdown, packages/business and the business command spellings were the only names, Quick Setup ended with no route to a north star, an archived org could not be restored from a fresh page, and a dead bus after setup had no browser control. After, each through one Codex Sol xhigh review round plus a lead or builder fix round: #1876 re-execs a relative Console launch with its absolute script path (build 744753d7e, reproduced 121/0; review APPROVE 0/0/1). #1877 F1 renders the north star as sanitized markdown with a short preview (review APPROVE 0/0/1; live retest 4/0 twice). #1878 rename stage B moves packages/business to packages/organization and adds the org command spellings, keeping the business ones and their messages (builder 6828/6, see report; review CHANGES 0/1/0, fixed in e19db4009). #1879 F2 adds the onboarding interview after Quick Setup and on the project page, with human-only north-star approval (`mosaic project north-star --approve`) and no new agent grant (builder 411/0; review CHANGES 0/1/1: a refused start left its button disabled, fixed in 5937a977f along with the P3 stale approval capability). #1880 lands the U0 unified workspace samples, approved by Jason with the four-tile logo kept; the module names stay undecided at his request. #1881 E2E round 5 fixes restore from a fresh page, show an owner bus Start whenever the org list reads the bus down, say room changes are unavailable on org Consoles and recover the composer (builder 258/0; review CHANGES 0/1/0, 231/0: Settings kept Done after the bus died because only opening the org reread health). Lead fix fdd6658a6: every org list read is announced and the open setup redraws on a flip, and app.js reads the list before bus-status so a failed bus-status read cannot skip it; regression red on 8a8edc44b, 18 touched webui files 199/0 (Node 26.12.1, one file at a time). uiorg redeployed at fdd6658a6, 19:53Z. Open follow-ups: a down bus offers both the F3 banner Restart and the Settings Start, focus can move when setup redraws, and a Console stop also stops the org buses it started. Reports: /mnt/storage/scratch/mosaic-lead/reports/org-{reqget-2,f1,rename-b,f2,u0-design,e2e5}-*.md.
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
2026-11-10 UTC, unified workspace U1 and SYS1 (#1873; PRs #1889 d35447005 and #1890 2c6556ab1). U1, before: each org page read only its own bus and nothing read every org at once. After: /api/workspace returns projects, sessions, inbox, tasks and metrics from every org the owner holds, each row tagged with its org and each org with a state (ok, starting, down, timeout, invalid); `mosaic console restart` and `mosaic org reload <org>` send the F3 routes. Sol review CHANGES 0/2 on 2109458b3: malformed data from one org turned the whole read into a 503, and the per-org deadline left sockets and transport children running. Terra fix 0672d5fe8 validates each org's result inside its boundary and carries an absolute deadline and AbortSignal through the status check and transport; the new workspace-failures test is 0/10 on the parent and 10/0 on the fix. The lead re-pinned the webui bus.mjs hash in bus channel-proof (the deliberate signal/deadline change; nonces, argv and limits unchanged) and amended as d35447005. SYS1, before: no system-type kind. After: a system-type plug-in kind with five modules (business-management, coding, creative-writing, marketing, montage), an org enable list under plugins.system-type, owner-only project selection with an append-only project_system_types table (migration 3t-3u) and CLI verbs; the modules grant no tools yet (REQ-SYS-2 deferred). Sol review CHANGES 1/2 on a9629f332: the restore guard did not count project_system_types, the CLI channel did not admit the two project verbs, and the #811 migration-reader pin still expected 18 steps. Lead fix 2c6556ab1 (rebased on d35447005) adds the table to COUNTED and LISTED with refusal and in-run recount tests, admits both verbs with a run()-level channel test (red on the unfixed channel), and moves the pin to 19 steps ending 3t-3u; fix files 323/0 and rebase overlap files 63/0 (Node 26.12.1, one file at a time, env -i). Full gate on a9629f332 4040/5: the two stack-release pins above, and tester-reads 3/3, which also fails on 1616d1dd6 (project.mjs:95 refuses an org --tester init; follow-up). Correction to the uiorg deploy at 00:50Z: the deploy script switched the tree without the offline schema migration, so all five buses refused to start (normal startup refuses an older schema by design). The lead ran migrate-schema.mjs for the five org stores (3t to 3u, all ready), restarted the buses (201 each) and added the migration to the deploy script with stop-on-refusal. test3 refused writer-locked and stays down, as before. Reports: /mnt/storage/scratch/mosaic-lead/reports/{u1-review,u1-fix,org-sys1-build,sys1-review}.md.
+21
View File
@@ -76,6 +76,8 @@ session's workspace and its own process group. Same contract, plus:
| `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. |
| `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. |
| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
| `MOSAIC_MODEL_ACTIVITY_DIR` | Managed runner's existing writable run directory. Trusted model streaming events publish the fixed `output\n` token to `proxy-first-output` (0600, exclusive create). No event contents or credentials enter it. Claude's private stream reader and Pi's extension signal thinking, tool-use and text activity before final stdout; init/status, tool results and workspace writes do not count. |
| `MOSAIC_MODEL_ACTIVITY_SOURCE` | The harness module may set `adapter` in its bundle environment to declare model events authoritative and disable the runner's stdout activity fallback. Claude declares this because final stdout can be a synthetic error. Pi and mock retain the fallback. The runner reads the declaration from module metadata, ignoring any ambient environment copy. |
| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). |
| `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `<run>/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). |
| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
@@ -85,6 +87,25 @@ session's workspace and its own process group. Same contract, plus:
The layers each adapter relies on, and what they don't cover, are in
`packages/harness/README.md`.
Claude uses `--output-format stream-json --verbose --include-partial-messages`.
The adapter's `stream.mjs` consumes those events and prints a complete final
result, including error results, with the same newline as the CLI's text format.
CLI failures retain their nonzero exit status. Claude's model events alone mark
activity; its final stdout may be a synthetic error. The runner retains stdout
activity marking for Pi and mock adapters. Malformed or incomplete streams emit
only the fixed diagnostic and fail the turn; a failed or interrupted first turn
does not persist a reusable session ID. The helper is mounted read-only and
its digest is recorded in the launch bundle. A private completion pipe separates
handled CLI exits from helper launch/runtime failures, even if their exit codes
collide. The helper acknowledges only after its answer has been handed to
stdout, and the token names its exit status (`complete <status>`). The adapter
emits the fixed diagnostic if completion is absent, the stream is invalid, or
the helper's actual exit differs from the acknowledged status (the helper died
while its answer was draining), suppressing helper stacks and shell crash text. CLI stderr
keeps its existing channel; the CLI does not inherit the completion pipe. No extra
writable mount or completion file is needed. Pi retains print mode and signals
activity through its existing extension. Mock remains a test-only echo.
### A role's skills (row 64)
A managed session's skills reach both adapters as text in the bundle's
+34 -15
View File
@@ -11,6 +11,7 @@
# wrapped as `timeout -k 2 10 <gate> || exit 2`, and the tool limit (--tools).
# --bare is never passed: it turns settings hooks off.
set -eu
STREAM="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/stream.mjs"
need() {
eval "v=\${$1:-}"
@@ -60,7 +61,9 @@ fi
# Flags:
# -p one-shot: print the answer and exit
# --output-format text stdout is the answer only
# --output-format stream-json private model events; stream.mjs emits only
# --verbose --include-partial-messages the final result on stdout and marks
# first model activity before long tool work
# --system-prompt-file the generated prompt replaces the default, given
# as a file so no local user reads it in
# /proc/<pid>/cmdline (row 213); absolute, since
@@ -84,18 +87,34 @@ fi
# text in the system prompt (row 64)
ALLOWED=mcp__mosaic
[ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic"
"$CLAUDE" -p "$MOSAIC_REQUEST" \
--output-format text \
--system-prompt-file "$PROMPT_FILE" \
--model "$MOSAIC_MODEL" \
--effort "$MOSAIC_THINKING" \
--restricted \
--tools "${MOSAIC_TOOLS:-}" \
--allowedTools "$ALLOWED" \
--permission-mode dontAsk \
--settings "$MOSAIC_CLAUDE_SETTINGS" \
--strict-mcp-config \
--mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \
--disable-slash-commands \
"$SESSION_FLAG" "$SESSION_ID" || exit $?
# Completion travels on fd 3, independently of stdout and the CLI exit code.
# fd 4 preserves answer stdout; fd 5 preserves CLI stderr while helper/shell
# crashes stay private. The CLI never inherits the completion descriptor.
HELPER_STATUS=0
{
HELPER_COMPLETION=$(
{ node "$STREAM" "$CLAUDE" -p "$MOSAIC_REQUEST" \
--output-format stream-json --verbose --include-partial-messages \
--system-prompt-file "$PROMPT_FILE" \
--model "$MOSAIC_MODEL" \
--effort "$MOSAIC_THINKING" \
--restricted \
--tools "${MOSAIC_TOOLS:-}" \
--allowedTools "$ALLOWED" \
--permission-mode dontAsk \
--settings "$MOSAIC_CLAUDE_SETTINGS" \
--strict-mcp-config \
--mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \
--disable-slash-commands \
"$SESSION_FLAG" "$SESSION_ID" 3>&1 1>&4 4>&-; } 2>/dev/null
) || HELPER_STATUS=$?
} 4>&1 5>&2
# The token must name the helper's actual exit status: "complete 0" for
# success, "complete N" for a complete CLI error. A helper killed after its
# answer was written but before exit leaves a mismatched status.
if [ "$HELPER_COMPLETION" != "complete $HELPER_STATUS" ]; then
printf 'claude adapter: invalid or incomplete model stream\n' >&2
[ "$HELPER_STATUS" -ne 0 ] || HELPER_STATUS=2
fi
[ "$HELPER_STATUS" -eq 0 ] || exit "$HELPER_STATUS"
[ "$SESSION_FLAG" = --resume ] || printf '%s\n' "$SESSION_ID" > "$ID_FILE"
+81
View File
@@ -0,0 +1,81 @@
// Consume Claude Code's SDK event stream privately. A complete final result
// reaches stdout, including errors, with the same newline as text mode.
import { spawn } from 'node:child_process';
import { writeSync } from 'node:fs';
import { constants } from 'node:os';
import { firstOutputMarker } from '../../packages/harness/src/first-output.mjs';
const mark = process.env.MOSAIC_MODEL_ACTIVITY_DIR
? firstOutputMarker(process.env.MOSAIC_MODEL_ACTIVITY_DIR) : () => {};
const [binary, ...args] = process.argv.slice(2);
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 5] });
let pending = '', answer, resultSeen = false, resultFailed = false, failed = false, killTimer;
const fail = () => {
if (failed) return;
failed = true;
child.kill('SIGTERM');
killTimer = setTimeout(() => child.kill('SIGKILL'), 5000).unref();
};
function event(line) {
if (!line.trim()) return;
const item = JSON.parse(line);
const model = item.type === 'stream_event' ? item.event : null;
// Init/status, hooks, user/tool results and arbitrary local writes aren't
// model activity. message_start is the upstream assistant response header;
// partial blocks also cover models that first respond with thinking/tools.
const start = model?.type === 'message_start' && model.message?.role === 'assistant';
const block = model?.type === 'content_block_start'
&& ['text', 'thinking', 'tool_use'].includes(model.content_block?.type);
const delta = model?.type === 'content_block_delta'
&& ['text_delta', 'thinking_delta', 'input_json_delta'].includes(model.delta?.type);
const assistant = item.type === 'assistant' && !item.error
&& item.message?.model !== '<synthetic>' && item.message?.role === 'assistant'
&& item.message.content?.some(c => ['text', 'thinking', 'tool_use'].includes(c.type));
if (start || block || delta || assistant) mark('activity');
if (item.type === 'result') {
if (resultSeen) throw Error();
resultSeen = true;
// Let a normal CLI failure exit on its own, retaining its exit code.
resultFailed = item.subtype !== 'success' || !!item.is_error;
if (typeof item.result === 'string') answer = item.result;
else if (!resultFailed) throw Error();
}
}
child.stdout.setEncoding('utf8');
child.stdout.on('data', chunk => {
if (failed) return;
try {
pending += chunk;
let end;
while ((end = pending.indexOf('\n')) !== -1) {
if (end > 16 * 1024 * 1024) throw Error();
event(pending.slice(0, end));
pending = pending.slice(end + 1);
}
if (pending.length > 16 * 1024 * 1024) throw Error();
} catch { fail(); }
});
child.on('error', fail);
child.on('close', (code, signal) => {
clearTimeout(killTimer);
if (!failed && pending) {
try { event(pending); } catch { fail(); }
}
// Integrity and rendering are separate from the CLI's success classification.
// Buffer until close so corrupt/truncated streams never leak a final result.
if (!failed && (!resultSeen || signal)) fail();
if (signal) writeSync(5, `model child killed by ${signal}\n`);
const number = signal && constants.signals[signal];
const signalStatus = signal ? (Number.isInteger(number) && number > 0 ? 128 + number : 1) : 0;
const status = process.exitCode = signalStatus || code || (failed || resultFailed ? 2 : 0);
// Only a fully consumed stream whose answer has been handed to stdout can
// acknowledge completion, and the token names the status it will exit with.
// A helper that dies after acknowledging exits with a different status, so
// the adapter still reports it. The adapter owns the fixed diagnostic,
// including when this helper never loads or dies early. spawn's explicit
// stdio keeps fd 3 out of the CLI and its descendants.
const acknowledge = error => writeSync(3, failed || error ? 'invalid' : `complete ${status}`);
if (!failed && answer !== undefined) {
process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n', acknowledge);
} else acknowledge();
});
+4
View File
@@ -132,4 +132,8 @@ set -- \
# One-shot mode appends -p and the request (both safely quoted);
# interactive mode appends nothing - clean TUI.
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
if [ -n "${MOSAIC_MODEL_EVENT_MONITOR:-}" ] && [ "$INTERACTIVE" != "1" ]; then
STREAM="$(dirname "$0")/../../packages/plugins/harness/pi/model-stream.mjs"
exec node "$STREAM" pi "$MOSAIC_FIRST_OUTPUT_DIR" pi "$@" --mode json
fi
exec pi "$@"
+41
View File
@@ -0,0 +1,41 @@
# Organization archive and restore
Archive and restore add the `archived` registry state and increment the
revision. Archive takes the existing lifecycle lock, verifies the revision,
stops and verifies the bus through the caller-injected stop function, then
checks the revision again before changing state. The registry lock is not held
while stopping the bus. A concurrent update during the stop causes a revision
conflict and leaves the organization active, although its bus has stopped.
Restore uses the same lifecycle lock, changes the state back to active and does
not start the bus. Files remain in place; delete remains available.
The lifecycle log is append-only and private. Its line is appended under the
registry lock immediately before registry publication. A crash or a failed
registry publication in that narrow interval can leave a log line for an
operation whose state change was not published; reconciliation or a durable
transaction journal is a follow-up. A failed or uncertain stop never records a
completed archive.
Archived organizations remain available to the owner-only administration
routes, but ordinary organization resolution rejects them with `org-archived`.
The UI shares delete's reply verification, organization refresh and current-org
switch logic. Archived documents are read-only, setup controls are hidden with
an explanatory notice, and restore does not start the bus. Screenshots are
untracked under `shots/`.
The service, CLI, route and browser regressions and the requested existing
checks passed in clean scratch homes. The full business suite passed. An
additional routing check first caught a too-broad admin-hook exemption; it was
narrowed to the built-in handler and the web checks passed again.
## Review fix: lifecycle fences
The internal host now takes the organization lifecycle lock before checking
registry state and holds it through host readiness. The parent takes a short
separate lock around its own host-directory preparation, then releases it before
spawning the child, avoiding a parent/child deadlock. If archive wins between
those two lock acquisitions, the child rechecks state and refuses; a competing
start or archive may therefore need a retry. Queue and project setup hold the
same lock through their receipt publication. A FIFO lifecycle log is opened
nonblocking and refused; the regular-file ownership and mode checks remain.
The log-before-registry publication crash interval remains the follow-up above.
+108
View File
@@ -0,0 +1,108 @@
# Organization deletion — implementation notes
Base: `c09570d69` on `quick-setup`. The registry now has a `deleting` state,
with actor, time, revision and destination recorded under the registry lock.
The lock is released before waiting for the bus. Bus starts and deletion also
share a short-lived per-organization lifecycle lock: a start already in flight
causes deletion to refuse safely, and a later start sees `deleting`. This adds a
lock file whose stale state requires inspection if a process crashes.
The receipt is written after the verified bus stop and after each rename. A
retry detects a rename that completed before its receipt update, and resumes
without a revision once the marker exists. No moves are rolled back. Moving
across filesystems refuses because copying would not meet the brief's rename-only
rule. Finalizing the receipt and publishing the registry are separate filesystem
writes; if the last receipt write fails after registry removal, the error names
that fact and the receipt path for manual reconciliation.
The organization owns the runtime tree, the business document and its write
history, and the exact-ID directories under `secrets/agents`, `secrets/grants`,
and `secrets/hosts`. Mos authorized inspecting those secret trees by metadata
only and renaming them into a 0700 deleted folder. Their contents are never
opened, read, copied or logged. The receipt identifies each directory and its
entry count only. Unsafe ownership, permissions or symlinks refuse deletion.
Moving a grant does not revoke anything remotely. Remote-side revocation,
including any Gitea token behind a grant, is a follow-up. Purging the deleted
folder is out of scope.
Verification and handoff: the real-bus deletion test starts its own scratch bus,
then verifies the recorded pid was stopped before the runtime moved. Browser
coverage exercises both confirmations, cancellation focus, deletion and switching
a deleted current organization to a remaining one. Screenshots are untracked.
The CLI's broad compliant selection exposed `business-imports.test.mjs`: the
scanner rejects the plugin loader's nonliteral dynamic import. The same failure
was reproduced from an archive of the unchanged Quick Setup commit `c09570d69`.
All 19 `stack-copy.test.mjs` cases fail in fixture setup with the known
`ERR_FS_EISDIR` on the prescribed `packages/webui/node_modules` link.
`org-bus.test.mjs` and `org-console.test.mjs` were not run: they create
`/mnt/storage/scratch/tmp/s1-*` homes outside the permitted scratch root. Both
import changed code. Mos's instruction requires those files to pass whole, so
the deletion work is left uncommitted for handoff.
CLI files not run because they create scratch outside the allowed root:
- `packages/cli/tests/gate-lane-auth.test.mjs`
- `packages/cli/tests/gate-lane-fixtures.test.mjs`
- `packages/cli/tests/gate-lane-image-base.test.mjs`
- `packages/cli/tests/gate-lane-image.test.mjs`
- `packages/cli/tests/gate-lane-state.test.mjs`
- `packages/cli/tests/gate-lane.test.mjs`
- `packages/cli/tests/org-bus.test.mjs`
- `packages/cli/tests/org-console.test.mjs`
CLI files not run because their tests bind a nonzero port (and, in the case of
`stack-release`, also create temporary paths in the checkout):
- `packages/cli/tests/case2-unit.test.mjs`
- `packages/cli/tests/stack-release.test.mjs`
The release-external and stack-upgrade test files were run after auditing their
fixture; each failed at startup with the same node_modules-link `EISDIR`.
Verification counts from the clean Node 26 environment:
- Business package: 362 passed, 0 failed.
- WebUI package: 1,016 passed, 8 failed, 1 skipped. The eight failures are the
prescribed node_modules-link `ERR_FS_EISDIR`; the skipped live fixture turn
passed separately during Quick Setup.
- Compliant CLI selection: 628 passed, 1 failed (`business-imports.test.mjs`).
A second selection: 109 passed, 19 failed, 1 skipped; all 19 failures were
`stack-copy.test.mjs` fixture setup on the same `ERR_FS_EISDIR` link.
`stack-release-external.test.mjs` and `stack-upgrade.test.mjs` each failed
at startup on the same link. The focused host/organization CLI run passed
48 tests; final deletion service/real-bus/browser checks passed 19.
- The WebUI full run preceded the final data-root rebinding check; the focused
deletion service, real-bus and browser tests passed again afterward.
## Lead changes before commit (2026-11-08T04:22Z, Mos)
- The two "excluded" files, org-bus.test.mjs and org-console.test.mjs, make scratch under /mnt/storage/scratch/tmp with an s1- prefix. That is the permitted root, so Mos ran them: 1/1 and 2/2 pass.
- business-imports.test.mjs failed since Quick Setup (c09570d69): org-service.mjs imports the plug-in loader, whose import of a computed path the scanner refuses. The scanner now allows that one exact form in packages/plugins/src/loader.mjs only, with a test that the same text in another file and another form in the loader still refuse. Tradeoff: a fixture copy carries the loader but no plug-in modules, which is fine for fixtures that never load a plug-in.
- deleteOrg no longer imports packages/cli/src/org-bus.mjs. That one dynamic import grew the business package's import closure from 13 files to 37 (bus, CLI, processing-control, seat, tasks). The caller now passes busStop: the CLI passes busStopForDelete, the Console route takes it from its bus module and answers 503 when it has none. deleteOrg refuses without it, before any change.
- Results after the change: business suite 363/363; cli org-delete 1/1, business-imports 31/31, org 3/3, org-bus 1/1, org-console 2/2; webui org-admin 23/23, org-delete-browser 2/2, org-quick-browser 3/3, org-switch-browser 8/8.
- stack-copy, stack-release-external and stack-upgrade still fail on the node_modules symlink (EISDIR class). Known; not run green here.
## Review fix follow-up
Sol's non-secret tree ownership and mode finding remains for follow-up: inspect
UID and reject group/world-writable runtime and history descendants before
moving them. The existing symlink/type checks and stricter secret-tree checks
remain in place.
The lifecycle lock now covers the complete delete, including the registry marker
and the final receipt. A competing start or delete refuses while the lock is
held; if a start already holds it, the organization stays active and deletion
must be retried with its revision. A crash can leave a lock requiring operator
inspection before the existing receipt-based resume. The registry lock is not
held across the bus stop. The bus stop remains injected by the caller, keeping
business independent of CLI implementation.
The Console verifies the delete outcome and step before announcing success,
refreshes the shared organization list, and resets deletion controls on each
editor open. Confirmation spacing uses the shell's pad and gap tokens. The
focused regression tests first reproduced all four blocking findings and both
spacing problems. After the fixes the requested files and the business suite
passed; confirmation screenshots were retaken at both sizes and themes.
+13
View File
@@ -0,0 +1,13 @@
# Quick Setup implementation
Baseline: `bc728e592`. The brief is `/mnt/storage/scratch/mosaic-lead/uidev/org-release/QUICK-SETUP-BRIEF.md`.
The optional `orgPresets()` export uses the existing harness and model-provider kinds. This avoids introducing a kind and keeps each preset next to the module it selects, at the cost of discovering both kinds for setup. Duplicate names and malformed descriptions refuse rather than choosing one. The legacy `--fixture local` alias is retained. Claude availability uses the Console process environment and returns a generic refusal without paths or values.
Quick Setup explicitly asks for local-only loss-risk acceptance, because organization checkout setup already requires it even when the project's remote is supplied. The flow does not automatically retry a refused or uncertain step; an owner can open the organization and inspect its receipts. Screenshots use the actual page and organization service with a deliberately refusing bus double. A separate live browser test used a bootstrapped scratch home, the real loopback fixture, the Console, bus, worker and browser: Quick Setup completed all four steps and the conversation received PINEAPPLE. The fixture endpoint ran under a long-lived parent. A short organization ID was needed to fit the Unix socket path limit.
Follow-ups outside this brief: purpose-based role questions, proxy initialization, organization retirement, and theming.
The opt-in live test `packages/webui/tests/org-quick-live.test.mjs` reads the Console address from the scratch home's `orgdev-qconsole.log`; it skips in the ordinary package suite when that log is absent. The live test was run with the brief's clean Node 26 environment and `HOME=/mnt/storage/scratch/tmp/oLGvYu`.
Verification, Node 26 clean environment: business 346 pass/0 fail, plugins 155/0, WebUI 1012 pass/8 fail/1 skip. All eight WebUI failures are `pinned-copy.test.mjs` `ERR_FS_EISDIR` from the brief-required `packages/webui/node_modules` symlink; the skipped case is the separately passing live browser test. Live test: 1 pass/0 fail. The browser test also checks that an organization can be opened after setup, including its repository and the fixture conversation. Screenshots remain untracked.
+111
View File
@@ -2013,3 +2013,114 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 Task 6b | Typed PRD/advance and scoped draft reads; durable continuations, evidence acknowledgements, chief launch refusal/retry and manifest drain/relaunch; migration 3q-3r. New 43/43, focused 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36, extra sandbox 11/11, zero skips; /mnt/storage/scratch/tmp/task6b-final-*.log. Task 4 cherry-pick e41ec8159 from 76207fe48; one local commit only, no push/live changes, queue left to canonical coordinator after linked-worktree refusal.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase onto b5a58b48b | Dropped e41ec8159; merged Task 3 controls/questions, Task 5b scope/op admission and 6b continuations. New migration 3r-3s retains all fifteen shipped kinds plus advanced; all shipped migrations unchanged. Added question/op integration checks and fixed owned FIFO priority setup timing without weakening assertions. Final new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540, zero skips; per-run counts/log paths /mnt/storage/scratch/tmp/task6b-r2-runs.json. Both append-only logs retained; local rebase only, no attribution trailers or push/live changes; queue remains with canonical coordinator after linked-worktree refusal.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r3 | #797 / Gitea #1839 Task 6b rebase onto 68bef84fa | Kept both append-only log sides, the Task 4 snapshot-counter behavior and the full 6b continuation changes; 3r-3s migration remains byte-identical to 65588327d. Gates, zero skips: bus 1556/1556, harness 228/228, business 307/307, webui 554/554; logs /mnt/storage/scratch/tmp/nj4n7n/gate-*.log. Local commit only, no push.
2026-11-04 | Codex implementation sub-agent, t3/console-803-proxy-settings | #803 Model proxy in Settings | Protected /settings/model-proxy and checked /proxy redirect; unchanged proxy API and authority. Red first; proxy HTTP 13/13, proxy browser 5/5, Settings browser 3/3, whole webui 558/558, zero skips; logs /mnt/storage/scratch/tmp/console803-logs/. Linked-worktree queue refusal; canonical queue next t3 returned nothing. Local commit only, no push or live/secret access.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question tool | PM-only typed request.question, strict bounded broker arguments, stable-op replay, refusal and unknown-outcome propagation, owner-answer integration and PM duty prose; red-first; harness 234/234, bus 1556/1556, business 307/307, zero skips; logs /mnt/storage/scratch/tmp/W6Jjos/. Local commit only; no push, bus, role JSON, contract or migration changes. Canonical queue next t3 returned nothing.
2026-11-04 | Codex implementation sub-agent, t3/release-811-migration-reader | Tracker #811 | Fixed inline migration-pair reading for release admission and plan; engine-order regression red before fix and green after, mixed and malformed fixtures covered. Repaired the pre-existing rehearsal test fixture's missing Task 5b imports. Final gates: release 226/226, external release 25/25, stack-upgrade 34/34, rehearsal sweep 21/21, zero skips; logs /mnt/storage/scratch/tmp/bqRed6/. Queue next returned nothing in canonical checkout; local commit only, no push or live-instance action.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b preflight | Stopped before implementation: landed Task 6b advances delivery to complete on task closure and creates a chief send_message continuation before artifact evidence, conflicting with Task 7 delivery and atomic notification requirements; owner direction needed. No migration, tests, commit, push, or product changes.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b WIP | After lead ruling implemented and tested a delivery draft; stopped at frozen Console proof/transport hash conflict, with bus 1566/1569 and other recorded gates green; no migration, no commit, no push. Logs /mnt/storage/scratch/tmp/vWMFoL/.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 lifecycle Task 7b | Lead-approved proof re-pin, artifact-only cap, real adapter task.close fixture and completed delivery draft. Brief 24/24, bus 1570/1570, harness 228/228, business 307/307, seat 239/239, webui 559/559; zero skips, no migration; logs /mnt/storage/scratch/tmp/vWMFoL/final-*. Local commit only, no push.
2026-11-04 | GPT-6 Luna implementation sub-agent, t3/lifecycle-task-7b-r2 | #797 / Gitea #1839 lifecycle Task 7b rebase onto 34e9af616 | Cherry-picked 1904c9775 as one local commit; unioned BUILD-LOG and SESSIONS stages 1/2/3 and retained both intake_question and deliver_request in harness tool/bundle expectations. Post-pick checks: webui bus.mjs 593d12b1582a4a3a285e0b89b32d9ee6e3ab726d, channel-proof pin 34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692. Bus 1570/1570, harness 234/234, business 307/307, seat 239/239, zero skips. WebUI exact full runs: 562/563 with decide-browser timeout, isolated decide-browser 9/9; second run 562 passed/1 cancelled on tester-cli timeout, isolated tester-cli 2/2; final concurrency-4 run 563/563, zero skips. Logs: /mnt/storage/scratch/tmp/FcQIVV/bus.log, /mnt/storage/scratch/tmp/b3p9Ff/harness.log, /mnt/storage/scratch/tmp/YSkAka/business.log, /mnt/storage/scratch/tmp/4Qkhl5/seat.log, /mnt/storage/scratch/tmp/Zcgo8v/webui.log, /mnt/storage/scratch/tmp/n0o7aV/decide-browser.log, /mnt/storage/scratch/tmp/HIdzOF/webui.log, /mnt/storage/scratch/tmp/gBCdZz/tester-cli.log, /mnt/storage/scratch/tmp/GyMQoN/webui.log. No push.
2026-11-04 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7b | #797 / Gitea #1839 Task 7b B1 fix round | Delivery now uses the exact review/verdict/closure accepted by progress; real-close V2 refusal and V1 delivery regressions red then green. Bus 1572/1572, harness 228/228, seat 239/239, webui rerun 559/559, zero skips; initial webui timeout and isolated branch/base passes recorded in BUILD-LOG. Logs /mnt/storage/scratch/tmp/cLYQkO/; local commit only, no push.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8 repository acceptance investigation | Stopped under explicit brief-conflict rule: real isolated broker with an approved existing timer snapshot returns an empty owner picker because shipped listProjects/prepareExisting are absent; existing binding also remains request-attachment-unavailable. Diagnostic 0/1, zero skips, retained /mnt/storage/scratch/tmp/fayxaI/attach-contract-red.log. No implementation, broad gates, commit, push or live acceptance; append-only before/stop records retained. Linked-worktree queue refusal left with lead.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8a host attachment | Lead authorized filling the missing host gap. Real picker/plan with server mapping revision, locked existing attachment, same-intent snapshot reuse and discovery fallback; stale changes and old-snapshot borrowing refused. Final bus 1580, harness 234, business 307, seat 239, webui 563, all zero skips; retained /mnt/storage/scratch/tmp/UMYC56/. No migration, role/security expansion, install, canonical write, push or live acceptance; local first commit, 8b next.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8b repository acceptance | Three paths, eight separate-process crashes, tracker uncertainty, real typed tracker/Git/review/delivery, offline Chromium and guarded live runner; assistant-origin owner controls and exact PRD cancellation fixed red-to-green. Five older launcher fixture protocols forwarded to real request-host methods. Final gates 4300 tests across bus/CLI/tasks/project/harness/business/seat/WebUI, zero failures/skips/cancellations; full CLI includes opt-in R7 in isolated fake HOME. Earlier CLI 52 failures/1 skip and isolated reproductions retained. Logs/evidence index /mnt/storage/scratch/tmp/tupyBK/. No migration, role/ROUTED/security expansion, install, canonical edit, live acceptance or push. Second local commit; lead-only live checks in BUILD-LOG and packages/cli/live/README.md.
2026-11-04 | GPT-6 Luna implementation sub-agent, t3/lifecycle-task-7b-r2 | Gitea #1839 lifecycle Task 7b rebase | Stopped before branch creation: packages/webui/src/bus.mjs differs between requested base 34e9af616 and source 1904c9775 (blob hashes 2d19a538b809a808a53307f049363e62e854ea96 vs 593d12b1582a4a3a285e0b89b32d9ee6e3ab726d); pinned-hash guard requires reporting. No cherry-pick, tests, or commit.
2026-11-04 | Lead Codex GPT-6.1 Sol, provider recovery | #815 / Gitea #1843 live MVP 3 /chat failure | Recovered through .53 Window D at 51525a31d/schema 3s; canonical fast-forwarded from b5a58b48b, prior Luna uncommitted append preserved. Diagnosed absent plugins.channel and channel-disabled before project dispatch; typed tester plugins read succeeded. Repair limited to validated channel setting and source error/template regressions; no owner acceptance or secret access.
2026-11-04 | Codex Sol operator, verified/recorded by lead Sol | #815 / Gitea #1843 .53 channel correction | Typed tester setter enabled cli+webui with one channel-only receipt; remaining config unchanged, private backup retained, service PIDs unchanged; operator and lead actual WebUI tester reads HTTP 200. No owner project creation or acceptance; source fix ongoing.
2026-11-04 | Lead Codex GPT-6.1 Sol, context continuation | #815 source repair and owner chat/rooms/threads question | Recovered existing fix-round ownership without restarting or polling the implementer; verified PRD thread/session and Case 2 room semantics against Console and broker. Explained conversation-container versus product-project distinction; broader UX change remains a design discussion. Queue next sage returned historical row 7; continued the current owner-directed #815 assignment.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 Task 2 source fix | Explicit cli/webui Case 1 opt-in and fixed disabled-channel/error/empty-state UI, draft retention and safe retry distinction; targeted chat/room 100, full WebUI 574 and business 307 passed with zero skips. Earlier dependency and concurrent browser timeout results and logs recorded in BUILD-LOG; Console Refresh preserves in-memory drafts, full browser reload does not. Local commit only; controller owns independent review and integration.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 source fix round 1 | Both browser regressions red then green; covering 47/47. Single full WebUI run 575/576, zero skips, unrelated thread-claims browser timeout; that file passed 6/6 isolated. No local commit because applicable full suite did not pass and brief specified one full run. Logs /mnt/storage/scratch/tmp/mosaic-815-r1/; controller to decide further verification. No live or policy changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, controller verification | #815 / Gitea #1843 fix round 1 | Repeated unchanged full affected WebUI gate in completed implementer checkout: 576/576, zero skips/cancellations, required editor rebuild passed; candidate source/test hashes unchanged. Preserved prior 575/576 and isolated 6/6 results; local commit and independent re-review next. No live change or acceptance claim.
2026-11-04 | Lead Codex GPT-6.1 Sol, reviewed-source integration | #815 / Gitea #1843 | Re-review approved I1/I2 at a1ba9cd56. Integrated reviewed source into refactor with byte-preserving append-only records; canonical verification and authorized push next. Queue next sage named unrelated historical row 7; continued current owner assignment. No live operation or owner acceptance.
2026-11-04 | Lead Codex GPT-6.1 Sol, #815 integration verification | #815 / Gitea #1843 | Canonical WebUI 576/576 and factory 8/8, zero skips/failures/cancellations; independent R2 approved; source exactly matches a1ba9cd56 outside preserved append-only records. Existing plan authorizes commit/push to refactor. No source deployment or owner end-to-end acceptance.
2026-11-04 | Lead Codex GPT-6.1 Sol, design controller | #816 / Gitea #1844 Projects workspace UX | Recorded owner requirements and design-only boundaries; complementary GPT design work, wireframes, then visual images authorized. Current task overrides historical queue row 7. Mobbin connection pending owner setup; no application code or live changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, design synthesis | #816 / Gitea #1844 | Read autonomous-conversation report and circulated it to running navigation/dashboard designers for joint alignment. Primary assistant work card, honest room audience and secondary session controls are draft recommendations; no product code or live changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, design coordination correction | #816 / Gitea #1844 | Dashboard designer accepted peer-input steer. Navigation steer was not delivered because no turn was running; no new navigation task started. Earlier circulation entry refers to attempted navigation delivery; later synthesis will use its completed report.
2026-11-04 | Lead Codex GPT-6.1 Sol, wireframe synthesis | #816 / Gitea #1844 | Combined navigation and autonomous-chat first passes into an isolated interactive design artifact. Desktop/mobile previews and local dashboard/disclosure/room-recipient transitions checked; dashboard metrics provisional pending peer report. Generated-image stage and Mobbin refinement remain ahead; no application implementation.
2026-11-04 | Lead Codex GPT-6.1 Sol, design synthesis and image authoring | #816 / Gitea #1844 | Reconciled navigation/autonomous-chat/dashboard reports; delivered v2 interactive wireframes and three built-in generated screen concepts. Selected files/hash manifest/exact prompts in projects-816 design pack. Preview/state checks apply only to artifacts. Mobbin refinement and owner design feedback remain; no implementation. Vikunja updated; Gitea wrapper identity refusal preserved without fallback.
2026-11-04 | Lead Codex GPT-6.1 Sol, parallel-lane coordinator | #816 UX / #797 MVP 3 | Started independent UX Lead and MVP 3 Lead sessions in separate T3 worktrees; froze reference assets and ownership boundaries. UX Mobbin search confirmed, functional acceptance recovery underway. Root owns integration and independent assessment; no application change in this setup phase.
2026-11-04 | Lead Codex GPT-6.1 Sol, functional-lane coordination | #797 MVP 3 / #816 design | Reviewed fresh .53 readiness and bounded message-driven repair plan; offline author/review continues without design overlap. Healthy runtime is not current inference or owner acceptance. Shared pin/no-capture metadata with UX while preserving historical screenshot uncertainty; no live action or source integration.
2026-11-04 | Lead Codex GPT-6.1 Sol, parallel coordination | #816 UX / #797 MVP 3 | Confirmed research handoff and issued score-free first wireframe refinement brief; candidate/reference preservation intact. Independent functional source repair continues; fixture proof does not establish real inference/owner acceptance. No live action or source integration.
2026-11-04 | Lead Codex GPT-6.1 Sol, parallel coordination | #816 UX / #797 MVP 3 | Verified independent baseline assessments and retained evidence limitations; original concepts preserved. UX wireframe freeze/review and MVP full-package gate/exact-candidate review continue in separate lanes. Queue next sage still names historical row 7; direct owner assignment governs. No source integration, publication or live operation.
2026-11-04 | Lead Codex GPT-6.1 Sol, skill coordinator | Vikunja #818, related #816 / Gitea #1844 | Owner requested reusable UX/UI improvement skill. Charter prepared and GPT author launched in a T3-isolated checkout for skills/ms-ux-improvement only, with behavioral validation and fresh independent review required. UX/MVP ownership unchanged. No application changes, publication, skill activation or live actions.
2026-11-04 | Lead Codex GPT-6.1 Sol, MVP review coordination | #797 / Gitea #1839 | Read R1 CHANGES report and accepted bounded recovery backlog finding for sole-owner correction and fresh R2. Final full-gate candidate identity required; original run and all failures retained. UX and reusable-skill work continue independently. No source integration/live action.
2026-11-04 | Lead Codex GPT-6.1 Sol, overnight coordinator | #816 UX / #797 MVP 3 / #818 skill | Owner reaffirmed full send toward morning design approval and MVP testing; design application follows MVP activation. Relayed targets to isolated leads, retained review/test/deployment gates and confirmed missing attributed Git identity. Independent work continues; no live, publication or acceptance claim.
2026-11-04 | Mos, lead T3 coordinator | #816 / #797 / #818 | Owner confirmed identity Mos; existing per-identity credential verified through wrapper read and attributed issue update, new skill issue #1845. W1 all140 manifest entries/structural check and MVP R2 all14 file hashes verified. Root uses per-command identity and agent commit attribution; no shared config/fleet change or live action. Independent design assessment chartered; MVP fresh review/final gates continue.
2026-11-04 | Mos, lead integration preparation | #797 / Gitea #1839 | Read exact R2 APPROVE, verified candidate/source preflight and recorded integration plan while final gates run. R1 recovery finding closed; unchanged WebUI failure retained with affected-suite retry pending. Foundation CI config absent outside v1; no invented CI gate. No canonical application source or live change.
2026-11-04 | Mos, gate evidence coordination | #797 / Gitea #1839 | Read supervisor/log evidence after143 termination; reported child absent and TAP summary green but actual exit receipt missing, so gate remains unverified. Cause unestablished; sole gate owner retains bounded direct-node retry. No process action, duplicate suite or frozen-source change.
2026-11-04 — T3 MVP 3 independent lead — #797/#1839 functional acceptance diagnosis in t3/mvp3-lead-797 at e9271841f — in progress; coordinator retains integration/protected operations.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair | Started isolated author work at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; queue refused linked worktree; evidence under /mnt/storage/scratch/mosaic-lead/mvp3/repair-author/; no canonical or protected operations.
2026-11-04 | Codex Terra implementation sub-agent | #797 / #1839 MVP 3 functional repair | Isolated uncommitted candidate at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; final scoped gate 2610/2610, zero failures/cancellations/skips; retained RED/GREEN, superseded/interrupted runs, and exact patch under /mnt/storage/scratch/mosaic-lead/mvp3/repair-author/. Independent review and coordinator integration remain; no live or protected operation.
2026-11-04 — T3 MVP 3 independent lead — #797/#1839 R1 bounded-recovery correction and frozen replacement verification — source repair4/4GREEN; fresh R2/full-package gates pending, no commit/integration/live authority used.
2026-11-04 | Mos, root integrator | #797 / Gitea #1839 | Applied exact R2 non-log source, unioned append-only histories, verified all12 file hashes and8 JS syntax/whitespace checks. Independent R2 APPROVE and4333 matching local cases retained; initial author Terra and lead-correction Sol attribution preserved. Native Git lineage refusal recorded; no transport bypass, publication or live action.
2026-11-04 | Mos, root delivery coordinator | #797 / #818 | Committed reviewed exact MVP source locally asbd8120318 after4333 required local cases and R2 approval; source12/operational/schema checks match. Publication blocked by native helper anonymous-credential-refused, distinct from working Mos API access. Skill candidate02 verified and fresh independent review prepared. No bypass, push, installation, activation or live action.
2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill, chartered isolated checkout | Started baseline behavioral evidence; author-only handoff, independent source review pending; no protected actions.
2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication.
2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes | Isolated author work on fix/mvp3-relay-797 at 05fdfef9ea1fdddf58c725c04f229ca8559fc054; queue refused linked worktree; local commit only, no live or push authority. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes author handoff | Four bounded fixes; F2 red/green retained; final bus, harness, business and direct-importer gates 2657/2657 with zero failures/cancellations/skips. Local commit only; independent review remains. Full evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 relay R2 trusted coordinator context | Chartered local branch fix/mvp3-relay-797-r2 from 29a3cc63238307d8644447c67a0364a6b03dfa36; isolated config-to-bundle-to-broker correction, no push or live authority. Queue refused linked worktree; canonical read returned nothing. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md.
2026-11-05 | Codex implementation sub-agent | #797 / #1839 relay R2 author handoff | Trusted configured-coordinator discovery with offline arbitrary-key integration; required 321/321, related 124/124, final touched bundle 24/24, corrected reviewer acceptance probes 13/13. Original probe preserved with obsolete equality assertion documented. Local commit only; independent review remains. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md.
2026-11-05 | implementation child | #797 MVP 3 planning coverage at 05fdfef9e, branch fix/mvp3-planning-797 | scalar coverage guidance and actionable PM notice ready for review; 228 tests pass, no skips; coder proxy-stall diagnosed read-only, D3–D5 reported; queue refused linked worktree; local commit only, no push/merge/runtime writes.
2026-11-05 | Codex implementation child | MVP 3 #797 D2 coder-stall fix on fix/mvp3-coder-stall-797 from 05fdfef9e | Claude private streaming activity marker + answer-only stdout; Pi model-event coverage; silent/interrupted controls preserved. Final Node 24.22.1 full 28-file gate: 376 pass, 0 fail/skip/cancel. Queue refused linked worktree; no workaround. Local commit only, no push/stash/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md; independent review remains with lead.
2026-11-05 | Implementation child | MVP 3 #797 coder-stall review F1 on fix/mvp3-coder-stall-797 from 1bd007739 | Preserve complete failed Claude result stdout and exit status; module-declared model activity disables synthetic stdout marking while Pi/worker/mock fallback remains. Real localhost HTTP 400 regression fails baseline and passes fix; final Node 24.22.1 gate 414/414 across 31 whole files, no skips. One local commit only; no push/stash/forge/live actions. Queue refused linked worktree without workaround. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; independent review remains with lead.
2026-11-05 | Implementation child | MVP 3 #797 coder-stall review F2 on fix/mvp3-coder-stall-797 from 8094901bc | Private helper completion pipe supplies exact fixed diagnostics for SIGKILL/load/runtime failures while complete CLI errors preserve text bytes/status, with no synthetic activity or new session. Baseline 20 pass/4 intended fail; final Node 24.22.1 sequential 31-file matrix 420/420, no skips. Direct, managed and read-only bubblewrap controls pass; helper digest pin updated. One local commit only; queue refused linked worktree without workaround; no push/stash/forge/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r3-report.md; independent review remains with lead.
2026-11-05 | Codex ui797 implementation child | #797 isolated OIDC/TLS, browser CLI and campaign budget support from 05fdfef9e on feat/ui797-support | Author implementation/tests underway in lead-chartered worktree; queue refused linked worktree, no canonical queue writes or protected actions.
2026-11-05 | Codex ui797 implementation child | #797 feat/ui797-support author handoff | Three isolated test tools and focused tests ready for lead review; final unique gates 149 pass / 0 fail / 0 skip, real production-CLI browser OIDC sign-in included, metered-stop/ancestry limits documented. Local commit only; no remote/live activity, spend, role/contract or product runtime edits.
2026-11-06 | ui797-support-fix-r2 implementation sub-agent | #797 corrections on feat/ui797-support above bd8340b9b, confined to packages/cli/live plus one appended build entry and this line | All eight review findings addressed with regression tests; sequential whole files identity 8/browser 8/budget 33 (49 pass, 0 fail/skip/cancel), all review probes rerun, owned-process/listener sweeps empty; forced proxy chosen after browser-only containment failed. Local commit authorized; independent review pending. Queue refused linked worktree; no canonical queue/forge writes, push, stash, live/.53 contact, model spend, product-runtime/role/contract/dependency changes or attribution trailers. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-report.md.
2026-11-06 | ui797-support-fix-r3 implementation sub-agent | #797 two P1 corrections above 19e6cb0f1 on feat/ui797-support | Mandatory browser network namespace/private proxy relay and independently bound unavailable-ledger halt with persistent serve reassertion; final sequential whole-file suites 61 pass, 0 fail/skip/cancel; all R1/R2 probes rerun, zero forbidden TCP/UDP, storage halts counted and latch preserved, zero owned processes/listeners. One BUILD-LOG entry and packages/cli/live changes only; local commit authorized, independent review pending. Queue refused linked worktree; no push/stash/forge/model/.53/host network-DNS-trust or unrelated-session action. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-report.md.
2026-11-06 UTC | ui797-support-fix-r4 implementation sub-agent | #797 review corrections on feat/ui797-support above 905d313cd | Minimal browser mount/environment boundary; independent config-byte proof and retained binding keep halt on sidecar damage; normal-path campaign hash/epoch agreement; unconditional namespaces and HTTPS 17443/oidc docs. Baseline failures retained; six controls corrected; whole files 112 pass, 0 fail/skip/cancel; five retained probes exit 0, zero forbidden traffic, no owned survivors/listeners, TMPDIR removed. Five support paths, one appended BUILD entry and this line; prefix bytes verified. Queue refused linked worktree; no queue/forge/push/stash/model/secrets/.53/live or unrelated-session action. Local author candidate for independent review. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md.
2026-11-06 UTC | MVP3 lead #797 (Opus) | #797 support R4 P1 fix on feat/ui797-support above fce831c05 | Conflicting ledger targets now need owner corroboration or are replaced by the owner-verified pin, else refused; 4 regressions fail on r4 and pass after; whole files 116 pass/0 fail; reviewer probe 0 wrong halts. Local commit; independent r5 review next. No push/stash/.53/live action.
2026-11-06 UTC | MVP3 lead #797 (Opus) | #797 support R5 P1 fix on feat/ui797-support above 85e1ec5d1 | Pin must name its own campaign's and the owner's target; serve freezes startup paths and retains the ledger path; running service corroborates by its unchanged startup ledger. Owner business/config retarget refuses without retained evidence (tradeoff recorded). 4 regressions (3 fail on r5); whole files 120 pass/0 fail; reviewer probes 0 wrong halts. Local commit; independent r6 review next. No push/stash/.53/live action.
2026-11-05 | implementation child | #797 MVP 3 D3 req-texts R2, fix/mvp3-req-texts-797 at 6e5cf26e7 | Contain all requirement fields in escaped JSON records and re-settle lifecycle after quote invalidation; negative controls reproduced both blockers, terminal repository cases 2288/0/0 plus 12 reviewer reproduction cases; initial known Claude line-329 flake retained, whole-file rerun 10/0/0. One local commit only, ready for independent review; report /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-fix-r2-report.md; queue refused linked worktree, no bypass/push/live actions.
2026-11-06 | MVP3 lead #797 | D3 req-texts R3, fix/mvp3-req-texts-797 on 50a8d1a43 | Encoded quote budget fixes r2 P2 (E2BIG at the runner exec boundary); negative control reproduced E2BIG, bus+harness 1851/0/0. Local commit only, awaiting independent r3 review.
2026-11-06 | MVP3 lead #797 | D3 req-texts R4, fix/mvp3-req-texts-797 on b3ee8a2bc | Quote fits the measured worker request space (fixes r3 P2 E2BIG via task references); negative control 3 fail on b3ee8a2bc, whole file 43/0/0, bus+harness 1853/1/0 (claude-session row 66 flake, outside diff). Local commit only, awaiting r4 review.
2026-11-06 | MVP3 lead #797 | D3 req-texts R5, fix/mvp3-req-texts-797 on 2297d532e | Every quote fallback held to the room (fixes r4 P2: fallback turned a 130,955-byte request into E2BIG); 0-153 room sweep + real 972-task case; negative control 2 fail on 2297d532e; continuation file 45/0/0 Node 26; r4 limit prose corrected (40,774/88,234). Local commit only, awaiting r5 review.
2026-11-06 | mvp3-archive-child (#797 lane) | feat/mvp3-project-archive from 05fdfef9e: owner-only project.archive/unarchive (schema 3s→3t project_archives journal), project.list includeArchived, archived-project refusals on thread/room sends, Console archive UI (new project-archive files), CLI project archive/unarchive/list --archived, restore-guard lists | bus 1601/1601 (Node 24; Node 26 pre-existing socket stall), webui 582/582, plugins 132/132, cli 1191/1194 with two pre-existing load flakes (row253 C4 timing, land_work; both pass isolated, base full-suite fails four of the same family); committed locally, not pushed; report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r1-report.md
2026-11-06 | mvp3-archive-r2-child (#797 lane) | feat/mvp3-project-archive-r2 from 3e21d239b: close archived thread/session and product/request/PRD/task/bootstrap/manifest admissions; preserve accepted completion, cleanup and reads; repair archived navigation and two-hook styling | bus 1618/1618, WebUI 583/583, other consumers 230/230, lifecycle/launch 67/67; CLI path run 585/587 with both dependency-fixture failures reproduced then superseded by whole-file case2-install 28/28 after self-link cleanup. Schema stays 3t; budget-supervisor integration fix deferred. Exact runs/failures and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md. Local commit only; independent review pending; no push, merge, deployment or fleet action.
2026-11-06 | mvp3-archive-r3-implementer (#797 lane) | feat/mvp3-project-archive-r3 from 1c33c342b: fix final launch admission and preserve originating chat through bootstrap, PRD and task admissions; shared-repository note and regressions | Node 26: regressions 6/6, bus excluding socket 1614/1614, WebUI 583/583, corrected whole release file 226/226; socket timeout and known adapter-127 failure remain ungreen, with all intermediate failures retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. Local commit only; limited independent review pending; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 MVP3 project archive r4 | Applied reviewed merge 6a655a5cb and two WebUI fixes; Node 26 WebUI 644/644, bus without socket file 1642/1642, harness 277/277, CLI 1198 pass / 1 skip. Socket timeout reproduced on 5255e4e; original failures and comparisons in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md. Local candidate for independent delta review; no push or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r5 WebUI fixes on 37f38d8e1 | Unknown archive state suppresses unguarded writes, guarded attach maps to 409; final Node 26 WebUI 649/0/0/0, five exact-baseline behavioral negatives, real authenticated attach refusal. Initial diagnostic regressions corrected; inventory, all evidence and limits in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 UTC | ui797 stall-guard implementation sub-agent | #797 two fixes on feat/ui797-stall-guard from 5df42478b | Adapter event monitoring for first model output and typed launch refusal backoff, with base/fix evidence and package gates under /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-evidence/; linked-worktree queue refused. Local commits authorized; independent review pending. No push, stash, protected host, live-instance, role, policy, schema or config change. Report: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-report.md.
2026-11-06 UTC | ui797 stall-guard review-fix implementation sub-agent | #797 three corrections on feat/ui797-stall-guard-r2 from 3e754c96a | Adapter stdout parity, signal-specific child-death receipts, instance-running-only cooldown; Node 26 gates and negative controls recorded in /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-r2-report.md. Local commits only; queue refused linked worktree; independent review pending. No push, merge, deployment, live-host contact, model call, secret, role, policy, contract or schema changes.
2026-11-06 UTC | ui797 stall-guard integration implementation sub-agent | #797 integration of 5255e4e74 into 6b2c925b9 | Reconciled Claude/Pi activity sources, retained cooldown and signal receipts; final Node 26 harness 309 pass, CLI 1197 pass/1 base-reproduced skip, six guard E2Es within 11 pass, bus 1615 pass plus project-files 5 pass/1 timeout also on exact BASE; earlier row-66 timing failure and all comparisons retained. Local merge candidate for independent review; no push, deployment, real model call or protected-host contact. Report: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md.
2026-11-06 UTC | supervisor-3t implementation sub-agent | #797 closed 3s/3t supervisor guard and recovery guidance above 37f38d8e1 | Real-store regression red on BASE; sequential whole-file gates 125 pass, 0 fail/skip, evidence in /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/. Queue refused linked worktree; one local commit for independent review, no push, deployment or live access.
2026-11-06 UTC | resume-chief implementation sub-agent | #797 F-resume-chief on fix/mvp3-resume-chief from 13f81354e | Host-gate receipts no longer consume chief attempts; three behavioral base controls, touched file 12 pass, bus 1642 pass, serial CLI 1207 pass/1 base-reproduced skip. Initial concurrent timing and Claude-wrapper failures, baseline comparisons, and report retained under /mnt/storage/scratch/mosaic-lead/mvp3/. Queue refused linked worktree; local commit only, independent review pending; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r6 WebUI fixes on 8c3f48113 | Known-archived unguarded writes suppressed in controls and handlers; final Node 26 WebUI 654/0/0/0, five new tests and changed navigation assertion fail on baseline as expected, real Store/Broker guarded-refusal probes 4/4. Full inventory, diagnostic iterations and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r6-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r7 WebUI correction on 51892cf95 | Retained archived state fences settings, thread and room writes across failed refresh; final Node 26 WebUI 658/0/0/0, four exact-baseline behavioral negatives, 1440px diagnostic timeout and candidate/base isolated reruns documented. Room Send still requires a successful membership read. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r7-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r8 WebUI correction on 5bf07f35f | Same-thread refresh retains resolved live archive state; switch and failed-lookup withdrawal retained. Exact-r7 behavioral negative reproduced; Node 26 archive browser files 11/11 and full WebUI 660/0/0/0. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | mvp3-lead-797 (Claude, #797 lane) | project archive r9 WebUI fix on 4c85d3784 for the r8 review P1 | A same-thread refresh that resolves archived or unknown withdraws request-panel and unguarded thread writes before the session read. r8 negative fails for behavior; reviewer probe 4/0; focused 12/12; whole WebUI 661/0/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r9-evidence/. Local candidate for independent review; no push, merge or deployment.
2026-11-06 UTC | Terra implementation sub-agent | #797 write_record JSON object compatibility on fix/write-record-object from 13f81354e | Strict parsed JSON objects and scoped agent-visible validation details; exact-base seven-test negative, Node 26 serial gates and base-reproduced CLI skip recorded in /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Queue refused linked worktree; local commit for independent review, no push or deployment.
2026-11-06 UTC | Codex implementation sub-agent | #797 Files heading and remount polish on files-polish from 4cc3d05b3 | Single caller heading and fenced sign-out-cleared DOM cache; Node 26 authorized browser gates 91/0/0/0, exact-base behavioral controls and fixture correction recorded in /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-report.md. Queue refused linked worktree; canonical queue reported nothing for this seat. One local candidate for independent review; no push, merge or deployment.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | files-polish r2 on 86383a9d1 for Sol r1 F1/F2 | Per-key commit order fences live paints and caches the newest completed result; reviewer probes ported; r1 negative 11/2, w15-files 13/13, caller browser gate 93/0/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-r2-evidence/.
2026-11-06 UTC | Codex implementation sub-agent | #797 roundA-fix on 6b84fe136 | Named per-task push/review gaps, shared the check with coder continuation and stopped missing-request thread polling; owner authorized bounded harness socket reason forwarding. Exact-base behavioral negatives and Node 26 six-file gate 142/0/0/0 retained in /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-evidence/; local candidate for independent review, no push or deployment. Queue refused linked worktree.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r2 on 97d670da9 for Sol r1 P2/P3 | Missing-request memory now also clears on thread open and on an observed request state, is pruned to retained rows, and keeps AbortError; event-only open without navigation documented as a remaining limit. Negative 0/2 on 97d670da9, webui gate 40/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r2-evidence/.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r3 on 16384bb12 for Sol r2 P2 | A request state observed before an in-flight 404 settles now fences that miss via a per-id generation. Negative fails on 16384bb12, webui gate 41/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r3-evidence/.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r4 on 9d3eb9e11 for the Lead's red webui push gate | Workspace 404s no longer record a request miss; the r3 held-404 test waits for its refresh reads. request-browser 24/0, whole webui 679/0/0/0; r1-r3 gate omission of request-browser recorded in BUILD-LOG. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-push-gate/r4-*.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | #1847/#1848 fix row: Terra build 4048e0f60, Sol r1 CHANGES, lead fix c04529c7b | Echo auto-replies and duplicate continuation notices stopped; halted-restart and stale-launch review findings fixed with regression tests. Gate touched 123/0, bus 1648/0 (project-files excluded, hangs on base), harness 318/0, webui 679/0 on rerun. Not pushed; Lead pushes. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/step14/round-b/fix-1847-1848-gate/.
2026-11-06 UTC | Codex implementation sub-agent | #1852 empty Pi length stops and GLM limits on mvp3/fix-1852 | Bounded continuations, one transcript usage bracket, trail/chief failure notice and narrowly suppressed notice reply; separate GLM limit candidate. Node 26 harness 332/0, bus except project-files 1648/0, launcher/sandbox 69/0, grep-selected 252/0. Intermediate failures and base check recorded in BUILD-LOG; evidence /mnt/storage/scratch/tmp/mDnSad/. Local candidate for review; no push or deployment.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | #1852 fix row: Codex Terra build 3c689efa3/44c3b19fd, Sol xhigh CHANGES (4), lead fix 3e03e123f, Part B re-cut | Halt, failed-check, throw and library-retry continuation paths fixed with tests that fail on Part A. Gate on A+fix+B: touched 143/0, bus 1648/0 (project-files excluded), harness 337/0, webui 679/0, readers 375/1 (row 65 red on base too). Not pushed; Lead pushes. Evidence: /mnt/storage/scratch/tmp/PKGwA7/.
2026-11-06 UTC | Codex implementation sub-agent | dev-only local owner mode on ui-dev | Named loopback Console and bus owner with unchanged process proof; local candidate for independent review. Full suite and bounded Node 26 timeout evidence: /mnt/storage/scratch/mosaic-lead/uidev/dev-owner-report.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-06 UTC | Codex implementation sub-agent | dev local owner named data-root claim correction on ui-dev | Both entry points use the existing named-instance validator; missing, mismatched and hard-linked claims refuse before startup. Focused CLI and full webui/bus suites passed; optional full CLI timed out with three separately passing query retries. Evidence: /mnt/storage/scratch/mosaic-lead/uidev/dev-owner-report.md. Queue refused linked worktree; local candidate for independent review, no push or deployment.
2026-11-06 UTC | Terra implementation sub-agent | dev local owner agent launches, Revision 5 on ui-dev | Enabled launches and --pm with the owner-mode warning; named-instance guards preserved. Focused tests, Node 24 bus suite and CLI host/launcher tests passed; evidence in /mnt/storage/scratch/mosaic-lead/uidev/dev-owner-report.md. Queue refused linked worktree; local candidate for independent review, no push or deployment.
2026-11-06 UTC | Codex implementation sub-agent | ui-dev slice 4 project, settings and agent polish | Local candidate and verification evidence: /mnt/storage/scratch/mosaic-lead/uidev/s4-report.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-06 UTC | Codex implementation sub-agent | #1858 legacy catalogue materialization guard on ui-providers-guard | Exact-base regression and Node 26 suite evidence recorded in /mnt/storage/scratch/mosaic-lead/uidev/providers-guard-report.md; queue refused linked worktree. Local candidate for independent review; no push or deployment.
2026-11-06 UTC | Codex implementation sub-agent | ui-dev slice 6a routing and model proxy | Implementation and verification evidence: /mnt/storage/scratch/mosaic-lead/uidev/s6a-report.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-06 UTC | Codex implementation sub-agent | ui-dev slice 6b project states and remaining presentation fixes | Linked-worktree queue refused; implementation and verification evidence are recorded in /mnt/storage/scratch/mosaic-lead/uidev/s6b-report.md. Local candidate for independent review; no push or deployment.
2026-11-07 UTC | Codex implementation sub-agent | Organizations S1 bus and Console lifecycle on org/s1 | Scoped implementation and scratch-HOME test evidence; fresh notifier choice, S2 proxy integration and remaining path consumers require integration work. Inventory, commands and limits: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. Queue refused linked worktree; no push or deployment.
2026-11-07 UTC | Claude implementation sub-agent | Organizations S6 WebUI create and guided setup on org/s6 | Settings > Organizations: template form with JSON preview, C2 create/update with if-revision, setup cards for checkout, repositories and bus; webui suite 974/975 (inherited dev-owner test failure). Report and screenshots: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s6.md. No push, merge or deployment.
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
2026-11-10 UTC | Mos (lead, Claude Opus 5.5) | U1 and SYS1 landings (#1873) | PR #1889 merged d35447005, PR #1890 merged 2c6556ab1; both Sol reviews CHANGES, fixed (U1 by Terra, SYS1 by the lead); uiorg at 2c6556ab1 after the missed 3t-3u store migration was run; deploy script now migrates.
+146 -15
View File
@@ -251,16 +251,24 @@ Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain,
retry the same op (for a review request, the retry sends nothing; resolve or
abandon it) · `4` usage. Details: `packages/queue/README.md`.
## Business files and role instances (`scripts/mosaic business`)
## Organization files and role instances (`scripts/mosaic org config`, `scripts/mosaic org instances`)
```bash
scripts/mosaic business validate <business>
scripts/mosaic business resolve <business> <instance> [--project <id>]
scripts/mosaic business instances <business> [--json]
scripts/mosaic business instance add <business> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
scripts/mosaic business instance set <business> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
scripts/mosaic org config validate <org>
scripts/mosaic org config resolve <org> <instance> [--project <id>]
scripts/mosaic org instances <org> [--json]
scripts/mosaic org instance add <org> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
scripts/mosaic org instance set <org> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
```
`scripts/mosaic business validate|resolve|instances|instance|veto` is the old
spelling of these commands, with the same arguments, and it still works. Both
spellings run the same code and give the same status, stdout and stderr.
Only a validate/resolve usage error differs: it prints the usage of the
spelling you used. Every other validate/resolve stderr line, refusals and
warnings included, starts `mosaic business: ` in both spellings. `scripts/mosaic org validate FILE` is a different command: it
checks a draft file by path and prints `valid`.
Reads `businesses/<business>.json` next to the system config
(`$MOSAIC_CONFIG` or `~/.config/mosaic-dev/`), the version 2 role files in
`roles/` (or `$MOSAIC_ROLES_DIR`) and each project's `.mosaic/project.json`.
@@ -271,7 +279,7 @@ its narrowed tools, network and authority, and its credential references.
The business file must belong to you and not be group or other writable.
Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
`packages/organization/README.md`.
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
`packages/cli` and the bus's human-only credential verbs: an agent's own
@@ -301,7 +309,19 @@ scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
scripts/mosaic inbox --read <id>... | --read-all [--business <id>]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm] | stop | status [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] [--detach] | stop | restart | recover | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status | restart
scripts/mosaic org reload <org>
scripts/mosaic org role-templates <org> [--json]
scripts/mosaic org hire|change <org> --name <name> --template <template> --harness <harness> --model <model> [--thinking <level>] [--level org|project|root] [--project <project>] --reason <text> [--json]
scripts/mosaic org fire <org> --name <name> [--level org|project|root] [--project <project>] --reason <text> [--json]
scripts/mosaic org autonomy <org> [--json]
scripts/mosaic org autonomy <org> set --revision <rev> [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver <name>|""] [--json]
scripts/mosaic org autonomy <org> review cross-role|merge|policy|authority [--json]
scripts/mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
scripts/mosaic tasks edit task:<n> --expect <digest> [--title ...] [--state ...] [--assignees ...] [--priority ...] [--due ...] --business <id> [--json]
scripts/mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -318,6 +338,16 @@ scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--p
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
```
For local development on a named instance only, `mosaic bus start BUSINESS --dev-local-owner` admits the fixed local-dev Console owner and refuses the default instance. Launches are enabled unless `--no-launch` is supplied; `--pm` can start the business's PM. Launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only. Start the matching Console with `--dev-local-owner`; the usual Console wrapper, process proof and nonce checks still apply. This mode has no sign-in and is for local development only.
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start`, or by `mosaic console start --dev-local-owner --detach`, which leaves a detached supervisor (`packages/cli/src/console-supervisor.mjs`, its own session) running the Console as the foreground start does; its receipt adds the supervisor's `supervisor` pid, and its later warnings go to `console/supervisor-*.log` under the data root. `mosaic console stop` stops that Console, and its supervisor then exits. `mosaic console start` refuses inside an agent run (exit 3), with or without `--dev-local-owner` or `--detach`, checking the starting process's own environment as well as the one given for the Console (agent-guard, #1873). A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`.
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
@@ -790,13 +820,52 @@ the port and the backup's `SHA256SUMS`. Then it makes
Roles come from the target copy's `roles/`, as the live bus reads them.
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
- When the live config has `console.sso` (M3, #1873), the scratch copy keeps
its provider ids, client ids, algorithms and subjects and moves the public
origin to `rehearsal.invalid`, a name that never resolves. For a stamped
target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below;
otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/`
holding `rehearsal-placeholder-secret`; the live secret is never read.
A stamped target copy (one with `.stack-build.json`) serves only with `--sso`,
so its rehearsal needs the live instance's human-made SSO steps. Without a live
`console.sso` it refuses `sso-not-prepared`; without the live `accounts.json`
beside the config it refuses `accounts-not-initialized`. Both name the run-sheet
step. Otherwise it copies the live `accounts.json` into `config/`, writes
`data/.deployed` with the target copy's own `writeDeployment`, prints
`PASS deployment`, and starts the Console with `--sso`.
Before that, a stamped run starts a rehearsal provider and prints
`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves
HTTPS with a certificate made for this run, under `provider/` (mode 0700). For
each live provider id it serves discovery and a JWKS whose public key it made
in memory for the first algorithm it can make from that provider's list. Its
authorize and token routes answer 403 to any method and query: it issues no
code and no token, and
it reads no live secret. It logs each request to `provider/requests.log`,
deletes its TLS key file once it has read it, and exits on SIGTERM or when
the rehearsal's process is gone. `provider-start` fails when it exits early,
writes no origin within the timeout, writes an origin that is not
`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets
`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not.
The scratch Console runs every startup check against that provider. The gate
adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate,
`provider-discovery` requires a 200 discovery and JWKS request per provider and
no authorize or token request. The end stops the provider with the Console and
host and prints `provider-stopped`. The live `accounts.json` joins the end check
`live-config-unchanged`. The rehearsal adds no subject and no account.
A guard then loads the scratch config with the target's code and refuses
with a named check: `data-root-live` when the scratch data root resolves to
the live one, `sessions-live` when a `sessions.json` there names a running
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
`tracker-configured` when `bootConfig` still sets a tracker, and
`credential-ref-live` for an env reference or a file outside `placeholders/`.
`tracker-configured` when `bootConfig` still sets a tracker,
`credential-ref-live` for an env reference or a file outside `placeholders/`,
and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`,
an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on
`rehearsal.invalid` for an unstamped target), or a slot is not its scratch
placeholder.
When the backup's schema differs from the target's, the run checks that the
target refuses the store, migrates it, opens it, migrates it again as a
no-op, and that the pointer's copy refuses the migrated store.
@@ -805,7 +874,8 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
`serve --port N --business BUSINESS` from the target copy, without `--send`.
The gate needs the host's `launches disabled` line, a live `bus status` host
with this run's pid and a boot from this start, the Console's address line,
`/healthz` 200, and both command lines on the target copy. Then it checks
`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines
on the target copy. Then it checks
that the host wrote no `sessions.json` and no `launches`, stops the Console
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
the host released `writer.lock`, and compares the backup and the live config
@@ -817,6 +887,21 @@ Usage exits 4. Nothing prunes rehearsal directories.
starts no thread ticker, and leaves an earlier host's `sessions.json` and
its runs alone. No unit passes it, and `--pm` with it is a usage error.
An accounts instance runs system units (M3, #1873). Without `--units external`,
`activate`, `rollback` and `pm-on` refuse `accounts-user-unit`, before
anything stops and before `--plan` prints, when the target copy holds a
`.stack-build.json` (a stamped copy), when `accounts.json` sits beside the
config, when the config holds `console.sso`, or when `<dataRoot>/.deployed`
exists. A config that cannot be read as JSON refuses as well. The message
names the first sign found and says root renders the units with
`case2-unit.sh`. For `activate` and `rollback` it adds that the run takes
`--units external`. For `pm-on` it says the PM is a flag in root's bus unit.
So a user-unit run never turns `--sso` on any more. The `--sso`, `sso-slot`
and `--no-sso` text above describes the pointers that pre-M3 hosts left, and
the Console's own start checks the slot (`readSlot` in
`packages/webui/src/sso.mjs`). A rollback or `--no-sso` from such a pointer
runs once `console.sso` is gone and no other sign is present.
#### Root-owned units (`--units external`, row 232)
On a host whose bus and Console are root's system units, the stack user runs
@@ -877,8 +962,9 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under
```
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
[--rehearse-port N] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
[--rehearse-port N] [--next-args] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
[--timeout S]
```
Root runs it from a root-owned checkout of the reviewed commit, never from a
@@ -918,7 +1004,13 @@ user's clone `<home>/src/mosaic-stack`:
(`args-differ`), and
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
(`plan-refused`, which covers `open-activation`, `migrate-required` and
`pointer-differs`).
`pointer-differs`). Then the render checks (#1873 M3):
`case2-unit.sh --check` renders OLD from the args file and compares the
installed units with it, so a rollback could render them, then checks
SHA's render the same way. Either refusal is `render-refused`. A re-pin
that changes the serve flags fails the second check, because an
installed unit must equal the render but for the copy id (row 195); it
takes `--next-args`, below.
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
@@ -933,6 +1025,22 @@ user's clone `<home>/src/mosaic-stack`:
still names the old one, and root moves the new file (the result line
names it) into place by hand.
`--next-args` changes the serve flags, as the SSO migration does
(`docs/guides/host-run-sheet.md` section 7). The run reads
`/etc/mosaic-stack/dev.args.next` as it reads the args file
(`args-unreadable`). It must equal the args file before `--` but for
`--copy`'s value (`next-args-differ`) and differ from it after `--`
(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a
first install's checks from the next args, without the installed units'.
After A3, root moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders
as a first install. A failure from there moves them back unchanged
(`units-back`), runs `daemon-reload` and starts the old units with no render.
After A5 the old args file is copied into the replaced directory, the next
args file is removed (step `next-args`), and the new args file, made from
the next args, moves into place. The result line names the replaced
directory. Nothing removes it.
A failure in A2 or a stage that exits 3 starts the old units again and gates
them (A2f). A later failure stops both units, runs `revert`, renders the old
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
@@ -1044,6 +1152,8 @@ including `console.sso` (exit 2 if missing). Without `--send` the unit is
read-only. The template is
`packages/webui/systemd/mosaic-webui.service.in`.
For direct local development (not the service renderer), `node packages/webui/src/cli.mjs serve --dev-local-owner --send` uses the owner without sign-in. It requires `MOSAIC_CONFIG` naming a non-default `~/.config/mosaic-NAME/config.json`, listens on 127.0.0.1 unless `--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens it to the LAN as described for `mosaic console start` above, and requires an exact local or allowed Host and same-origin writes. It refuses SSO, preview, proof, and tester flags. Use an absolute path to the Console CLI so the bus can verify its process path. Start the named instance bus with `--dev-local-owner` as above for decision and request operations. Anyone able to reach the local listener acts as the owner.
Under `--send` the Console's Docs page (row 148) can also save an existing
spec/north-star.md or docs/NAME.md of a project: one local commit authored by the
business's human, refused `stale-base` if the file changed since the editor opened it.
@@ -1248,6 +1358,23 @@ input; error on stderr, never a clean verdict).
problem. Scripts print diagnostics on stderr; model responses (and only
model responses) on stdout.
## Model proxy setup (`scripts/mosaic proxy`, #1873)
Human-shell verbs that match the Console's Proxy setup panel; each refuses
`human-required` in an agent shell or with `--tester`, and prints no key or
absolute path. `proxy fetch` (existing output unchanged) now prints the
page's progress steps on stderr and writes the same fetch receipt to
`<dataRoot>/state/proxy-admin.jsonl`. `proxy status [--json]` shows release, init, unit, fence, init
lock, own proxy and the next step. `proxy init --generate-key [--port N]
[--rotate-grace-minutes N] [--first-output-seconds N] --business ID` has the
bus generate the management key (no prompt, no TTY). `proxy unit
render|status|install|start|stop|restart` manages the proxy user unit.
`proxy fence clear --restart --business ID` restarts the unit, requires a
confirmed new process listening on the port, then clears the fence with that
evidence (`proxy-restart-unconfirmed` otherwise). `proxy init-lock
status|clear` clears a stale init lock only when every bus is stopped and no
proxy state exists. Details: `packages/model-proxy/README.md`.
## Agent limits (`scripts/mosaic limits`)
View: `scripts/mosaic limits --business ID --json`. Set: `scripts/mosaic limits
@@ -1312,7 +1439,11 @@ refuses `project-no-commit` if the layout has no readable HEAD commit. It
exits 0 when valid, 2 for project/record problems (including an undeclared
project), 3 for configuration loading problems and 4 for usage. The optional business project `repo` is a normalized absolute
record-repository path; the default is
`<dataRoot>/projects/<business>/<project>/`. `root` remains the code checkout.
`<dataRoot>/projects/<business>/<project>/`. For a registered organization,
`<dataRoot>` is that organization's data root for both the CLI and its bus.
If an instance-root records repository already exists, initialization and reads
refuse and name both locations for inspection; they never move or copy it.
`root` remains the code checkout.
The package README describes schemas and the shared agent record writer; agent
writes never enter `spec/`, never absorb pending edits and refuse remotes.
+1 -1
View File
@@ -157,7 +157,7 @@ Kinds of work:
| Talk `#/talk` | the conversation History view exists; the live PM session is row 41 | blocked | Row 41 (S6). Reuse the controller/observer rules of `packages/conversation` |
| Queue `#/queue` | `packages/queue/src/store.mjs` `list`/`show`; no endpoint | new view | Read-only. Rows move only through `queue move` |
| Ledger `#/ledger` | `packages/ledger` `summarize`, `queueChecks` | new view | Read-only |
| Business `#/business` | `packages/business` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
| Business `#/business` | `packages/organization` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
| Runs and releases `#/runs` | readers live inside `scripts/mosaic-task.mjs` and `scripts/release.sh`, not exported | backend | Extract a read-only module first |
| Library `#/library` | no meta-harness in committed code | blocked | Row 41 (S6) creates `packages/harness` |
| Settings `#/settings` | appearance exists in the webui header; notifier config `readNotifyConfig` | extend | Appearance, notifications (binding names only), about (loopback, release). Read-only apart from appearance |
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sidebar · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app drawer">
<header class="cmdbar">
<button class="menu" aria-label="Close navigation" aria-expanded="true"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="crumb">Shell</div>
<div class="vh"><h1>Sidebar</h1><p class="sub">Every organization stays in view. There is no organization switcher: selecting an organization opens its dashboard, and its projects sit in the group below.</p></div>
<div class="specimen">
<figure>
<div class="mini">
<div class="grp-h" aria-expanded="false"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span></div>
<div class="collapsed-marks"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
</div>
</div>
<figcaption>Organizations collapsed. The row keeps the count and the three marks, so nothing disappears from view.</figcaption>
</figure>
<figure>
<div class="mini"><div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="false">By org</span><span aria-pressed="true">Flat</span></span></div>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a>
<a class="nav proj flat" href="#" aria-current="page"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Brand refresh</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Garden plan</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Monthly newsletter</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">CLI tools</span><span class="n">paused</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Trip planning 2027</span><span class="n">paused</span></a>
</div></div>
<figcaption>Projects flat, sorted by activity. Each row leads with its organization's mark.</figcaption>
</figure>
<figure>
<div class="mini">
<div class="grp-h"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organization states</span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="dot s-offline"><b aria-hidden="true">○</b>bus not read</span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="dot s-waiting"><b aria-hidden="true">◐</b>needs you</span></a>
</div>
<figcaption>Selected, bus unreachable, and an item waiting. Status is a symbol plus a word, never colour alone.</figcaption>
</figure>
</div>
<ol class="callouts">
<li><b>The org mark</b> is a tile in the organization's theme colour with its initials (or an uploaded image), always beside the name. The type icon on the right (business, coding, personal) is the third cue the owner asked for.</li>
<li><b>Plus</b> on the Organizations row starts the organization editor (11-07 ruling 6); it is tied to <code>mosaic org create ID --from FILE</code>.</li>
<li><b>Settings</b> no longer holds an "Existing organization" select. Organization settings open from each org dashboard.</li>
<li><b>Open state</b> of each group and the By org / Flat choice are remembered per viewer.</li>
<li><b>On a phone</b> the sidebar becomes a drawer behind the menu button, with the same groups.</li>
</ol>
</main>
</div>
</body>
</html>
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Dashboard</h1><div class="right"><span class="seg" role="group" aria-label="Period"><span aria-pressed="false">Today</span><span aria-pressed="true">7 days</span><span aria-pressed="false">30 days</span></span></div>
<p class="sub">All 3 organizations and 10 projects. Totals add up every organization you belong to; each tile breaks them down by organization.</p></div>
<div class="grid g5 keep2">
<div class="tile"><span class="label">Needs you</span><span class="big">3<small>items</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.333"></i><i data-org="forge" style="flex:0.333"></i><i data-org="home" style="flex:0.333"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>1</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span></div><span class="delta">oldest 52 min</span></div>
<div class="tile"><span class="label">Sessions</span><span class="big">7<small>4 working</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.429"></i><i data-org="forge" style="flex:0.286"></i><i data-org="home" style="flex:0.143"></i><i style="background:var(--muted);flex:0.143"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>3</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>2</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span><span><span class="av all " aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span>1</span></div><span class="delta">1 waiting · 1 error · 1 idle</span></div>
<div class="tile"><span class="label">Open tasks</span><span class="big">10</span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.500"></i><i data-org="forge" style="flex:0.300"></i><i data-org="home" style="flex:0.200"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>5</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>3</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>2</span></div><span class="delta"><b>2</b> done this week</span></div>
<div class="tile"><span class="label">Projects</span><span class="big">10<small>8 active</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.400"></i><i data-org="forge" style="flex:0.300"></i><i data-org="home" style="flex:0.300"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>4</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>3</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>3</span></div><span class="delta">2 paused</span></div>
<div class="tile"><span class="label">Model spend</span><span class="big">$38.40<small>7 days</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.549"></i><i data-org="forge" style="flex:0.406"></i><i data-org="home" style="flex:0.044"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>$21.10</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>$15.60</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>$1.70</span></div><span class="delta">of $110 in weekly limits</span></div>
</div>
<div class="split" style="margin-top:4px">
<section>
<div class="sec-h"><h2>Activity across organizations</h2><span class="note">newest first, every bus</span><a href="#">All activity</a></div>
<ol class="trail"><li><time>14:32</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>coder</b> pushed 3 commits on #41 Rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>14:28</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>copywriter</b> saved V-112 Launch email, draft 2 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>14:21</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>reviewer</b> stopped: model provider timed out <span class="src">· Forge · Web app</span></span></li><li><time>14:15</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>editor</b> asked you: keep the revised ending? <span class="src">· Distinctly Digital · The Lantern Coast</span></span></li><li><time>14:02</time><span class="av " data-org="home" aria-hidden="true">HM</span><span class="what"><b>assistant</b> flagged the insurance renewal <span class="src">· Home · Household admin</span></span></li><li><time>13:55</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>montage-producer</b> rendered teaser cut 3 of 4 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>13:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>chief</b> raised a gated decision: merge the rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>13:12</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>chief</b> created V-120 Logo shortlist in Vikunja <span class="src">· Distinctly Digital · Brand refresh</span></span></li><li><time>12:58</time><span class="av " data-org="home" aria-hidden="true">HM</span><span class="what"><b>you</b> closed #9 Order seed potatoes <span class="src">· Home · Garden plan</span></span></li></ol>
<div class="sec-h"><h2>Tasks by state</h2><span class="note">all trackers</span><a href="06-tasks.html">Open tasks</a></div>
<div class="panel tight states">
<div class="srow"><span class="sl">To do</span><span class="sn tnum">5</span><div class="bar" style="width:90%" aria-hidden="true"><i data-org="ddigital" style="flex:2"></i><i data-org="forge" style="flex:1"></i><i data-org="home" style="flex:2"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>2</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>2</span></span></div><div class="srow"><span class="sl">In progress</span><span class="sn tnum">3</span><div class="bar" style="width:54%" aria-hidden="true"><i data-org="ddigital" style="flex:2"></i><i data-org="forge" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>2</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span></span></div><div class="srow"><span class="sl">Review</span><span class="sn tnum">2</span><div class="bar" style="width:36%" aria-hidden="true"><i data-org="ddigital" style="flex:1"></i><i data-org="forge" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>1</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span></span></div><div class="srow"><span class="sl">Done this week</span><span class="sn tnum">2</span><div class="bar" style="width:36%" aria-hidden="true"><i data-org="forge" style="flex:1"></i><i data-org="home" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span></span></div>
</div>
</section>
<section>
<div class="sec-h"><h2>Needs you</h2><a href="05-inbox.html">Inbox</a></div>
<ul class="needs"><li class="edge" data-org="forge"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="q">Merge the rate limiter branch into main?</span><span class="src">Forge · API gateway · chief</span><span class="chip gated">gated</span></li><li class="edge" data-org="ddigital"><span class="av md" data-org="ddigital" aria-hidden="true">DD</span><span class="q">Chapter 7: keep the revised ending?</span><span class="src">Distinctly Digital · The Lantern Coast · editor</span><span class="chip within">within-role</span></li><li class="edge" data-org="home"><span class="av md" data-org="home" aria-hidden="true">HM</span><span class="q">Home insurance renews in 12 days. Compare the two quotes now?</span><span class="src">Home · Household admin · assistant</span><span class="chip routine">routine</span></li></ul>
<div class="sec-h"><h2>Sessions now</h2><a href="08-agents.html">Agents</a></div>
<div class="tbl"><table class="sess"><thead><tr><th>Agent and project</th><th>Status</th></tr></thead><tbody>
<tr><td class="who"><span class="orgref"><span class="av all xs" aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span>assistant</span><span class="muted"> · All organizations</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">with you</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>coder</span><span class="muted"> · API gateway</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">65 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>copywriter</span><span class="muted"> · Spring launch</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">38 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>montage-producer</span><span class="muted"> · Spring launch</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">72 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>editor</span><span class="muted"> · The Lantern Coast</span></td><td class="st"><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting</span> <span class="muted">on you</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>reviewer</span><span class="muted"> · Web app</span></td><td class="st"><span class="dot s-error"><b aria-hidden="true">▲</b>error</span> <span class="muted">timeout</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="home" aria-hidden="true">HM</span>planner</span><span class="muted"> · Household admin</span></td><td class="st"><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span> <span class="muted">since 09:10</span></td></tr>
</tbody></table></div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Distinctly Digital · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="crumb"><a href="#">Organizations</a> › Distinctly Digital</div>
<div class="orghead" data-org="ddigital">
<span class="av lg" data-org="ddigital" aria-hidden="true">DD</span>
<h1>Distinctly Digital <svg class="i" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></h1>
<div class="right">
<span class="btn" title="mosaic bus restart ddigital"><svg class="i" aria-hidden="true"><use href="#i-restart"/></svg>Restart bus</span>
<span class="btn"><svg class="i" aria-hidden="true"><use href="#i-doc"/></svg>Reload organization file</span>
<a class="btn pri" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-split"/></svg>Open in a pane</a>
</div>
<div class="meta"><span>Business</span><span>id <code>ddigital</code></span><span>Tracker: Vikunja</span><span><span class="dot s-working"><b aria-hidden="true">●</b>bus read 4 s ago</span></span><span>Restart and reload are trailed human actions; no agent can take them.</span></div>
</div>
<div class="typechips chips"><span class="label" style="margin-right:4px">System types</span><span class="chip outline">Coding</span><span class="chip outline">Creative writing</span><span class="chip outline">Marketing</span><span class="chip outline">Mosaic Montage</span><span class="chip outline">Business management</span><span class="note">Each project picks from these; none is required.</span></div>
<div class="docrow chips"><span class="label" style="margin-right:4px">Docs</span><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>North star</a><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Brand voice guide</a><a class="doc" href="#" title="Spring launch"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Launch plan<small>Spring launch</small></a><a class="doc" href="#" title="The Lantern Coast"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Series bible<small>The Lantern Coast</small></a><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Operating notes</a><a href="#" class="more">All docs</a></div>
<div class="split" style="margin-top:6px">
<section>
<div class="sec-h"><h2>KPIs</h2><span class="note">defined in the organization file</span><a href="#">Edit KPIs</a></div>
<div class="grid g4 keep2">
<div class="tile"><span class="label">Subscribers</span><span class="big">2,480</span><span class="delta"><b>+6.2%</b> in 30 days · goal 3,000</span></div>
<div class="tile"><span class="label">Launch pieces</span><span class="big">7<small>of 12</small></span><div class="bar"><i data-org="ddigital" style="flex:7"></i><i style="flex:5;background:transparent"></i></div></div>
<div class="tile"><span class="label">Novel draft</span><span class="big">61,200<small>words</small></span><span class="delta">target 90,000 · 68%</span></div>
<div class="tile"><span class="label">Tasks closed</span><span class="big">18</span><span class="delta">30 days · <b>+4</b> on last month</span></div>
</div>
<div class="sec-h"><h2>Metrics</h2><span class="note">measured by the bus</span></div>
<div class="grid g4 keep2 metrics">
<div class="tile"><span class="label">Spend this week</span><span class="big">$21.10<small>of $60</small></span><div class="bar"><i data-org="ddigital" style="flex:21.1"></i><i style="flex:38.9;background:transparent"></i></div></div>
<div class="tile"><span class="label">Agent hours</span><span class="big">31.5<small>7 days</small></span></div>
<div class="tile"><span class="label">Decision wait</span><span class="big">22<small>min median</small></span></div>
<div class="tile"><span class="label">Open tasks</span><span class="big">5</span><span class="delta">2 in progress · 1 review</span></div>
</div>
<div class="sec-h"><h2>Active agent sessions</h2><span class="note">2 working, 1 waiting on you</span><a href="08-agents.html">All agents</a></div>
<div class="tbl"><table><thead><tr><th>Agent</th><th>Project and task</th><th class="hide-m">Model</th><th>Status</th></tr></thead><tbody>
<tr><td class="t1">copywriter</td><td>Spring launch<span class="t2">V-112 Launch email, draft 2</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">38 min</span></td></tr>
<tr><td class="t1">montage-producer</td><td>Spring launch<span class="t2">V-118 Teaser reel, 30 s cut</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">1 h 12 min</span></td></tr>
<tr><td class="t1">editor</td><td>The Lantern Coast<span class="t2">V-97 Chapter 7 revision</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td><td><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting on you</span></td></tr>
</tbody></table></div>
</section>
<section>
<div class="sec-h"><h2>Settings</h2><span class="note">every setting, with where its value comes from</span></div>
<ul class="cfg one"><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg><span class="k">Identity</span><span class="v">Distinctly Digital · ddigital · Business</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-flag"/></svg><span class="k">Appearance</span><span class="v">Rosewood theme · DD avatar</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-runs"/></svg><span class="k">System types</span><span class="v">5 of 5 installed enabled</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="k">Projects</span><span class="v">4 projects · 3 with a remote, 1 local only</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="k">Agents and roles</span><span class="v">5 org · 2 project · 3 from your root</span><span class="src-tag">org + root</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg><span class="k">Models and providers</span><span class="v">Your shared proxy · editor runs opus-5.5</span><span class="src-tag">override</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="k">Task tracker</span><span class="v">Vikunja module · project DD</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="k">Channels</span><span class="v">Discord off · daily email digest</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-ledger"/></svg><span class="k">Launch limits</span><span class="v">4 sessions at once · $60 a week</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-queue"/></svg><span class="k">Routing and arbiters</span><span class="v">chief routes · editor arbitrates copy</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="k">Members and invites</span><span class="v">You (owner) · invites arrive in Phase M</span><span class="src-tag">not built</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="#i-restart"/></svg><span class="k">Bus</span><span class="v">Running · local-only checkout</span><span class="src-tag">system</span><a class="go" href="#">Open</a></li><li class="danger"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg><span class="k">Archive or delete</span><span class="v">Removes this organization's tasks and data only</span><span class="src-tag">danger</span><a class="go" href="#">Open</a></li></ul>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Projects · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Projects</h1><div class="right"><span class="btn"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>New project</span></div>
<p class="sub">10 projects in 3 organizations, sorted by latest activity. Every row names its organization by mark, colour edge and type icon.</p></div>
<div class="toolbar">
<span class="search"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg>Filter by name</span>
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">10</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">4</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">3</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">3</span></span></div>
<span class="sel"><span class="label">Status</span>Active, paused<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<div class="right"><span class="label hide-m">Sort</span><span class="seg" role="group" aria-label="Sort"><span aria-pressed="true">Activity</span><span aria-pressed="false">Name</span><span aria-pressed="false">Org</span><span aria-pressed="false">Status</span></span></div>
</div>
<div class="tbl"><table class="projects">
<thead><tr><th class="hide-m">Organization</th><th>Project</th><th>Status</th><th class="hide-m">System types</th><th class="hide-m">Sessions</th><th class="num hide-m">Open tasks</th><th class="num">Needs you</th><th class="sorted hide-m">Last activity<span class="ar">↓</span></th><th class="hide-m">Git</th></tr></thead>
<tbody>
<tr class="edge" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>API gateway</span><span class="t2 mono hide-m">api-gateway</span><span class="t2 show-m">2 min ago · coder pushed 3 commits on #41</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>1 working</span></td>
<td class="num hide-m">2</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">2 min ago<span class="t2">coder pushed 3 commits on #41</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Spring launch</span><span class="t2 mono hide-m">spring-launch</span><span class="t2 show-m">4 min ago · copywriter saved draft 2</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Creative writing</span> <span class="chip outline">Mosaic Montage</span></td>
<td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>2 working</span></td>
<td class="num hide-m">2</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">4 min ago<span class="t2">copywriter saved draft 2</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>Web app</span><span class="t2 mono hide-m">webapp</span><span class="t2 show-m">11 min ago · reviewer stopped: provider timeout</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="dot s-error"><b aria-hidden="true">▲</b>1 error</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">11 min ago<span class="t2">reviewer stopped: provider timeout</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>The Lantern Coast</span><span class="t2 mono hide-m">novel-draft</span><span class="t2 show-m">17 min ago · editor asked about chapter 7</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Creative writing</span></td>
<td class="hide-m"><span class="dot s-waiting"><b aria-hidden="true">◐</b>1 waiting</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">17 min ago<span class="t2">editor asked about chapter 7</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Household admin</span><span class="t2 mono hide-m">household</span><span class="t2 show-m">30 min ago · assistant flagged a renewal</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Business management</span></td>
<td class="hide-m"><span class="dot s-idle"><b aria-hidden="true">–</b>1 idle</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">30 min ago<span class="t2">assistant flagged a renewal</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Brand refresh</span><span class="t2 mono hide-m">brand-refresh</span><span class="t2 show-m">1 h ago · chief created V-120</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Mosaic Montage</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">1 h ago<span class="t2">chief created V-120</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Garden plan</span><span class="t2 mono hide-m">garden</span><span class="t2 show-m">1 h ago · #9 marked done</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">0</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">1 h ago<span class="t2">#9 marked done</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Monthly newsletter</span><span class="t2 mono hide-m">newsletter</span><span class="t2 show-m">yesterday · V-121 created</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Creative writing</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">yesterday<span class="t2">V-121 created</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge dim" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>CLI tools</span><span class="t2 mono hide-m">cli-tools</span><span class="t2 show-m">12 days ago · #29 done</span></td>
<td><span class="dot s-idle"><b aria-hidden="true">–</b>paused</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">0</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">12 days ago<span class="t2">#29 done</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge dim" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Trip planning 2027</span><span class="t2 mono hide-m">travel</span><span class="t2 show-m">3 weeks ago · #14 created</span></td>
<td><span class="dot s-idle"><b aria-hidden="true">–</b>paused</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">3 weeks ago<span class="t2">#14 created</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr>
</tbody></table></div>
<p class="note" style="margin-top:10px">Sorting by Org groups the rows under each organization's header. Paused projects stay listed, dimmed, and the Status filter hides them.</p>
</main>
</div>
</body>
</html>
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Inbox · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app with-insp">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Inbox</h1>
<p class="sub">3 items need you, from 3 organizations. Each row names where it came from. Seen is not resolution: an item leaves only when it is answered.</p></div>
<div class="toolbar">
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">3</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">1</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">1</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">1</span></span></div>
<div class="right"><span class="seg" role="group" aria-label="State"><span aria-pressed="true">Needs you</span><span aria-pressed="false">Seen</span><span aria-pressed="false">Answered</span></span></div>
</div>
<div class="tbl"><table class="ibx">
<thead><tr><th class="hide-m">Source</th><th>Needs</th><th class="hide-m">Class</th><th class="num">Waiting</th></tr></thead>
<tbody>
<tr class="row edge" data-org="forge" aria-selected="true"><td class="src hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="via">API gateway · chief</span></td><td><span class="msrc show-m"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge · API gateway · chief</span><span class="q">Merge the rate limiter branch into main?</span><span class="via">decision 4f2a91 · seen 14:05</span><span class="show-m"><span class="chip gated">gated</span></span></td><td class="hide-m"><span class="chip gated">gated</span></td><td class="num">52 min</td></tr><tr class="row edge" data-org="ddigital"><td class="src hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="via">The Lantern Coast · editor session</span></td><td><span class="msrc show-m"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital · The Lantern Coast · editor</span><span class="q">Chapter 7: keep the revised ending?</span><span class="via">decision 7c03e2 · not seen</span><span class="show-m"><span class="chip within">within-role</span></span></td><td class="hide-m"><span class="chip within">within-role</span></td><td class="num">17 min</td></tr><tr class="row edge" data-org="home"><td class="src hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home</span><span class="via">Household admin · assistant</span></td><td><span class="msrc show-m"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home · Household admin · assistant</span><span class="q">Home insurance renews in 12 days. Compare the two quotes now?</span><span class="via">decision b19d40 · not seen</span><span class="show-m"><span class="chip routine">routine</span></span></td><td class="hide-m"><span class="chip routine">routine</span></td><td class="num">30 min</td></tr>
</tbody></table></div>
<div class="sec-h"><h2>Answered today</h2><span class="note">kept for 24 hours, then in the trail</span></div>
<div class="tbl"><table class="ibx"><tbody>
<tr class="dim"><td class="src hide-m"><span class="orgref quiet"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="via">Spring launch · copywriter</span></td><td><span class="msrc show-m"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital · Spring launch</span>Use the shorter subject line?<span class="via">answered 11:40 by you · option 2</span></td><td class="num">done</td></tr>
<tr class="dim"><td class="src hide-m"><span class="orgref quiet"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="via">Web app · reviewer session</span></td><td><span class="msrc show-m"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge · Web app</span>Retry with the fallback model?<span class="via">expired 10:02 · no answer needed</span></td><td class="num">expired</td></tr>
</tbody></table></div>
</main>
<aside class="insp" aria-label="Decision" data-org="forge">
<header><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="label">Decision 4f2a91</span><span class="chip gated">gated</span><span class="x" aria-label="Close"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></header>
<div class="body">
<h3>Merge the rate limiter branch into main?</h3>
<div class="auth">Forge · API gateway · raised by <b>chief</b> at 13:40. Only you can answer a gated decision.</div>
<p class="note">Branch <code>rate-limiter-41</code> passes the suite (312 tests). The review is missing: the reviewer session stopped on a provider timeout at 14:21.</p>
<ul class="opts">
<li class="rec"><div class="row"><span class="k">1</span><b>Merge after a fresh review</b><span class="chip ok">chief recommends</span></div><span class="note">Restart the reviewer on the fallback model, then merge if it passes.</span><div class="row"><span class="btn pri">Answer 1…</span></div></li>
<li><div class="row"><span class="k">2</span><b>Merge now</b></div><span class="note">Skips review. The trail records the waiver.</span><div class="row"><span class="btn">Answer 2…</span></div></li>
<li><div class="row"><span class="k">3</span><b>Hold until Monday</b></div><div class="row"><span class="btn">Answer 3…</span></div></li>
</ul>
<div><span class="label">Answer from a terminal</span>
<div class="cmd" style="margin-top:6px"><code>mosaic decide 4f2a91 1 --org forge</code><span class="btn" title="Copy"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-copy"/></svg></span></div>
<p class="note" style="margin-top:6px">The inbox mixes organizations, so the copied command always names the organization's bus.</p></div>
<div><span class="label">History</span>
<ol class="hist" style="margin-top:6px"><li><time>13:40</time>chief raised it on the Forge bus</li><li><time>13:41</time>reached your inbox</li><li><time>14:05</time>seen by you (not an answer)</li></ol></div>
</div>
</aside>
</div>
</body>
</html>
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tasks · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Tasks</h1><div class="right"><span class="seg" role="group" aria-label="Layout"><span aria-pressed="true" class="kb">Kanban</span><span aria-pressed="false" class="ls">List</span></span><span class="btn"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>New task</span></div>
<p class="sub">12 tasks from 3 organizations. Each organization keeps its own task store; this view reads all of them and writes each change back to the store it came from.</p></div>
<div class="trackers"><span class="label">Trackers</span><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital <span class="trk vik">Vikunja</span></span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge <span class="trk">built-in</span></span><span><span class="av " data-org="home" aria-hidden="true">HM</span>Home <span class="trk">built-in</span></span><span class="note">Ids keep their tracker's form: V-112 is Vikunja, #41 is built in. Archiving an organization removes only its tasks.</span></div>
<div class="toolbar">
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">12</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">5</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">4</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">3</span></span></div>
<span class="sel"><span class="label">Project</span>All 10<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<span class="sel"><span class="label">Assignee</span>Anyone<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<div class="right"><span class="search"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg>Find a task</span></div>
</div>
<div class="board">
<section class="col"><div class="col-h"><h3>To do</h3><span class="n">5</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#44</span><span class="trk">built-in</span></div>
<div class="tt">Settings page: model picker</div>
<div class="bot"><span>Web app</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-120</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Logo shortlist</div>
<div class="bot"><span>Brand refresh</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-121</span><span class="trk vik">Vikunja</span></div>
<div class="tt">November issue outline</div>
<div class="bot"><span>Monthly newsletter</span></div>
</article><article class="kcard edge you" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#12</span><span class="trk">built-in</span></div>
<div class="tt">Insurance renewal comparison</div>
<div class="bot"><span>Household admin</span><span class="chip warn">needs you</span></div>
</article><article class="kcard edge" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#14</span><span class="trk">built-in</span></div>
<div class="tt">Shortlist ferry dates</div>
<div class="bot"><span>Trip planning 2027</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>In progress</h3><span class="n">3</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#41</span><span class="trk">built-in</span></div>
<div class="tt">Rate limiter: per-key buckets</div>
<div class="bot"><span>API gateway</span><span class="dot s-working"><b aria-hidden="true">●</b>coder</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-112</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Launch email, draft 2</div>
<div class="bot"><span>Spring launch</span><span class="dot s-working"><b aria-hidden="true">●</b>copywriter</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-118</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Teaser reel, 30 s cut</div>
<div class="bot"><span>Spring launch</span><span class="dot s-working"><b aria-hidden="true">●</b>montage-producer</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>Review</h3><span class="n">2</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#38</span><span class="trk">built-in</span></div>
<div class="tt">Load test at 2k rps</div>
<div class="bot"><span>API gateway</span><span class="dot s-idle"><b aria-hidden="true">–</b>reviewer</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-97</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Chapter 7 revision</div>
<div class="bot"><span>The Lantern Coast</span><span class="dot s-waiting"><b aria-hidden="true">◐</b>editor, on you</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>Done</h3><span class="n">2</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#29</span><span class="trk">built-in</span></div>
<div class="tt">zsh completion</div>
<div class="bot"><span>CLI tools</span></div>
</article><article class="kcard edge" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#9</span><span class="trk">built-in</span></div>
<div class="tt">Order seed potatoes</div>
<div class="bot"><span>Garden plan</span></div>
</article></section>
</div>
<section class="tasklist">
<div class="sec-h"><h2>List layout</h2><span class="note">same filters, one row per task, grouped by state</span></div>
<div class="tbl"><table><thead><tr><th>Org</th><th>Id</th><th>Task</th><th class="hide-m">Project</th><th>State</th><th class="hide-m">Assignee</th></tr></thead><tbody>
<tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#44<span class="t2">built-in</span></td><td><span class="t1">Settings page: model picker</span><span class="t2 show-m">Web app</span></td><td class="hide-m">Web app</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-120<span class="t2">Vikunja</span></td><td><span class="t1">Logo shortlist</span><span class="t2 show-m">Brand refresh</span></td><td class="hide-m">Brand refresh</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-121<span class="t2">Vikunja</span></td><td><span class="t1">November issue outline</span><span class="t2 show-m">Monthly newsletter</span></td><td class="hide-m">Monthly newsletter</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#12<span class="t2">built-in</span></td><td><span class="t1">Insurance renewal comparison</span><span class="t2 show-m">Household admin</span></td><td class="hide-m">Household admin</td><td>To do</td><td class="hide-m"><span class="chip warn">you</span></td></tr><tr class="edge" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#14<span class="t2">built-in</span></td><td><span class="t1">Shortlist ferry dates</span><span class="t2 show-m">Trip planning 2027</span></td><td class="hide-m">Trip planning 2027</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#41<span class="t2">built-in</span></td><td><span class="t1">Rate limiter: per-key buckets</span><span class="t2 show-m">API gateway</span></td><td class="hide-m">API gateway</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>coder</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-112<span class="t2">Vikunja</span></td><td><span class="t1">Launch email, draft 2</span><span class="t2 show-m">Spring launch</span></td><td class="hide-m">Spring launch</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>copywriter</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-118<span class="t2">Vikunja</span></td><td><span class="t1">Teaser reel, 30 s cut</span><span class="t2 show-m">Spring launch</span></td><td class="hide-m">Spring launch</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>montage-producer</span></td></tr><tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#38<span class="t2">built-in</span></td><td><span class="t1">Load test at 2k rps</span><span class="t2 show-m">API gateway</span></td><td class="hide-m">API gateway</td><td>Review</td><td class="hide-m"><span class="dot s-idle"><b aria-hidden="true">–</b>reviewer</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-97<span class="t2">Vikunja</span></td><td><span class="t1">Chapter 7 revision</span><span class="t2 show-m">The Lantern Coast</span></td><td class="hide-m">The Lantern Coast</td><td>Review</td><td class="hide-m"><span class="dot s-waiting"><b aria-hidden="true">◐</b>editor</span></td></tr><tr class="edge dim" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#29<span class="t2">built-in</span></td><td><span class="t1">zsh completion</span><span class="t2 show-m">CLI tools</span></td><td class="hide-m">CLI tools</td><td>Done</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge dim" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#9<span class="t2">built-in</span></td><td><span class="t1">Order seed potatoes</span><span class="t2 show-m">Garden plan</span></td><td class="hide-m">Garden plan</td><td>Done</td><td class="hide-m"><span class="muted">nobody</span></td></tr>
</tbody></table></div>
</section>
</main>
</div>
</body>
</html>
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Side by side · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main flush">
<div class="panebar"><h1>Side by side</h1><span class="note">2 panes · each pane reads its own organization's bus and keeps its own state</span>
<span class="seg pane-tabs" role="group" aria-label="Shown pane"><span aria-pressed="true"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span> Spring launch</span><span aria-pressed="false"><span class="av xs" data-org="forge" aria-hidden="true">FG</span> API gateway</span></span>
<span class="btn hide-m" style="margin-left:auto"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>Add pane</span></div>
<div class="panes">
<section class="pane" data-org="ddigital">
<div class="pane-h"><span class="av md" data-org="ddigital" aria-hidden="true">DD</span><span class="ttl">Spring launch<small>Distinctly Digital</small></span><span class="tools"><span title="Open in full view"><svg class="i sm" aria-hidden="true"><use href="#i-split"/></svg></span><span title="Close pane"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></span></div>
<div class="pane-body">
<div class="subtabs"><span aria-current="page">Overview</span><span>Tasks</span><span>Sessions</span><span>Docs</span></div>
<div class="grid g3 mini-tiles"><div class="tile"><span class="label">Open tasks</span><span class="big">2</span></div><div class="tile"><span class="label">Sessions</span><span class="big">2<small>working</small></span></div><div class="tile"><span class="label">Needs you</span><span class="big">0</span></div></div>
<div class="sec-h"><h3>copywriter · live</h3><span class="note">V-112 Launch email, draft 2</span></div>
<pre class="term"><span class="m">14:26</span> read brand-voice.md, launch-plan.md
<span class="m">14:27</span> drafted subject lines (3), kept the shortest
<span class="m">14:28</span> saved drafts/launch-email-v2.md 182 words
<span class="m">14:29</span> next: tighten the call to action
<span class="c">▍</span></pre>
<div class="sec-h"><h3>montage-producer · live</h3><span class="note">V-118 Teaser reel</span></div>
<pre class="term"><span class="m">13:55</span> rendered cut 3 of 4 00:30 1080x1920
<span class="m">14:10</span> waiting on the music bed licence check
<span class="c">▍</span></pre>
<div class="sec-h"><h3>Activity</h3></div>
<ol class="trail"><li><time>14:28</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>copywriter</b> saved V-112 Launch email, draft 2 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>13:55</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>montage-producer</b> rendered teaser cut 3 of 4 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>11:20</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>chief</b> updated the launch plan <span class="src">· Distinctly Digital · Spring launch</span></span></li></ol></div>
</section>
<section class="pane alt" data-org="forge">
<div class="pane-h"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="ttl">API gateway<small>Forge</small></span><span class="tools"><span title="Open in full view"><svg class="i sm" aria-hidden="true"><use href="#i-split"/></svg></span><span title="Close pane"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></span></div>
<div class="pane-body">
<div class="subtabs"><span aria-current="page">Overview</span><span>Tasks</span><span>Sessions</span><span>Docs</span></div>
<div class="grid g3 mini-tiles"><div class="tile"><span class="label">Open tasks</span><span class="big">2</span></div><div class="tile"><span class="label">Sessions</span><span class="big">1<small>working</small></span></div><div class="tile"><span class="label">Needs you</span><span class="big">1<small>gated</small></span></div></div>
<ul class="needs" style="margin-top:12px"><li class="edge" data-org="forge"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="q">Merge the rate limiter branch into main?</span><span class="src">chief · 52 min · <a href="05-inbox.html">Open in inbox</a></span><span class="chip gated">gated</span></li></ul>
<div class="sec-h"><h3>coder · live</h3><span class="note">#41 Rate limiter: per-key buckets</span></div>
<pre class="term"><span class="m">14:30</span> npm test 312 passed, 0 failed
<span class="m">14:31</span> bench: 2,140 rps at p99 41 ms
<span class="m">14:32</span> pushed 3 commits to rate-limiter-41
<span class="m">14:32</span> waiting for review
<span class="c">▍</span></pre>
<div class="sec-h"><h3>Activity</h3></div>
<ol class="trail"><li><time>14:32</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>coder</b> pushed 3 commits on #41 Rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>13:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>chief</b> raised a gated decision: merge the rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>12:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>load-tester</b> finished run 7 of 7 <span class="src">· Forge · API gateway</span></span></li></ol></div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agents · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Agents</h1><div class="right"><span class="btn"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg>Edit root agents</span></div>
<p class="sub">Agents are defined at three levels. A project can use the union of your root agents, its organization's agents and its own. Every agent except the assistant works inside one organization.</p></div>
<div class="span-band"><span class="av all md" aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span><b>assistant</b><span class="lvl root">root</span><span class="dot s-working"><b aria-hidden="true">●</b>working</span><span>Works across</span><span class="orgs"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="av " data-org="home" aria-hidden="true">HM</span></span><span class="muted">One shared memory. Talks with you and hands each organization's work to its chief; it does not direct agents, create tasks or launch sessions.</span></div>
<div class="levels" style="margin-top:12px">
<div class="lh hide-m"></div><div class="oh hide-m" data-org="ddigital"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</div><div class="oh hide-m" data-org="forge"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</div><div class="oh hide-m" data-org="home"><span class="av " data-org="home" aria-hidden="true">HM</span>Home</div>
<div class="lh"><span class="lvl root">root</span><small>Your root configuration. Only you change it. Each organization runs its own copy, with memory, files and credentials kept in that organization.</small></div>
<div data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>reviewer <small>copy</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span></div>
<div data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent "><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span>reviewer <small>copy</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span></div>
<div data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span><span class="note" style="display:block;margin-top:4px">reviewer turned off for Home</span></div>
<div class="lh"><span class="lvl org">org</span><small>Defined in the organization file. The chief hires and fires here; you can override any setting.</small></div>
<div data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>copywriter</span><span class="agent "><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span>editor</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>montage-producer</span></div>
<div data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>coder</span></div>
<div data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>planner</span></div>
<div class="lh last"><span class="lvl proj">project</span><small>Defined in one project and available only there. The chief hires and fires here too.</small></div>
<div class="last" data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>social-scheduler <small>Spring launch</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>continuity-checker <small>The Lantern Coast</small></span></div>
<div class="last" data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>load-tester <small>API gateway</small></span></div>
<div class="last" data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="muted">none</span></div>
</div>
<div class="split" style="margin-top:4px">
<section>
<div class="sec-h"><h2>Available in a project</h2><span class="sel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Spring launch<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span></div>
<div class="tbl"><table><thead><tr><th>Agent</th><th>Level</th><th class="hide-m">Defined in</th><th class="hide-m">Memory</th><th>Status</th><th class="hide-m">Model</th></tr></thead><tbody>
<tr><td class="t1">assistant</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">Shared across all organizations</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">reviewer</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">researcher</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">chief</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">copywriter</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">editor</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">montage-producer</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">social-scheduler</td><td><span class="lvl proj">project</span></td><td class="hide-m">Spring launch project</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">haiku-5.5 <a href="#">Change</a></td></tr>
</tbody></table></div>
<p class="note" style="margin-top:8px">Not available here: continuity-checker (The Lantern Coast only) and every Forge or Home agent.</p>
</section>
<section>
<div class="sec-h"><h2>Who changes what</h2></div>
<div class="panel tight rules">
<p><span class="lvl root">root</span> You add, change and remove root agents. The chief cannot.</p>
<p><span class="lvl org">org</span> <span class="lvl proj">project</span> The chief hires and fires to meet your needs, within the role rules. Each hire lands in the trail.</p>
<p><span class="lvl proj">override</span> You can change any agent's settings at any level, at any time. An override shows where it was set.</p>
<p class="note">Changing an agent's model is a trailed human action, applied to its running session on reload.</p>
</div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,92 @@
// Renders the U0 samples to PNG with headless Chromium: every page at 1440x900 and 390x844,
// light and dark (prefers-color-scheme, no data-mode), 32 shots for 8 pages.
// Usage: node render.mjs <out-dir>
// Serves the repository over HTTP on 127.0.0.1, port 0, because Chromium refuses cross-file SVG
// <use> under file://. Only docs/design/ and the WebUI font directory are served.
// Checks each shot: no script error, no failed request, no sideways page scroll, fonts loaded.
import { createServer } from 'node:http';
import { readFile, mkdir } from 'node:fs/promises';
import { join, normalize, extname, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { browser } from '../../../../packages/webui/tests/browser.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repo = normalize(join(here, '../../../..'));
const out = process.argv[2];
if (!out) { console.error('usage: node render.mjs <out-dir>'); process.exit(2); }
await mkdir(out, { recursive: true });
const ALLOW = ['docs/design/', 'packages/webui/src/public/assets/fonts/'];
const TYPES = { '.html': 'text/html', '.css': 'text/css', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.js': 'text/javascript' };
const failed = [];
const server = createServer(async (req, res) => {
const path = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname)).replace(/^\/+/, '');
if (path === 'favicon.ico') { res.writeHead(204).end(); return; }
if (path.includes('..') || !ALLOW.some((a) => path.startsWith(a))) { failed.push(`403 ${path}`); res.writeHead(403).end(); return; }
try {
const body = await readFile(join(repo, path));
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' }).end(body);
} catch { failed.push(`404 ${path}`); res.writeHead(404).end(); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}/docs/design/samples/2026-11-09-unified-workspace/`;
const PAGES = ['01-sidebar', '02-dashboard', '03-org-dashboard', '04-projects', '05-inbox', '06-tasks', '07-side-by-side', '08-agents'];
const SIZES = [[1440, 900], [390, 844]];
const THEMES = ['light', 'dark'];
const CHECK = `(() => {
const vw = innerWidth, wide = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (!r.width || r.right <= vw + 1) continue;
let clipped = false;
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
const s = getComputedStyle(p);
if (/(auto|hidden|scroll|clip)/.test(s.overflowX) || s.position === 'fixed' && s.display === 'none') { clipped = true; break; }
}
if (!clipped && getComputedStyle(el).visibility !== 'hidden') wide.push(el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).join('.') : ''));
}
const faces = [...document.fonts].map((f) => f.family.replace(/"/g, '') + ' ' + f.weight + ' ' + f.status);
return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, wide: wide.slice(0, 8), faces,
manrope800: document.fonts.check('800 16px Manrope'), dark: matchMedia('(prefers-color-scheme: dark)').matches,
canvas: getComputedStyle(document.body).backgroundColor };
})()`;
const b = await browser({ machine: false });
const errors = [];
b.on('Runtime.exceptionThrown', (p) => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text));
b.on('Runtime.consoleAPICalled', (p) => { if (p.type === 'error') errors.push(p.args.map((a) => a.value ?? a.description).join(' ')); });
const report = [];
let bad = 0;
try {
for (const [w, h] of SIZES) {
await b.viewport(w, h);
for (const theme of THEMES) {
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: theme }] });
for (const name of PAGES) {
errors.length = 0; failed.length = 0;
await b.navigate(base + name + '.html');
await new Promise((r) => setTimeout(r, 250));
const info = await b.evaluate(CHECK);
const file = `${name}-${w}-${theme}.png`;
await b.screenshot(join(out, file));
const problems = [];
if (errors.length) problems.push('script: ' + errors.join(' | '));
if (failed.length) problems.push('requests: ' + failed.join(', '));
if (info.sw > info.cw) problems.push(`sideways scroll ${info.sw}>${info.cw}`);
if (info.wide.length) problems.push('past the edge: ' + info.wide.join(', '));
if (info.faces.some((f) => !f.endsWith('loaded') && !f.endsWith('unloaded'))) problems.push('fonts: ' + info.faces.join(', '));
if (!info.faces.some((f) => f.startsWith('Manrope') && f.endsWith('loaded') && !f.endsWith('unloaded'))) problems.push('Manrope not loaded');
if (info.dark !== (theme === 'dark')) problems.push('theme emulation failed');
if (problems.length) bad++;
report.push({ file, canvas: info.canvas, problems });
}
}
}
} finally {
await b.close();
server.close();
}
for (const r of report) console.log(`${r.problems.length ? 'FAIL' : 'ok '} ${r.file} ${r.canvas}${r.problems.length ? '\n ' + r.problems.join('\n ') : ''}`);
console.log(`${report.length} shots, ${bad} with problems`);
process.exit(bad ? 1 : 0);
@@ -0,0 +1,450 @@
/* U0 unified workspace samples, 2026-11-09. Static mocks, not product code.
Colours, type, spacing and radii come from tokens.css. The few additions are marked EXT
and listed in the U0 design report; nothing here redefines a token. */
@import url("../../tokens.css");
/* Fonts: the WebUI's self-hosted files, so the samples render with no network. The four
manrope-*.woff2 files are one variable font (weights 200 to 800), so one face covers 400-800. */
@font-face{font-family:"Manrope";font-style:normal;font-weight:200 800;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/manrope-400.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:400;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:500;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:700;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2") format("woff2")}
/* EXT 1: org identity colours. Each organization's mark takes the action colour of the palette
its theme uses (11-07 ruling 7), copied from tokens.json for the three sample palettes, with
that palette's onAction for the initials. The shell keeps the user's own palette. */
:root{--org-grove:#377752;--org-rosewood:#a53b72;--org-atlantic:#19719a;--org-on:#ffffff}
@media (prefers-color-scheme:dark){:root:not([data-mode="light"]):not([data-mode="dim"]){--org-grove:#8fcca9;--org-rosewood:#d487af;--org-atlantic:#73c3e8;--org-on:#10141a}}
:root[data-mode="dark"]{--org-grove:#8fcca9;--org-rosewood:#d487af;--org-atlantic:#73c3e8;--org-on:#10141a}
:root[data-mode="dim"]{--org-grove:#8fcca9;--org-rosewood:#dc9ebe;--org-atlantic:#73c3e8;--org-on:#10141a}
[data-org="home"]{--org:var(--org-grove)}
[data-org="ddigital"]{--org:var(--org-rosewood)}
[data-org="forge"]{--org:var(--org-atlantic)}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
html,body{height:100%}
body{margin:0;background:var(--canvas);color:var(--text);font:400 14px/1.5 var(--font)}
h1,h2,h3,h4{color:var(--strong);margin:0;line-height:1.2;text-wrap:balance}
p{margin:0}
a{color:var(--action)}
code,kbd,.mono{font-family:var(--mono);font-size:12.5px}
kbd{border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 .35em;background:var(--surface);color:var(--strong);white-space:nowrap;font-size:11px}
button,select,input{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
.tnum{font-variant-numeric:tabular-nums}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
svg.i{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
svg.i.sm{width:15px;height:15px}
.label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.note{font-size:13px;color:var(--muted)}
.muted{color:var(--muted)}
.sim{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);border:1px dashed var(--accent);border-radius:4px;padding:0 5px;white-space:nowrap}
/* ---------- shell: command bar, section list, main, inspector (d3 layout) ---------- */
.app{display:grid;grid-template-columns:240px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);height:100vh}
.app.with-insp{grid-template-columns:240px minmax(0,1fr) minmax(300px,360px)}
.cmdbar{grid-column:1/-1;display:flex;align-items:center;gap:12px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);min-width:0}
.cmdbar .mk{display:flex;align-items:center;gap:8px;font-weight:800;color:var(--strong);letter-spacing:-.01em;white-space:nowrap;text-decoration:none}
.cmdbar .mk svg{width:24px;height:auto}
/* The WebUI's temporary four-tile logo (index.html, processing.css), kept by owner ruling 11-09 until a real mark is chosen. */
.mosaic-logo{flex:none;fill:var(--action)}.mosaic-logo rect:nth-child(2){fill:color-mix(in srgb,var(--action) 75%,var(--strong))}.mosaic-logo rect:nth-child(4){fill:color-mix(in srgb,var(--action) 45%,var(--canvas))}
.cmdbar .menu{display:none;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:4px;color:var(--strong)}
.cmdk{flex:1;max-width:520px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:5px 10px;color:var(--muted);text-align:left;min-width:0}
.cmdk span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fresh{display:flex;align-items:center;gap:8px;margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap}
.fresh b{color:var(--success);font-weight:600}
.me{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--raised);color:var(--strong);font-weight:700;font-size:12px;flex:none}
.side{border-right:1px solid var(--line);background:var(--surface);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent;padding:10px 8px 14px;display:flex;flex-direction:column;gap:2px;min-height:0}
.nav{display:flex;align-items:center;gap:9px;padding:5px 8px;border-radius:var(--r);color:var(--text);text-decoration:none;font-weight:600;font-size:13.5px;min-width:0}
.nav:hover{background:var(--raised)}
.nav[aria-current="page"]{background:color-mix(in srgb,var(--action) 13%,var(--surface));color:var(--strong)}
.nav[aria-current="page"] svg.i{color:var(--action)}
.nav .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.n{margin-left:auto;font:600 11px var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
.n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
.side hr,.specimen .mini hr{border:0;border-top:1px solid var(--line);margin:6px 4px}
.side .foot{margin-top:auto;padding-top:8px}
/* EXT 2: collapsible sidebar group. A label row with a chevron; closed groups keep their count. */
.grp{display:flex;flex-direction:column;gap:1px}
.grp-h{display:flex;align-items:center;gap:6px;padding:10px 8px 4px;color:var(--muted)}
.grp-h .label{flex:1}
.grp-h svg.i{width:14px;height:14px;transition:transform .15s}
.grp-h[aria-expanded="false"] svg.chev{transform:rotate(-90deg)}
.grp-h .seg{margin-left:auto}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
.seg > span,.seg > button{border:0;background:none;padding:2px 8px;color:var(--muted);font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.seg [aria-pressed="true"]{background:var(--action);color:var(--onAction)}
.seg.xs > span{padding:0 6px;font-size:11px}
/* EXT 3: org mark. Avatar tile in the org's colour with its initials (or image), always beside the
name or initials in text: colour is never the only cue. Sizes 16, 20, 28, 44. */
.av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:5px;background:var(--org,var(--muted));color:var(--org-on);font:700 9.5px/1 var(--font);letter-spacing:.02em}
.av.xs{width:16px;height:16px;border-radius:4px;font-size:8.5px;letter-spacing:0}
.av.md{width:28px;height:28px;border-radius:7px;font-size:12px}
.av.lg{width:44px;height:44px;border-radius:10px;font-size:17px}
.av.all{background:var(--raised);color:var(--strong)}
.orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
.orgref.quiet{color:var(--text);font-weight:500}
.org-row .ic{color:var(--muted)}
.org-row .ic svg.i{width:15px;height:15px}
/* Org edge: a 3px stripe in the org colour on cards, rows and panes that belong to one org. */
.edge{box-shadow:inset 3px 0 0 var(--org)}
.pgroup{display:flex;flex-direction:column;gap:1px;margin-bottom:4px}
.pgroup-h{display:flex;align-items:center;gap:7px;padding:4px 8px 2px;font-size:12px;font-weight:700;color:var(--strong)}
.pgroup-h svg.i{width:13px;height:13px;color:var(--muted)}
.proj{padding-left:20px;padding-top:3px;padding-bottom:3px;font-weight:500;font-size:13px}
.proj .dot-s{font:400 12px/1 var(--glyph);width:12px;text-align:center}
.main{overflow:auto;min-width:0;min-height:0;padding:18px 24px 28px}
.crumb{font-size:12.5px;color:var(--muted);margin-bottom:4px;display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.crumb a{color:var(--muted)}
.vh{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center;margin-bottom:14px}
.vh h1{font-size:28px;font-weight:800;letter-spacing:-.02em}
.vh .sub{flex-basis:100%;font-size:13.5px;color:var(--muted)}
.vh .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
h2.sec{font-size:20px;font-weight:800;letter-spacing:-.015em}
h3.q{font-size:16px;font-weight:700}
.sec-h{display:flex;align-items:baseline;gap:10px;margin:22px 0 8px;flex-wrap:wrap}
.sec-h h2,.sec-h h3{font-size:15px;font-weight:700}
.sec-h .note{flex:1}
.sec-h a{font-size:13px;font-weight:600;text-decoration:none}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:16px;min-width:0}
.panel.tight{padding:12px 14px}
.grid{display:grid;gap:var(--gap)}
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
.g5{grid-template-columns:repeat(5,minmax(0,1fr))}
.split{display:grid;gap:16px;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);align-items:start}
/* status vocabulary: symbol plus word, never colour alone */
.dot{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
/* EXT 10, status glyph font: Manrope has no geometric shapes, and the generic fallback (Liberation Sans here)
draws the waiting half-circle at half the size of the dot. Name fonts that draw all five at one size. */
:root{--glyph:"DejaVu Sans","Segoe UI Symbol","Apple Symbols","Noto Sans Symbols 2",var(--font)}
.dot b{font:400 13px/1 var(--glyph);width:14px;text-align:center}
.s-working b{color:var(--success)}.s-waiting b{color:var(--warning)}.s-error b{color:var(--danger)}
.s-offline b,.s-idle b,.s-unknown b{color:var(--muted)}
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
.chip.gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
.chip.block{background:var(--danger);color:var(--surface)}
.chip.cross{background:color-mix(in srgb,var(--accent) 16%,var(--surface));color:var(--accent)}
.chip.within,.chip.routine{background:var(--raised);color:var(--muted)}
.chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
.chip.ok{background:color-mix(in srgb,var(--success) 15%,var(--surface));color:var(--success)}
.chip.outline{background:none;border:1px solid var(--line);color:var(--text);font-weight:500}
.chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.chips .f{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font-size:12.5px;font-weight:600;color:var(--text);white-space:nowrap}
.chips .f.plain{padding-left:10px}
.chips .f[aria-pressed="true"]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
.chips .f .av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
.chips .f .n{margin-left:2px;color:inherit;opacity:.8}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font-size:13px;font-weight:600;color:var(--strong);white-space:nowrap;text-decoration:none}
.btn.pri{background:var(--action);border-color:var(--action);color:var(--onAction)}
.btn.ghost{border-color:transparent;background:none;color:var(--muted)}
.btn svg.i{width:15px;height:15px}
.sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:3px 8px;font-size:13px;color:var(--strong);white-space:nowrap}
.sel .label{font-size:10px}
.sel svg.i{width:13px;height:13px;color:var(--muted)}
.search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:3px 8px;color:var(--muted);font-size:13px;min-width:180px}
.toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
.toolbar .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
/* dense tables */
.tbl{overflow-x:auto;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
table{border-collapse:collapse;width:100%;font-size:13px}
th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;background:var(--surface)}
th.sorted{color:var(--strong)}
th .ar{font-family:var(--mono);margin-left:3px;color:var(--action)}
td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
td .t1{color:var(--strong);font-weight:600}
td .t2{display:block;font-size:12px;color:var(--muted)}
tbody tr.row[aria-selected="true"]{background:color-mix(in srgb,var(--action) 10%,var(--surface));box-shadow:inset 3px 0 0 var(--action)}
tbody tr.dim td{color:var(--muted)}
/* EXT 4: metric tile with a per-org breakdown. The total is the headline; the stacked bar and the
labelled list under it carry the same numbers by org, so the bar is never read on colour alone. */
.tile{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:12px 14px;min-width:0;display:grid;gap:6px;align-content:start}
.tile .big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
.tile .big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
.tile .delta{font-size:12px;color:var(--muted)}
.tile .delta b{color:var(--success);font-weight:600}
.tile .delta b.down{color:var(--danger)}
.bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--raised);gap:2px}
.bar i{display:block;background:var(--org,var(--action))}
.by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
.by span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
.by .av{width:14px;height:14px;border-radius:3px;font-size:7px}
/* activity trail across orgs */
.trail{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
.trail li{display:grid;grid-template-columns:48px 20px minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px;align-items:center}
.trail li:last-child{border-bottom:0}
.trail time{font-family:var(--mono);font-size:12px;color:var(--muted)}
.trail .what{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.trail .what b{color:var(--strong);font-weight:600}
.trail .what .src{color:var(--muted)}
/* needs-you list (inbox rows on dashboards) */
.needs{list-style:none;margin:0;padding:0;display:grid;gap:8px}
.needs li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:4px 10px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:9px 12px}
.needs .q{color:var(--strong);font-weight:600;font-size:13.5px}
.needs .src{grid-column:2;font-size:12px;color:var(--muted)}
.needs .chip{grid-row:1/3;grid-column:3}
.kv{display:grid;grid-template-columns:auto 1fr;gap:5px 12px;margin:0;font-size:13px}
.kv dt{color:var(--muted)}
.kv dd{margin:0;color:var(--strong);min-width:0;overflow-wrap:anywhere}
/* EXT 5: settings index. Every configurable is one row: group, current value, where the value comes
from, and a link to its editor. The dashboard reaches every setting without showing all inline. */
.cfg{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);overflow:hidden}
.cfg li{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.cfg li:nth-child(2n){border-right:0}
.cfg li:nth-last-child(-n+2){border-bottom:0}
.cfg svg.i{color:var(--muted);width:16px;height:16px}
.cfg .k{font-weight:600;color:var(--strong);font-size:13px}
.cfg .v{grid-column:2;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cfg .go{grid-row:1/3;grid-column:3;font-size:12.5px;font-weight:600;text-decoration:none}
.src-tag{font:500 11px var(--mono);color:var(--accent)}
.docs{list-style:none;margin:0;padding:0;display:grid;gap:2px}
.docs li{display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:10px;align-items:center;padding:6px 4px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px}
.docs li:last-child{border-bottom:0}
.docs svg.i{width:16px;height:16px;color:var(--muted)}
.docs .d{color:var(--strong);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.docs time{font-size:12px;color:var(--muted);white-space:nowrap}
/* org header on the org dashboard */
.orghead{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px}
.orghead h1{font-size:28px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:10px}
.orghead h1 svg.i{width:20px;height:20px;color:var(--muted)}
.orghead .meta{flex-basis:100%;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:13px;color:var(--muted);margin-top:-6px;padding-left:58px}
.orghead .right{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.typechips{display:flex;flex-wrap:wrap;gap:6px}
/* EXT 6: Kanban. Columns by state; a card carries the org mark, the task id from its tracker, the
project and the assignee's status. Cards from several orgs share one column. */
.board{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;align-items:start}
.col{background:color-mix(in srgb,var(--raised) 55%,var(--canvas));border:1px solid var(--line);border-radius:var(--r-lg);padding:8px;display:grid;gap:8px;min-width:0}
.col-h{display:flex;align-items:center;gap:8px;padding:2px 4px}
.col-h h3{font-size:13px;font-weight:700}
.col-h .n{margin-left:0}
.col-h .mix{margin-left:auto;display:flex;gap:2px}
.kcard{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px 8px 12px;display:grid;gap:5px;min-width:0}
.kcard .top{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.kcard .top .id{font:500 11.5px var(--mono);color:var(--muted)}
.kcard .top .trk{margin-left:auto}
.kcard .tt{color:var(--strong);font-weight:600;font-size:13px;line-height:1.35}
.kcard .bot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap}
.kcard.you{border-color:color-mix(in srgb,var(--warning) 55%,var(--line))}
/* EXT 7: tracker badge. Names the task tracker module behind an org: built-in or Vikunja. */
.trk{display:inline-flex;align-items:center;gap:4px;font:600 10.5px var(--mono);letter-spacing:.02em;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
.trk.vik{border-style:dashed;color:var(--accent);border-color:color-mix(in srgb,var(--accent) 60%,var(--line))}
.trackers{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:12.5px;color:var(--text);padding:8px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);margin-bottom:12px}
.trackers > span{display:inline-flex;align-items:center;gap:6px}
/* EXT 8: side-by-side panes. Each pane has its own header with the org mark and edge, and its own
scroll; panes never share state. Two panes at 1440; one pane with a pane switcher on a phone. */
.panes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;height:100%;min-height:0}
.pane{display:flex;flex-direction:column;min-width:0;min-height:0;border-right:1px solid var(--line)}
.pane:last-child{border-right:0}
.pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org);min-width:0}
.pane-h .ttl{font-weight:700;color:var(--strong);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px}
.pane-h .tools{margin-left:auto;display:flex;gap:2px}
.pane-h .tools span{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted)}
.pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
.pane-tabs{display:none}
.main.flush{padding:0;overflow:hidden;display:flex;flex-direction:column}
.panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
.panebar h1{font-size:16px;font-weight:700}
/* EXT 9: agent level badge and the three-level map. */
.lvl{display:inline-flex;align-items:center;gap:4px;font:700 10.5px var(--font);letter-spacing:.06em;text-transform:uppercase;padding:1px 6px;border-radius:3px;white-space:nowrap}
.lvl.root{background:var(--strong);color:var(--surface)}
.lvl.org{background:color-mix(in srgb,var(--org,var(--action)) 18%,var(--surface));color:var(--strong);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--org,var(--action)) 50%,transparent)}
.lvl.proj{background:none;color:var(--text);box-shadow:inset 0 0 0 1px var(--line)}
.levels{display:grid;grid-template-columns:200px repeat(3,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-lg);overflow:hidden;background:var(--surface)}
.levels > div{padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent);min-width:0}
.levels > div:nth-child(4n){border-right:0}
.levels .lh{background:color-mix(in srgb,var(--raised) 45%,var(--surface));display:grid;gap:4px;align-content:start}
.levels .lh small{font-size:11.5px;color:var(--muted);line-height:1.35}
.levels .lh .lvl{justify-self:start}
.levels .oh{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--strong);background:color-mix(in srgb,var(--raised) 45%,var(--surface))}
.levels .span3{grid-column:2/5;border-right:0}
.levels .last{border-bottom:0}
.agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);background:var(--canvas);white-space:nowrap;margin:2px 4px 2px 0}
.agent .dot b{width:auto}
.agent.assist{border-color:var(--action);background:color-mix(in srgb,var(--action) 10%,var(--surface))}
.agent.cos{border-style:solid}
.agent small{font-weight:500;color:var(--muted)}
.inst{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.span-band{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 12px;border-radius:var(--r-table);border:1px solid var(--action);background:color-mix(in srgb,var(--action) 8%,var(--surface));font-size:13px;color:var(--strong)}
.span-band .orgs{display:flex;gap:4px}
/* inspector */
.insp{border-left:1px solid var(--line);background:var(--surface);overflow:auto;min-height:0;display:flex;flex-direction:column}
.insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--surface);z-index:1}
.insp header .x{margin-left:auto;color:var(--muted);display:inline-flex}
.insp .body{padding:14px;display:grid;gap:14px}
.insp h3{font-size:16px;font-weight:700}
.opts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
.opts li{border:1px solid var(--line);border-radius:var(--r-table);padding:9px 10px;display:grid;gap:6px;background:var(--canvas)}
.opts li.rec{border-color:var(--action)}
.opts .k{font:700 13px var(--mono);color:var(--strong)}
.opts .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.cmd{display:flex;gap:6px;align-items:center;min-width:0}
.cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
ol.hist{list-style:none;margin:0;padding:0;display:grid;gap:6px;font-size:12.5px}
ol.hist li{display:grid;grid-template-columns:48px 1fr;gap:8px}
ol.hist time{font-family:var(--mono);color:var(--muted)}
.auth{border:1px solid var(--line);border-left:3px solid var(--org,var(--action));border-radius:0 6px 6px 0;padding:8px 10px;background:var(--canvas);font-size:13px;color:var(--strong)}
/* inbox list */
.ibx td.src{white-space:nowrap}
.ibx .q{color:var(--strong);font-weight:600}
.ibx .via{display:block;font-size:12px;color:var(--muted)}
/* sidebar details: add button, collapsed marks, project rail */
.grp-h .add{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:var(--r);color:var(--muted);border:1px solid var(--line)}
.grp-h .n{margin-left:0}
.collapsed-marks{display:flex;gap:4px;padding:0 8px 6px 28px}
.pgroup .projs{margin-left:17px;border-left:2px solid var(--org);display:flex;flex-direction:column;gap:1px}
.pgroup .proj{padding-left:10px}
.proj.flat{padding-left:8px}
.proj .n{font-family:var(--font);font-weight:500;font-size:11px}
.s-ok b{color:var(--success)}
/* tasks by state on the main dashboard */
.states{display:grid;gap:8px}
.srow{display:grid;grid-template-columns:112px 24px minmax(0,1fr) auto;gap:10px;align-items:center;font-size:13px}
.srow .sl{color:var(--strong);font-weight:600}
.srow .sn{font:700 13px var(--mono);color:var(--strong);text-align:right}
.srow .bar{height:8px;max-width:100%}
.needs li > .av{grid-row:1/3}
/* settings index, one column, with the destructive row set apart */
.cfg.one{grid-template-columns:minmax(0,1fr)}
.cfg.one li{grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;border-right:0;padding:7px 12px}
.cfg.one .v{grid-column:3;grid-row:1}
.cfg.one .src-tag{grid-column:4;grid-row:1}
.cfg.one .go{grid-column:5;grid-row:1}
.cfg.one li:nth-last-child(2){border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.cfg li.danger .k,.cfg li.danger svg.i,.cfg li.danger .go{color:var(--danger)}
/* live transcript, mini tiles and sub tabs inside a pane */
.term{margin:0;font:400 12px/1.55 var(--mono);background:var(--canvas);color:var(--text);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px;white-space:pre-wrap;overflow-wrap:anywhere}
.term .m{color:var(--muted)}
.term .c{color:var(--action)}
.mini-tiles .tile{padding:8px 10px;gap:2px}
.mini-tiles .tile .big{font-size:20px}
.subtabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin:-4px 0 12px}
.subtabs span{padding:4px 10px;font-size:13px;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px}
.subtabs [aria-current="page"]{color:var(--strong);border-bottom-color:var(--org,var(--action))}
.rules{display:grid;gap:10px;font-size:13px}
.rules p{display:flex;gap:6px;flex-wrap:wrap;align-items:baseline}
/* one-line session rows on the main dashboard */
.sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
.sess td.who .orgref{vertical-align:middle}
.sess td.st{white-space:nowrap}
/* docs row on the org dashboard */
.docrow{margin-top:8px}
.docrow .doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none;white-space:nowrap}
.docrow .doc svg.i{width:14px;height:14px;color:var(--muted)}
.docrow .doc small{font-weight:500;color:var(--muted)}
.docrow .more{font-size:13px;font-weight:600;text-decoration:none;margin-left:4px}
/* source line inside a row, used on a phone where the source column is hidden */
.msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
.av.mob{display:none}
/* phone-only labels and rows; desktop hides them */
.show-m,.mlabel{display:none}
td .t2.show-m{display:none}
/* mobile strip and drawer */
.strip{display:none}
.specimen{display:grid;gap:14px;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}
.specimen .mini{border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface);padding:8px;display:flex;flex-direction:column;gap:2px}
.specimen figcaption{font-size:12.5px;color:var(--muted);margin-top:6px}
.specimen figure{margin:0}
.callouts{margin:14px 0 0;padding:0 0 0 18px;display:grid;gap:6px;font-size:13px}
.callouts b{color:var(--strong)}
@media (max-width:1100px){
.app.with-insp{grid-template-columns:220px minmax(0,1fr)}
.app.with-insp .insp{position:fixed;right:0;top:49px;bottom:0;width:min(380px,100%);box-shadow:-12px 0 30px -12px color-mix(in srgb,#000 35%,transparent);z-index:10}
.g5{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:760px){
.app,.app.with-insp{display:block;height:auto;min-height:100vh}
.cmdbar{position:sticky;top:0;z-index:20;gap:8px;padding:8px 12px}
.cmdbar .menu{display:inline-flex}
.cmdbar .mk span,.cmdbar .sim,.fresh .wide{display:none}
.cmdk{max-width:none}
.cmdk kbd{display:none}
.fresh{margin-left:0}
.side{display:none}
.strip{display:flex;gap:2px;overflow-x:auto;padding:6px 8px;border-bottom:1px solid var(--line);background:var(--surface)}
.strip .nav{white-space:nowrap;flex:none}
.app.drawer .side{display:flex;position:fixed;inset:49px 0 0 0;z-index:30;border-right:0;padding:10px 12px 20px}
.app.drawer .strip{display:none}
.main{padding:14px 16px 24px;overflow:visible}
.vh h1,.orghead h1{font-size:24px}
.g2,.g3,.g4,.g5,.split,.specimen{grid-template-columns:minmax(0,1fr)}
.g4.keep2,.g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
.app.with-insp .insp{position:static;display:flex;width:auto;box-shadow:none;border-left:0;border-top:1px solid var(--line);margin:0 0 24px}
.show-m,td .t2.show-m{display:block}
span.show-m{display:inline-flex}
span.msrc.show-m{display:flex}
.av.mob{display:inline-grid;margin-right:6px;vertical-align:-3px}
.strip{scrollbar-width:none}
.g5.keep2 > :last-child:nth-child(odd){grid-column:1/-1}
.sec-h .note{flex-basis:100%;order:3}
.grid.g3.mini-tiles{grid-template-columns:repeat(3,minmax(0,1fr))}
.mini-tiles .tile .big{font-size:18px}
.mini-tiles .tile .big small{display:block;margin-left:0;font-size:11.5px}
.cfg.one li{grid-template-columns:18px minmax(0,1fr) auto auto;gap:2px 10px}
.cfg.one .k{grid-column:2;grid-row:1}
.cfg.one .src-tag{grid-column:3;grid-row:1}
.cfg.one .go{grid-column:4;grid-row:1/3}
.cfg.one .v{grid-column:2/4;grid-row:2}
.mlabel{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--strong);margin-bottom:6px}
.board,.tasklist .sec-h{display:none}
.vh .seg .kb{background:none;color:var(--muted)}
.vh .seg .ls{background:var(--action);color:var(--onAction)}
.srow{grid-template-columns:92px 20px minmax(0,1fr)}
.srow .by{grid-column:1/-1}
.cfg{grid-template-columns:minmax(0,1fr)}
.cfg li{border-right:0}
.cfg li:nth-last-child(2){border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.orghead .meta{padding-left:0;margin-top:0}
.orghead .right{margin-left:0}
.levels{grid-template-columns:minmax(0,1fr)}
.levels > div{border-right:0}
.levels .span3{grid-column:auto}
.levels .hide-m{display:none}
.panes{grid-template-columns:minmax(0,1fr);height:auto}
.pane{border-right:0}
.pane.alt{display:none}
.pane-tabs{display:flex}
.main.flush{overflow:visible}
.hide-m{display:none}
.trail li{grid-template-columns:42px 18px minmax(0,1fr);gap:8px}
.trail .what{white-space:normal}
.toolbar .right{margin-left:0}
.search{min-width:0;flex:1}
}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
@@ -15,6 +15,7 @@
"bus.tester": true
},
"plugins": {
"channel": ["cli", "webui"],
"harness": ["claude-code"]
},
"roles": {
+89 -11
View File
@@ -327,7 +327,7 @@ After install, `stack-copy.sh check --units external` reads the copy the units r
Before running `case2-install.sh`, check the exact file you will pass as `--business-file`. Each
non-library `projects` key must equal the `id` in `<root>/.mosaic/project.json`, and that file must exist
in the stack's user's clone. Library entries are skipped by validate (`packages/business/src/cli.mjs:72`).
in the stack's user's clone. Library entries are skipped by validate (`packages/organization/src/config-command.mjs:95`).
Run this read-only check as `mosaic` with section 2's pinned node:
```sh
@@ -356,7 +356,7 @@ MOSAIC_PROJECT_KEYS
For .53 expect `ok project stack: /srv/mosaic/src/mosaic-stack/.mosaic/project.json has id stack`
(`.mosaic/project.json:3`, `docs/guides/case1-business.mosaic-stack.json:10-11`). Any STOP or nonzero exit
stops the install. Fix a mismatched business key, not the project id (mos-1563). The install otherwise
reaches that refusal at step 7, after making the copy (`packages/business/src/cli.mjs:80`,
reaches that refusal at step 7, after making the copy (`packages/organization/src/config-command.mjs:103`,
`scripts/case2-install.sh:178`, `scripts/case2-install.sh:217`).
The stack's user's `scripts/case2-install.sh` (C2-13b, design 10 step 2b) makes the first copy with
@@ -365,7 +365,7 @@ harness, so the host needs to reach the npm registry during the install. The ins
`release.sh package` or `activate`.
A missing `.mosaic/project.json` only warns: `project <id>: no project file at <path>; it has no project
variables`. Validate reports that project `absent` and continues (`packages/business/src/cli.mjs:74-78`).
variables`. Validate reports that project `absent` and continues (`packages/organization/src/config-command.mjs:97-101`).
An install can therefore pass step 7 without the project's variables. It captures validate's output
and prints `business <id> validates` (`scripts/case2-install.sh:217-221`). Treat the missing-file warning as a stop,
place the correct project file and repeat the check above before continuing (Duckworth 323 N2;
@@ -386,10 +386,15 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
profile and its private tester socket are unchanged.
A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles
(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its
private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console
stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`,
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not
apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old
proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy
that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in
order.
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
@@ -418,10 +423,83 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
lead with the command's output. Removing only the args line does not help: the run then refuses
`console-unit-legacy`.
The Console stays read-only after the upgrade, until #412 (#385).
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
accounts, as the migration below sets up.
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
rules, before it stops a unit.
### Migrating a host to SSO accounts (M3, #1873)
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
`accounts.json` comes only from the human verb `mosaic accounts init`.
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
it refuses one that is invalid or names another instance. Check it read-only:
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
providers, their subjects and its client-secret slots, per
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
refuses if `accounts.json` exists. It also refuses `accounts-unsafe` when any folder above the config
directory is not root's or `mosaic`'s, or is group or other writable without the sticky bit (#1919).
Check first, read-only: every line printed by
`p=/srv/mosaic/.config/mosaic-dev; while [ "$p" != / ]; do stat -c '%U %a %n' "$p"; p=$(dirname "$p"); done; stat -c '%U %a %n' /`
names `root` or `mosaic`, and its mode has no group or other write bit unless it starts with 1 (sticky).
The config directory itself is `mosaic`'s with no group or other write bit. A group-writable `.config`
refuses on purpose; fix the mode, never the check.
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
of its primary group, and owner of its config directory and data root. An accounts instance never runs
under a human or development seat uid, and never under the `webui-service.sh` user units.
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
(`--proof-header`, `--proof-file`). Then run
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
it. After the stage it moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
there moves them back unchanged and starts the old copy again. After `confirm` passes,
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
rollback needs it.
To roll back past the migration, put the replaced args back as the next args and roll back with
`--next-args`:
```sh
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
```
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
with a plain `rollback`.
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
numbered in this order, for example:
```text
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
4. add --sso to the serve flags, then re-pin
```
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
## 8. Enrollment, once per host
@@ -450,7 +528,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
| Path | File | Mode | Owner | Profile | Precheck line |
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
@@ -0,0 +1,58 @@
# MVP 3 chat-channel recovery
Tracking: Vikunja #815, [Gitea #1843](https://git.mosaicstack.dev/mosaicstack/stack/issues/1843).
Source baseline: deployed `51525a31d`, schema 3s. Owner feedback is the
2026-11-04 `/chat` read error and refused Pomodoro Timer project creation.
## Diagnosis
The live business has no `plugins.channel` setting. The typed tester `plugins`
read succeeds and returns only the existing transcript usage source and
Claude Code harness selections. The Console logs the matching `channel-disabled`
refusal. `createChannelCaller` refuses before project dispatch; the failed create
did not create a project. The Case 1 starter also omits the WebUI channel.
The browser then obscures that specific refusal and draws successful-empty
project/thread statements despite a failed read.
## Task 1: Restore the configured live channel
- Use only the existing typed, revision-checked `mosaic plugins` /
`plugins set --tester` path against the pinned .53 copy and config. This
instance permits tester plugin management; use its real tester attribution.
- Privately back up the business file with mode 0600, then read the revision
immediately before applying only `plugins.channel: ["cli", "webui"]`.
- Preserve every other JSON field and plugin kind. Verify typed readback,
the transaction receipt and unchanged remaining configuration.
- No owner identity/cookie, direct configuration edit fallback, role/grant,
credential, model route, schema, service restart, deployment, bridge or .54
change. A refusal stops the operation and is reported; do not bypass it.
- Read health/service state without a signed-in request. Do not create a
project/thread or run the owner acceptance workflow. Reconcile an uncertain
outcome by readback; never blindly repeat the write.
## Task 2: Prevent recurrence and explain failures correctly
- In `docs/guides/case1-business.mosaic-stack.json`, explicitly enable the
CLI and WebUI channels. Preserve its no-tracker starter behavior and all
other role/routing/limit semantics; keep global channel defaults unchanged.
- In the Console chat view, give `channel-disabled` a fixed, understandable
message. Keep the user's draft and distinguish definite refusal from
uncertain delivery. Do not invent a Settings control or require the user
to run a terminal command as the normal recovery flow.
- After a failed initial read, do not claim there are no projects/threads.
Show unavailable/error state; retained cached data must not be presented as
a successful fresh read. A genuine successful empty read keeps its normal
creation UI. Avoid offering an enabled creation action when the known
channel refusal prevents it; preserve the draft for recovery.
- Add failing then passing factory-template and real-browser regression
coverage for disabled reads/writes, draft retention, recovery and genuine
empty data. Preserve HTTP status and sent/outcome fields, authentication,
same-origin checks and actual channel enforcement.
- Use the existing test harness and pinned dependencies. Run affected suites,
obtain an independent GPT review, then integrate and push. Do not expand
this into plugin-settings UI, internal identifier migration or new policy.
The live configuration repair and source fix are independent and may run in
parallel. This is a continuation of the owner-authorized MVP 3 acceptance/fix
work. Actual signed-in project-to-artifact acceptance remains with the owner;
these operational checks do not replace it.
@@ -0,0 +1,47 @@
# Organizations: owner rulings, 2026-11-07
Recorded by Mos (lead) from Jason's messages of 2026-11-07, around 16:39Z and
16:55Z. These bind the multi-organization design (working files under
`/mnt/storage/scratch/mosaic-lead/uidev/business-design-r*.md`). Corrections
go in a new dated file, not edits here.
## Rulings
1. **Name.** "Business" becomes "Organization" in labels, routes, help and new
CLI verbs now. The internal rename (package `packages/business`, the
`businesses/` config directory, `--business` flags, schema fields) is not
optional. It may wait, but it is a priority follow-up, because mixed names
cause confusion.
2. **Isolation.** Each organization runs its own bus. One Console switches
between them from Settings.
3. **No migration.** No existing business must be migrated. The full setup is
tested from scratch on a fresh instance.
4. **Test order.** An agent runs the full creation end to end with the CLI,
then with the WebUI, before Jason walks through it in the WebUI.
5. **Dev instance authority.** The dragon-lin instance is throwaway and for
development only. Agents may use it, and act as its human for these tests,
without asking. That is why it needs no sign-in. A deployed system keeps
human sign-in and the agent restrictions on actions taken for the user.
6. **WebUI creation.** The WebUI creates an organization with a WYSIWYG editor
of its JSON, starting from a template. Every WebUI action is tied to a CLI
command.
7. **Theme.** Each organization can have its own theme, stored in its file.
Themes are plugins: the choice lists whatever themes are installed.
8. **Proxy.** One model proxy per user by default; that user's organizations
share it, and an organization may opt into its own. On multi-user instances,
sharing between users is by explicit, revocable grant (time-limited,
unlimited, per key). Grants come later.
9. **Organization git.** Each organization has its own canonical git checkout
for its high-level information and queue. A remote is optional when the user
chooses "local only" and accepts the risk.
10. **Project git.** Every project uses git. The user chooses per project
whether it has a remote or is **local only** (a local repository that is
never pushed), for throwaway or temporary work that needs no durable
remote. Jason first said this on 2026-11-06; it was not recorded then.
11. **Access.** JSON access control is acceptable for the MVP. Database-backed
RBAC with tighter security comes later.
12. **Modules.** The system is modules connected through documented API calls.
Each function (for example the model proxy, the OpenMontage plugin, the
creative writing system) is a module with a declared set of calls.
Swapping or disabling a module must not break the core. Adding a module
must never require rewriting the core.
+143
View File
@@ -0,0 +1,143 @@
# PRD version 1.20, draft 2: unified workspace
- Status: approved as version 1.20 on 2026-11-09; landed in docs/prd/mosaic-stack.md
- Source: `docs/plans/2026-11-09_unified-workspace-owner-direction.md`,
Jason's direction and answers of 2026-11-09.
- Drafted by Mos (lead).
Version 1.20 adds the unified workspace: every organization visible at once,
cross-organization views, three levels of agent configuration, per-org task
stores behind the task tracker kind, system type plug-ins and self-service
operation. It amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3.
## Amended requirements
Each entry gives the 1.19 text that changes and its 1.20 replacement.
### REQ-ASSIST-1
1.19: "Each user has one assistant role per organization."
1.20: Each user has one assistant across all of the organizations they
belong to. It knows the user's organizations, projects and sessions and
keeps one shared memory. It talks with the user and passes each
organization's work to that organization's chief of staff. It doesn't
direct other agents, create or assign tasks, or launch sessions. (The rest
of REQ-ASSIST-1 stands.)
### REQ-USER-1
1.19: "An agent belongs to one organization and reaches only its allowed
projects and threads."
1.20: Users and role-based access control exist at install and
organization level. An agent other than the assistant works in one
organization at a time. Its memory, files and credentials are scoped to that
organization, even when its definition comes from the user's root
configuration. It reaches only that organization's allowed projects and
threads. The assistant is the exception in REQ-ASSIST-1, and it reaches only
organizations its user belongs to.
### REQ-TASK-1
1.19: "Tasks live in Vikunja..."
1.20: Each organization stores its own tasks, through the task tracker
plug-in kind (REQ-PLUG-1). The default module is a store built into Mosaic
Stack. Vikunja is an optional module, and when an organization selects it
the 1.19 rules for Vikunja (bot tokens per role, one writing role per field,
REQ-TASK-2 polling) apply to that organization. Every task tracker module
supports create, read, update, archive and delete, ordering and grouping.
Archiving or removing an organization removes its tasks without touching
another organization's.
### REQ-WEB-3
1.19: "The Console has controls to restart the bus and to reload the
organization file."
1.20: The Console has controls to restart itself, to restart an
organization's bus, to reload the organization file, and to change the model
an agent instance runs on and apply it. Each control is a gated, trailed
action that a human takes. No agent can take it. A reload applies
`launch.instances` changes to running sessions without a manual
`systemctl restart`. On a dev instance (`--dev-local-owner`) the controls
need no sign-in.
## New requirements
### Workspace
- **REQ-WS-1.** The WebUI shows every organization the user belongs to at
all times. A collapsible Organizations item in the sidebar lists them.
There is no organization switcher.
- **REQ-WS-2.** The Console reads across the buses of the user's
organizations to build shared views. Each organization keeps its own bus
as the tenant boundary (REQ-ORG-1). A shared view shows only
organizations the user belongs to.
- **REQ-WS-3.** The main dashboard sums activity, work and metrics across
all of the user's organizations and projects.
- **REQ-WS-4.** Each organization has a dashboard with its docs, every
configurable setting, its KPIs and metrics, and its active agent
sessions.
- **REQ-WS-5.** The projects list covers every organization. It sorts and
filters by organization, status, name and activity. Each project shows
its owning organization by icon, color and avatar.
- **REQ-WS-6.** One inbox holds every item that needs the user, from every
organization, project and session.
- **REQ-WS-7.** One view shows activity across all organizations and
projects. Organizations and projects can be open side by side.
- **REQ-WS-8.** The tasks view aggregates every organization's tasks, with
Kanban and list layouts, filtered by organization and project. It works
the same for every task tracker module.
### Agent levels
- **REQ-AGENT-1.** Agents are configured at three levels: the user's root,
the organization and the project. A project's available agents are the
union of the three.
- **REQ-AGENT-2.** The user can see and change the configuration of agents,
organizations, projects and sessions, and can override any setting at any
time, through the write path (REQ-ROLE-3).
- **REQ-AGENT-3.** The chief of staff hires and fires agents to meet the
user's needs. At the organization and project levels it acts under
REQ-COS-3. At the root level its hire or fire changes the user's root
configuration, which every organization of that user sees, so it reaches
outside the chief's own organization. A root-level hire or fire is a
decision the user approves in the inbox (REQ-DEC-2) before it takes
effect. The user can also change root-level agents directly.
### System types
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include
coding, creative writing, marketing, Mosaic Montage (the OpenMontage
fork) and business management. An organization enables a list of them.
- **REQ-SYS-2.** A project may use any system type its organization
enables, and none is required. An agent working in a project gets the
toolsets of the system types that project uses.
### Accounts
- **REQ-ACCT-1.** A deployed system requires user sign-in (OIDC, Q203). A
user creates organizations and belongs to any number of them.
- **REQ-ACCT-2.** An organization's owner invites another user into it.
The invited user joins with the grant the owner chose (REQ-USER-3).
### Setup
- **REQ-SETUP-1.** Quick Setup leaves its project with its records
initialized.
- **REQ-SETUP-2.** After Quick Setup, the assistant offers an onboarding
interview. It asks for the project's outcome and limits and drafts the
north star and a first PRD. The user approves each through the existing
approval path.
## Open questions for the approval
1. The final name for the Mosaic Montage module. A council of three agents
is producing three suggestions for Jason to choose from.
2. Answered 2026-11-09: "yes, the COS should be able to hire and fire
agents to fulfill the user's needs". REQ-AGENT-3 now lets the chief hire
and fire at every level. Draft 2 keeps one approval step at the root
level because a root change reaches every organization; Jason can drop
that step when he approves the text.
@@ -0,0 +1,290 @@
# Unified workspace: owner direction, 2026-11-09
Recorded by Mos (lead) from Jason's T3 message of 2026-11-09, around 17:50Z,
and his answers to four clarifying questions at about 18:00Z. It binds the
next phase of the organizations work. Corrections go in a new dated file, not
edits here.
## What prompted it
Jason ran Quick Setup on the dragon-lin dev instance (uiorg) to create the
`test3` organization. Two things went wrong:
- The new project showed "This project has no records yet" with an
"Initialize project records" button. Quick Setup should have done that.
The setup receipts show the org, checkout, project repository and bus
steps at 17:19:20Z. The records repository first appears at 17:21:13Z,
from the button press. The fix round reproduces it.
- After he pressed the button, the overview showed the north star template
as raw text: `# North star Describe the outcome this project should
achieve...`. The overview prints the markdown source instead of rendering
it.
He also named a standing problem with how the dev instance is operated.
## Owner text, verbatim
> The Quick Setup should auto-initialize since this didn't ask any
> questions. An onboarding interview to determine the north star and PRD
> would be beneficial here.
>
> I've noticed that we have a perennial issue with needing to restart the
> console, or requiring a sign-in (model proxy), and other limiting behaviors
> that don't seem to make sense for the operating method. We are running
> this as a test instance on my dev workstation (dragon-lin), auth is not
> required for this local testing env., and we need to be capable of rapid
> iteration without undue manual intervention by the user. I also don't see
> where we can trigger any type of restart or relaunch with the available
> config from within the webui. The user should be able to change the
> running model and restart the service from within the system.
>
> Here is what I want:
> - Mosaic Stack system runs
> - Allows user to log in
> - User can create orgs, and be invited to join another user's org
> - User belongs to N organizations
> - All orgs are visible in the interface for the user to see the org,
> project, sessions within each
> - Tasks are overarching for all projects; a central store of tasks
> - Agents can be configured at the root, org, and project level
> - The user is able to see all happenings in the system from one pane
> - Projects are sortable and filterable by org, status, name, activity
> - Inbox contains all items needing the user; all orgs/projects/sessions
> with needs surface here
> - System allows for side-by-side operation of orgs and projects all
> visible in single pane
> - Rather than selecting an organization with a switch or in the settings
> menu, the user sees all orgs at all times.
> - Sidebar collapsible item for Organizations; user clicks to see all info
> about the organzation
> - The Docs are available on the org dashboard
> - All configurables are visible on the org dashboard
> - Org dashboard features KPIs/Metrics for the org
> - Active agent sessions visible on the org dashboard
> - Projects sidebar list indicates the organization owning the project via
> iconography, color, and "avatar" for org
> - Main dashboard for user surfaces sum and cumulative data for all
> orgs/projects, not just one
> - The system is configured to use plugins/modules to allow for various
> system types like coding, creative writing, Mosaic Montage (fork of
> OpenMontage) for social media production, business management
> - Each Project within an org is capable of using multiple system types
> - Example: Org. Distinctly Digital
> - enabled plugins: coding, creative writing, marketing, Mosaic
> Montage/Mosaic Studio (whatever we name this...), business management,
> - Each project within "Distinctly Digital" has access to the plugins,
> but is not required to use all of them
> - Agents configured for the user's root config are available
> - Org agents are available
> - Project agents are available
> - Agents use the toolsets for the plugin/module appropriately
> - Configuration for the agents, orgs, projects, sessions are all
> available for the user to tweak as needed
> - Chief of Staff can hire and fire agents to meet the user's system
> needs
> - User can override settings at any time via the configuration
>
> I'm thinking this information needs to be recorded for durability and we
> need to work toward this implementation.
## Answers to the clarifying questions
1. **Task store.** Verbatim: "We need to keep the tasks separated at an org
level. This allows for archive, instant removal of org tasks without
violating the other orgs. I'd like to see a basic config use per-org,
aggregated store with the option to use Vikunja. Not all users will use
Vikunja. Either way, we need a way to store, retrieve, archive, maintain,
organize, and view all the tasks. If we use Vikunja or a custom-rolled
solution within Mosaic Stack, they all need to surface with Kanban, etc.
for the user to see properly."
2. **Agent scope across orgs.** Verbatim: "The user's main interaction agent
needs to be aware of all orgs, projects, etc. That one interaction agent
uses a shared memory. The other agents are isolated per org."
3. **Multi-user.** Design sign-in, membership and invites now. Build them
in the second phase, after the single-user unified pane.
4. **Order.** Remove the operating friction first, then build the unified
pane.
## Rulings that follow
1. **All orgs at once.** The WebUI shows every organization the user
belongs to at all times. A collapsible Organizations item in the sidebar
lists them, and selecting one opens its dashboard. There is no
organization switcher, in Settings or elsewhere.
2. **Per-org isolation stays.** Each organization keeps its own bus as the
tenant boundary (REQ-ORG-1). The Console reads across the buses of the
user's organizations to build the shared views. Removing or archiving an
organization removes its data, tasks included, without touching another.
3. **Tasks.** Each organization stores its own tasks. The default store is
built into Mosaic Stack. Vikunja is an optional alternative, selected per
organization through the task tracker plug-in kind (REQ-PLUG-1). Both
support store, retrieve, archive, maintain and organize. The WebUI
aggregates every organization's tasks into one view with Kanban and list
layouts, filterable by organization and project.
4. **Agents.** Agents are configured at three levels: the user's root,
the organization and the project. A project's agents are the union of
all three. The user's main interaction agent (the assistant) is the one
exception to isolation. It knows all of the user's organizations,
projects and sessions, and it keeps one shared memory. Every other agent
is isolated to the organization it works in: its memory, files and
credentials are scoped to that organization, even when its definition
comes from the root.
5. **One pane.** One screen shows activity across every organization and
project. Organizations and projects can be open side by side.
6. **Main dashboard.** It sums across all organizations and projects, not
only one.
7. **Org dashboard.** It shows the organization's docs, every configurable
setting, KPIs and metrics, and its active agent sessions.
8. **Projects list.** It sorts and filters by organization, status, name
and activity. Each project shows its owning organization by icon, color
and avatar.
9. **Inbox.** One inbox holds everything that needs the user, from every
organization, project and session.
10. **System types are plug-ins.** Coding, creative writing, marketing,
Mosaic Montage (the OpenMontage fork; final name open) and business
management are modules. An organization enables a set of them. Each of
its projects may use any of the enabled set, and none is required.
Agents get the toolsets of the modules their project uses.
11. **Configuration.** The user can see and change the configuration of
agents, organizations, projects and sessions, and can override any
setting at any time. The chief of staff hires and fires agents to meet
the user's needs, within the existing PRD rules (REQ-ROLE, version
1.7).
12. **Accounts.** The deployed system has user sign-in. Users create
organizations, belong to any number of them, and can be invited into
another user's organization. The dev instance keeps running with no
sign-in (ruling 5 of 2026-11-07).
13. **Operating the dev instance.** Routine operation needs no manual
intervention from the user. From the WebUI the user can restart the
Console and an organization's bus, and change the model an agent runs
on. Dev mode needs no sign-in, including for the model proxy. Agents
still cannot restart the Console; the user can, from the WebUI.
14. **Quick Setup.** Quick Setup leaves a project with its records
initialized. An onboarding interview then helps the user write the
north star and a first PRD.
## What this supersedes or amends
- **2026-11-07 ruling 2.** "One Console switches between them from
Settings" is superseded by ruling 1 above. The per-org bus in the same
ruling stands.
- **2026-11-07 ruling 9.** Each organization's queue lived only in its own
checkout. Ruling 3 above keeps tasks per organization, adds the Vikunja
option through the task tracker kind, and adds the aggregated view.
- **PRD 1.19, REQ-USER-1.** "An agent belongs to one organization" gains
the assistant exception in ruling 4. This needs a PRD version. The draft
is `docs/plans/2026-11-09_prd-1-20-draft.md`, and Jason approves the
exact text before it enters `docs/prd/mosaic-stack.md`.
## Delivery plan
Each phase follows the MVP rule: build, one review round, one fix round, an
agent E2E on a scratch instance, then a deploy to uiorg for Jason.
### Phase F: operating friction (now)
- **F1.** Quick Setup records: reproduce the `test3` gap on a scratch
instance and fix it, so a Quick Setup project opens with live Docs, Files
and north star. Render the north star as markdown on the project
overview.
- **F2.** Onboarding interview: after Quick Setup, the assistant opens a
conversation that asks for the outcome and limits, then drafts
`spec/north-star.md` and a first PRD. The user approves each through the
existing approval path.
- **F3.** Self-service operation: restart the Console and an org bus from
the WebUI, change an agent's model from Settings and apply it, and list
and remove every manual step the dev instance still needs, model proxy
sign-in included.
### Phase U: unified pane
- **U0.** Design samples first. Sample screens for the sidebar with all
orgs, the main dashboard, an org dashboard, the projects list, the inbox,
the task Kanban and side-by-side panes. Jason approves them before the
build (design samples are the acceptance target).
- **U1.** Cross-org read model in the Console: one API that reads every
organization's bus the user belongs to, for projects, sessions, inbox
items, tasks and metrics.
- **U2.** Sidebar and org dashboards. **U3.** Main dashboard, projects list
and inbox. **U4.** Task store: built-in per-org store behind the task
tracker kind, the Vikunja module, and the aggregated Kanban. **U5.**
Side-by-side panes.
### Phase A: agent levels
Root, organization and project agent configuration. The assistant spans
organizations with shared memory, and every other agent is isolated per
organization. The user can override settings, and the chief of staff hires
and fires at each level.
### Phase P: system type plug-ins
A plug-in kind for system types. Organizations enable modules, projects opt
in, and agents receive the modules' toolsets. First modules: coding (the
current behavior), creative writing, then Mosaic Montage, marketing and
business management.
### Phase M: accounts (design now, build second)
OIDC sign-in (OIDC-only per Q203), membership in N organizations, invites
into another user's organization, and access checks on the cross-org read
model. The design lands with Phase U so the read model has membership from
the start.
## U0 approval (11-09, 19:26Z)
Owner text, verbatim:
> The U0 screen designs look good. We need to retain the temporary logo
> already used in the site.
> I don't like these module names either. It isn't clicking for me. That may
> need to be a later decision.
What follows from it:
1. The samples in `docs/design/samples/2026-11-09-unified-workspace/` are the
acceptance target for U2 to U5.
2. The WebUI's four-tile mark stays the logo. The samples now draw it in
place of the design system's Relay placeholder, and the build does not
adopt Relay.
3. The Montage module name stays open. Two naming rounds produced no pick.
The label "Mosaic Montage" stays until Jason names it; nothing in phase U
depends on the name.
The ten open questions in the U0 report were not answered one by one. Under
the lead's standing authority to decide routine choices, phase U takes the
report's recommendations, and Jason can overturn any of them:
- Every decide command copied from a shared view carries `--org`.
- The chief may propose a root-level hire only as a gated inbox item the user
approves.
- An org's colour is its own theme's action colour, the same for every member.
- Side by side on a phone is a two-way switch with one pane visible.
- Sidebar projects default to By org, remembered per viewer.
- Tasks on a phone default to List, with Kanban one tap away.
- The CLI verbs for the new header controls are `mosaic org reload <id>` and
`mosaic console restart`, added before U2 wires the buttons.
- Members and invites shows in the settings index, labelled "not built".
- Messages becomes Assistant. Docs live on org dashboards and in panes. Queue
opens from each org dashboard.
## Open items
- The final name for the Mosaic Montage module (deferred by Jason, 11-09).
- Which "sign-in (model proxy)" step Jason hit. F3 inventories every manual
step on the dev instance and reports it.
## Addendum, 2026-11-09 18:13 UTC
Jason's answers to the two open questions in PRD 1.20 draft 1, verbatim:
> 1. I would like you to use a council of agents to decide on 3 name
> suggestions for this
> 2. yes, the COS should be able to hire and fire agents to fulfill the
> user's needs
Effect: the chief of staff hires and fires agents at the root,
organization and project levels (PRD 1.20 draft 2, REQ-AGENT-3). A council
of three agents proposes three names for the Mosaic Montage module, and
Jason picks one.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,361 @@
# Agent levels and the cross-organization assistant (Phase A design, #1873)
Status: design for lane org-a1, 2026-11-10. Slice A1 (read-only store, union
resolution, Agents grid) is built on branch `org-a1`. Everything that gives an
agent a new capability is in the roles proposal and the later slices listed in
section 9; none of it is applied.
Direction: PRD 1.20 draft 2 (REQ-ASSIST-1, REQ-USER-1 amendment, REQ-AGENT-1
to REQ-AGENT-3, REQ-COS-3, REQ-DEC-2, REQ-ROLE-3), owner direction rulings 4
and 11, the U0 approval and the addendum in
`docs/plans/2026-11-09_unified-workspace-owner-direction.md`, and the role model
in `roles/` and `docs/plans/2026-09-02_atomic-mosaic-foundation.md`.
Owner words: "Agents are configured at three levels: the user's root, the
organization and the project. A project's agents are the union of all three.
The user's main interaction agent (the assistant) is the one exception to
isolation. [...] Every other agent is isolated to the organization it works
in." And: "the COS should be able to hire and fire agents to fulfill the
user's needs."
## 1. Terms
- **Definition**: a named agent entry at one level. It names a role
definition in `roles/` (`definition`) and may carry vars. It never names a
credential, a holder, a tracker bot, an identity or a publish grant at the
root or project level.
- **Role definition**: `roles/<name>.json`, versioned and changed only by
reviewed commits (invariant 7). A level chooses among role definitions and
never creates one, so no level can grant an authority that `roles/` does
not already hold.
- **Copy**: a root definition as it runs inside one organization. Each
organization runs its own copy, and each copy has that organization's
memory, files and credentials.
- **Union**: the agents available in one project of one organization.
## 2. Where each level lives
| Level | File | Writer today | Owner |
|---|---|---|---|
| root | `<configDir>/agents.json`, next to the instance's system config | the user, by hand (no writer in A1) | the instance's OS user |
| org | `roles` in `<configDir>/businesses/<org>.json` (unchanged) | existing org writers | the organization |
| project | `projects.<project>.agents` in the same organization file | the owner's `updateOrg` only; no agent writer can reach it | the organization |
### Root: `<configDir>/agents.json`
`configDir` is the directory of the instance's system config
(`~/.config/mosaic-<instance>/`). The file sits outside the data root, so it is
outside every organization root (`<dataRoot>/org-runtime/<org>`) and every
bus, and no bus or sandbox mount reaches it. It is per instance, as the system
config is (invariant 2). When accounts arrive (Phase M), it moves to a
per-principal path under the same directory; the format stays.
```json
{
"agentsVersion": 1,
"agents": {
"assistant": { "definition": "assistant", "vars": { "model": "opus-5.5" } },
"reviewer": { "definition": "reviewer", "excludeOrgs": ["home"] },
"researcher": { "definition": "researcher" }
}
}
```
- Keys: `definition` (required), `vars` (agent layer, `checkVars(..., "agent")`),
`excludeOrgs` (distinct organization ids; "reviewer turned off for Home" in
sample 08). Any other key refuses.
- The file must be a regular file opened without following a link, owned by
the process uid and not writable by group or other, the same checks as the
organization file. A missing file means no root agents. Anything else that
is wrong refuses the whole root level (section 6).
- Refused at the root: a role definition that is version 1 or missing, the
`chief-of-staff` definition (each organization's chief is its own,
REQ-COS-3), an `assistant` definition under any name but `assistant`, and
`credentials`, `holder`, `tracker`, `identity`, `publish`, `hosts`.
### Organization: `roles`
Unchanged. Every organization instance stays as it is, validated by
`validateBusinessDocument`, with its credential map, tracker bot and identity.
### Project: `projects.<project>.agents`
The project level is in the organization file, not in the project repository.
The repository's `.mosaic/project.json` can be written by any agent that can
push to the project, so a definition there would let an agent create agents.
The organization file is reachable only by the trusted writer, and the
existing projects fence (`organization-projects-protected` in
`organization.mjs`) already refuses every general write that changes
`projects`. No agent writer can add, change or remove a project agent. The
owner's `updateOrg` can, and the Console's organization editor holds the key
on the server (`SERVER_PROJECT_KEYS`), so the browser draft cannot change it in
A1.
```json
"projects": {
"spring-launch": {
"root": "...",
"agents": {
"social-scheduler": { "definition": "pm", "vars": { "model": "haiku-5.5" } },
"coder": { "vars": { "limits.network": "none" } },
"reviewer": { "exclude": true }
}
}
}
```
Each entry has exactly one form:
- **define**: `{ "definition", "vars"? }`. Agent-layer vars. Not
`chief-of-staff`, not `assistant`.
- **narrow**: `{ "vars" }` only. Every key must be an intersect key
(`limits.tools`, `limits.network`, `limits.authority`), checked at the
project layer. A project narrows an inherited agent and never widens it.
- **exclude**: `{ "exclude": true }`. The inherited agent is not available in
this project.
`validateBusinessDocument` checks each entry's shape and role definition for
every reader (bus, Console, CLI). It does not copy `agents` into the loaded
business object, so a bus built from the file sees exactly what it saw before.
A project that is deleted takes its agents with it, and so does a deleted
organization.
Library projects (`kind: "library"`) keep their own mapping check and take no
`agents` key.
This is an additive, optional key in the organization file. It is not a bus
store (SQLite) schema change: no migration, no new table, no bus verb.
## 3. Union resolution
For organization O and project P:
1. Start with the root agents, minus any whose `excludeOrgs` names O.
2. Lay the organization's `roles` over them. An org instance with the same
name as a root agent replaces it entirely; the root entry is recorded as an
override of that agent.
3. Lay P's entries over the result:
- **define** replaces any inherited agent of that name; the inherited
entry is recorded as an override.
- **narrow** merges vars through `mergeVars`, inherited layer first and the
project layer after it, so limits only intersect. The keys it narrowed
are recorded.
- **exclude** removes the agent and records who removed it.
Rules:
- The most specific level that defines a name wins, and the whole entry
wins: definition and vars, never a field-by-field mix.
- A narrow or exclude entry naming an agent that no lower level provides
refuses. A typo never silently does nothing.
- A project entry of any form naming the organization's chief (an instance
whose definition is `chief-of-staff`) or the assistant refuses. A project
cannot remove, narrow or replace the chief or the assistant.
- Two levels never merge vars, apart from a project narrowing. Root vars stay
with the root definition, and an org instance of the same name brings its
own.
- The result for each agent: name, definition, level, source (which file),
the overrides it shadows, the keys narrowed, and its scope. Every agent in a
union is scoped to O, an organization's own `assistant` instance included.
Only the root assistant, which no union holds, is cross-organization.
- The union also lists what O or P does not get: root agents turned off for
O and agents P excluded. A name that a more specific level then defines is
available under that definition and is not listed.
Implementation: `packages/organization/src/agent-levels.mjs`
(`loadRootAgents`, `checkRootAgents`, `checkProjectAgents`,
`resolveAgentLevels`, `projectUnion`). It is pure apart from the one root file
read.
## 4. Isolation of every non-assistant agent
Every agent except the root assistant resolves to scope `{ org: O }` in the
organization it runs in, whichever level defined it. The scope fixes three
things:
- **Memory and records**: under the organization's root,
`<dataRoot>/org-runtime/O/...`, on that organization's bus, as every org
instance has today. A root agent's copy in O and its copy in O2 share
nothing: no thread, no record, no session file.
- **Files**: the project workspaces of O only. The sandbox mounts come from
O's project mapping, never from another organization's.
- **Credentials**: only from O's credential map. A root definition carries
none (refused, section 2). In A1 a root or project agent is shown as
unbound: it has no credential and cannot be launched, because the bus
builds instances from `roles` alone and the loaded business object does not
carry project agents.
When root copies run (slice A3), each copy becomes a bus instance in O under
O's writer, with O's credential bindings, through the same `checkRoles` path an
org instance takes. A copy never reads the root file at run time; it gets its
definition when O's bus boots.
## 5. The assistant across organizations
### Reads
The assistant reads another organization only through the Console's
membership-checked cross-organization read: `organizations.members(session)`
(`packages/webui/src/organizations.mjs`), the read `GET /api/workspace` uses.
Each organization is admitted by `showOrg` (owner check, registry digest,
document validation) and `resolveOrg` (marker, modes) before any transport is
built, and each bus read goes over that organization's own socket with a
deadline. The assistant process gets no org root path, no bus socket path and
no file mount of any organization. It calls a read broker that the Console
holds for the owner's session. A direct read of another organization's bus
files does not exist as a code path: the assistant sandbox mounts only its own
memory directory.
### Shared memory
The assistant's one memory lives at `<dataRoot>/assistant/` (mode 0700, owned
by the instance user). It sits inside the instance data root and outside every
`org-runtime/<org>` root, so deleting or archiving an organization never
touches it, and no organization's agents can mount it. It holds the
assistant's notes and session files. It never holds a copy of an
organization's records; what the assistant learns from an organization stays
in that organization, plus whatever the assistant chose to write in its own
memory.
### Authority
REQ-ASSIST-1: the assistant talks with the user and hands each organization's
work to that organization's chief. It does not direct agents, create tasks or
launch sessions. Its role keeps `message.send` and `record.write` only. The
roles proposal (section 8) adds no action to it.
### A1 reality
Today each organization file declares its own `assistant` instance. A1 shows
those as org agents. A root `assistant` entry, if the user writes one, shows in
the band as the root assistant with cross-organization scope. Nothing reads
across organizations for it yet.
## 6. Fail-closed boundaries
| Boundary | A1 behavior | Later slices |
|---|---|---|
| Root file (new) | `O_NOFOLLOW` regular file, process uid, no group or other write; any problem refuses the whole root level and the grid says so; a missing file is "no root agents". The refusal names the file and the key at fault, never a value from the file: parser text and a var check's quote of the refused value are dropped, since a value can be a secret typed in the wrong place. Project var refusals drop the value the same way. | The root writer accepts only the human session (section 7). |
| Organization file `projects.<p>.agents` (new key) | Checked by `validateBusinessDocument` for every reader; a bad entry refuses the whole organization file, as any bad key does. Not copied into the loaded business, so buses gain nothing. The projects fence keeps every agent writer out. | A project-agent writer verb, with its own guard, like `addProject`. |
| Union | A narrow or exclude naming nothing refuses that project; touching the chief or assistant refuses that project; the grid shows the refusal for that project. | A launch resolver refuses to launch any agent in a project whose union refuses. |
| Console read `GET /api/agents/levels` (new) | Owner only (granted users and the tester get `owner-only`, 403); organizations come only from `organizations.members(session)`; the body carries ids, names, role definition names, model var and level. It never carries a path, a credential reference or a var other than `model` and the narrowed key names. | Unchanged. |
| Org isolation | Root and project agents are not instances on any bus; nothing reads across organizations except the existing Console read. | Each copy is materialized by O's own writer and runs under O's credentials. |
| Assistant memory | Not built. | `<dataRoot>/assistant/` with 0700; the sandbox mounts it and nothing else. |
| Roles | No change in commits. | The proposal patch, applied only after Jason approves it. |
A root file that refuses hides the root level everywhere rather than showing a
partial list: an exclusion that did not parse must not let an excluded agent
appear.
## 7. Chief-of-staff hire and fire, per level
Today `roles/chief-of-staff.json` lists `role.hire` and `role.fire` within its
role, with `"hireable": []`: it can hire nothing. Each level adds a write path;
none exists in A1.
- **Organization level** (REQ-COS-3): the chief hires or fires an
organization instance through the organization's trusted writer, for a role
in its `hireable` list. Each hire lands in the action trail. The user can
override any setting at any time through REQ-ROLE-3.
- **Project level**: the chief adds or removes a project entry through a new
writer verb that changes only `projects.<p>.agents` (a guard like
`addProject`'s), for a role in `hireable`. The projects fence still refuses
every other change.
- **Root level** (U0 ruling): the chief cannot write the root file. It raises
an inbox decision (REQ-DEC-2) in its own organization that names the role
definition, the agent name and the reason. The user approves or denies it in
the Console inbox. On approval the Console, acting with the human's session,
writes the root file through a root writer that refuses any actor other than
the signed-in owner (`human-required`). A denied or expired decision changes
nothing. A fire works the same way. The chief never learns the file's path.
`hireable` decides what can be hired at all; widening it is a `roles/` change
and needs Jason's reviewed commit. The proposal (section 8) names the roles.
## 8. Authority changes proposed, not applied
The patch is at `/mnt/storage/scratch/mosaic-lead/reports/org-a1-roles-proposal.patch`.
It is never applied on this branch. It applies with `git apply` on
`origin/refactor` and needs a reviewed commit that Jason approves.
What it changes:
- `roles/chief-of-staff.json`: `hireable` goes from `[]` to `coder` and the
five advisory chiefs (`chief-executive-officer`, `chief-financial-officer`,
`chief-information-officer`, `chief-information-security-officer`,
`chief-marketing-officer`). These are the only shipped definitions that pass
today's hire check: version 2, not creative, and every action on
`HIRE_ALLOWED`. The chief can then hire at the organization level with the
existing `hire` verb. The project level (A4) uses the same list.
- `roles/chief-of-staff.md`: drops "your empty hireable list", names the list,
says a hired coder has no credential until the human binds one, and says the
chief hires only in its own organization, never at the root.
- `packages/organization/tests/chief-of-staff.test.mjs` and `role.test.mjs`:
the tests that pin the shipped chief file move to the new list, and the
contract test pins the new sentence. Two bus test comments that said "shipped chief hireable:[]" are
reworded. These go in the same commit, or the organization suite fails.
What it leaves alone, and why:
- `roles/assistant.json`: no change. The cross-organization read is the
Console's read broker acting for the owner's session (section 5), not a
role action, so the assistant needs no new grant. It keeps `message.send`
and `record.write`. Adding an action would widen it for no gain.
- `roles/assistant.md`: no change yet. Its "one user in one business" text
stays true until A6 builds the root assistant; the contract text for the
cross-organization read and the shared memory lands with A6, in that
slice's own reviewed commit.
- Root-level hire text in `chief-of-staff.md` lands with A5, when the inbox
decision exists. A contract sentence for a tool the chief doesn't have would
invite it to try.
- `HIRE_ALLOWED`: unchanged. `reviewer`, `security-reviewer` (`review.verdict`),
`pm` (`task.create`), `cto` and `merge-gate` stay unhireable. Making any of
them hireable is a separate vocabulary decision, not part of this proposal.
## 9. Phase A slices after the roles change is approved
1. **A2, root and project writers.** Owner-only root file writer (human
session, trail entry, revision check) and the project-agent writer verb
under the projects fence, both refusing every agent actor. Settings,
Agents gets the edit forms.
2. **A3, root copies run.** Each organization's bus boot materializes its
root copies as instances through `checkRoles`, with the organization's
credential bindings; unbound copies stay unlaunchable and say why. Project
agents launch only in their project.
3. **A4, chief hire and fire at org and project level**, bounded by the
approved `hireable` list, trailed.
4. **A5, root-level hire and fire as inbox decisions**, with the owner's
approval writing the root file through A2's writer.
5. **A6, the root assistant.** `<dataRoot>/assistant/` memory, the Console
read broker over `organizations.members`, and the per-organization
`assistant` instances retired in favor of the one root assistant.
6. **A7, the Agents grid's write controls** (model Change, override markers,
turn off for an organization), each a trailed human action.
## 10. Slice A1 as built
- **Store and union**: `packages/organization/src/agent-levels.mjs`, with the
old-path re-export `packages/business/src/agent-levels.mjs` that the rename
r2 stage B test requires of every organization module.
`validateBusinessDocument` checks `projects.<p>.agents` and drops it from
the loaded business.
- **Read**: `GET /api/agents/levels` (`packages/webui/src/agent-levels-read.mjs`),
wired in `cli.mjs` with `loadRoot` reading `<configDir>/agents.json`. The
route is instance-wide, like `/api/workspace`, so `display.js` leaves it
unscoped: its fetch wrapper would otherwise rewrite it to
`/api/orgs/<id>/agents/levels`, which does not exist.
- **Console editor**: `SERVER_PROJECT_KEYS` holds `agents`, so a browser draft
of the organization file keeps the server's value. The CLI's full-document
path (`mosaic org update --from`) is the owner's and can set project agents;
A1 adds no other writer.
- **Agents grid** (sample 08): the level band shows the root agents and their
per-organization opt-outs, each organization's instances, and each
project's own definitions. Below it, a project picker chooses which union
the table shows: agent, level, defined in, memory, status, model. Root and
project agents read "not running", because nothing launches them yet. The
root assistant's status reads "status not reported" and its model is the
one the root file sets, shown as configured. A
narrowed agent names the keys, an override names what it replaces, and a
line under the table lists what this project does not get and why. A
refused root file, organization or project shows its reason in place of
its rows. The grid has no write controls; those are A7.
@@ -0,0 +1,671 @@
# Roles, rails and hiring
Date: 2026-11-10. Lane org-roles-design, issue #1873. Status: proposed, doc
only. Nothing here changes `AGENTS.md`, `roles/`, `contracts/`, rights policy
or code. The slices in section 13 that touch role or rights policy land only
with Jason's approval.
Round 2 follows ruling 13. Merge and policy decisions stay with the arbiter
agent. The rails split into system rails and role rails (section 3), and a
per-organization autonomy setting decides where each class of decision goes
(section 9).
Read with `docs/plans/2026-11-10_agent-levels-design.md` (levels, isolation,
Phase A) and `docs/plans/2026-11-10_accounts-design.md` (rights, host trust
boundary). This design replaces agent-levels section 7 and the A1 roles patch,
which widened the chief's `hireable` list. That patch is withdrawn and nothing
here extends it.
Line numbers are against `origin/refactor` at `f98f278fb`. Its code is the
same as at `50be33355`.
## 1. Rulings
Jason, 2026-11-10:
1. Who hires: the chief asks, a human approves. A hire is a decision, not a direct write.
2. Which roles, verbatim: "Chief should be capable of hiring ANY role with any capacity. This will include custom roles invented by the user and the chief at any time."
3. No cap on the number of hires.
4. Timing: lands with A4.
5. Templates, verbatim: "Mosaic Stack should ship role templates for the most common roles. These stay within the system as templates. Chief and user have the capability to import the roles from template and customize them as needed. New roles outside of the supplied templates must follow a set of rules from within the system to prevent an absolute destruction of the system's workings. We need a set of primitives available to the agent roles to allow for adherence. A new role constructed by chief and the user must plugin and follow the rules with the RBAC and other tools available. The agent will have an arbitrary set of tools specified by chief and/or the user. The agent will also have a personality and other traits specified by the user. The system must remain unopinionated upon the opinionated basal code provided. We provide the operational rails and allow an open system to build upon it."
6. Ceiling: the approval is the ceiling. A hire may carry grants above the chief's own, but the decision lists those grants separately and flags them.
7. Approver: a new grantable right `org.hire` (owner yes, member no) in the M1 rights table.
8. Only the chief raises hires. No hire chains: a hired agent cannot hire.
9. Rails, summarized (full text in the lead tracker; ask nothing, design from this): a base of inalienable rails the system needs to function, with everything else customizable on top. The smallest working system is user, assistant and chief; the chief staffs the rest from the user's plan. Start with the fewest agents, add agents as roles are needed, and keep configurations apart with organizations and projects. Full extensibility: modules and plugins, preconfigured systems for any use (software factory, writing workshop, video production, business, home, health), installed from GitHub, a zip or another source, and maybe a marketplace later. Flexibility comes from a set of primitives controlled by routing, agent configuration and plugins. Jason: "The Venn diagram for usability and security/rails needs to be understood."
10. Credentials: the hire decision lists each credential and scope the agent needs; approving the decision binds them.
11. Template updates: an imported role is a frozen copy. When the shipped template changes, the system offers a diff; it never changes the copy by itself.
12. Personality: the chief may draft personality and traits inside the hire decision. After the hire only a human changes them.
13. Trust, verbatim, answering whether merge and policy decisions should go to a person instead of the arbiter agent: "I need to be able to trust the agents to do their jobs. The agent role should establish the rails they operate upon. Tooling and instructions should be clear. We are aiming for a move from "Human in the loop" to "Human near the loop" to "Human out of the loop" operations. We are currently HEAVILY in Human in the loop." They stay with the arbiter.
Also binding, Jason 2026-11-07: the system is modules joined by documented
calls. Swapping or disabling one must not break core, and adding one must not
need a core rewrite (`docs/plans/2026-11-07_organizations-owner-rulings.md:43-47`).
## 2. Today
- A role is `roles/<name>.json` plus its `.md` contract. Version 2 allows
only known keys, so an unknown field such as `personality` refuses
(`packages/organization/src/role.mjs:21,190`). Tools come from one closed
core list (`role.mjs:24-29`, `packages/organization/src/vocabulary.mjs:142`).
Actions come from `ACTIONS` (`vocabulary.mjs:11-65`); an unknown one
refuses, and so does one outside `ROUTED`, because those are hard waits the
human always decides (`role.mjs:42-43`, `vocabulary.mjs:109`).
- The chief hires by writing directly. `instance.hire` checks that the caller
is the instance named in `launch.by` (`packages/bus/src/broker.mjs:6193`)
and holds `role.hire`, refuses kept names (`broker.mjs:6200`), then runs
`#hireable` (`broker.mjs:6291-6306`). That needs a version 2 definition whose
every action is on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`, checked by
`unhireableAction` at `vocabulary.mjs:127`), named in the chief's own
`hireable` list, and not in `launch.noHire`.
- The shipped chief's list is empty (`roles/chief-of-staff.json:13`), so the
chief can hire nothing today. Its contract says so
(`roles/chief-of-staff.md:17`).
- Creative definitions are human-only. The writer refuses them for the chief
(`packages/organization/src/instance-settings.mjs:365-367`) and the role
check refuses them in a `hireable` list (`role.mjs:86-97`).
- A hire writes `roles[name] = { definition, vars? }` and appends the name to
`launch.instances` (`instance-settings.mjs:354-391`). Its vars may set only
`model` and `limits.*` (`broker.mjs:216-221`). So `launch` refuses the new
instance with `no-route` until a human sets harness, model and thinking
(`packages/harness/src/bundle.mjs:96`, `roles/chief-of-staff.md:53-58`).
- Fire revokes the instance's service credentials and delists it
(`instance-settings.mjs:396-414`).
- Rights for people are a separate table: `RIGHTS`, the `ROLE_RIGHTS`
bundles and `canGrant` (`packages/organization/src/org-rights.mjs:6-17,21-26,122-129`).
It has no hire right yet.
## 3. The rails
Rails come in two layers. System rails hold for every role at every autonomy
level, and only a reviewed commit changes one. Role rails are what each role's
definition says its agent may do. Ruling 13 asks for both: agents trusted to
do their jobs, inside rails their role sets.
### System rails
Nothing changes these while the system runs: no role, template, plugin,
approval or autonomy setting. Each row says where the code enforces the rail
today and which slice closes the gap.
| # | Rail | Enforced today | Gap, and the slice that closes it |
|---|---|---|---|
| R1 | Secrets never enter the repository, images, role files, decision text or the trail. A credential reaches an agent only through a human-approved binding, scoped and revocable. | Message and record bodies pass `credentials.assertClean` (`packages/bus/src/runtime.mjs:833`, `broker.mjs:840,1313,2081`). Credential verbs are human-only (`broker.mjs:6144`). `credential.mint` decisions route to the human (`broker.mjs:2266-2268,2368`). Fire revokes (`instance-settings.mjs:396`). | Role definitions, personality text and hire decisions are not scanned. P1 runs the same check on every string in a composition and a hire decision. |
| R2 | The trail and run records are write-once. | `events` and `decision_events` refuse update, delete and replace (`packages/bus/src/migrations/3b.sql:131-132,137-138,144,147`). | None. Hire, deny, expiry, binding, setting and reverse entries use the same tables (A4, AU). |
| R3 | Fail closed: an unknown action, tool, right, plugin kind, role field or autonomy value refuses. | Role keys, actions and tools (`role.mjs:190,42,24-29`). Rights (`org-rights.mjs:122-129`). Plugin kinds (`packages/plugins/src/kinds.mjs:147-148`). | Inline roles and plugin-declared tools and actions have no check yet. P1 and PL1. AU refuses an unknown class or level. |
| R4 | Hard waits stay with the human. A role can never hold an action outside `ROUTED`. | `vocabulary.mjs:109`, `role.mjs:43`. This covers `git.push.protected`, `git.push.remote`, `host.sudo`, `credential.mint`, `spend`, `deploy` and `data.delete` (`vocabulary.mjs:22,26,29,49,50,51,55`). | The brief's system-rail list did not name it, and "any capacity" must not dissolve it. A hard wait leaves the list only when a PRD version moves it into `ROUTED` (REQ-WAIT-3, `vocabulary.mjs:67-69`), never through the autonomy setting. P1 keeps it for every composition. |
| R5 | No agent widens its own authority. No agent resolves a decision it raised, a decision whose subject is itself or a definition it runs, or anything that changes the autonomy setting or a rail. | An arbiter's own raise goes to the human (`broker.mjs:7075`). PRD approval refuses an arbiter that is the PM or an author (`broker.mjs:1060`). | Rewritten in round 2. Round 1 also sent merge and policy decisions to a person; ruling 13 reverses that, and REQ-DEC-2 stays as ratified. Nothing checks a decision's subject against its resolver today. AU adds that check, which sends such a decision to a person. |
| R6 | Every agent runs sandboxed as the instance uid, with only the tools its role names. A tool exists only if core or an installed, enabled plugin declares it. | bwrap with user, pid and ipc namespaces and a read-only root (`packages/seat/src/sandbox.mjs:76,176`). Tools are a closed core list (`vocabulary.mjs:142`) and the typed bus tools are fixed in core (`packages/harness/src/tools.mjs:96`). | No plugin declares a tool today. PL1 adds plugin-declared tools. A sandbox escape holds the instance uid, which equals root for the instance (accounts design, "Host trust boundary"). |
| R7 | Organizations are isolated. Cross-organization reads go only through the assistant's Console read broker. | Each organization has its own bus and every non-assistant agent's scope is one organization (agent-levels design section 4). | The read broker is not built. It is A6. Until then nothing reads across organizations. |
| R8 | No grant beyond the granter, except a flagged line the owner approves. This holds at every autonomy level. | People: `canGrant` subset (`org-rights.mjs:122-129`). Agents: a hire is bounded by `HIRE_ALLOWED` and the chief's list (`broker.mjs:6303-6305`). | A4 replaces that bound with the approval itself. The broker computes the flagged lines; the chief cannot omit them. |
| R9 | No hire chains. Only the chief raises hires, and a hired agent cannot hire. | `role-not-chief` (`broker.mjs:6193`). `role.hire` is not on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`). | From ruling 8. P1 refuses `role.hire` and `role.fire` in any hired composition, so the rail survives `HIRE_ALLOWED` going away. |
| R10 | Plugin code runs with the host's authority, so installing one is an instance-level human act. An organization's setting cannot move it. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | The loader says a module "runs with the host's authority" (`loader.mjs:5`). Install from GitHub or a zip has to keep that visible. No hash pin exists. PL1. |
| R11 | System config is written only by bootstrap. | `scripts/bootstrap.sh:5`, AGENTS.md invariant 2. | None. Nothing in this design writes system config. |
| R12 | Rails change only by reviewed commit, and an organization's autonomy setting only by its owner. | The vocabulary and `roles/` change by reviewed commit (AGENTS.md invariant 7). | New in round 2. The setting does not exist yet; AU adds it as an owner-only Console verb. |
Round 1 changed R5 and R6 from the brief. Round 2 rewrites R5 again and adds
R12.
### Role rails
A role's definition is its rails. Each part says what that agent may do, and
the broker enforces it:
| Part | What it says | Enforced today | Gap |
|---|---|---|---|
| `tools` | The tools the agent has | Typed bus tools follow the held actions (`tools.mjs:97`); built-ins follow the policy (`packages/harness/src/bundle.mjs:368`) | None |
| `authority.withinRole` | Actions the agent takes alone | The broker's classification (`broker.mjs:7048`) | None |
| `authority.crossRole` | Actions it raises to its domain's arbiter | `broker.mjs:7072-7075` | The raiser picks the domain (`broker.mjs:7067`), so it picks its arbiter. RR fixes the domain for merge and policy. |
| `decision.resolve.<domain>` in `withinRole` | Which decisions it may resolve for other agents | Only for PRD approval (`broker.mjs:1059`) | An ordinary resolve checks only that the caller's role is the route (`broker.mjs:7212-7215`). The organization's `arbiters` map names the arbiter, not its role (`org-template.json:5`), and the default arbiter, the chief, holds no resolve right (`roles/chief-of-staff.json:8-9`). RR makes an arbiter hold the right for its domain. |
| `credentials` | Each service and scope | Bound by a person only (`broker.mjs:6144`) | None |
| `messaging` | Whom it may message | `#checkMessaging` (`broker.mjs:7082`) | None |
**The validator carries them.** P1's `checkComposition` checks every part
against the registry, and treats every `decision.resolve.*` action as a
flagged line, because it lets the holder decide for other agents.
**The instructions state them plainly.** The contract keeps the duties and
says what to do on a refusal. The list of what the agent may do is generated
from the resolved definition into its prompt (section 10), so the wording
cannot drift from what the broker enforces. Every shipped contract has an
`## Authority` section today; RR makes the role check refuse a version 2
contract without one (`role.mjs:146-157`).
## 4. The primitives
A role is a composition of these parts and nothing else.
**Core set.** Shipped in `vocabulary.mjs`, versioned, changed only by reviewed
commit:
- `tools`: core built-ins and `web` (`vocabulary.mjs:142`).
- `network`: one of `NETWORKS`.
- `authority.withinRole` and `authority.crossRole`: actions from `ROUTED`
(`vocabulary.mjs:73-102`).
- `credentials`: a known service with grantable scopes. Gitea `admin` and
`all` are never granted (`GITEA_SCOPE_CATEGORIES`).
- `messaging.sendTo` and `messaging.receiveFrom`.
- `skills`.
- `instructions`: the contract text, today the `.md` file.
- `personality`: new. Bounded text fields (summary, traits, voice), no
authority. Ruling 5 and ruling 12.
**Plugin-declared set.** From an enabled plugin's manifest (section 7):
tools, actions and credential services. A plugin action is always routed. A
plugin cannot declare a hard wait, cannot reuse a core name, and every name
carries the plugin's prefix (`<plugin>.<name>`).
**The validator.** P1 adds `checkComposition(role, registry, chief)`. It
replaces `HIRE_ALLOWED` and `unhireableAction` at hire time. It:
1. refuses any unknown key, as `role.mjs:190` does;
2. refuses any action, tool, network, service or scope the registry lacks,
where the registry is the core set plus the plugins enabled in that
organization;
3. refuses any action outside `ROUTED` or a plugin's routed set (R4);
4. refuses `role.hire` and `role.fire` (R9);
5. returns every `decision.resolve.*` action as a flagged line (role rails);
6. refuses any string that fails `assertClean` (R1), and any field over its
length bound;
7. returns the flagged lines: every action, tool, network, credential scope
and messaging target the chief's own resolved role does not hold (R8).
The validator accepts every composition that passes. There is no allowlist of
roles and no list of hireable definitions.
**What "any role with any capacity" means.** The chief can propose any name,
any mix of registered tools and routed actions, any credential scope a service
grants, any personality, any number of agents. A human approves each one.
**What it cannot mean.** A hired agent cannot hold a hard wait, hire, resolve
its own request or a decision about itself, read another organization, name a tool nothing declares,
carry a secret in its text, or run outside the sandbox. A new capability that
needs new enforcement, for example a new kind of external write, needs a
plugin that declares and enforces it. A name with no code behind it gives an
agent nothing.
## 5. Templates and organization roles
**Templates.** The shipped files in `roles/` become templates. They stay
there, versioned by reviewed commit. A template's identity is its name plus
the sha256 of its JSON and contract together, so no new field goes into
`roles/`. Plugins may ship templates too (section 7).
**Organization roles.** An organization's own definitions live in its file
under a new `definitions` key, one entry per name:
```json
"definitions": {
"release-writer": {
"rev": 3,
"from": { "template": "coder", "sha256": "<64 hex>" },
"role": { "tools": ["read", "edit"], "authority": { "withinRole": ["record.write"] } },
"instructions": "...",
"personality": { "summary": "...", "traits": ["..."], "voice": "..." }
}
}
```
An instance names `definition: "org:release-writer"` instead of a template
name. Project agents use the organization's definitions. Root agents keep
their own `definitions` in `<configDir>/agents.json`.
- **Import** copies a template into `definitions` with `from` set. The copy is
frozen: nothing changes it except an approved change.
- **Customize** edits the copy. **Invent** writes a definition with no `from`.
Both pass `checkComposition` before they are written.
- **Who writes.** A human holding `org.hire` drafts or edits a definition in
the Console. A draft no agent uses does nothing. A change to a definition a
hired agent uses takes effect only through a change decision (section 6).
The chief proposes a definition only inside a decision.
- **Template changes.** When a template's current hash differs from a copy's
`from.sha256`, the Console shows a diff and offers apply or keep. Apply is a
change decision (section 6). Nothing applies by itself (ruling 11).
- **Versioning.** `rev` counts up on every write. Every hire and change
decision records the definition's `rev` and hash.
- **A running agent.** Its sandbox, tools and contract are fixed at launch
from the bundle. A change that narrows authority takes effect at the broker
on the next call. Anything else takes effect at the next launch, and the
Console shows the agent as running an older `rev`.
- **Creative templates** are templates like the others. They still get no
host grant (`broker.mjs:2415`).
## 6. Hire as a decision
One decision action covers hire, fire and change, at every level.
**Fields.** The chief supplies:
| Field | Meaning |
|---|---|
| `op` | `hire`, `fire` or `change` |
| `level` | `org`, `project` (with the project name) or `root` |
| `name` | the agent's instance name, unused, not kept or retired |
| `role` | `{ template, sha256 }`, `{ definition, rev }` or `{ inline }` |
| `credentials` | each service and scope the agent needs |
| `personality` | the chief's draft |
| `route` | a proposed harness, model and thinking level |
| `reason` | 1 to 500 characters |
The broker adds, and the chief cannot set or edit:
- `flagged`: the lines `checkComposition` returns above the chief's own role;
- `credentialLines`: one line per service and scope;
- `digest`: a hash over the whole decision, as the Console resolution path
already checks (`broker.mjs:7200-7204`);
- `expiresAt`: seven days.
**Raise.** Among agents, only the instance in `launch.by` may raise one, as
today (`broker.mjs:6193`). It routes by the organization's autonomy setting
for authority changes (section 9). At the default, in the loop, it goes to a
person, so the arbiter path at `broker.mjs:7074` does not apply. A hire needs no
approval count and has no cap (ruling 3). Spend stays bounded by the
organization's limits.
**Approve, in the loop.** A human whose membership in that organization holds
`org.hire` approves in the Console, against the current digest. Two limits,
at every autonomy level:
- a decision with any flagged line or any credential line needs the owner.
This mirrors `decide` stopping short of credential mints (accounts design,
section 3 "Rights"). A member with `org.hire` approves plain hires.
- a root-level decision needs the instance owner, since the root file covers
every organization (agent-levels section 7).
The human may change the route, narrow the role or drop credential lines
before approving. Widening means a new decision.
**Near and out of the loop.** Only a plain decision moves: no flagged line, no
credential line, organization or project level. An instance whose role holds
`decision.resolve.authority` resolves it. It is never the chief that raised it
and never the agent the decision names (R5). Near the loop, the owner is told
and can reverse it (section 9).
**What approval writes.** One writer call, with the human as actor and the
decision id in the context:
- org level: the organization writer, extended from `prepareHire`
(`instance-settings.mjs:354`). It writes the definition, the instance with
its route, and `launch.instances`;
- project level: the A2 project-agent writer, behind the projects fence;
- root level: the A2 root writer.
Credential bindings then go through the existing human-only credential path
(`broker.mjs:6144`). Until every binding succeeds, `launch` refuses the agent
with `credential-unbound` and names the missing one, as A3 does for unbound
copies. Because approval sets the route, launch no longer refuses `no-route`
for an approved hire.
**Deny, expire, withdraw.** Deny and expiry write nothing but the trail entry.
The chief may withdraw its own decision (`broker.mjs:7205-7211`).
**Fire and change** are the same decision with `op` set. Fire revokes and
delists as today (`instance-settings.mjs:396`).
**A person starting a hire.** A human holding `org.hire` may start a hire,
fire or change in the Console. It is still a decision, with the same fields,
broker-computed lines, digest and trail, raised and resolved in one step by
that person. Flagged and credential lines still need the owner. I read ruling
8 as limiting which agent raises hires, not as stopping a person from hiring.
**Trail.** Raise, each resolution, the writer receipt and each binding are
events in the write-once tables (R2).
**What this replaces.** The direct `instance.hire` and `instance.fire` paths
(`broker.mjs:6185-6280`) and the `hireable` check (`broker.mjs:6291-6306`) go
away. The chief's `hire` tool (`packages/harness/src/tools.mjs:225-226`)
raises the decision instead of writing. Agent-levels section 7 had three
paths, one per level. Now there is one decision with a `level` field, so A4
and A5 become one slice, with the root level waiting for A2's root writer.
## 7. Plugins and modules
**Today.** Plugin kinds are a closed core list (`kinds.mjs:4-5,22-105`). The
loader reads only `packages/plugins/<kind>/<name>/index.mjs` in its own tree,
checks symlinks, exports and interface version, and pins no hash
(`loader.mjs:1-5,41-72`). A refused module keeps the kind's last good set
(`loader.mjs:7-9`). Enabling is `plugins.set` under `org.setup`
(`org-rights.mjs:74`), owner-only.
**Manifest.** Each plugin gets a `plugin.json`: name, version, kinds,
interface versions, a sha256 over its files, and what it declares: tools
(name, description, argument schema, whether it runs in the sandbox or on the
host), routed actions, credential services, and role templates.
**Install.** Sources are a GitHub URL at a commit, a zip, or a local
directory. The instance owner installs at the host with the expected hash.
The install refuses a hash mismatch, a core name, a hard-wait action or an
unknown kind. Installed plugins live read-only in an instance-level plugin
root, listed with their hashes in a lock file. That reads a path outside the
loader's own tree, which changes lead decision 143 (`loader.mjs:3-5`). Jason
decides that in PL1.
**Pin and load.** Every load checks the hash in the lock file. A mismatch
refuses the plugin and logs it. The loader's existing refusal handling keeps
core running.
**Enable.** Per organization, by the owner, through `plugins.set`. Install is
instance-wide because the code runs as the instance uid for every
organization (R10).
**A disabled or missing plugin.** Core keeps running. An agent whose role
names the plugin's tools or actions refuses at launch with
`plugin-disabled` and the plugin's name. A running agent's calls to those
tools refuse with the same code. Nothing is fired or rewritten.
**Preconfigured systems** such as a software factory or a writing workshop
are plugins that ship templates, a system type and tools. Installing one hires
nobody. The chief still raises a decision for each agent.
**Marketplace.** Out of scope. The design must not preclude one, so the
manifest keeps the source URL, version and hash, and reserves a `signature`
field. The install screen already lists every tool that runs on the host.
## 8. The minimal base
A new organization already starts with the human, a chief and an assistant
(`org-template.json:4,6`). That matches ruling 9. A6 later moves the
assistant to the root level (agent-levels section 9).
The chief reads the user's plan and raises one hire decision per agent it
needs, each citing the plan in its reason. It starts with the fewest agents
and adds one when a task needs a role nobody holds. Separate concerns go in
separate organizations or projects.
Every other shipped organization role becomes a template only: coder, pm,
reviewer, security-reviewer, merge-gate, cto, the five chiefs (executive,
financial, information, information security, marketing) and the six creative
roles (companion, continuity, fact-checker, ideas, proofreader, research).
`researcher.json` is a version 1 worker role (`roles/researcher.json:2`), which
a hire refuses (`broker.mjs:6303`), and `conductor-policy.json` is not a role.
Neither changes.
## 9. Autonomy, usability and security
Read this first. It says who decides what, and what changes as trust grows.
Ruling 13 sets the direction: from a person in the loop, to near the loop, to
out of the loop. Today most decisions sit in the loop.
**The three levels.**
- **In the loop.** A person approves before anything happens.
- **Near the loop.** An agent whose role holds the resolve right decides, and
the decision takes effect. A person is told in the Console and can reverse
it within a window the owner sets. I propose 24 hours as the default.
- **Out of the loop.** An agent whose role holds the resolve right decides.
The trail records it, and nobody is told.
For a class that routes to an arbiter today (cross-role work, merge, policy),
the arbiter resolves at every level, as PRD 1.5 REQ-DEC-2 and acceptance 314
step 5 require. The level only decides what happens after the arbiter
resolves. In the loop, the resolution waits for a person to confirm it before
it takes effect. A person who declines sends it back to the arbiter with a
reason. Near and out of the loop, it takes effect at once. No level makes a
person the resolver of these classes.
**The setting.** An organization's file gets an `autonomy` key with one level
per class below. A missing key means today's routing. Only the owner changes
it, in the Console: the verb maps to `null` in `VERB_RIGHTS`, the explicit
owner-only mark (`org-rights.mjs:28-29`), and it is not on the tester's verb
list (`broker.mjs:192`). Each change is a trail event. No agent changes it at
any level (R12). The owner can pull a class back toward the loop at any time
with no preconditions.
**Classes and levels.** "Default" marks where each class sits today.
| Class | In the loop | Near the loop | Out of the loop |
|---|---|---|---|
| Cross-role work, such as a task scope change | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default, `broker.mjs:7072-7075`) |
| Merge, `git.merge.protected` | The technical arbiter resolves; a person confirms before it lands | The technical arbiter resolves; a person is told | The technical arbiter resolves (default). Acceptance 314 step 5 has the CTO do it (`docs/acceptance/314.md:116-120`) |
| Policy, `policy.change` | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default). No shipped role holds this action |
| Plain hire, fire or role change | A person with `org.hire` approves (default, ruling 1) | An agent holding `decision.resolve.authority` resolves; the owner is told | The same agent resolves |
| Flagged grants and credential bindings | The owner approves (default) | Does not move (R8) | Does not move |
| Root-level agents | The instance owner (default) | Not an organization setting | Not an organization setting |
| Plugin install | The instance owner, at the host (default) | Not an organization setting (R10) | Not an organization setting |
| Hard waits: push, deploy, spend, mint, delete, sudo | A person, every time (R4) | Not a setting | Not a setting |
| The autonomy setting | The owner (R12) | Never | Never |
PRD approval already has its own switch: a request in `autonomous` mode sends
it to the technical arbiter, and one in `guided` mode to a person
(`packages/bus/src/request-lifecycle.mjs:308`, `broker.mjs:1057-1060`). A
person sets the mode (`broker.mjs:5918-5919`). This design leaves it as is.
**What reverse can undo.** Near the loop, reverse is a one-step act by the
owner, recorded against the decision. After the window it is an ordinary new
decision.
| Class | Reverse does | It cannot undo |
|---|---|---|
| Cross-role work | Restores the task's prior scope or state | Messages sent, records written (write-once, R2), work done in the window |
| Merge | Lands a revert commit through the normal path | The merge in history (no force push, AGENTS.md invariant 8), or anything that ran or shipped the merged code in the window |
| Policy | Restores the prior policy | Actions taken under the new policy in the window |
| Hire | Fires the agent, which revokes its credentials (`instance-settings.mjs:396-414`) | What the agent did or spent in the window |
| Fire | Rehires the same definition `rev` | The run and session state the fire ended, and the credential bindings the fire revoked. Each needs a new binding the owner approves |
| Role change | Returns the definition to the prior `rev`, and relaunches the agent if it runs the new one. A narrowing back takes effect at the next broker call (section 5) | What the agent did under the new `rev` |
| Credential binding | Not offered: it does not move | Each use of the token at the service. That is why it stays in the loop |
**Before a class moves a level.** The organization needs:
- to move any class to near the loop: a resolver instance whose role holds
that class's resolve right and is not the raiser (RR), the class's reverse
built and tested (AU), and Console notices on for the owner;
- to move any class to out of the loop: time at near the loop, and the
owner's review of its reversals in the trail;
- for merge, in addition: a reviewer role whose approve verdict a landing
needs, and the land gate's tests. Acceptance 314 has both, which is why
merge stays out of the loop by default;
- for policy, in addition: a reviewer role that sees the policy diff first;
- for plain hires, in addition: organization spend limits that cover a new
agent, and a resolver other than the chief. Out of the loop also needs
per-agent limits in every definition the resolver can approve.
Flagged grants, credential bindings, root-level agents and plugin installs do
not move in this design. Moving one needs a new ruling.
**A person changes directly.** Organizations and projects (owner). Drafts of
role copies and invented roles, which do nothing until a decision uses them
(`org.hire`). An agent's model, limits and route (`org.hire`). An agent's
personality and traits after hire (ruling 12). Enabling a plugin in an
organization (owner). Installing a plugin (instance owner, at the host).
**Only a reviewed code change.** The rails in section 3, the core list of
tools and actions, the shipped role templates, the rights table including
`org.hire`, plugin kinds, and system config, which only bootstrap writes.
**What never moves, at any level:**
- An agent cannot push to a protected branch, push to a remote, deploy, spend,
mint a credential, delete data or use sudo by itself. A person decides each
of those every time.
- An agent cannot resolve its own request or a decision about itself, and it
cannot change the autonomy setting or a rail.
- An agent cannot grant more than the chief holds, or bind a credential,
without the owner.
- Only the chief raises hires, and a hired agent cannot hire.
- An agent cannot see another organization's work. The assistant's read
across organizations is planned (A6) and not built yet.
- A plugin runs with the same power as the Mosaic service itself. Install
only code you trust.
## 10. Tooling and instructions
An agent trusted to work without a person has to know its own limits without
asking one. Three things today fall short.
**Its rails, readable at runtime.** The prompt names the agent's built-in and
bus tools (`packages/harness/src/bundle.mjs:283-284`) and the held actions
that have no bus tool (`bundle.mjs:308`). It does not say which actions the
agent takes alone, which go to which arbiter, which go to a person, or what it
may resolve. Six contracts tell the agent "the launch record you received
shows the resolved list" (`roles/coder.md:41`, `roles/cto.md:46`,
`roles/merge-gate.md:54`, `roles/pm.md:109`, `roles/reviewer.md:38`,
`roles/security-reviewer.md:27`). No session receives one. Launch records are
host-side, for principal checks (`packages/bus/src/runtime.mjs:885-901`,
`broker.mjs:2047`).
Fix, in TI: the prompt gets a generated "## Your rails" section from the
resolved definition. It lists each held action with where it goes (alone,
which arbiter instance, or a person), each resolve right with its class's
autonomy level, the network, each credential service and scope by name, and
the messaging targets. A read-only `my_rails` bus tool returns the same from
the broker, since a narrowing takes effect at the next call (section 5). The
six contracts drop the launch-record sentence and point at that section.
**Refusals that say what to do next.** The broker's `fail(code)` throws a
bare code (`broker.mjs:251-253`), and 653 lines of `broker.mjs` call it. A
reason travels only where a call builds one (`broker.mjs:243-249`, for
example `941`, `961` and `6468`). The prompt says only that "a refusal names
its reason" (`bundle.mjs:284`).
Fix, in TI: one table maps each refusal code to the rail or role rail it
enforces and the next step, for example "raise a decision for the delivery
arbiter" or "a person decides this; raise it with blocking false". The
envelope carries both. A test fails when a `fail` code has no entry.
**The tools it holds.** These are covered. The prompt names each tool
(`bundle.mjs:283-284`) and the bundle writes each tool's description and
schema to `tools.json` for the harness (`bundle.mjs:372`). What was missing
is the routing behind each tool, which the rails section adds.
## 11. Threat model
| Threat | What stops it | What is left |
|---|---|---|
| A prompt-injected chief proposes a hostile role | The broker computes the flagged and credential lines; the chief cannot hide them. Hard waits, hiring and unknown parts refuse (R4, R9, R3). A human approves against the digest. | A human can approve a hostile role. Flagged and credential lines need the owner. |
| A custom role names an unknown tool | `checkComposition` refuses it at the decision and at the writer. Launch refuses it again (R3). | None. |
| A plugin from an untrusted source | Instance owner only; hash pin; the install screen lists host-run tools. | Approved plugin code runs as the instance uid (R10). A pin does not make code safe. |
| A credential scope wider than the task | Each scope is its own line; owner-only approval; the approver may drop lines; admin and all never granted. | The service's own scope granularity. |
| An arbiter agent resolves a bad merge | Its role must hold `decision.resolve.technical` (RR). It never resolves its own raise (R5). The review verdict and the land gate come first (acceptance 314). The owner can pull merge back toward the loop. | A wrong merge is reverted, not undone. |
| An agent widens its own authority through a decision | No agent resolves its own raise or a decision about itself (R5). Flagged grants and credentials stay with the owner at every level (R8). Only the owner changes the setting (R12). | One agent raises a plain change for another. Plain means within the chief's own authority, and near the loop tells the owner. |
| A prompt-injected resolver near the loop | The owner is told and can reverse within the window. | What reverse cannot undo (section 9). |
| Approval fatigue on flagged grants | Flagged lines shown apart from plain ones; credential and flagged decisions go to the owner; no batch approval; seven-day expiry. | A busy owner who approves without reading. A later slice could limit how many flagged decisions one approval screen holds. |
| An imported template drifts from its source | The copy is frozen with its source hash. A changed template produces a diff offer, never a silent change. | An owner who keeps an old copy keeps its old behaviour. That is the ruling. |
## 12. Invariant 7 amendment
Proposed only. Jason approves it through a reviewed commit.
Replace AGENTS.md invariant 7 (`AGENTS.md:45-47`) with:
> 7. **Policy**: missions govern tasks; a task narrows, never widens. The
> rails, the core vocabulary of tools and actions, and the shipped role
> templates in `roles/` change only by reviewed commit. An organization's
> own roles are copies or compositions of known parts. One takes effect
> only through a decision, resolved where the organization's owner-set
> autonomy level sends it, by default a human with `org.hire`. The
> decision lists every grant above the chief's own as a flagged line,
> which only the owner approves. Only the chief raises hires, and a hired
> agent cannot hire.
Touched role-model lines:
- `AGENTS.md:107`: keep "policy/role expansion" as a protected act, and add
"an approved hire decision is that authority for the one agent it names".
- `AGENTS.md:209`: change to "`roles/`: shipped role templates and the
conductor policy".
## 13. Slices
Phase A after A3, in order: R1, P1, T1, A4, RR, TI, AU, PL1. A6 and A7 stay
as agent-levels section 9 has them.
Slices that change `roles/`: A4 (the chief), RR (the chief, and any role an
organization names as delivery arbiter) and TI (six contracts). Slices that
change rights policy: R1 and AU. P1 changes no file in `roles/` but defines
authority policy. All of them need Jason's approval.
**R1. The `org.hire` right.** Add `'org.hire': flag(true)` to `RIGHTS` and a
verb entry for the hire resolution in `VERB_RIGHTS`. The owner bundle picks it
up from `Object.keys(RIGHTS)` (`org-rights.mjs:22`); member, collaborator and
approver do not get it. Rights policy, so Jason's approval
(accounts design, `Grant needed` at line 1391). Tests: the owner has it; the
three templates lack it; `canGrant` lets an owner grant it to a member; an
unknown right still refuses.
**P1. Primitives validator.** `checkComposition` and the registry, beside
`role.mjs`. `HIRE_ALLOWED` stays until A4 switches over. It changes no role
file but it defines authority policy, so Jason's approval. Tests: an unknown
key, action, tool, service or scope refuses; each hard wait refuses;
`role.hire` and `role.fire` refuse; a secret in instructions or personality
refuses; a `decision.resolve.*` action comes back as a flagged line;
flagged lines match a hand-built expected set for coder against the
shipped chief.
**T1. Organization definitions and templates.** The `definitions` key,
`org:` references, import, the human edit verbs under `org.hire`, the
personality fields, the template hash and the diff read. Tests: import pins
the hash; editing a template file changes no copy; the diff read shows the
change; an `org:` reference to a missing definition refuses at launch; an
agent cannot call the edit verbs.
**A4. Hire as a decision, all levels.** The decision action, raise, Console
approval, the writer calls, credential lines, expiry, fire and change, the
Console path that raises and resolves in one step, and removal of the direct
path and `#hireable`. Every hire decision sits in the loop until AU. Org and project land first; root
waits for A2's root writer. This edits `roles/chief-of-staff.json` and `.md`
(drops `hireable`, rewords hire), the vocabulary and AGENTS.md invariant 7,
so Jason's approval. Tests: a hired agent's raise refuses `role-not-chief`; a
chief decision cannot carry its own `flagged`; a member with `org.hire`
approving a credential or flagged decision refuses; a stale digest refuses;
deny and expiry write nothing; approval writes the role, the route and the
trail, and launch refuses `credential-unbound` until binding; fire revokes;
the old `instance.hire` verb refuses; a cross-role `git.merge.protected`
decision still routes to the technical arbiter and the CTO resolves it, as
acceptance 314 step 5 expects; a hire a person starts in the Console leaves a decision record
with its flagged lines.
**RR. Role rails and resolve rights.** An arbiter must hold
`decision.resolve.<domain>` for its domain, checked when the organization
file is written and again at resolve, which extends `broker.mjs:1059` to
every decision. The vocabulary adds `decision.resolve.delivery`. A fixed
domain per action for `git.merge.protected` (technical) and `policy.change`
(delivery), so a raiser cannot pick its arbiter. The chief gets both resolve
rights, since the template names it arbiter for both
(`org-template.json:5`). The role check refuses a version 2 contract with no
`## Authority` section. Changes `roles/chief-of-staff.json` and the
vocabulary, so Jason's approval. Tests: naming an arbiter without the right
refuses when written; an arbiter whose role lost the right refuses
`not-resolver`; a merge raised with domain `delivery` still routes to the
technical arbiter; acceptance 314 step 5, the CTO resolving the merge, still
passes; a contract with no `## Authority` refuses.
**TI. Tooling and instructions.** The generated "## Your rails" prompt
section, the read-only `my_rails` tool, the refusal table and envelope
fields, and the six contract edits (section 10). Changes `roles/*.md`, so
Jason's approval. Tests: coder's prompt lists each held action with its
route; `my_rails` matches the broker after a narrowing; every `fail` code in
`broker.mjs` has a table entry; a refused cross-role call's envelope names
its rail and next step.
**AU. Autonomy setting and routing.** The `autonomy` key, the owner-only
Console verb, routing per class, the subject check that sends a decision
about its resolver to a person (R5), `decision.resolve.authority`, the
Console notice, reverse per class with its window, and the trail events.
Needs A4 and RR. Changes rights policy (a `VERB_RIGHTS` entry) and the
vocabulary, but no role file, so Jason's approval. Acceptance tests:
- with no `autonomy` key, routing is exactly today's: a merge goes to the
technical arbiter, a hire to a person;
- a member, an agent and the dev tester each refuse to change the setting;
an unknown class or level refuses;
- with merge in the loop, the technical arbiter still resolves the merge
decision, a person's resolve refuses `not-resolver`, and the merge does not
land until a person confirms; a decline returns it to the arbiter;
- with merge near the loop, the arbiter's resolve takes effect, the owner
gets a notice, and reverse within the window lands a revert commit
recorded against the decision;
- with hires near the loop, an instance holding `decision.resolve.authority`
resolves a plain hire; a hire with a flagged or credential line still goes
to the owner; the chief cannot resolve its own raise; no agent resolves a
change that names itself;
- reverse after the window refuses and names the ordinary decision path;
- each setting change is a trail event with the owner as actor;
- no setting value moves a hard wait, a flagged grant or a credential
binding away from a person.
**PL1. Plugin manifest, install and declared tools.** Manifest, pinned
install root, lock file, the registry merge, and `plugin-disabled` refusals.
Changes lead decision 143 and the loader's trust rule, so Jason's approval.
Tests: a hash mismatch refuses and core keeps running; a manifest naming a
core tool or a hard wait refuses; disabling a plugin makes its agents refuse
at launch with its name; a running agent's call to a disabled tool refuses.
@@ -0,0 +1,212 @@
# System-type toolsets at launch
Status: design only. Nothing here is built and no `roles/` or contract file is
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
project gets the toolsets of the system types that project uses."
## What exists today
- **Modules.** `installedSystemTypes()` (`packages/plugins/src/system-types.mjs`)
loads every installed `system-type` module and returns
`{id, label, description, toolsets}`. It is fail-closed: a refused module, a
missing or extra key, or a toolset name that fails `NAME` throws
`system-type-invalid`. All five shipped modules (business-management, coding,
creative-writing, marketing, montage) declare `toolsets: []`.
- **Organization enable.** The organization file's
`plugins['system-type']` holds the enabled ids. `plugins.set` writes it
through the organization writer behind the owner fence
(`organization-system-types-human-only`) and returns a transaction receipt.
The bus reads the current set with `system-types.current` on every read and
write, so it never trusts a stale snapshot.
- **Project selection.** `project.system-types.set` appends a row to
`project_system_types` (bus store, `seq`-ordered, latest wins) and accepts
only enabled ids (`system-type-not-enabled`). A read returns
`{selected, active, inactive}`, where `active = selected ∩ enabled` and
`inactive = selected − enabled`.
- **Launch.** `resolveInstance` (`packages/organization/src/resolve.mjs`)
starts from the role definition's `tools` and `network`, narrows them by
each layer's `limits.tools` and `limits.network` (system, business,
project, agent), and freezes the result with a digest. The role's `tools`
is therefore a ceiling: a launch gets those tools minus whatever a layer
limit removes, never more. The launcher (`packages/cli/src/launcher.mjs`) passes that
result to `buildBundle` (`packages/harness/src/bundle.mjs`), which writes
`policy.json` (`tools: builtinTools(resolved.limits.tools)`), `tools.json`
(typed host tools) and `manifest.json` once each (`flag: "wx"`). Typed
host tools (`typedTools`, `packages/harness/src/tools.mjs`) are not granted
by tool name: each is built only when the resolved `authority` holds the
action it needs.
- **Run tasks.** `scripts/mosaic-task.mjs` validates a mission's
`capabilities.tools` as a ceiling and a task's `capabilities.tools` as the
allowlist, both drawn from `SUPPORTED_TOOLS`. A task narrows the mission.
It never widens it.
Nothing on the launch path reads system types today, so every launch gets
its role's tools as narrowed by the layer limits. That is the coding
module's behavior, and the design keeps it as the default.
## Resolution at launch
A launch resolves its toolsets in the host-side launcher, before
`resolveInstance` freezes the digest. In order:
1. **Pick the project.** The launch's project is the one the session is
started for: the thread's project for a project thread, or none. A launch
with no single project (an organization-wide session, or a role started
with several code projects in scope) gets no system-type toolsets. Taking
the union across projects would widen one project's agent with another
project's tools, so the union is ruled out.
2. **Read the active set.** The launcher asks the bus, as the host, for the
project's selection and computes `active = selected ∩ enabled`. `enabled`
comes from `system-types.current`, the same trusted read the routes use.
Inactive selections are dropped and recorded in the manifest. They do not
refuse the launch, because the owner may disable a type at any time and
that must not strand running work.
3. **Load the modules.** `installedSystemTypes()` runs. If it throws
`system-type-invalid`, the launch refuses with that code. An active id
with no installed module refuses `system-type-missing`.
4. **Map toolset names to tools.** Each active module's `toolsets` are looked
up in the reviewed mapping file (below). The result is a set of tool names,
each a member of the role vocabulary `TOOLS`
(`packages/organization/src/vocabulary.mjs`). Typed host tools cannot be
named: they stay gated by the role's `authority` actions exactly as today,
and a system type never changes `authority`.
5. **Bound by the ceilings.** The role's `tools` stays the hard ceiling for
every effective tool, including any a system type switches on. A role may
hold some of its tools back for typed projects by listing them in
`systemTypeTools` (new, see the proposal), which must be a subset of the
role's `tools`. Then:
- `base` = (role `tools` − `systemTypeTools`), narrowed by every layer's
`limits.tools`, as `resolveInstance` does today;
- `added` = mapped tools ∩ `systemTypeTools`, narrowed by every layer's
`limits.tools` the same way;
- `effective` = `base ∪ added`;
- for a run task, `effective` is then intersected with the mission's
`capabilities.tools` and then the task's. That intersection covers
base tools too, as it does today.
Every term is a subset of role `tools` ∩ every layer limit, so a system
type can only switch on a tool the role already holds and the layers
already allow. Invariant 7 holds because each later step can only remove
entries. A role without `systemTypeTools` behaves exactly as today.
6. **Record it.** `resolveInstance` takes the added list as an input and
includes `systemTypes: {project, active, dropped, toolsets, added}` in the
resolved object, so the digest covers it. `buildBundle` writes the same
block into `manifest.json`. The run record then shows which types and
mapping produced each tool.
No step adds credentials, network reach or authority. `limits.network` and
`authority` are computed exactly as today. A toolset that needs a credential
gets it the way any role does, through the role file's `credentials` and a
reviewed commit. A system type never grants one.
## The mapping file
The mapping is repository policy, so it lives under `roles/` and changes only
through a reviewed commit, the same rule that governs `roles/*.json`
(invariant 7, AGENTS.md "Role model"). Proposed path:
`roles/toolsets/system-types.json`. It sits in a subdirectory because two
loaders read every `roles/*.json` as a role definition
(`instance-settings.mjs` `definitions()`, `harness/tests/tools.test.mjs`).
A mapping file at `roles/` top level would be read as a broken role.
Proposed format:
```json
{
"toolsetsVersion": 1,
"toolsets": {
"repository": { "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"] },
"web-research": { "tools": ["web"] }
}
}
```
The two entries are illustrations, not part of the proposal. No shipped
module names a toolset today, so the file would ship as
`{"toolsetsVersion": 1, "toolsets": {}}`.
Rules a loader enforces, fail-closed:
- Exactly the keys `toolsetsVersion` (must be `1`) and `toolsets`. Each entry
has exactly the key `tools`.
- Each toolset name matches `NAME` from `packages/plugins/src/kinds.mjs`, the
same pattern modules must use.
- `tools` is a non-empty list of distinct names, each in `TOOLS`. A typed
host tool name, or anything else, refuses `toolset-mapping-invalid` at
load.
- The file is read from the release copy, like the role files, and its
sha256 goes into `manifest.sources`.
Who may change it: a reviewed commit on the repository, landed by the lead
with Jason's authority over role policy. An organization owner cannot change
it at runtime, and neither the Console nor the bus has a write path to it.
Organization owners choose which types are enabled and projects choose among
them. The repository decides what a type's toolsets may contain.
## Fail-closed behavior
| Condition | Result |
| --- | --- |
| An installed module is refused or malformed | launch refuses `system-type-invalid` |
| An active id has no installed module | launch refuses `system-type-missing` |
| A module names a toolset the mapping lacks | launch refuses `toolset-unmapped` |
| The mapping file is missing, unreadable or invalid | launch refuses `toolset-mapping-invalid`, but only when at least one active type declares a toolset; a launch with nothing to map never reads it |
| A mapped tool is outside the role's `systemTypeTools` (and so not switched on) | the tool is dropped and recorded under `dropped`, launch continues |
| The bus cannot answer the project's selection | launch refuses `system-types-unavailable`; it does not fall back to "no types", which could hide a missing toolset the operator expects |
| A selection is inactive (disabled in the organization) | dropped and recorded, launch continues |
The two "continue" rows are narrowing, never widening. Every row that could
change what an agent may do refuses instead.
## The coding module
Coding declares `toolsets: []`. It adds nothing, and a coding project's agent
keeps its role's tools as the layer limits narrow them: for
`roles/coder.json` the ceiling is `read, write, edit, bash, grep, find, ls`
with network `api-only`, the same as today. Turning the design on changes nothing for any shipped module until a
module declares a toolset and a reviewed commit maps it.
## Proposed `roles/` change (not applied)
1. Add an optional V2 key `systemTypeTools` to the role schema
(`packages/organization/src/role.mjs`): a distinct list from `TOOLS` (same
check as `checkTools`, but empty is allowed) that must be a subset of the
role's own `tools`, or the role file refuses at load. It names the tools
the role holds back unless an active system type maps them. Absent means
`[]`: nothing is held back and no system type changes that role's tools.
Every existing role file stays valid and behaves as today, and
`checkCreative` keeps inspecting `tools`, which still bounds everything.
```diff
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
-const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
+const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable", "systemTypeTools"];
```
2. Add `roles/toolsets/system-types.json` with an empty `toolsets` object, and
a loader beside the role loader with the checks listed above.
3. Have `resolveInstance` accept `systemTypes` (the launcher's step 1-4
result) and apply step 5. Have `buildBundle` copy the block into the
manifest. `scripts/mosaic-task.mjs` already bounds tools by mission and
task. It would apply that filter to the final effective set.
No contract under `contracts/` changes: the constitution's least-privilege
rule already covers this. The design adds no grant path: the effective set
stays within role `tools` and every layer limit, and `authority`, network and
credentials are untouched. What it does add is a runtime input (the
project's active system types) that can switch a held-back tool on, so a
role author who lists a tool in `systemTypeTools` is deciding that an
organization owner's enable plus a project's selection may turn it on. Each of the
three steps is a reviewed change to role policy and needs Jason's sign-off
before it is built.
## Open points for the build
- Business-file projects (`b.projects`, used for checkouts) and bus projects
(UUIDs in `project_system_types`) are separate records today. Step 1 needs
the launch to carry the bus project id of the thread it serves. The build
should confirm how a project thread names its project at launch before
wiring step 2.
- A running session keeps the toolsets it launched with. Disabling a type
takes effect at the next launch, which matches how role changes behave.
@@ -0,0 +1,450 @@
# Credential binding for agent copies
Date: 2026-11-11 UTC. Lane `org-cred-bind-design`, issue #1873.
Status: proposed, for Jason's approval before implementation. Doc only.
Baseline: `6415e81ee75a6783b889b67b985f61825f9a074e` (`origin/refactor`).
All current-code citations below refer to that commit. References to other
designs describe proposed policy, not a claim that it is implemented.
## 1. Outcome and boundaries
An owner can approve a credential binding for one root or project agent copy
inside one organization. A launch uses only that copy's own secret slot,
with verified service permissions bounded by its frozen role and the
organization's narrower approval. No agent can bind credentials for itself
or another agent. Missing, stale or uncertain authority refuses launch and
credential use. Copies in other organizations remain independent.
This proposes a writer, schema and enforcement changes for later slices. It
changes no code, right, role, contract, secret or running instance. Host and
model credentials remain on their existing grant and proxy paths. There is
no migration of named-agent credentials, no root-wide secret binding, no
shared token pool, and no new plugin service in the first build.
Owner direction requires each non-assistant agent's credentials to stay
inside its organization even when its definition comes from root
(`docs/plans/2026-11-09_unified-workspace-owner-direction.md:124-131`). A3
explicitly deferred binding and its two-org thread-copy test
(`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:52,216-217`).
## 2. Current behavior, traced before the proposal
| Step | Current behavior and evidence |
| --- | --- |
| Declare requirements | `checkCredentials` accepts each known service once and validates its scope shape. Gitea uses category levels; Vikunja uses route-group verbs; host has no scopes (`packages/organization/src/role.mjs:100-143`). `admin` and `all` are outside the grantable Gitea categories (`packages/organization/src/vocabulary.mjs:187-191`). |
| Declare a named instance | `roles.<instance>.credentials` must reference exactly the role's services, except host grants and the chief's Gitea exception when no library exists. Vikunja also requires a unique bot identity (`packages/organization/src/organization-config.mjs:50-59,124-167`). |
| Locate the slot | `parseCredentialRef` accepts exactly one of `store`, `file` or `env`. `{store:true}` derives the file from config directory, business, agent and service; it does not accept another agent's store path. Named agents can still use external file and environment references (`packages/organization/src/credentials.mjs:23-59`). This lane proposes only the store form for copies. |
| Resolve launch inputs | `resolveInstance` takes the entry from the validated organization, intersects tools/network/actions with limits, then returns its non-host credential references and tracker metadata (`packages/organization/src/resolve.mjs:34-69,81-95`). These limits do not compute a credential scope intersection. |
| Load secrets | Runtime indexes references by `business/role` and sets the store root to the agents secret directory (`packages/bus/src/runtime.mjs:333-345,378-379`). `Credentials` loads those references into its private map (`packages/bus/src/credentials.mjs:130-165`). It derives its store filename from business, agent and service (`packages/bus/src/credentials.mjs:351-355`). File reads reject symlinks, wrong ownership/mode and repository/data-root placement (`packages/bus/src/credentials.mjs:357-415`). |
| Use secrets | `Credentials.useFor` derives business and role from the session, rather than a caller-supplied target. Missing tokens refuse `credential-unavailable`; Vikunja expiry refuses `credential-expired` (`packages/bus/src/credentials.mjs:597-624`). The task adapter uses that session-bound path (`packages/tasks/src/adapter.mjs:84-88`). |
| Issue and revoke | The broker requires a human, with an explicit tester-verb exception; it checks that the agent exists and prevents delete while ordinary or thread claims remain (`packages/bus/src/broker.mjs:6212-6225`). The credential writer reads only `document.roles[agent].credentials`; issue writes a store reference, revoke drops memory/file before writing or recording the receipt (`packages/bus/src/credential-verbs.mjs:16-51`). |
| Record writes | The organization writer takes the shared registry lock, then the organization's history `write.lock`, validates the revision and document, records a prepared change and publishes the file (`packages/organization/src/organization.mjs:143-159,175-220,278-340`; `packages/organization/src/org-registry.mjs:73-101`). Its `pathsOnly` option records changed field paths and presence without values (`organization.mjs:276-278,324-327`). |
| Materialize copies | Root and project entries explicitly refuse `credentials`, `tracker`, `identity`, `publish`, `holder` and `hosts` (`packages/organization/src/agent-levels.mjs:18-41,53-59,105-115`). Copy names preserve root names or append project names. Collisions refuse both claimants; org instances shadow copies. Every required non-host service makes a copy `unbound` (`packages/organization/src/agent-copies.mjs:36-53,73-104`). An unbound copy never joins the role map and launch refuses `credential-unbound`, naming missing services (`agent-copies.mjs:223-245,279-287`; `packages/cli/src/launcher.mjs:361-375`). |
| Guard launch races | Launch checks against a fresh organization revision, then final `request.admission` compares it again after async preparation (`packages/cli/src/launcher.mjs:357-364,802-813`; `packages/bus/src/runtime.mjs:636-661`). This revision covers organization bytes, not role file bytes. A3 records that limitation explicitly (`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:215`). |
Important limit: validating a slot reference does not prove the remote
token's permissions. For non-host/non-model services, `Credentials.#format`
checks token syntax and length, not remote scopes
(`packages/bus/src/credentials.mjs:448-451`). Existing Gitea minting has a
fixed seat scope list, not a per-copy intersection
(`packages/bus/src/gitea-grant.mjs:27-30,262-269`). Vikunja startup has
specific negative permission probes, not a complete per-composition scope
proof (`packages/tasks/src/startup.mjs:30-55`). The new build must close
these gaps for copies; a JSON field that asserts scopes is insufficient.
## 3. Choice and storage
Considered three approaches:
1. Add credentials to root/project definitions. Rejected: a root definition
would carry a per-org concern and invite cross-org reuse.
2. Write synthetic copies into `roles`. Rejected: this conflicts with org
shadowing and turns a boot copy into a persistent named instance.
3. Add an org-local binding overlay and reuse the existing per-agent store.
Recommended. It keeps copy definitions secret-free, preserves shadowing,
and gives bindings the same revision fence as other org settings.
The authoritative metadata lives in the existing organization file,
`<configDir>/businesses/<org>.json`, under a proposed top-level
`copyBindings` key. It is not written into `agents.json` or `roles/`.
An absent key means no bindings. The writer stores one record per concrete
copy instance; the example below is illustrative schema, not current JSON:
```json
"copyBindings": {
"writer.site": {
"copyId": "<random 128-bit lowercase hex>",
"orgIncarnation": "<registry incarnation>",
"source": { "level": "project", "name": "writer", "project": "site" },
"definition": { "ref": "org:release-writer", "rev": 3, "sha256": "<digest>" },
"generation": 1,
"state": "bound",
"services": {
"gitea": {
"slot": "self",
"scopes": ["read:repository"],
"resources": { "projects": ["site"] },
"rotateBy": "2026-12-01",
"grantId": "<opaque service grant id>",
"grantGeneration": 1
}
},
"decision": "<authority decision id>"
}
}
```
`slot: "self"` means exactly `(org, concrete instance, service)`. It is a
selector for today's derived slot, not an arbitrary string slot name. The
record contains no path, environment name, token, token fragment or token
hash. Grant ids identify authorization records, never encode token bytes.
Secret values stay exclusively in the existing private credential store
and trusted service-call memory, never in metadata, evidence, bundles,
decision text or errors. The symbolic paths in this design describe source
architecture; runtime responses never emit resolved host paths.
`source` includes the level and original name/project, so dotted-name
collisions cannot silently attach an old binding. `copyId` is created on
first approval and retired on fire, shadowing, source removal or replacement.
`generation` increases for every binding change or revocation and never
resets while that copyId exists. An unbound tombstone remains after unbind.
A new copyId never adopts an old slot file. Name reuse requires verified
old-slot cleanup and newly verified credentials before activation.
For a root copy, source.project is null and its definition is the frozen
root definition identity. For a project copy it names the org definition
or template identity. A definition hash includes composition and contract;
the applicable instruction/personality revision is pinned too. Import
provenance (`from.sha256`) is not the customized definition's current hash.
Vikunja metadata additionally contains the existing bot identity shape
(`tracker: {bot, botId}`) at the copy record level. It must pass the same
org-wide uniqueness check as named instances, including other copies.
No service binding implicitly grants identity, publication, host enrollment
or project access. Existing identity/publish requirements remain separate.
### Writer and lock order
Extend `organizationWriter` with dedicated trusted methods
`applyCopyBindingDecision` and `revokeCopyBinding`; names are proposed API.
Only the authenticated broker/Console owner path invokes these methods.
Generic document writes and root/project writers must reject changes to
`copyBindings`, even if a submitted document validates. An actor string,
decision id or hash supplied by an agent is never authorization.
Reuse the shared registry lock `<configDir>/org-runtime.lock`, then the
organization lock `<configDir>/businesses/.history/<org>/write.lock`.
Do not introduce a second competing metadata file or lock. Reuse the
writer's revision conflict, prepared/committed receipt and paths-only
evidence machinery. Under the locks, establish fresh active org incarnation,
owner identity, expected document revision and decision digest. Perform
the final checks in the publication critical section, not only in HTTP.
Do not hold filesystem locks across remote service calls or waits for runs
to stop. Before such work, persist a non-active pending operation with a
unique id and expected generations. Reacquire the locks, re-read and compare
all identities before publishing. A competing write invalidates preparation.
Use the same operation id for idempotent recovery; a prepared or uncertain
operation cannot produce a usable binding or resurrect the prior token.
## 4. Authority, decisions and deployed ownership
Use the existing grantable `org.hire` right as the authority-change entry
right. It already exists, defaults to owner yes/member no, and maps
`hire.resolve.console` to `org.hire`
(`packages/organization/src/org-rights.mjs:6-26,67-69`). Do not add an
`org.credentials` right that would imply delegable token-grant authority.
The right is necessary but not sufficient: any decision containing a
credential line requires the current owner of the target org, even if a
member holds `org.hire` or `decide`. Map credential resolution to an
explicit owner check as well as the right check, rechecked at commit.
The owner-only credential read stays owner-only; `board.read` gets at most
the existing copy's missing-service status, never a binding record.
Bind and unbind are **authority-change decisions**, represented by the
hire model's `op: "change"`, with a binding-specific payload. The
decision names source, copyId (or new-copy proposal), org incarnation,
definition revision/hash, before/after service scopes and resources, and
expected binding generation. The broker computes credential lines and
the whole-decision digest. Approval consumes the decision once. Dropping,
narrowing or replacing a line invalidates the old digest and requires
confirmation of the new digest; widening always raises a new decision.
This reuses hire-as-a-decision instead of creating a second approval
model. It follows the roles-and-rails design's credential ruling,
owner restriction, digest, seven-day expiry, one-step human-originated
decision, and post-approval binding steps
(`docs/plans/2026-11-10_roles-and-rails-design.md:35,232-310`). The chief
may propose a binding only through that decision path, never by naming a
slot, supplying a token or calling the writer. Other agents cannot raise
this authority-change subtype. No agent resolves it, including the chief,
an arbiter, its subject, or a different copy of the same definition.
This is an authority change under system rails R1, R5, R7 and R8 and the
role's credential rail. Default autonomy is **in the loop**. As required by
the existing design, credential bindings do not move to near or out of the
loop at all without a new owner ruling
(`docs/plans/2026-11-10_roles-and-rails-design.md:90-119,416-425,445-463`).
Unbind uses the same human decision record, including a one-step
owner-originated decision. Emergency **revoke** remains an owner-only
immediate verb: it only removes use, must not wait for a chief proposal,
and records the authority reduction. Revoke never issues a token or changes
an unbound binding to bound.
A root-level definition change still needs the instance owner. Binding one
root copy inside org O needs O's owner and grants nothing in another org.
Being an instance operator or root-business owner alone does not make a
person O's owner. Accounts design separates these principals explicitly
(`docs/plans/2026-11-10_accounts-design.md:314-349`). No account-only session
falls back to a human/root transport. All Console mutations retain Origin,
session, fresh ownership and confirmation fences.
The host remains the trust boundary. Host root and the instance uid can
already change the instance and its secrets; this proposal does not claim
protection from either, or from a sandbox escape to that uid. The deployed
instance uid must remain separate from human/development seats
(`docs/plans/2026-11-10_accounts-design.md:887-936`).
## 5. Permission calculation and proof
For every service V, compute:
`effective(V) = role-declared(V) intersect org-approved-binding(V)`.
The binding is the org's per-copy narrowing. Omitted services are unbound,
not permission to use another source. Unknown services/scopes refuse; they
are never ignored. The requested scopes must already be a subset of the
role. Do not silently trim a broader request and report that it succeeded.
Resources also intersect the org's current admitted project set and the
copy's union. A project copy may access its project only. A root copy may
access only the approved projects that still admit it. An empty intersection
allows no service operations and leaves a required service unbound.
Scope comparison is semantic: Gitea read is narrower than write in the same
category, categories are independent, and neither admin nor all is allowed.
Vikunja compares each route-group/verb pair. Missing categories/groups mean
no permission; there is no wildcard. All adapters must apply their service's
registered semantics, never compare scope strings lexicographically.
### At proposal, approval and bind
1. Resolve the concrete copy from the target org's boot snapshot and current
project union. Refuse missing, shadowed, colliding, excluded or fired
copies. Never create `roles[copyName]` to make the operation succeed.
2. Validate the role through the core composition validator and current
enabled registry. Read T1's definition revision/hash, or the frozen root
role identity. Verify org active state and incarnation. Compute all
credential lines from these inputs, not from chief-supplied summaries.
3. Check every requested service/scope/resource against that definition and
org. Verify the resolver is the fresh org owner. The owner cannot override
a system rail or widen a binding past the approved role; changing the role
requires its own approved change first.
4. Obtain a dedicated service credential for this exact org/copy/service.
Trusted service adapters must establish both token scopes and remote
identity resource grants. Prove the allowed projects/repos at the service,
not just a local `resources` list. A root-wide or cross-org bot account is
unacceptable even if its token is stored in a per-org directory.
5. Accept a token only after trusted live introspection verifies its scopes,
service account, permitted resources and expiry against this operation,
or after a reviewed managed mint path produces and verifies that exact
bounded grant. An owner-entered assertion, copied grant id, token format,
or a few successful/negative probes are not proof of complete permissions.
If the service/version cannot prove the bound, refuse
`credential-scope-unverified`. Ship that refusal rather than an unsafe
compatibility mode. This is a required service-adapter build item.
6. Stage the secret privately in the existing store with the binding inactive.
Recheck decision digest, definition hash, org incarnation, document
revision and slot generation after remote work. Only then commit the
metadata/receipt. A grant record binds the opaque grant id, copyId,
incarnation and generation to the issued slot. It stores no secret or
token-derived digest. Ambiguous crash recovery keeps use closed and
requires a new verified issue if exact provenance cannot be established.
Gitea's existing fixed `SEAT_SCOPES` must not be reused for a read-only role.
Vikunja needs a reviewed verifier for the complete route-group scope set
and bot/project access. Until each adapter exists, that service's copy
binding refuses. Do not claim the existing named-agent path provides this
proof. Extending remote minting beyond its current approved envelope is a
separate protected change, identified in slice CB2 below.
### At launch, restart and every use
The first build uses the existing restart model for **new references**.
Approval may record a binding, but it is not usable until the org bus
restarts and verifies it. Surface `restart-required`, not a successful
launch. Today reference/bot changes already require restart
(`packages/bus/src/runtime.mjs:547-583`). Do not add live reference reload
in the first slice. Revocations take effect immediately and never wait
for this restart.
On boot and every launch, re-resolve the effective role and binding,
validate source/copyId/incarnation/generation, and obtain fresh trusted
service scope/resource proof for the actual slot contents. If verification
is unavailable, launch refuses. All required services must be bound and
available. Bindings may be approved incrementally, but partial success
cannot launch. A stale file alone never counts as a binding.
Preserve A3's organization revision fence. Add a separate launch authority
digest over frozen definition identity, copyId, org incarnation, binding
generation and verified grant generation. Do not redefine the writer's
document revision. Carry both into final admission and `bindLaunch`, and
bind the admitted generations to the session. A pending revocation blocks
admission, including the interval between final admission and spawn/bind.
If a child was spawned before revocation won, deny its capability and stop
it; it must perform no credential call. Do not release a launch fence on an
uncertain outcome until that child is confirmed stopped.
At every credential-bearing operation, check session org/instance, active
incarnation, effective scopes/resources, binding/grant generation and slot
liveness before any external request. Derive the slot from the session,
never from request arguments or thread text. Raw tokens and credential file
paths do not enter sandboxes. The trusted service adapter checks each
method/resource as well as token permissions. Constrain outbound destinations
to the approved service origin and reject redirects carrying authorization
to another origin. Operation completion rechecks the generation before
publishing its result. Unknown or stale state refuses closed.
## 6. Lifecycle and running copies
All transitions below close credential admission first, invalidate affected
session generations, then stop **all** affected ordinary and thread runs.
Serialize close versus new admission so a concurrent launch cannot escape
the stop inventory. Abort cancellable service calls and wait for all held
uses to settle before slot deletion or replacement. A completed external
write cannot be undone; report that limit, never call local revoke a remote
rollback. Existing non-host `Credentials.use` does not provide the host-key
abort behavior, so generalizing the use fence is required, not assumed
(`packages/bus/src/credentials.mjs:556-569,618-653`).
| Event | Durable outcome and effect on a running copy | Next launch/use |
| --- | --- | --- |
| Unbind service | Owner-approved change bumps generation, records unbound tombstone, closes use, stops every run of that copy, revokes/removes its slot. Never retain a reusable token behind an inactive binding. | `credential-unbound`: `credentials.gitea needs an owner-approved binding.` |
| Emergency revoke | Owner verb immediately closes use, bumps generation, stops all copy runs and performs local revoke. A managed remote grant is also revoked through its approved adapter. Keep tombstone; reissue needs a fresh decision and verification. | `credential-revoked`: `credentials.gitea was revoked; the owner must approve a new binding.` |
| Slot removed or altered outside the writer | A slot liveness/identity check at use and launch refuses even if memory still holds the token. Mark binding unavailable, invalidate session use and stop its runs. Never fall back to cached memory, environment or another slot. Replacement is a newly verified issue. | `credential-unavailable`: `credentials.gitea is unavailable; the owner must verify the slot.` |
| Expiry or upstream revocation | Local expiry checks and service authentication failure close the binding, invalidate use and stop runs. Rotation is a new verified slot generation and owner-approved change; no in-place replacement behind active uses. | `credential-expired`: `credentials.vikunja has expired; the owner must approve a replacement.` Upstream revoke uses `credential-revoked`. |
| Role/definition changed | Any role identity/hash/revision mismatch closes use and stops affected copies. Do not inherit old credentials even for equal service names. A narrower role requires new bounded service proof; a wider role requires approval first and fresh binding. T1 blocks edits of an in-use definition until the change-decision path exists. | `credential-role-changed`: `credentials.gitea no longer matches the approved definition; the owner must review the binding.` |
| Copy fired, source removed, excluded or shadowed | Close use, retire copyId, stop every run, revoke slots and delist the copy. Preserve run/trail evidence. A newly shadowing named instance must not load the old copy's file at the identical slot name. Require cleanup before it can use that name. | `copy-unavailable`: `copy is no longer active in this organization.` |
| Copy-name collision | Refuse attaching either binding. If introduced while running, close affected use and stop both claimants. No first-wins fallback. | `copy-refused`: `copy identity is ambiguous; the owner must resolve the definition collision.` |
| Organization archived | Close the org's admission, invalidate its running sessions and stop every org run before archive completes. Revoke all copy service slots and mark bindings unbound. Restore preserves definitions but requires fresh binding decisions. Never affect another org's runs or slots. | `org-archived`: `organization is archived.` After restore, `credential-unbound`. |
| Organization deleted | Close all admission/use and stop every org run, revoke managed remote grants and local slots before the existing quarantine flow. Preserve evidence/tombstones in deletion evidence. A new org with the same id has a new incarnation and no inherited bindings. | `org-unavailable`: `organization is not active.` Recreated org starts unbound. |
| Revoke/stop/cleanup uncertain | Persist a non-usable pending-revocation state and keep the affected copy or org fenced. Do not publish success or restore memory on write failure. Retry with the same operation id. Remote revocation uncertainty remains visible to the owner. | `credential-revoke-failed`: `credentials.gitea revocation is incomplete; the owner must retry cleanup.` |
These archive/delete effects are proposed extensions. Current archive stops
the bus and marks the registry archived; deletion quarantines org runtime,
document/history and org secret trees, while its receipt says remote-side
revocation is out of scope (`packages/organization/src/org-service.mjs:239-267,332,343-347`).
The new build must explicitly test and add copy-run and copy-grant cleanup;
it cannot infer it from a stopped bus.
Refusal codes in this section are proposed public contracts where new.
Use `human-required` with `Only the organization owner can approve credential
binding.` for an agent/member mutation, `credential-not-yours` with
`credentials.gitea must use this copy's own organization slot.` for a wrong
slot, `credential-scope-exceeded` with `credentials.gitea.scopes exceeds the
approved role.` for excess scopes, and `credential-scope-unverified` with
`credentials.gitea permissions could not be verified.` for missing proof.
`credential-binding-changed` says `credentials.gitea changed; read and approve
again.` for stale generation; `organization-changed` retains the existing
document-race code. Validation errors name field positions such as
`copyBindings entry 1 services field`, not untrusted submitted keys.
All new boundaries allow only registered service/field labels and fixed
remediation text. Never interpolate a submitted value, resolved path, raw
service error, URL, bot name, secret, token hash or token fragment into an
error, receipt or trail. New metadata serializers use an allowlist and
paths-only receipts. Scan human text with the existing secret scanner and
fixed field-size limits; arbitrary unknown keys refuse with a fixed field
label. Metadata holds only validated ids, known scope names and bounded
dates/integers. The scope is every new copy-binding surface, consistent
with the brief's #1916/#1920 requirement, without changing unrelated legacy
diagnostics. Neither reviewer nor implementer needs live secrets to test it.
## 7. Two organizations and identity reuse
Example acceptance fixture: org A and org B both materialize root `writer`
from the same creative template. A's binding selects `(A, writer, gitea)`;
B's selects `(B, writer, gitea)`. Each uses a dedicated remote bot/token whose
resources exclude the other org. B cannot select A's slot or reuse A's grant
record. A path restriction alone would not prevent a token with both orgs'
remote grants, so service proof must check those grants too.
Enforce isolation at all of these independent boundaries:
1. Console resolves the target org from the authorized route and fresh
ownership, not a body-supplied org or an instance-wide owner flag.
2. The writer derives its org and slot from that context and binds the
metadata to the registry incarnation. A copied record from A has the
wrong incarnation in B and refuses before opening any slot.
3. Copy source tuple, copyId and definition digest must match B's materialized
copy. A root definition carries no binding to propagate.
4. Credential loader derives the slot, enforces private regular-file rules,
rejects alias/symlink/hardlink substitution for copy slots and verifies
remote grant ownership. Reject nlink other than one for these slots.
5. Every use derives org/instance from the authenticated session and checks
the active binding generation. A thread, room or caller-supplied instance
cannot choose the key. Session and grant records cannot cross buses.
6. Archive/delete touches only that incarnation's resources. A failed cleanup
never makes credentials valid for a later org or copy with the same name.
Registry incarnation is a dependency of accounts design
(`docs/plans/2026-11-10_accounts-design.md:501-514`). The binding build must
use that stable value; if the target baseline lacks it, land an explicitly
approved incarnation slice first. Do not substitute timestamps, a pathname
or an org id alone. The dedicated store layout remains unchanged.
## 8. T1 dependency and implementation slices
T1 is in flight. Its brief is
`/mnt/storage/scratch/mosaic-lead/uidev/org-release/T1-BRIEF.md`. This design
depends on its org-local definitions, `org:` resolver, revision/hash read,
composition validation and in-use definition guard, as specified in
roles-and-rails sections 4 and 5
(`docs/plans/2026-11-10_roles-and-rails-design.md:157-170,186-228`). Do not
patch T1's files in this lane. Project copies use those org definitions;
root copies keep a frozen root identity. Supporting root-local customized
definitions waits for that resolver to exist, rather than inventing another
definition format here. A4 supplies the authority-change decision engine;
this build extends it, rather than shipping a temporary direct bind verb.
Every slice below is a later proposal requiring Jason's approval before
work. Each ends with focused tests plus affected organization, business,
bus, CLI and Console suites. No slice edits `roles/` or `contracts/`.
| Slice and dependencies | Build and acceptance evidence | Protected changes needing approval |
| --- | --- | --- |
| CB0: identity foundation, after T1/accounts | Reuse incarnation or add its approved missing support. Define copyId, source/hash pin and binding generation semantics. Pure resolver tests cover identical root copies across two orgs, project dotted-name collisions, org shadowing, removed/recreated org and source-name reuse. No secret read and no launch enabled. | Org schema; registry schema only if incarnation is absent. No new rights or role files. |
| CB1: metadata and decisions, after CB0/A4 | Add `copyBindings`, guarded organization writer, owner-only credential subtype under `org.hire`, digest/expiry/single-consumption and paths-only receipts. Tests: owner binds/unbinds; member with org.hire/decide, chief, arbiter, subject agent, tester and reader all fail to resolve/write; agent may not call raw writer; only chief agent can propose; stale revision/digest/generation, expired approval, replay, wrong incarnation and generic write injection have no activation. Pending/partial binding never launches. | Organization and decision payload schema; `VERB_RIGHTS`/owner dispatch changes even though no new grantable right. Approval explicitly covers the reuse of org.hire with the owner restriction. |
| CB2: verified service slots, after CB1 | Reuse self-only store and dedicated bot identity; add complete Gitea/Vikunja verification or exact bounded mint adapters. Tests use fake services for narrower/equal/broader scopes, read/write hierarchy, extra route verbs, unrelated repos/projects, reused foreign bot/grant, opaque/unverifiable token, wrong date, symlinks/hardlinks, stale file and token replacement. No request, path or token enters metadata/evidence; lost replies and crash points remain inactive. A live scratch-service acceptance must prove each service/version's permission verifier before that service enables copy launch. | Credential handling/security boundary; grant metadata schema and any remote mint/revoke envelope changes. No automatic reuse of existing fixed Gitea seat scopes. |
| CB3: admission and use, after CB2 | Materialize only verified bound copies; new references visibly need restart. Check document revision plus separate authority digest at prepare, final admission and bindLaunch. Session-bound adapter checks every operation and slot liveness. Race tests mutate role hash, org bindings, token contents or admitted projects during async prepare and between admission/spawn/bind/use. Assert no admitted credential call, including an empty project manifest. Concurrent revoke denies new work; no cached-token fallback after unlink. | Launcher/broker credential enforcement boundary; session/launch authority fields and any store schema needed to persist fences. No live reference-reload policy. |
| CB4: lifecycle and recovery, after CB3 | Test unbind, emergency revoke, slot removal, expiry, role change, fire, collision, shadowing, archive/restore, delete/recreate. Each closes admissions, invalidates ordinary and every thread run, aborts or drains uses, and denies later calls. Inject failures before/after metadata publication, secret staging, remote revoke, stop acknowledgement and receipt write; restart never resurrects authority. Include named-instance shadowing over a former copy's slot. Other org continues. | Credential/revocation and org lifecycle boundary; durable pending-operation schema. Existing deletion quarantine must not silently claim remote revoke. |
| CB5: two-org thread acceptance and operator flow, after CB4 | Finish A3's deferred test with a shipped Gitea-requiring creative thread role in both orgs. Approve independent verified bindings; boot real buses and use fake harness/service, create one thread/session per org, launch both, write/read records, reopen each. Assert distinct session/run/trail/workspace and slot identities, no A record visible in B, no B request uses A's token, and B survives A revoke/archive/delete. Reject raw A binding/grant replay in B. Operator flow shows computed scope lines, confirmation, missing service, restart and remediation states, with sentinel redaction tests on every response/event/log. No screenshot gate for this doc lane. | Console mutation/decision flow; no further right or role changes. Any discovered schema delta returns to approval before build. |
Existing regressions to retain include organization `credentials.test.mjs`,
`role.test.mjs`, `agent-copies.test.mjs`, `instance-credential-transitions.test.mjs`,
bus `credential-store.test.mjs`, `credential-verbs.test.mjs`,
`agent-copies.test.mjs`, and CLI `thread-launch.test.mjs` and
`launcher-final-race-probe.test.mjs`. Add meaningful new focused tests for
the guarantees above; merely checking the proposed JSON shape is inadequate.
Acceptance samples for this document are the seven brief requirements:
storage/writer/locks in section 3; right/rail/decision in section 4;
role ceiling and verification in section 5; lifecycle/code/text in section 6;
two-org enforcement in section 7; tests/slices/protected changes in section 8;
and at most three owner questions below. No visual samples apply.
## 9. Questions for Jason, with recommendations
1. Approve `org.hire` as the entry right while keeping every credential
binding/unbinding approval owner-only? Recommend yes: it matches the
existing hire decision model and does not grant members token authority.
2. Approve the overlay schema and CB0-CB5 security changes, including complete
service scope/resource proof as a prerequisite and restart for new
references? Recommend yes. A service without a verifier stays refused;
implement Gitea first if its verifier is ready before Vikunja's.
3. On archive/restore, discard copy credentials and require fresh bindings?
Recommend yes. It adds setup on restore, but avoids keeping dormant
usable grants and gives archive one clear revocation meaning.
These are recommendations, not approvals. The Lead takes this design to
Jason. No implementation or rollout is authorized by this document.
+126 -20
View File
@@ -1,12 +1,11 @@
# PRD: Mosaic Stack
- Status: approved, version 1.19. Jason approved the exact text in Q289.
- Version 1.19 adds disk-space governance, launch-capacity holds,
human-controlled retention rules, and the protected `data.delete.policy`
route. It preserves per-batch `data.delete` hard waits. Jason approved
the exact text at `e5400456` under REQ-WAIT-2: "1.18: yes" (DECISIONS
Q289, relayed by Mos in mos-2300). Q288 approved the recurring-rule principle.
- Prior approval history, through version 1.18: Jason approved draft 0.4 unchanged as
- Status: approved, version 1.20. Jason approved draft 2 on 2026-11-09.
- Version 1.20 adds the unified workspace: every organization visible at once,
cross-organization views, three levels of agent configuration, per-org task
stores behind the task tracker kind, system type plug-ins and self-service
operation. It amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3.
- Prior approval history, through version 1.19: Jason approved draft 0.4 unchanged as
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
on 2026-10-14. They approved version 1.2, the chief of staff, the
@@ -64,7 +63,18 @@
the approved row 255 design. The stack prints the providers entry and
never writes config.json. What the requirement allows or forbids is
unchanged (mos-1671, sage-4418). Doc-only, so it takes the 1.11 path:
one seat drafts, a second reviews, Sage lands.
one seat drafts, a second reviews, Sage lands. Version 1.19 adds
disk-space governance, launch-capacity holds, human-controlled retention
rules, and the protected `data.delete.policy` route. It preserves per-batch
`data.delete` hard waits. Jason approved the exact text at `e5400456` under
REQ-WAIT-2: "1.18: yes" (DECISIONS Q289, relayed by Mos in mos-2300).
Q288 approved the recurring-rule principle. Jason approved the exact text in Q289.
- Version 1.20 source: Mos asked Jason to approve draft 2 in
`docs/plans/2026-11-09_prd-1-20-draft.md`, with the Montage name deferred.
Jason replied on 2026-11-09 at 22:57Z: "The file looks fine other than that."
The exception is the module name, which he had deferred at 19:26Z: "I don't
like these module names either. It isn't clicking for me. That may need to be
a later decision."
- Version 1.19 source, Jason's DECISIONS Q287, verbatim:
> All of the learnings from this drive space issue need to be integrated into Mosaic Stack. We need watchers, cleanups, enforcement tooling and agents to check for space waste, etc.
@@ -475,9 +485,11 @@ Added in version 1.2:
names the roles and how many of each. The chief of staff launches them
through role.launch, within the agent limits (REQ-LIMIT-1). (Version
1.5.)
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has
one assistant role per organization. It talks with the user, and it
passes work for the organization to the chief of staff. It doesn't
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has one
assistant across all of the organizations they
belong to. It knows the user's organizations, projects and sessions and
keeps one shared memory. It talks with the user and passes each
organization's work to that organization's chief of staff. It doesn't
direct other agents, create or assign tasks, or launch sessions.
- **REQ-ASSIST-2.** The broker limits the assistant's messages, not its
contract alone:
@@ -522,6 +534,24 @@ Added in version 1.7:
- Every hire and fire is logged with the chief's instance, its run and
the reason.
### Agent levels
Added in version 1.20.
- **REQ-AGENT-1.** Agents are configured at three levels: the user's root,
the organization and the project. A project's available agents are the
union of the three.
- **REQ-AGENT-2.** The user can see and change the configuration of agents,
organizations, projects and sessions, and can override any setting at any
time, through the write path (REQ-ROLE-3).
- **REQ-AGENT-3.** The chief of staff hires and fires agents to meet the
user's needs. At the organization and project levels it acts under
REQ-COS-3. At the root level its hire or fire changes the user's root
configuration, which every organization of that user sees, so it reaches
outside the chief's own organization. A root-level hire or fire is a
decision the user approves in the inbox (REQ-DEC-2) before it takes
effect. The user can also change root-level agents directly.
### Variables
- **REQ-VAR-1.** Variables come in four layers: system, business, project
@@ -879,8 +909,15 @@ consumed ordinary decision as standing permission.
### Tasks
- **REQ-TASK-1.** Tasks live in Vikunja, reached through `/api/v2` with a
bot token for each role. Each task cites a requirement id. Each task
- **REQ-TASK-1.** Each organization stores its own tasks, through the task tracker
plug-in kind (REQ-PLUG-1). The default module is a store built into Mosaic
Stack. Vikunja is an optional module, and when an organization selects it
the 1.19 rules for Vikunja (bot tokens per role, one writing role per field,
REQ-TASK-2 polling) apply to that organization. Every task tracker module
supports create, read, update, archive and delete, ordering and grouping.
Archiving or removing an organization removes its tasks without touching
another organization's.
Each task cites a requirement id. Each task
field has one writing role, listed in the slice 1 brief's field table,
and the assigned role writes the task's state. Since version 1.19, the
scoped disk-service principal in REQ-DISK-12 may initialize incident
@@ -1031,10 +1068,40 @@ Added in version 1.7:
- The decision event records via=console and the (iss, sub) pair.
- Without a signed-in session, the Console resolves nothing and shows
the `mosaic decide` line instead.
- **REQ-WEB-3.** The Console has controls to restart the bus and to
reload the organization file. Each control is a gated, trailed action.
A reload applies `launch.instances` changes to running sessions without
a manual `systemctl restart`. (Version 1.4, decision 021af0c8.)
- **REQ-WEB-3.** The Console has controls to restart itself, to restart an
organization's bus, to reload the organization file, and to change the model
an agent instance runs on and apply it. Each control is a gated, trailed
action that a human takes. No agent can take it. A reload applies
`launch.instances` changes to running sessions without a manual
`systemctl restart`. On a dev instance (`--dev-local-owner`) the controls
need no sign-in.
### Workspace
Added in version 1.20.
- **REQ-WS-1.** The WebUI shows every organization the user belongs to at
all times. A collapsible Organizations item in the sidebar lists them.
There is no organization switcher.
- **REQ-WS-2.** The Console reads across the buses of the user's
organizations to build shared views. Each organization keeps its own bus
as the tenant boundary (REQ-ORG-1). A shared view shows only
organizations the user belongs to.
- **REQ-WS-3.** The main dashboard sums activity, work and metrics across
all of the user's organizations and projects.
- **REQ-WS-4.** Each organization has a dashboard with its docs, every
configurable setting, its KPIs and metrics, and its active agent
sessions.
- **REQ-WS-5.** The projects list covers every organization. It sorts and
filters by organization, status, name and activity. Each project shows
its owning organization by icon, color and avatar.
- **REQ-WS-6.** One inbox holds every item that needs the user, from every
organization, project and session.
- **REQ-WS-7.** One view shows activity across all organizations and
projects. Organizations and projects can be open side by side.
- **REQ-WS-8.** The tasks view aggregates every organization's tasks, with
Kanban and list layouts, filtered by organization and project. It works
the same for every task tracker module.
### Launching
@@ -1173,6 +1240,17 @@ Added in version 1.3 (Q155).
changes it, not in one rewrite (Q155). Until it converts, Technical
considerations lists where it is hard-coded.
### System types
Added in version 1.20.
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include
coding, creative writing, marketing, Mosaic Montage (the OpenMontage
fork) and business management. An organization enables a list of them.
- **REQ-SYS-2.** A project may use any system type its organization
enables, and none is required. An agent working in a project gets the
toolsets of the system types that project uses.
### Meta-harness
- **REQ-HARN-1.** The meta-harness generates each session's launch bundle
@@ -1329,9 +1407,13 @@ Added in version 1.1 (decision 03130d08).
- **REQ-ORG-2.** The data model is install, then organization, then
projects, then threads, so v1 doesn't lock in one human per
organization.
- **REQ-USER-1.** Users and role-based access control exist at install
and organization level. An agent belongs to one organization and
reaches only its allowed projects and threads.
- **REQ-USER-1.** Users and role-based access control exist at install and
organization level. An agent other than the assistant works in one
organization at a time. Its memory, files and credentials are scoped to that
organization, even when its definition comes from the user's root
configuration. It reaches only that organization's allowed projects and
threads. The assistant is the exception in REQ-ASSIST-1, and it reaches only
organizations its user belongs to.
- **REQ-USER-2.** Only the user who owns a thread reads it or writes to
it, plus that user's assistant and the chief of staff's replies on work
the thread sent. This fills in REQ-USER-1's "threads" for the chat
@@ -1462,6 +1544,15 @@ design `docs/plans/2026-10-21_t1-sessions.md`, 2c843d190 on `huey/t1-sessions-de
with `processing-halted` and changes nothing. A clear never sets or
clears the kill switch. (Design D8, test T6.)
### Accounts
Added in version 1.20.
- **REQ-ACCT-1.** A deployed system requires user sign-in (OIDC, Q203). A
user creates organizations and belongs to any number of them.
- **REQ-ACCT-2.** An organization's owner invites another user into it.
The invited user joins with the grant the owner chose (REQ-USER-3).
### Creative library
Added in version 1.12 (Q176 Case 2, Q211). Drafted from the Case 2
@@ -1788,6 +1879,17 @@ Added in version 1.11 (Q187, Q189, Q211):
monitored coverage. Install and upgrade enable no deletion policies
(REQ-DISK-1, REQ-DISK-4, REQ-DISK-10).
### Setup
Added in version 1.20.
- **REQ-SETUP-1.** Quick Setup leaves its project with its records
initialized.
- **REQ-SETUP-2.** After Quick Setup, the assistant offers an onboarding
interview. It asks for the project's outcome and limits and drafts the
north star and a first PRD. The user approves each through the existing
approval path.
## Acceptance criteria and success measures
v1 is done when Jason gives one-sentence requests to the assistant, in a
@@ -2367,6 +2469,10 @@ gate that slice.
## Risks and open questions
- The final name for the Mosaic Montage module is deferred to a later owner
decision. Mosaic Montage remains a working name; the existing four-tile
logo stays. (Version 1.20.)
- On each v1 instance, the stack runs as that instance's own OS user,
without sudo (Q181). A managed role session runs in a sandbox
(REQ-HARN-2): it writes only its workspace and run directory, sees no
+59 -4
View File
@@ -189,8 +189,15 @@ those messages stranded: no later holder receives or reads them, and `agents`
lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets
an operator inspect content and delivery evidence after an uncertain result.
Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add
`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never
quotes caller input or file bytes. The runner and typed tools carry the guidance
to the agent. `src/rails.mjs` in the organization package supplies the shared
prompt/live projection; `decisionAutonomy` is the extension point for AU.
| Verb | Arguments and result |
|---|---|
| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. |
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
| `role.release` | No args; only its current holder releases. |
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
@@ -241,7 +248,9 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `project.list` | No args, or `{includeArchived:true}`; human, tester and user sessions. The business's projects, oldest first, with archived projects hidden unless `includeArchived` asks for them; each returned archived project carries `archived:true`. A user still lists granted projects only. |
| `project.archive` | `{project}`; human only (#797 MVP3). Appends one `project_archives` row (the audit trail: who, when); nothing is deleted. A repeat of the current state refuses `project-already-archived`. Returns `{project,archived:true}`. While archived the project hides from the plain list and refuses new work (`project-archived` from thread create/open/send, new session clears and thread run admission, room open/send, and product-bound request/PRD/task admission). Repository/product keys resolve to the chat archive journal too: an archived chat binding fences repository admission for all chat projects naming that repository, and every archived binding must be unarchived to restore it. Queued opens and clear successors are rechecked at launch and claim. Existing results, task completion receipts, cancellation, release/end and history stay usable; metadata-only session creation cannot execute while archived. |
| `project.unarchive` | `{project}`; human only (#797 MVP3). Restores the project completely; a project that is not archived refuses `project-not-archived`. Returns `{project,archived:false}`. |
| `room.open` | `{project}`; human only. Creates/reuses the caller's canonical member threads, named by sorted `THREAD_DEFINITIONS` instance keys. A taken name/owner/instance refuses `room-thread-name-taken` atomically. Returns `{project,threads:[{id,instance,name}]}`. |
| `room.send` | `{project,body}`; human only, room must be fully open (`room-not-open`). Leading whitespace and `@name` tokens (including a final EOF tag) address current display names or member keys; no tags broadcasts, repeats send once. Unknown/ambiguous members refuse `unknown-addressee`/`addressee-ambiguous`; tags-only refuses `invalid-request`. Stores the original body, one normal message/route/human.input per target in one transaction, with one shared message timestamp. Returns `{ids,to}` in member-key order. |
| `room.read` | `{project,after?,limit?}`; human only, complete owned room. Oldest first across all sessions of member threads. Returns entries with first-message fields plus `thread`, `session`, `instance`, `ids` and `to`. Only consecutive human rows with equal at/body/class and distinct instances merge; repeated instances split identical sends. `limit` counts merged entries (default 50, 1–200). Continue after the last entry's last `ids` element; an `after` outside the room refuses `message-not-found`. No tester access or schema change. |
@@ -716,7 +725,7 @@ keys with `invalid-request` before the writer runs (sage-0918). `instances` take
answers the business's instances, their definitions, vars and launch membership, plus the
definitions `instances.set` accepts. `instances.set` takes `{revision,instance,op,definition?,
vars?,launch?}`. `op` is `add` or `change`, and every other key is `invalid-request`. The
business package's `instance-settings.mjs` writes through the organization writer, which
organization package's `instance-settings.mjs` writes through the organization writer, which
logs the human as the actor, or `tester` for a tester's write. Its refusals pass through as `organization-*` codes, among them
`organization-credentials-out-of-scope` for a definition that declares credentials. The answer
carries `restart: true` after an add or a definition change: the broker keeps the roles it
@@ -729,8 +738,8 @@ tester may send `routes.set` (row 175), logged with actor `tester`; its `routes`
`ORGANIZATION_KEYS`, so the chief of staff's `change_organization` can't send a `routes` key.
The chief reaches a route only through the assistant's whole vars (Q169), and that write runs
the same route checks. `routes` takes no args and answers the routing table;
`routes.set` takes exactly `{revision,routes}`. The business package's `launch-settings.mjs`
writes them with the human as actor (`packages/business/README.md`, "Routes"). `harness-off`,
`routes.set` takes exactly `{revision,routes}`. The organization package's `launch-settings.mjs`
writes them with the human as actor (`packages/organization/README.md`, "Routes"). `harness-off`,
`harness-refused` and `thinking-unsupported` pass through as they are, from `routes.set`,
`instances.set` and `organization.set` alike. The runtime builds the write checks' `levels`
from `harnessLevels()`, which loads each harness module in the tree once at start, switched on
@@ -1080,3 +1089,49 @@ TERM concurrently, then KILL after five seconds and observe for two more.
Unknown targets remain visible and prevent resume. Existing run evidence is
not synthesized or rewritten. First activation stops old processing before
trusted initialization and aligns the copy and image in the human's window.
### Existing product intake
The request host lists only this business owner's declared, admitted code projects.
Each picker entry carries `mapping_revision`, the current organization revision.
The host retains the displayed inventory until the owner picks; a host restart
requires a fresh picker read. `request.attach.console` accepts no client revision
for the mapping: it journals the server's selected revision. `prepareExisting`
refuses a changed mapping and builds the confirmation from its existing paths,
source commit, tracker binding, staff and project mode. It creates no resources.
After confirmation, attachment holds the organization lock and the processing
lease through validation and its atomic receipt. It reuses an active, validated
product snapshot when that snapshot's original request has exactly the same intent
bytes. This is a conservative proof of fit; different intent enters discovery
for PM assessment and PRD revision. Discovery cannot borrow the older snapshot
for task creation. No PRD decision or historical snapshot is rewritten.
The existing `bootstrap.completed` journal payload has optional host-validated
attachment fields; old events retain their original meaning and schema 3s needs
no migration.
Lifecycle continuations wake a live PM when a new, complete validated push/review
evidence set becomes available. Committed attempt receipts deduplicate unchanged
evidence across recovery and host restarts. A normal recovery call inspects at
most its requested batch limit. An event overlapping a recovery keeps a full
reinspection cycle pending, in bounded batches, so retained history cannot hide
an already-inspected request until a later timer tick.
Assistant-to-chief-to-PM intake relays must link `in_reply_to` to the message
received. The broker derives the original request header from that authorized
recipient chain and its owner/thread/session evidence, never from a message body.
For lifecycle tasks, the first review request includes the exact raw JSON
verification-document and verdict-body contract; unrelated reviews retain their
existing free-text contract. The typed record tools describe the existing closed
requirement schema without relaxing host validation.
## Built-in task store writes
The outside-agent human session can call `tasks.create` with `title` and a `requirement` ID
(such as `REQ-TASK-1`), plus optional task fields. `tasks.edit` accepts `task_ref` and the
fields to change, including `requirement`; archive, unarchive, and delete take `task_ref`.
Each write records `human.input`. A task read returns a `digest` that can be supplied as
`expect` to a later write; any intervening revision, including archive or unarchive, refuses
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
must be current roles. Automatic tail placement and an explicitly occupied position refuse
`task-position-full` when there is no room; no task row is written.
+78
View File
@@ -364,6 +364,27 @@ CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
-- #797 MVP3: the owner's reversible project archive. A project's state is the op of its latest
-- row, and the rows are the audit trail (who archived or restored it, and when). The projects
-- table itself stays as written; an archived project keeps every record and is never deleted.
CREATE TABLE project_archives (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
op TEXT NOT NULL CHECK (op IN ('archive','unarchive')),
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_archives_project ON project_archives (business, project, seq);
CREATE TRIGGER project_archives_no_update BEFORE UPDATE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_delete BEFORE DELETE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_replace BEFORE INSERT ON project_archives WHEN EXISTS (SELECT 1 FROM project_archives WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_business BEFORE INSERT ON project_archives
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'an archive row belongs to its project''s business'); END;
CREATE TRIGGER project_archives_no_repeat BEFORE INSERT ON project_archives
WHEN (SELECT op FROM project_archives WHERE business = NEW.business AND project = NEW.project ORDER BY seq DESC LIMIT 1) = NEW.op
BEGIN SELECT RAISE(ABORT, 'a project archive row changes state'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
@@ -618,3 +639,60 @@ CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snaps
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+91
View File
@@ -0,0 +1,91 @@
// AU trusted host seam. No worker can choose a path, snapshot or inverse operation.
import { mkdirSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { prepareRevert, finishLand, dropLand } from '../../seat/src/working.mjs';
import { isMosaicStack, runGate, gateEnv } from '../../seat/src/landing.mjs';
const refuse=code=>{throw Object.assign(new Error(code),{code});};
// countsAccount(name) answers what the named harness module declares (runtime harnessLevels);
// the broker's authority budget check reads it here instead of naming a harness.
export function autonomyHost({writer,dataRoot,business,hires,reload,processing,countsAccount=()=>null,env=process.env,gate={classify:isMosaicStack,run:runGate}}) {
return {
countsAccount,
get:id=>{const r=writer.read(id);return {revision:r.revision,autonomy:r.document.autonomy??{}};},
set:(id,args,actor)=>{
const old=writer.read(id);if(old.revision!==args.revision)refuse('revision-conflict');
const result=writer.write(id,{...old.document,autonomy:args.autonomy},{revision:args.revision,actor,detail:{verb:'autonomy.set'}});
const live=reload(id);return {...result,restart:live===false};
},
reversible:cls=>['cross-role','merge'].includes(cls) || cls==='authority' && typeof hires?.reverse==='function',
async reverse({business:id,decision,decisionClass,raised,effects,actor,beforeWrite,beforePublish,fire,stop,complete,relaunch,record}) {
if(decisionClass==='authority') {
if(!hires?.reverse)refuse('reversal-unavailable');
let stopped;
const result=await hires.reverse(id,decision,{actor,beforeWrite,beforePublish,fire,stop:async p=>{const r=await stop(p);stopped=p;return r;},relaunch,record});
// The writer admitted publication within the window. Reload and relaunch
// complete that committed inverse; crossing the deadline here cannot undo it.
const receipt={committed:true,transaction:result.transaction??null,completion:stopped?'recorded':'not-needed',relaunch:result.relaunch?'pending':'not-needed'};
try {if(stopped)complete?.(stopped,result);} catch {receipt.completion='failed';receipt.restart=true;}
try {
if(reload(id)===false)receipt.restart=true;
if(result.relaunch && !receipt.restart){await relaunch(result.relaunch);receipt.relaunch='requested';}
} catch {receipt.restart=true;if(result.relaunch)receipt.relaunch='failed';}
return receipt;
}
const used=effects.filter(e=>e.operation!=='decision.raise' && !String(e.operation??'').startsWith('autonomy.'));
// An unused approval has changed no task or policy. Reversing it revokes that permission.
if(!used.length) {beforeWrite();return {permissionRevoked:true};}
if(decisionClass!=='merge')refuse('reversal-unavailable');
const landing=used.find(e=>e.action==='git.merge.protected' && typeof e.commit==='string');
if(!landing)refuse('reversal-unavailable');
const b=business(id),p=b.projects[landing.project];if(!p)refuse('project-not-found');
const name=`reverse-${randomBytes(6).toString('hex')}`,dir=join(dataRoot,'autonomy-reversals',decision,name);
mkdirSync(dir,{recursive:true,mode:0o700});
const where={dataRoot,business:id,project:landing.project,env};
let prepared;
return processing.work(async scope=>{
try {
if(!await gate.classify(p.root,env))refuse('gate-missing');
prepared=await prepareRevert({...where,commit:landing.commit,dir,name,decision});
symlinkSync(join(p.root,'node_modules'),join(prepared.worktree,'node_modules'));
const result=await gate.run({worktree:prepared.worktree,logs:dir,env:gateEnv(env),signal:scope.signal});
scope.guard();if(!result.ok)refuse('gate-failed');beforeWrite();
const receipt=await finishLand({...where,land:prepared,record:receipt=>{scope.guard();beforeWrite();record({...receipt,reverted:landing.commit});}});
return {...receipt,reverted:landing.commit};
} finally {if(prepared)await dropLand({...where,worktree:prepared.worktree});}
});
},
};
}
// The runtime adapter is shared with integration tests, including key revocation order.
export function authorityHost(service,{modelKeys,reload,proxyOrganization}) {
return {
...service,
reverse:async(id,decision,caller)=>{
const proposal=service.read(decision);
if(!modelKeys || (proposal.level==='org' && proposal.op==='hire'))return service.reverse(id,decision,caller);
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
let prepared=false;
return service.reverse(id,decision,{
...caller,
beforeWrite:()=>{caller.beforeWrite();if(prepared){modelKeys.completeRevoke(instance);prepared=false;}caller.beforeWrite();},
stop:async(p,guard)=>{await modelKeys.prepareRevoke(instance,guard);prepared=true;return caller.stop(p);},
});
},
apply:async(id,decision,caller)=>{
const proposal=service.read(decision);
const publish=async()=>{
let approved=caller;
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
await modelKeys.prepareRevoke(instance,()=>caller.beforeWrite?.());
approved={...caller,beforeWrite:()=>{caller.beforeWrite?.();modelKeys.completeRevoke(instance);caller.beforeWrite?.();}};
}
const result=await service.apply(id,decision,approved);reload(id);return result;
};
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
return publish();
},
};
}
+13
View File
@@ -0,0 +1,13 @@
// AU reads only immutable evidence. These operations are reserved broker writes.
export function autonomyEvent(q, business, decision, operations) {
const placeholders=operations.map(()=>'?').join(',');
const row=q.get(`SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IN (${placeholders}) ORDER BY seq DESC LIMIT 1`,business,decision,...operations);
return row?{...row,body:JSON.parse(row.body)}:null;
}
export function decisionResolution(q, business, decision) {
const terminal=q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision);
if(terminal)return terminal;
const overlay=autonomyEvent(q,business,decision,['autonomy.resolved','autonomy.declined']);
if(overlay)return overlay.body.operation==='autonomy.declined'?null:{...overlay.body.resolution,seq:overlay.seq};
return q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision);
}
+1403 -163
View File
File diff suppressed because it is too large. Load diff
+2 -27
View File
@@ -1,27 +1,2 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../business/src/vocabulary.mjs';
// Adapt S1's validated loadBusiness + resolveInstance outputs, without loading or writing files.
export function busBusiness(business, resolved) {
if (!business || typeof business.id !== 'string' || !business.roles || !resolved)
throw new BusError('invalid-business');
const out = structuredClone(business);
for (const role of Object.keys(out.roles)) {
const r = resolved[role];
if (
!r ||
r.business !== business.id ||
r.instance !== role ||
r.definition !== out.roles[role].definition ||
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
const hostKeys = Object.entries(business.roles[role].credentials ?? {}).filter(([kind]) => isHostKind(kind));
out.roles[role].credentials = { ...structuredClone(r.credentials ?? {}), ...structuredClone(Object.fromEntries(hostKeys)) };
}
return out;
}
// Old path of organization-context.mjs (rename design r2, stage B): busBusiness is busOrganization.
export { busOrganization as busBusiness } from './organization-context.mjs';
+1 -1
View File
@@ -41,7 +41,7 @@ export class Client {
const r = JSON.parse(input.toString('utf8'));
if (r.ok === true) end(null, r.result);
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
// Preserve a bounded reason when an explicitly scoped refusal has one.
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
else end(new BusError('invalid-response'));
} catch {
+233
View File
@@ -0,0 +1,233 @@
// #1873 CB2 (design section 5 steps 4 to 6): credentials.issue for an agent copy's bound service.
// Gitea is the only service with a verifier in this slice. A copy token is accepted only after the
// Gitea server it belongs to proves the whole grant live: the token's scopes, the account it belongs
// to (the copy's own dedicated account, never an administrator or another copy's or agent's), every
// repository that account can reach and the token's expiry, against the owner-approved binding. A
// Vikunja binding refuses credential-scope-unverified: that is the shipped behaviour.
//
// Order (no lock is held across the remote work):
// 1. checks that need nothing written: owner, service, date, token form, definition scan, the
// record and the boot snapshot, the bound projects' remotes and the approved scopes;
// 2. stage: the writer deactivates the record (pending, the service's grant fields gone), then the
// token goes to the copy's slot in the self-only store;
// 3. the slot is read back by the identity the stage wrote, and Gitea is asked;
// 4. commit: the writer rechecks registry incarnation, document revision, binding generation,
// copyId, decision and definition pin under its locks; its beforeWrite reads the slot back by
// identity again and runs the caller's session recheck. Only then do the grant metadata and the
// receipt land.
// Any refusal after the stage removes the slot it wrote and leaves the record pending. A lost reply
// or a crash leaves the record pending too; nothing resolves a pending record, and recovery is a
// new verified issue. A token a crash left staged goes at the next start (sweepCopySlots). Nothing here logs, and no result, receipt or refusal holds the token, the
// slot's path, a request or anything derived from the token.
import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { BusError } from './broker.mjs';
import { Credentials, assertNotHeld, day, privateDir } from './credentials.mjs';
import { send, versionAtLeast, REPO_PAGE, REPO_PAGES } from './gitea-grant.mjs';
import { scanOrganizations } from './definition-scan.mjs';
import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
import { copyBotLogin, scopesWithin } from '../../organization/src/copy-bindings.mjs';
const deny = (code, reason) => {
throw new BusError(code, reason);
};
const unverified = (reason) => deny('credential-scope-unverified', reason);
const DAY_MS = 86400000;
// A copy credential's rotation date is after today and at most this many days ahead.
export const MAX_ROTATE_DAYS = 366;
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const positive = (n) => Number.isSafeInteger(n) && n > 0;
const lower = (s) => (typeof s === 'string' ? s.toLowerCase() : null);
// The scopes a Gitea token reports, as {category: level}. Gitea writes write:X as implying read:X and
// may list both; the higher level wins. "all", any admin category, a category Gitea added that this
// bus doesn't know, or anything that doesn't parse returns null: such a token is broader than any
// binding can approve.
export function tokenLevels(scopes) {
if (!Array.isArray(scopes) || scopes.length === 0) return null;
const out = {};
for (const scope of scopes) {
const m = typeof scope === 'string' ? /^(read|write):([a-z]+)$/.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) return null;
if (out[m[2]] !== 'write') out[m[2]] = m[1];
}
return out;
}
const asList = (levels) => Object.entries(levels).map(([c, l]) => `${l}:${c}`).sort();
// The approved Gitea repositories of a copy's binding: one https origin, lower-cased "owner/repo"
// names, from each bound project's remote. A project with no Gitea remote, two remotes that disagree
// or two servers cannot be proved.
export function boundRepositories(doc, projects) {
let origin = null;
const repos = new Set();
for (const project of projects ?? []) {
const entry = doc.projects?.[project];
if (!object(entry)) unverified('remote');
const remotes = [entry.remote, entry.git?.mode === 'remote' ? entry.git.remote : undefined].filter((r) => r !== undefined);
if (remotes.length === 0 || remotes.some((r) => r !== remotes[0])) unverified('remote');
const parsed = parseGiteaUrl(remotes[0], { repo: true });
if (!parsed.url) unverified('remote');
if (origin !== null && parsed.url.origin !== origin) unverified('remote');
origin = parsed.url.origin;
repos.add(`${parsed.owner}/${parsed.repo}`.toLowerCase());
}
if (origin === null) unverified('remote');
return { origin, repos };
}
// Live introspection of one Gitea token against an approved binding. Returns { userId, tokenId,
// login } or throws the refusal. `approved` is the binding's Gitea scopes; `repos` the lower-cased
// names it may reach; `login` the account this copy's grant must belong to; `rotateBy` the date the
// owner gave. Every call is GET with the token; a missing or ambiguous answer is unverified.
export async function verifyGiteaToken({ transport = send, url, ca, timeoutMs, token, approved, repos, login, rotateBy }) {
const call = (path) => transport({ url, method: 'GET', path, auth: { token }, ca, timeoutMs });
const version = await call('/api/v1/version');
if (version.status !== 200 || !object(version.json) || !versionAtLeast(version.json.version)) unverified('version');
const info = await call('/api/v1/token');
if (info.status === 401 || info.status === 403 || info.status === 404) unverified('token');
if (info.status !== 200 || !object(info.json) || !positive(info.json.id) || !object(info.json.user) || !positive(info.json.user.id)
|| typeof info.json.user.login !== 'string') unverified('token');
if (lower(info.json.user.login) !== lower(login)) deny('credential-identity-refused', login);
const levels = tokenLevels(info.json.scopes);
if (!levels || !scopesWithin('gitea', asList(levels), approved)) deny('credential-scope-exceeded');
if (!levels.user) unverified('scopes');
// An expiry Gitea reports must not come before the owner's rotation date ends.
if (info.json.expires_at !== undefined && info.json.expires_at !== null) {
const at = typeof info.json.expires_at === 'string' ? Date.parse(info.json.expires_at) : NaN;
if (Number.isNaN(at)) unverified('token');
if (at < Date.parse(rotateBy + 'T00:00:00Z') + DAY_MS) deny('credential-date');
}
const user = await call('/api/v1/user');
if (user.status !== 200 || !object(user.json) || typeof user.json.is_admin !== 'boolean') unverified('user');
if (user.json.id !== info.json.user.id || lower(user.json.login) !== lower(login) || user.json.is_admin !== false)
deny('credential-identity-refused', login);
// Every repository the account can reach: owned, collaborator and team access. One unrelated
// repository, an admin permission, or push where the binding approved read refuses. Push is the
// account's right, so it is held to what the binding approved, not to the token's own level.
const write = tokenLevels(approved)?.repository === 'write';
const seen = new Map();
let total = null;
for (let page = 1; ; page++) {
if (page > REPO_PAGES) unverified('repositories');
const r = await call(`/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`);
if (r.status !== 200 || !Array.isArray(r.json) || r.total === null || (total !== null && r.total !== total)) unverified('repositories');
total = r.total;
if (total > REPO_PAGE * REPO_PAGES) unverified('repositories');
for (const repo of r.json) {
if (!object(repo) || typeof repo.full_name !== 'string' || !object(repo.permissions)) unverified('repositories');
const name = repo.full_name.toLowerCase();
if (!repos.has(name)) deny('credential-scope-exceeded');
const p = repo.permissions;
if (p.admin !== false || typeof p.push !== 'boolean' || p.pull !== true || (p.push && !write)) deny('credential-scope-exceeded');
seen.set(name, true);
}
if (seen.size >= total || r.json.length === 0) break;
}
if (seen.size !== total) unverified('repositories');
for (const name of repos) if (!seen.has(name)) unverified('repositories');
// An organization scope reaches organizations' own settings: the account must belong to none.
if (levels.organization) {
const orgs = await call(`/api/v1/user/orgs?limit=${REPO_PAGE}&page=1`);
if (orgs.status !== 200 || !Array.isArray(orgs.json)) unverified('organizations');
if (orgs.json.length) deny('credential-scope-exceeded');
}
return { userId: info.json.user.id, tokenId: info.json.id, login };
}
// The issue for one copy's service. `copies` is the bus's boot snapshot ({ org, copies,
// definitions }); `writer` the organization writer; `credentials` the bus's store. `hooks` are the
// tests' crash points (afterStage, afterRemote, beforeCommit), never set at runtime.
export function copyGrant({ credentials, writer, copies = null, organizations = null, transport = send, ca = undefined, timeoutMs = undefined, now = () => new Date(), hooks = {} }) {
return async (business, args, { actor, owner = false, recheck = () => {} } = {}) => {
const { agent: instance, service, token, date } = args;
if (owner !== true || actor === 'tester') deny('human-required');
if (service === 'vikunja') unverified('service');
if (service !== 'gitea') deny('credential-unknown');
const today = now().toISOString().slice(0, 10);
if (!day(date) || date <= today || Date.parse(date) - Date.parse(today) > MAX_ROTATE_DAYS * DAY_MS) deny('credential-date');
const value = Credentials.checkFormat(service, token);
const { document: doc, revision } = writer.read(business);
scanOrganizations({ organizations: organizations?.() ?? [business], own: { id: business, document: doc }, readRaw: (b) => writer.readRaw(b),
boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null }, (v) => assertNotHeld(v, [value]));
const record = doc.copyBindings?.[instance];
if (!object(record) || record.state === 'unbound' || !object(record.services?.[service])) deny('credential-unbound');
const copy = copies?.org === business && Object.hasOwn(copies.copies ?? {}, instance) ? copies.copies[instance] : null;
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
if (copy.level !== record.source?.level || copy.name !== record.source?.name || (copy.project ?? null) !== (record.source?.project ?? null)
|| copy.definition !== record.definition?.ref) deny('credential-binding-changed');
const approved = record.services[service];
if (!Array.isArray(approved.scopes) || !approved.scopes.some((s) => /^(read|write):user$/.test(s))) unverified('scopes');
const { origin, repos } = boundRepositories(doc, approved.resources?.projects);
const login = copyBotLogin(record.orgIncarnation, record.copyId, service);
if (!login) deny('credential-binding-changed');
const op = { instance, service, copyId: record.copyId, generation: record.generation, decision: record.decision, incarnation: record.orgIncarnation };
const definitions = copies?.org === business ? copies.definitions ?? {} : {};
const staged = writer.stageCopyGrant(business, op, { revision, actor, owner, definitions });
const slot = business + '/' + instance;
let identity = null;
try {
identity = credentials.stageCopy(slot, service, value.token);
await hooks.afterStage?.();
// An inode number can be reused after an unlink, so the read-back must also be the staged token.
const read = credentials.readCopy(slot, service, identity);
if (read.token !== value.token) deny('credential-file');
const grant = await verifyGiteaToken({ transport, url: origin, ca, timeoutMs, token: read.token, approved: approved.scopes, repos, login, rotateBy: date });
await hooks.afterRemote?.();
await recheck();
const grantId = `gitea:${grant.userId}:${grant.tokenId}`;
const after = { ...op, generation: staged.generation };
const committed = writer.commitCopyGrant(business, after, {
grantId, rotateBy: date, revision: staged.revision, actor, owner, definitions,
beforeWrite: () => {
hooks.beforeCommit?.();
const again = credentials.readCopy(slot, service, identity);
if (again.token !== read.token) deny('credential-file');
recheck();
},
});
return { agent: instance, service, verb: 'issue', date, state: committed.state, account: login, generation: committed.generation };
} catch (e) {
if (identity) {
try {
credentials.unlinkCopy(slot, service, identity);
} catch {}
}
throw e;
}
};
}
// Security review P2: at start, before Credentials loads and before any issue can run, each staged
// file of a copy's service whose binding holds no grant for that service goes, and so does any
// interrupted write's temp file. An issue killed after its stage leaves the record pending and the
// token on disk; nothing resolves a pending record and no reference loads a copy slot, so the file
// only keeps a secret nothing will use. `documents` is [business, validated organization document]
// for each loaded business (null when unreadable: nothing of it is touched). Only names the document
// binds as copies are looked at, never a name its own roles hold or a slot a loaded reference names.
// A store dir that isn't private refuses credential-file, as Credentials would. Returns
// [{ instance, service }], never a path.
export function sweepCopySlots(storeRoot, documents, references = {}) {
const removed = [];
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
for (const [business, doc] of documents) {
if (!object(doc) || !object(doc.copyBindings) || !ID_PATTERN.test(business)) continue;
const dir = join(storeRoot, business);
if (!privateDir(dir, { missing: true })) continue;
for (const [copy, record] of Object.entries(doc.copyBindings)) {
if (!ID_PATTERN.test(copy) || Object.hasOwn(doc.roles ?? {}, copy)) continue;
const copyDir = join(dir, copy);
if (!privateDir(copyDir, { missing: true })) continue;
const instance = business + '/' + copy;
for (const name of readdirSync(copyDir)) {
const service = name.endsWith('.tmp') ? name.slice(0, -4) : name;
if (!SERVICES.includes(service) || references[instance]?.[service] !== undefined) continue;
if (name === service && typeof record?.services?.[service]?.grantId === 'string') continue;
if (lstatSync(join(copyDir, name)).isDirectory()) continue;
unlinkSync(join(copyDir, name));
removed.push({ instance, service });
}
}
}
return removed;
}
+13 -4
View File
@@ -1,5 +1,6 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../business/src/vocabulary.mjs';
import { isHostKind } from '../../organization/src/vocabulary.mjs';
import { scanOrganizations } from './definition-scan.mjs';
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
@@ -10,7 +11,10 @@ const dateKey = (service) => (isHostKind(service) ? 'rotateBy' : DATE_KEY[servic
const deny = (code) => {
throw new BusError(code);
};
export function credentialVerbs({ credentials, writer }) {
// `organizations` lists every business this bus serves; issue checks each one's definitions. Without
// it, only the issuing business's. `boot` is the bus's boot copy snapshot's definitions, { org,
// definitions }, checked as well.
export function credentialVerbs({ credentials, writer, organizations = null, boot = null }) {
const state = (instance, service) =>
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
return (id, verb, args, actor) => {
@@ -27,8 +31,13 @@ export function credentialVerbs({ credentials, writer }) {
const next = structuredClone(document);
next.roles[agent].credentials[service] = { store: true, [dateKey(service)]: date };
const detail = { verb: kind, agent, kind: [service], date };
const status = credentials.issue(instance, service, token, date, () =>
writer.write(id, next, { revision, actor, detail }),
// org-defscan (#1873): the value must not already sit in any organization's definitions. The
// check runs inside issue, before the store, the file or memory changes, on this bus loop, so
// no launch admission can run between the check and the value becoming held.
const status = credentials.issue(
instance, service, token, date,
() => writer.write(id, next, { revision, actor, detail }),
(clean) => scanOrganizations({ organizations: organizations?.() ?? [id], own: { id, document }, readRaw: (b) => writer.readRaw(b), boot }, clean),
);
// Row 210 (design D9): a host key's public line and fingerprint, for ops-01's authorized_keys.
const host = status.fingerprint ? { publicKey: status.publicKey, fingerprint: status.fingerprint } : {};
+121 -26
View File
@@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { BusError } from './broker.mjs';
import { credentialKind, isHostKind, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { credentialKind, isHostKind, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
@@ -87,6 +87,19 @@ export function parseHostKey(text) {
}
return { text: lines.join('\n') + '\n', publicLine, fingerprint, seed, needles };
}
// Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token
// or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is
// about to hold.
export function assertNotHeld(value, entries) {
let encoded;
try {
encoded = JSON.stringify(value);
} catch {
deny('invalid-request');
}
for (const { token, model } of entries)
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
}
// D8: refuses credential-leak when the value's JSON text holds any part of the key.
export function assertCleanKey(value, key) {
let encoded;
@@ -424,7 +437,7 @@ export class Credentials {
if (service === 'model') {
let m;
try { m = JSON.parse(token); } catch { deny('credential-format'); }
const keyOk = k => typeof k === 'string' && /^msk-[a-z0-9]+-[A-Za-z0-9_-]{43}$/.test(k);
const keyOk = k => typeof k === 'string' && /^(?:msk-[a-z0-9]+|mso-[a-f0-9]{12}-[a-z0-9][a-z0-9._-]{0,63}-key)-[A-Za-z0-9_-]{43}$/.test(k);
const keyId = k => createHash('sha256').update(k).digest('hex').slice(0, 12);
const instant = s => typeof s === 'string' && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s;
// Row 333 R3 (#1831): `previous` lists the agent's earlier key ids, ids only, oldest first, at
@@ -458,11 +471,26 @@ export class Credentials {
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
// `commit` (the business file's reference becomes store), then memory. The token comes from the
// caller's stdin and is never logged. Returns the entry's new status.
issue(instance, service, token, date, commit = () => {}) {
// org-defscan (#1873): `check` runs before anything changes. It gets an assertClean for this value
// alone, the test assertClean makes once the value is held; a throw leaves the store as it was.
issue(instance, service, token, date, commit = () => {}, check = () => {}) {
this.#known(instance, service);
if (!day(date)) deny('credential-date');
const value = Credentials.#format(service, token);
check((v) => assertNotHeld(v, [value]));
this.#invalidateModelProof(instance, service);
this.#writeSlot(instance, service, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
const file = this.#storePath(instance, service);
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { ...value, date });
this.#revoked.delete(instance + ':' + service);
this.#held.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// The store slot write issue and stageCopy share: tmp (O_EXCL, O_NOFOLLOW, 0600), fsync, rename
// over the slot, fsync the dir. Returns the identity of the file it wrote.
#writeSlot(instance, service, text, { undo = false } = {}) {
const file = this.#storePath(instance, service);
const agentDir = dirname(file);
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
@@ -477,27 +505,101 @@ export class Credentials {
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
let fd;
// A write that fails leaves no token behind: the temp file this call made goes, and with `undo`
// (stageCopy) so does the slot it renamed into place.
let fd, identity;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
fsyncSync(fd);
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
const st = fstatSync(fd);
identity = { dev: st.dev, ino: st.ino };
writeSync(fd, text);
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
} catch (e) {
if (identity) {
try {
const st = lstatSync(tmp);
if (st.isFile() && st.dev === identity.dev && st.ino === identity.ino) unlinkSync(tmp);
} catch {}
}
throw e;
}
try {
const dirFd = openSync(agentDir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
} catch (e) {
if (undo) {
try {
this.unlinkCopy(instance, service, identity);
} catch {}
}
throw e;
}
return identity;
}
// #1873 CB2 (design section 5 step 4): a copy's service slot, <store>/<business>/<copy>/<service>.
// The bus stages it while the copy's binding is inactive, and afterwards reads back or removes only
// the file with the identity (dev, ino) it wrote. Memory and references never hold a copy's token
// in this slice; launch admission (CB3) reads the slot. A name a loaded reference uses refuses.
static checkFormat(service, token) {
return Credentials.#format(service, token);
}
stageCopy(instance, service, token) {
if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) deny('credential-reference');
const value = Credentials.#format(service, token);
return this.#writeSlot(instance, service, value.token + '\n', { undo: true });
}
// The staged token, read back with the store's checks plus one link only and the exact identity the
// stage wrote: a symlink, a hard link, a replaced or a stale file refuses credential-file.
readCopy(instance, service, identity) {
const file = this.#storePath(instance, service);
privateDir(this.#storeRoot);
privateDir(dirname(dirname(file)));
privateDir(dirname(file));
const same = (st) => st.dev === identity?.dev && st.ino === identity?.ino;
let original;
try {
original = lstatSync(file);
} catch {
deny('credential-file');
}
if (original.isSymbolicLink() || !original.isFile() || original.nlink !== 1 || !same(original)) deny('credential-file');
let fd, text;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384 || st.nlink !== 1 || !same(st))
deny('credential-file');
text = readFileSync(fd, 'utf8');
} catch (e) {
if (e instanceof BusError) throw e;
deny('credential-file');
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(agentDir, constants.O_RDONLY);
return Credentials.#format(service, text);
}
// Removes the slot only while it is still the file the stage wrote; another file is left alone.
unlinkCopy(instance, service, identity) {
const file = this.#storePath(instance, service);
let st;
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
st = lstatSync(file);
} catch (e) {
if (e.code === 'ENOENT') return false;
deny('credential-file');
}
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { ...value, date });
this.#revoked.delete(instance + ':' + service);
this.#held.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
if (!st.isFile() || st.dev !== identity?.dev || st.ino !== identity?.ino) return false;
unlinkSync(file);
return true;
}
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
@@ -600,14 +702,7 @@ export class Credentials {
return this.use(session.business + '/' + session.role, service, operation, options);
}
assertClean(value) {
let encoded;
try {
encoded = JSON.stringify(value);
} catch {
deny('invalid-request');
}
for (const { token, model } of this.#tokens.values())
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
assertNotHeld(value, this.#tokens.values());
}
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
busy(instance, service) {
+114
View File
@@ -0,0 +1,114 @@
// #1873 T1 follow-up (org-defscan): the live credential store's held-secret check on an org
// definition's text. A definition is role text written into the organization file; launch hands its
// instructions to an agent as the contract. A file edited by hand, or while the bus was down, never
// went through the writer's check, so bus start and every launch run this one.
//
// The answer names where a held credential sits, never the text there. A key that itself holds a
// credential is named by its parent ("personality key"). Not imported by broker.mjs (the sandbox
// loads that file); runtime.mjs and credential-verbs.mjs are its callers.
import { BusError } from './broker.mjs';
export const DEFINITION_HOLDS_CREDENTIAL = 'definition-holds-credential';
// An organization file credentials.issue could not read to check: the issue refuses.
export const DEFINITION_SCAN_UNREADABLE = 'definition-scan-unreadable';
// A definition name the scan quotes: the organization loader's id pattern. Anything else is "*".
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
// A path segment the scan quotes; anything else becomes "*" (the hit is still reported).
const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_DEPTH = 32;
const isClean = (assertClean, value) => {
try {
assertClean(value);
return true;
} catch {
return false;
}
};
// The first place in `entry` whose text the store holds, as a path ("instructions",
// "personality.traits[1]", "role key"), or null when the entry is clean. A hit the walk can't place
// (a value JSON.stringify sees and the walk doesn't) is "(entry)": the check fails closed.
export function heldPath(entry, assertClean) {
if (isClean(assertClean, entry)) return null;
const join = (path, key) => {
const segment = SEGMENT.test(key) && isClean(assertClean, key) ? key : '*';
return path ? `${path}.${segment}` : segment;
};
const walk = (value, path, depth) => {
if (typeof value === 'string') return isClean(assertClean, value) ? null : path || '(entry)';
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return null;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const hit = walk(value[i], `${path}[${i}]`, depth + 1);
if (hit) return hit;
}
return null;
}
for (const [key, child] of Object.entries(value)) {
if (!isClean(assertClean, key)) return path ? `${path} key` : 'key';
const hit = walk(child, join(path, key), depth + 1);
if (hit) return hit;
}
return null;
};
return walk(entry, '', 0) ?? '(entry)';
}
// The refusal for one definition. `name` is the definition's name without "org:". At issue time
// (`organization` given) it names the organization too, and the value is the one being issued.
export function definitionRefusal(name, path, { organization, boot = false } = {}) {
const quoted = typeof name === 'string' && NAME.test(name) ? name : '*';
const where = `${boot ? 'boot copy ' : ''}${name === null ? 'definitions' : `definitions.${quoted} ${path}`}`;
if (organization === undefined) return new BusError(DEFINITION_HOLDS_CREDENTIAL, `${where} holds a credential this bus holds`);
const org = typeof organization === 'string' && NAME.test(organization) ? organization : '*';
return new BusError(DEFINITION_HOLDS_CREDENTIAL, `organization ${org} ${where} holds the credential being issued`);
}
// The first entry of a `definitions` map that holds a credential, as { name, path }, or null.
// `prefix` is stripped from each name ("org:" for a business's resolved definitions). A map that is
// not an object is { name: null } when its text holds one.
export function heldDefinition(definitions, assertClean, { prefix = '' } = {}) {
if (definitions === undefined) return null; // an organization with no definitions key
if (definitions === null || typeof definitions !== 'object' || Array.isArray(definitions))
return isClean(assertClean, definitions) ? null : { name: null, path: null };
for (const [key, entry] of Object.entries(definitions)) {
if (prefix && !key.startsWith(prefix)) continue;
if (!isClean(assertClean, key)) return { name: '*', path: 'key' };
const path = heldPath(entry, assertClean);
if (path !== null) return { name: key.slice(prefix.length), path };
}
return null;
}
// Every entry of a `definitions` map; throws the refusal for the first that holds a credential.
export function scanDefinitions(definitions, assertClean, options = {}) {
const hit = heldDefinition(definitions, assertClean, options);
if (hit) throw definitionRefusal(hit.name, hit.path, options);
}
// org-defscan (#1873, Lead ruling on the re-check P2): credentials.issue and rotate refuse a value
// that already sits in any organization's definitions, so no definition text becomes a held value
// after its launch checks passed. `organizations` is every business this bus serves; `own` is the
// issuing one's document, already read. Any other file is read raw, as bus start reads it, so a
// definition no instance names is covered; a file that can't be read refuses. `boot` is the bus's boot
// copy snapshot ({ org, definitions }, #1873 A3/A4): the root agents file's definitions merged with the
// organization's at boot. Every reload re-applies it, so its text is live for the bus's lifetime.
export function scanOrganizations({ organizations, own, readRaw, boot = null }, assertClean) {
for (const organization of organizations) {
let definitions;
if (organization === own.id) definitions = own.document.definitions;
else {
let raw;
try {
raw = readRaw(organization);
} catch {
raw = { readable: false };
}
if (raw?.readable !== true) throw new BusError(DEFINITION_SCAN_UNREADABLE);
definitions = raw.document.definitions;
}
scanDefinitions(definitions, assertClean, { organization });
}
if (boot?.definitions !== undefined) scanDefinitions(boot.definitions, assertClean, { organization: boot.org, boot: true });
}
+2 -2
View File
@@ -17,8 +17,8 @@ import { join } from 'node:path';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { day, privateDir } from './credentials.mjs';
import { definitionSha256, grantTag } from './host-grant.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl, CREATIVE_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl, CREATIVE_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
+3 -3
View File
@@ -17,9 +17,9 @@ import { dirname, join } from 'node:path';
import { BusError } from './broker.mjs';
import { day, parseHostKey, privateDir } from './credentials.mjs';
import { runHost, HOST_LIMITS } from './host-run.mjs';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../business/src/hosts.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../organization/src/hosts.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
+1
View File
@@ -2,5 +2,6 @@ export { Broker, BusError, ACTIONS, TASK_VERBS } from './broker.mjs';
export { startBroker } from './runtime.mjs';
export { Client } from './client.mjs';
export { views } from './views.mjs';
export { busOrganization } from './organization-context.mjs';
export { busBusiness } from './business.mjs';
export { parseAddress } from './address.mjs';
+12 -1
View File
@@ -1,3 +1,6 @@
import { decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';
import { autonomyEvent } from './autonomy-state.mjs';
import { decisionResolution } from './autonomy-state.mjs';
// The checks a landing makes on its merge decision and the task's review
// (row 137, #1631). Two callers: scripts/land-work/read.mjs, which lands on
// canonical refactor from a read-only copy of the bus, and the broker's
@@ -96,7 +99,8 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
const raise = json(raises[0].body) ?? {};
if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
const closes = queries.closes(q, id);
const resolved=decisionResolution(q,b,id);
const closes=resolved?[resolved]:[];
if (closes.length !== 1 || closes[0].op !== 'resolved')
refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
const res = closes[0];
@@ -112,6 +116,13 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
// A cross-role decision routed to an agent closes only by decision.resolve, which takes it from
// the routed role alone (s.role === route_to), so res.by here is the arbiter's run.
const arbitrated = arbiter && d.class === 'cross-role' && d.route_to !== 'human' && res.via === 'broker' && !answers.length;
if(arbitrated && !business.roles?.[d.route_to]?.authority?.withinRole?.includes('decision.resolve.technical'))refuse('not-resolver','The technical resolver lost decision.resolve.technical; resolve afresh under current policy.');
if(arbitrated) {
if(d.route_to===d.raised_by_role || resolverSubject(business,d.route_to,raise.target))refuse('not-resolver','A person decides a resolver subject.');
const confirmed=autonomyEvent(q,b,id,['autonomy.confirmed']);
if((decisionAutonomy('merge',business.autonomy)==='in the loop' || raise.autonomyLevel==='in the loop') && (!confirmed || confirmed.body.resolution.at!==res.at || confirmed.body.resolution.by!==res.by))refuse('confirmation-required','A person must confirm the arbiter resolution.');
if(autonomyEvent(q,b,id,['autonomy.reverse.consume']))refuse('decision-reversed','This decision was reversed.');
}
if (!human && !arbitrated)
refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
const options = json(d.options) ?? [];
+2 -2
View File
@@ -67,7 +67,7 @@ class Failure extends Error {
// lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows
// the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a
// daily key quota is set and a check is switched on. Without keyLineages they refuse.
export function createLimits({ store, read, log, makeLoader = createLoader, keyForSubject = () => null,
export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null,
keyLineages = () => { throw new Failure('proxy-key-refused'); } }) {
if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log');
const loaders = new Map();
@@ -103,7 +103,7 @@ export function createLimits({ store, read, log, makeLoader = createLoader, keyF
return validLineages(value) ? value : null;
}
const loaderFor = business => {
if (!loaders.has(business)) loaders.set(business, makeLoader({ log }));
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
return loaders.get(business);
};
+21
View File
@@ -0,0 +1,21 @@
-- #797 MVP3: the owner's reversible project archive. A project's state is the op of its latest
-- row, and the rows are the audit trail (who archived or restored it, and when). The projects
-- table itself stays as written; an archived project keeps every record and is never deleted.
CREATE TABLE project_archives (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
op TEXT NOT NULL CHECK (op IN ('archive','unarchive')),
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_archives_project ON project_archives (business, project, seq);
CREATE TRIGGER project_archives_no_update BEFORE UPDATE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_delete BEFORE DELETE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_no_replace BEFORE INSERT ON project_archives WHEN EXISTS (SELECT 1 FROM project_archives WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END;
CREATE TRIGGER project_archives_business BEFORE INSERT ON project_archives
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'an archive row belongs to its project''s business'); END;
CREATE TRIGGER project_archives_no_repeat BEFORE INSERT ON project_archives
WHEN (SELECT op FROM project_archives WHERE business = NEW.business AND project = NEW.project ORDER BY seq DESC LIMIT 1) = NEW.op
BEGIN SELECT RAISE(ABORT, 'a project archive row changes state'); END;
+16
View File
@@ -0,0 +1,16 @@
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
+41
View File
@@ -0,0 +1,41 @@
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+71
View File
@@ -0,0 +1,71 @@
// In-memory only; a new loader is made for every refresh so disabled or refused
// modules can never inherit a loader's last-good authority.
import { createLoader } from '../../plugins/src/loader.mjs';
const validId = s => typeof s === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(s);
const validModel = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
const disabled = () => ({ status: 'disabled', observedAt: null, models: [], notices: [] });
export function createModelCatalogue({ client, settings, pluginDataRoot = null, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
const cache = new Map(), flights = new Map(); let generation = 0, writing = 0;
function invalidate() { generation++; cache.clear(); }
function begin() { writing++; invalidate(); }
function end() { writing = Math.max(0, writing - 1); invalidate(); }
function off(id) {
const names = settings(id)?.['model-provider'];
if (names === undefined || (Array.isArray(names) && names.length === 0)) return true;
if (!Array.isArray(names) || names.length > 32 || names.some(n => !validId(n)) || new Set(names).size !== names.length) throw Error();
return false;
}
async function refresh(id, previous) {
try {
if (off(id)) return disabled();
const names = [...settings(id)['model-provider']];
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
const result = await loader.load({ 'model-provider': names });
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
// A module that needs no model proxy (fixture) answers without one; a module
// that asks the proxy while none is configured gets a refusal and reads unavailable.
const c = client();
const proxy = method => async (...args) => { if (!c) throw Error('model proxy not configured'); return c[method](...args); };
const answers = await Promise.all(loader.snapshot('model-provider').map(async ({ name, module }) => {
const answer = await module.catalogue({ listAccounts: proxy('getCredentials'), listModels: proxy('getCredentialModels'), listGroups: proxy('getUpstreamGroups'), now: () => new Date(now()).toISOString() });
if (answer?.status === 'unavailable' && answer.reason === 'model-key-collision') throw Object.assign(Error(), { code: 'model-key-collision' });
if (answer?.status !== 'ready' || !Array.isArray(answer.models) || answer.models.length > 10000 || !Array.isArray(answer.notices) || answer.notices.length > 100) throw Error();
for (const m of answer.models) {
if (!validId(m.key) || m.module !== name || !validId(m.upstream) || !validId(m.provider) || !validModel(m.model) || typeof m.label !== 'string' || m.label.length > 300 || /[\x00-\x1f\x7f]/.test(m.label) || typeof m.available !== 'boolean' || ![null, 'unverified'].includes(m.reason) || !Array.isArray(m.harnesses) || m.harnesses.length > 32 || m.harnesses.some(h => !validId(h))) throw Error();
}
if (answer.notices.some(n => typeof n !== 'string' || n.length > 300 || /[\x00-\x1f\x7f]/.test(n))) throw Error();
return answer;
}));
const models = answers.flatMap(a => a.models), keys = new Set();
for (const m of models) { if (keys.has(m.key)) throw Object.assign(Error(), { code: 'model-key-collision' }); keys.add(m.key); }
return { status: 'ready', observedAt: new Date(now()).toISOString(), models, notices: [...new Set(answers.flatMap(a => a.notices))] };
} catch (error) {
return { status: 'unavailable', observedAt: previous?.observedAt ?? null, models: (previous?.models ?? []).map(m => ({ ...m, available: false, reason: 'proxy-down', harnesses: [] })), notices: [], ...(error.code === 'model-key-collision' ? { reason: error.code } : {}) };
}
}
async function read(id, { fresh = false } = {}) {
const unavailable = () => ({ status: 'unavailable', observedAt: null, models: [], notices: [] });
let sharedGeneration;
try { proxyLock?.assertCertain(); sharedGeneration = proxyLock?.generation(); } catch { return unavailable(); }
if (writing) return unavailable();
try { if (off(id)) { cache.delete(id); return disabled(); } } catch { return { status: 'unavailable', observedAt: null, models: [], notices: [] }; }
const existing = cache.get(id);
const fingerprint = JSON.stringify([settings(id)['model-provider'], sharedGeneration]);
if (!fresh && existing && existing.fingerprint === fingerprint && now() - existing.time < 30000) return structuredClone(existing.value);
const flightKey = `${id}:${fingerprint}:${generation}`;
let flight = flights.get(flightKey);
if (!flight || fresh) {
const at = generation;
flight = refresh(id, existing?.value).then(value => {
if (generation !== at || JSON.stringify([settings(id)?.['model-provider'], proxyLock?.generation()]) !== fingerprint) return null;
if (cache.size >= 128 && !cache.has(id)) cache.delete(cache.keys().next().value);
cache.set(id, { time: now(), fingerprint, value }); return value;
}).catch(() => null);
if (!fresh) { flights.set(flightKey, flight); flight.finally(() => { if (flights.get(flightKey) === flight) flights.delete(flightKey); }); }
}
const value = await flight;
try { proxyLock?.assertCertain(); if (proxyLock?.generation() !== sharedGeneration) return unavailable(); } catch { return unavailable(); }
return value ? structuredClone(value) : unavailable();
}
return { read, invalidate, begin, end };
}
+64 -27
View File
@@ -19,29 +19,39 @@ const withPrevious = (record, ids, retiredAt) => {
return previous.length ? { ...rest, previous } : rest;
};
export class ModelKeys {
#credentials; #client; #baseUrl; #prefix; #receipt; #now; #runs; #stopRuns; #grace;
#credentials; #client; #baseUrl; #prefix; #receipt; #lock; #org; #now; #runs; #stopRuns; #grace;
#tail = Promise.resolve(); #ready = false; #failure = 'proxy-down'; #confirmed = new Map();
constructor({ credentials, client, configDir, baseUrl, receipt = () => {}, now = () => Date.now(),
runs = () => [], stopRuns = async () => {}, rotateGraceMinutes = 120 }) {
runs = () => [], stopRuns = async () => {}, rotateGraceMinutes = 120, org = null, lock = null }) {
this.#credentials = credentials; this.#client = client; this.#baseUrl = baseUrl;
this.#prefix = `msk-${keyId(configDir)}-`; this.#receipt = receipt; this.#now = now;
if ((org === null) !== (lock === null) || (org !== null && !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(org))) fail('proxy-lock-invalid');
this.#org = org; this.#lock = lock;
this.#prefix = org === null ? `msk-${keyId(configDir)}-` : `mso-${keyId(configDir)}-${org}-`; this.#receipt = receipt; this.#now = now;
this.#runs = runs; this.#stopRuns = stopRuns; this.#grace = rotateGraceMinutes * 60_000;
}
#exclusive(operation) {
const result = this.#tail.then(operation);
const result = this.#tail.then(async () => {
try { return await (this.#lock ? this.#lock.run(operation) : operation()); }
catch (error) { if (error.code === 'proxy-uncertain') fail('proxy-uncertain'); throw error; }
});
this.#tail = result.catch(() => {});
return result;
}
#uncertain() { try { this.#lock.assertCertain(); return false; } catch { return true; } }
#ownKey(key) { return key.startsWith(this.#prefix + 'key-') && /^[A-Za-z0-9_-]{43}$/.test(key.slice((this.#prefix + 'key-').length)); }
#instances() { return this.#credentials.status().filter(s => s.service === 'model').map(s => s.instance); }
#record(instance) { return this.#credentials.modelRecord(instance); }
hasRecord(instance) { return this.#record(instance) !== null; }
assertLaunchable(instance) { this.#launchable(instance); }
#sentinel(keys) {
if (!keys.some(k => k.startsWith(this.#prefix + 'sentinel-') && /^[A-Za-z0-9_-]{43}$/.test(k.slice((this.#prefix + 'sentinel-').length)))) {
const matches = keys.filter(k => k.startsWith(this.#prefix + 'sentinel-') && /^[A-Za-z0-9_-]{43}$/.test(k.slice((this.#prefix + 'sentinel-').length)));
const registered = this.#org === null ? null : this.#lock.registration(this.#org);
if (!matches.length || (this.#org !== null && (matches.length !== 1 || (registered && createHash('sha256').update(matches[0]).digest('hex') !== registered)))) {
this.#ready = false; this.#failure = 'proxy-open'; fail('proxy-open');
}
}
#launchable(instance) {
try { this.#lock?.assertCertain(); } catch { fail('proxy-uncertain'); }
if (this.#record(instance)?.state === 'revoking') fail('proxy-key-revoking');
if (!this.#ready) fail(this.#failure);
}
@@ -58,11 +68,17 @@ export class ModelKeys {
}
async #replace(before, after) {
this.#sentinel(before); this.#sentinel(after);
if (this.#lock) {
const latest = await this.#client.getKeys();
if (!isDeepStrictEqual(latest, before)) fail('proxy-client-response');
this.#lock.fence({ org: this.#org, op: 'keys.put' });
}
await this.#client.putKeys(after);
const confirmed = await this.#client.getKeys();
this.#sentinel(confirmed);
const removed = before.filter(k => !after.includes(k));
if (after.some(k => !confirmed.includes(k)) || removed.some(k => confirmed.includes(k))) fail('proxy-client-response');
this.#lock?.confirm();
}
async #pollRevoked(keys) {
const deadline = performance.now() + REVOKE_TIMEOUT_MS;
@@ -88,7 +104,7 @@ export class ModelKeys {
}
#captureRevocation(instance) {
const record = this.#record(instance);
if (record?.state !== 'revoking' || [record.key, ...record.retiring.map(r => r.key)].some(key => !key.startsWith(this.#prefix))) fail('credential-revoke-failed');
if (record?.state !== 'revoking' || [record.key, ...record.retiring.map(r => r.key)].some(key => this.#org === null ? !key.startsWith(this.#prefix) : !this.#ownKey(key))) fail('credential-revoke-failed');
return { record, snapshot: this.#credentials.captureModelRevocation(instance) };
}
#confirmRevocation(instance, proof) {
@@ -101,15 +117,34 @@ export class ModelKeys {
return this.#exclusive(async () => {
this.#ready = false;
try {
const before = await this.#client.getKeys(); this.#sentinel(before);
let before = await this.#client.getKeys();
const sentinels = () => before.filter(k => k.startsWith(this.#prefix + 'sentinel-') && /^[A-Za-z0-9_-]{43}$/.test(k.slice((this.#prefix + 'sentinel-').length)));
const registered = this.#org === null ? null : this.#lock.registration(this.#org);
if (this.#org !== null && (sentinels().length > 1 || (registered && (sentinels().length !== 1 || createHash('sha256').update(sentinels()[0]).digest('hex') !== registered)))) fail('proxy-open');
if (this.#org !== null && sentinels().length === 0) {
const sentinel = this.#prefix + 'sentinel-' + randomBytes(32).toString('base64url');
const latest = await this.#client.getKeys();
if (!isDeepStrictEqual(latest, before)) fail('proxy-client-response');
this.#lock.fence({ org: this.#org, op: 'sentinel.register' });
await this.#client.putKeys([...before, sentinel]);
const confirmed = await this.#client.getKeys();
if (!confirmed.includes(sentinel) || before.some(k => !confirmed.includes(k))) fail('proxy-client-response');
this.#lock.register(this.#org, sentinel);
this.#lock.confirm();
this.#receipt({ action: 'proxy.sentinel.registered', org: this.#org });
before = confirmed;
}
this.#sentinel(before);
if (this.#org !== null && !registered) this.#lock.register(this.#org, sentinels()[0]);
const records = this.#instances().map(instance => ({ instance, record: this.#record(instance) })).filter(e => e.record);
const retained = records.filter(e => e.record.state !== 'revoking').flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);
if (this.#org !== null && retained.some(key => !this.#ownKey(key))) fail('proxy-open');
const revoking = records.filter(e => e.record.state === 'revoking');
const proofs = new Map(revoking.map(({ instance }) => { this.#confirmed.delete(instance); return [instance, this.#captureRevocation(instance)]; }));
const confirm = () => { for (const [instance, proof] of proofs) this.#confirmRevocation(instance, proof); };
confirm();
const intended = revoking.flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);
const removed = before.filter(k => k.startsWith(this.#prefix) && !k.startsWith(this.#prefix + 'sentinel-') && !retained.includes(k));
const removed = before.filter(k => (this.#org === null ? k.startsWith(this.#prefix) && !k.startsWith(this.#prefix + 'sentinel-') : this.#ownKey(k)) && !retained.includes(k));
const after = [...new Set([...before.filter(k => !removed.includes(k) && !intended.includes(k)), ...retained])];
const added = after.filter(k => !before.includes(k));
if (added.length || removed.length || intended.length) await this.#replace(before, after);
@@ -125,7 +160,7 @@ export class ModelKeys {
const receipt = { action: 'proxy.reconcile', addedKeyIds: added.map(keyId), removedKeyIds: [...new Set([...removed, ...intended])].map(keyId) };
this.#receipt(receipt); return receipt;
} catch (error) {
this.#failure = error.code === 'proxy-open' ? 'proxy-open' : 'proxy-down';
this.#failure = error.code === 'proxy-uncertain' || (this.#lock && this.#uncertain()) ? 'proxy-uncertain' : error.code === 'proxy-open' ? 'proxy-open' : 'proxy-down';
this.#receipt({ action: 'proxy.reconcile', code: this.#failure });
fail(this.#failure);
}
@@ -137,8 +172,9 @@ export class ModelKeys {
let before;
try { before = currentKeys ?? await this.#client.getKeys(); this.#sentinel(before); }
catch (error) { if (error.code === 'proxy-open') throw error; fail('proxy-key-issue-failed'); }
if (this.#org !== null && old && !this.#ownKey(old.key)) fail('proxy-open');
if (old && !rotate && before.includes(old.key)) return { baseUrl: this.#baseUrl, key: old.key };
const key = this.#prefix + randomBytes(32).toString('base64url');
const key = this.#prefix + (this.#org === null ? '' : 'key-') + randomBytes(32).toString('base64url');
const now = this.#now(), issuedAt = new Date(now).toISOString();
const date = new Date(now + 30 * 86400_000).toISOString().slice(0, 10);
const keep = rotate && before.includes(old?.key);
@@ -152,7 +188,8 @@ export class ModelKeys {
this.#receipt({ action: rotate ? 'proxy.key.rotated' : 'proxy.key.issued', keyIds: [keyId(key)], instance });
return { baseUrl: this.#baseUrl, key };
} catch (error) {
if (error.code === 'proxy-open') throw error;
if (error.code === 'proxy-open' || error.code === 'proxy-uncertain') throw error;
if (this.#lock && this.#uncertain()) fail('proxy-uncertain');
fail('proxy-key-issue-failed');
}
}
@@ -161,12 +198,12 @@ export class ModelKeys {
// Host-only callbacks are the existing synchronous organization path.
hire(instance, check, publish) {
return this.#exclusive(async () => {
check(); // Fresh validation and captured revision AFTER controller queue.
await check(); // Fresh validation and captured revision AFTER controller queue.
const existed = this.hasRecord(instance);
try {
await this.#issue(instance);
check(); // Recheck captured revision AFTER HTTP, before publication.
return publish();
await check(); // Recheck captured revision AFTER HTTP, before publication.
return await publish();
} catch (error) {
// A newly issued generation has not escaped to queued admission yet.
if (!existed && this.hasRecord(instance)) {
@@ -205,10 +242,11 @@ export class ModelKeys {
this.#credentials.prepareModelCompletion(instance, proof.snapshot);
this.#confirmed.set(instance, proof);
this.#receipt({ action: 'proxy.key.revoked', keyIds: [record.keyId, ...record.retiring.map(r => r.keyId)], remoteState: 'revoked', localState: 'kept' });
} catch { fail('credential-revoke-failed'); }
} catch (error) { if (error.code === 'proxy-uncertain' || (this.#lock && this.#uncertain())) fail('proxy-uncertain'); fail('credential-revoke-failed'); }
}
prepareRevoke(instance) { return this.#exclusive(() => this.#prepareRevoke(instance)); }
prepareRevoke(instance, beforeRevoke = () => {}) { return this.#exclusive(() => { beforeRevoke(); return this.#prepareRevoke(instance); }); }
completeRevoke(instance) {
try { this.#lock?.assertCertain(); } catch { fail('proxy-uncertain'); }
try {
const record = this.#record(instance), proof = this.#confirmed.get(instance);
if (!record && !proof) return; // Ordinary no-record/no-supplied-proof idempotence.
@@ -309,7 +347,7 @@ export function modelOrganization({ modelKeys, writer }) {
};
const wrap = invoke => (business, verb, args, actor, context) => {
const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
if (!isRevoke) return invoke(business, verb, args, actor, context);
return (async () => {
if (pending.has(business)) fail('organization-busy');
const agent = args.name ?? args.instance, instance = business + '/' + agent;
@@ -317,19 +355,11 @@ export function modelOrganization({ modelKeys, writer }) {
if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
const { revision } = writer.read(business);
if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
const checkHire = () => {
if (writer.read(business).revision !== revision) fail('revision-conflict');
const checked = invoke(business, 'instance.hireCheck', args, actor, context);
if (checked.revision !== revision) fail('revision-conflict');
return checked;
};
if (!isRevoke) checkHire(); // No registration or proxy I/O before shared checks.
const prepare = { instance, revision, remoteRevoked: false };
pending.set(business, prepare);
const existed = modelKeys.hasRecord(instance);
try {
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
else return await modelKeys.hire(instance, checkHire, () => invoke(business, verb, args, actor, context));
if (isRevoke) { await modelKeys.prepareRevoke(instance, () => context?.beforeWrite?.()); prepare.remoteRevoked = existed; }
return invoke(business, verb, args, actor, context);
} catch (error) {
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
@@ -337,5 +367,12 @@ export function modelOrganization({ modelKeys, writer }) {
} finally { pending.delete(business); }
})();
};
return { writer: adapter, revoke, wrap };
const hire = async (business, agent, check, publish) => {
if (pending.has(business)) fail('organization-busy');
const prepare = { instance: `${business}/${agent}`, revision: writer.read(business).revision, remoteRevoked: false };
pending.set(business, prepare);
try { return await modelKeys.hire(prepare.instance, check, publish); }
finally { pending.delete(business); }
};
return { writer: adapter, revoke, wrap, hire };
}
+30
View File
@@ -0,0 +1,30 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../organization/src/vocabulary.mjs';
// Adapt S1's validated loadOrganization (loadBusiness) + resolveInstance outputs, without loading
// or writing files. The result keeps the v1 business shape and error codes (rename design r2).
export function busOrganization(business, resolved) {
if (!business || typeof business.id !== 'string' || !business.roles || !resolved)
throw new BusError('invalid-business');
const out = structuredClone(business);
for (const role of Object.keys(out.roles)) {
const r = resolved[role];
if (
!r ||
r.business !== business.id ||
r.instance !== role ||
r.definition !== out.roles[role].definition ||
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].network = r.limits.network;
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].tools = [...(r.limits.tools ?? [])];
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
const hostKeys = Object.entries(business.roles[role].credentials ?? {}).filter(([kind]) => isHostKind(kind));
out.roles[role].credentials = { ...structuredClone(r.credentials ?? {}), ...structuredClone(Object.fromEntries(hostKeys)) };
}
return out;
}
+10 -2
View File
@@ -2,6 +2,7 @@
// never command arguments, stdout or service-token-bearing environment variables.
import { startBroker } from './runtime.mjs';
import { BusError } from './broker.mjs';
import { DEFINITION_HOLDS_CREDENTIAL } from './definition-scan.mjs';
const LAUNCH_OPS = new Set([
'identity',
'authorizeLaunch',
@@ -9,7 +10,9 @@ const LAUNCH_OPS = new Set([
'endLaunch',
'recoverLaunch',
'credentialStatus',
'definitionScan',
'modelPreflight',
'modelAdmission',
'modelStall',
'checkPush',
'recordPush',
@@ -87,8 +90,11 @@ function launchOp(m) {
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
if (m.op === 'request.chief') return runtime.requestChief(m.business);
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project });
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
// #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition.
if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry });
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
if (m.op === 'modelStall') return runtime.modelStall(m);
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
@@ -161,7 +167,9 @@ if (!process.send) {
}
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
} catch (e) {
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }), () =>
// org-defscan: a held credential in a definition says which definition and key, never the text.
const reason = e instanceof BusError && e.code === DEFINITION_HOLDS_CREDENTIAL && typeof e.reason === 'string' ? { reason: e.reason } : {};
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused', ...reason }), () =>
close(2),
);
}
+150
View File
@@ -0,0 +1,150 @@
// Instance-wide serialization for a single, verified proxy state directory.
import fs from 'node:fs';
import path from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
const fail = code => { throw Object.assign(new Error(code), { code }); };
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const syncDir = dir => { const fd = fs.openSync(dir, 'r'); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
export function createProxyLock({ stateDir, identity, waitMs = 5000 }) {
if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir) || typeof identity !== 'string' || !identity || !Number.isSafeInteger(waitMs) || waitMs < 0) fail('proxy-lock-invalid');
try {
const stat = fs.lstatSync(stateDir);
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(stateDir) !== path.resolve(stateDir) || stat.uid !== process.getuid() || (stat.mode & 0o077)) fail('proxy-lock-invalid');
} catch { fail('proxy-lock-invalid'); }
const lockFile = path.join(stateDir, 'mosaic-keys.lock');
const guard = path.join(stateDir, 'mosaic-keys.reap');
const fenceFile = path.join(stateDir, 'mosaic-keys.uncertain');
const identityFile = path.join(stateDir, 'mosaic-proxy.identity');
const digest = createHash('sha256').update(identity).digest('hex');
function readSafe(file) {
const stat = fs.lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.size > 4096) fail('proxy-lock-invalid');
return fs.readFileSync(file, 'utf8');
}
const identityTemp = path.join(stateDir, `.identity-${randomBytes(16).toString('hex')}`);
try {
const fd = fs.openSync(identityTemp, 'wx', 0o600);
try { fs.writeFileSync(fd, digest + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
try { fs.linkSync(identityTemp, identityFile); syncDir(stateDir); }
catch (error) { if (error.code !== 'EEXIST') throw error; }
} catch { fail('proxy-lock-invalid'); }
finally { try { fs.unlinkSync(identityTemp); } catch (error) { if (error.code !== 'ENOENT') fail('proxy-lock-invalid'); } }
try { if (readSafe(identityFile) !== digest + '\n') fail('proxy-lock-invalid'); } catch { fail('proxy-lock-invalid'); }
function assertCertain() {
try { fs.lstatSync(fenceFile); } catch (error) { if (error.code === 'ENOENT') return; fail('proxy-uncertain'); }
fail('proxy-uncertain');
}
function reap() {
// An orphaned reaper is deliberately not guessed away. It needs operator inspection.
try { fs.mkdirSync(guard, { mode: 0o700 }); } catch (error) { if (error.code === 'EEXIST') return false; fail('proxy-lock-uncertain'); }
try {
let before, owner;
try {
before = fs.lstatSync(lockFile);
owner = JSON.parse(readSafe(lockFile));
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || typeof owner.token !== 'string' || !/^[0-9a-f]{32}$/.test(owner.token)) fail('proxy-lock-uncertain');
} catch (error) { if (error.code === 'ENOENT') return true; fail('proxy-lock-uncertain'); }
try { process.kill(owner.pid, 0); return false; }
catch (error) { if (error.code !== 'ESRCH') fail('proxy-lock-uncertain'); }
const after = fs.lstatSync(lockFile);
if (before.dev !== after.dev || before.ino !== after.ino) fail('proxy-lock-uncertain');
fs.unlinkSync(lockFile); syncDir(stateDir); return true;
} finally { fs.rmdirSync(guard); }
}
const generationFile = path.join(stateDir, 'keys.generation');
const receiptFile = path.join(stateDir, 'mosaic-keys.receipts.jsonl');
function generation() {
let value;
try { value = readSafe(generationFile); } catch (error) { if (error.code === 'ENOENT') return 0; throw error; }
if (!/^(0|[1-9][0-9]*)\n$/.test(value) || !Number.isSafeInteger(Number(value))) fail('proxy-lock-invalid');
return Number(value);
}
function status() {
let value;
try { value = readSafe(fenceFile); } catch (error) { if (error.code === 'ENOENT') return null; fail('proxy-uncertain'); }
try { const entry = JSON.parse(value); return entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : { invalid: true }; }
catch { return { invalid: true }; }
}
function registration(org) {
const file = path.join(stateDir, `org-${createHash('sha256').update(org).digest('hex')}.json`);
try {
const entry = JSON.parse(readSafe(file));
if (entry.org !== org || typeof entry.sentinel !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sentinel)) fail('proxy-lock-invalid');
return entry.sentinel;
} catch (error) { if (error.code === 'ENOENT') return null; fail('proxy-lock-invalid'); }
}
function register(org, sentinel) {
const existing = registration(org), digest = createHash('sha256').update(sentinel).digest('hex');
if (existing !== null) { if (existing !== digest) fail('proxy-open'); return; }
const file = path.join(stateDir, `org-${createHash('sha256').update(org).digest('hex')}.json`);
const temp = path.join(stateDir, `.org-${randomBytes(16).toString('hex')}`);
const fd = fs.openSync(temp, 'wx', 0o600);
try { fs.writeFileSync(fd, JSON.stringify({ org, sentinel: digest, time: new Date().toISOString() }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
try { fs.linkSync(temp, file); syncDir(stateDir); } finally { fs.unlinkSync(temp); }
}
function fence({ org = null, op = 'write' } = {}) {
const fd = fs.openSync(fenceFile, 'wx', 0o600);
try { fs.writeFileSync(fd, JSON.stringify({ org, op, time: new Date().toISOString(), pid: process.pid }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
syncDir(stateDir);
}
function confirm() {
const value = generation() + 1;
if (!Number.isSafeInteger(value)) fail('proxy-lock-invalid');
const temp = path.join(stateDir, `.generation-${randomBytes(16).toString('hex')}`);
const fd = fs.openSync(temp, 'wx', 0o600);
try { fs.writeFileSync(fd, `${value}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
fs.renameSync(temp, generationFile); syncDir(stateDir);
fs.unlinkSync(fenceFile); syncDir(stateDir);
}
// `verify(previous)` (optional) re-proves a restart against the fence it would clear, inside
// the lock, and returns the evidence the receipt records.
async function clear({ restarted, read, verify }) {
if (restarted !== true) fail('proxy-restart-required');
return run(async () => {
const previous = status();
const evidence = verify ? await verify(previous) : undefined;
await read();
const fd = fs.openSync(receiptFile, 'a', 0o600);
try { fs.writeFileSync(fd, JSON.stringify({ op: 'fence.clear', time: new Date().toISOString(), previous, ...(evidence === undefined ? {} : { evidence }) }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
syncDir(stateDir);
if (previous) confirm();
return { cleared: Boolean(previous), ...(evidence === undefined ? {} : { evidence }) };
}, { allowUncertain: true });
}
async function run(operation, { allowUncertain = false } = {}) {
const deadline = Date.now() + waitMs, token = randomBytes(16).toString('hex');
let fd, stat;
for (;;) {
const temp = path.join(stateDir, `.lock-${randomBytes(16).toString('hex')}`);
let occupied = false;
try {
fd = fs.openSync(temp, 'wx', 0o600);
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }) + '\n'); fs.fsyncSync(fd);
try { fs.linkSync(temp, lockFile); } catch (error) { if (error.code !== 'EEXIST') throw error; occupied = true; }
fs.unlinkSync(temp);
if (!occupied) { stat = fs.fstatSync(fd); syncDir(stateDir); break; }
fs.closeSync(fd); fd = undefined;
} catch {
if (fd !== undefined) fs.closeSync(fd);
try { fs.unlinkSync(temp); } catch {}
fail('proxy-lock-uncertain');
}
if (reap()) continue;
if (Date.now() >= deadline) fail('proxy-lock-busy');
await pause(20);
}
try { if (!allowUncertain) assertCertain(); return await operation(); }
finally {
try {
const current = fs.lstatSync(lockFile);
if (current.dev !== stat.dev || current.ino !== stat.ino) fail('proxy-lock-uncertain');
const owner = JSON.parse(readSafe(lockFile));
if (owner.token !== token || owner.pid !== process.pid) fail('proxy-lock-uncertain');
fs.unlinkSync(lockFile); syncDir(stateDir);
} finally { fs.closeSync(fd); }
}
}
return { run, fence, assertCertain, confirm, clear, status, generation, registration, register };
}
+2 -2
View File
@@ -1,6 +1,6 @@
// Pure broker/runner parsing vocabulary. No host management or state imports.
export const PROXY_PAGE_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.keys']);
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys']);
export const PROXY_PAGE_CODES = Object.freeze(['not-configured', 'human-required', 'provider-not-allowed',
'login-state-unknown', 'callback-url-invalid', 'apikey-invalid', 'priority-invalid', 'priority-unconfirmed',
'priority-write-refused', 'proxy-down', 'invalid-request']);
'priority-write-refused', 'models-invalid', 'models-ambiguous', 'models-write-refused', 'models-unconfirmed', 'proxy-uncertain', 'proxy-down', 'invalid-request']);
+76 -15
View File
@@ -1,7 +1,9 @@
// P4: bus-only native Console operations. No upstream credential is persisted here.
import { randomBytes } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { sha256, refuse } from '../../model-proxy/src/common.mjs';
import { PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
import { ZAI_URL, MODEL_ID } from '../../plugins/model-provider/cliproxy/index.mjs';
export { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
const object = x => x !== null && typeof x === 'object' && !Array.isArray(x);
function fields(args, names) {
@@ -11,7 +13,6 @@ const priorityOk = n => Number.isInteger(n) && n >= -1000 && n <= 1000;
const identifier = x => typeof x === 'string' && x.length > 0 && x.length <= 512;
const stateOk = x => typeof x === 'string' && /^[a-zA-Z0-9_-]{1,512}$/.test(x);
const keyId = value => sha256(value).slice(0, 12);
const ZAI_URL = 'https://api.z.ai/api/anthropic';
const providerName = provider => ({ claude: 'Claude', codex: 'Codex', 'z.ai': 'z.ai', other: 'Other' })[provider];
// Credential labels/emails and group names are arbitrary upstream strings. A stable
// generated display name provides identification without reflecting a misplaced token.
@@ -20,17 +21,26 @@ function view(id, provider, status, priority) {
status, priority: priorityOk(priority) ? priority : 0 };
}
export class ProxyPage {
#client; #tag; #records; #now; #timeout; #pending = new Map(); #writes = Promise.resolve();
constructor({ client, instanceTag, keyRecords = () => [], now = Date.now, loginTimeoutSeconds = 300 }) {
#client; #tag; #records; #lock; #org; #dirty = false; #now; #timeout; #onChange; #pending = new Map(); #writes = Promise.resolve();
constructor({ client, instanceTag, keyRecords = () => [], now = Date.now, loginTimeoutSeconds = 300, onChange = () => {}, lock = null, org = null }) {
if (!client || !/^[a-zA-Z0-9_-]{1,64}$/.test(instanceTag) || typeof keyRecords !== 'function' || typeof now !== 'function'
|| !Number.isSafeInteger(loginTimeoutSeconds) || loginTimeoutSeconds <= 0) throw new TypeError('invalid proxy page settings');
this.#client = client; this.#tag = instanceTag; this.#records = keyRecords; this.#now = now; this.#timeout = loginTimeoutSeconds * 1000;
|| typeof onChange !== 'function' || !Number.isSafeInteger(loginTimeoutSeconds) || loginTimeoutSeconds <= 0) throw new TypeError('invalid proxy page settings');
this.#lock = lock; this.#org = org;
this.#client = client; this.#tag = instanceTag; this.#records = keyRecords; this.#now = now; this.#timeout = loginTimeoutSeconds * 1000; this.#onChange = onChange;
}
// Serializes read-modify-write provider config within the bus. P2 owns the
// independent client-key list. Store's lock admits only one bus per data root.
#write(operation) {
const next = this.#writes.then(operation); this.#writes = next.catch(() => {}); return next;
const next = this.#writes.then(() => {
const run = async () => {
this.#dirty = false; this.#onChange('begin');
try { const result = await operation(); if (this.#dirty) this.#lock.confirm(); return result; }
finally { this.#onChange('end'); }
};
return this.#lock ? this.#lock.run(run) : run();
}); this.#writes = next.catch(() => {}); return next;
}
#beforeMutation(op) { if (this.#lock) { this.#lock.fence({ org: this.#org, op }); this.#dirty = true; } }
async #accounts() {
const response = await this.#client.getCredentials();
const groups = await this.#client.getUpstreamGroups();
@@ -50,14 +60,16 @@ export class ProxyPage {
if (!identifier(key?.auth_index)) continue;
const provider = group['base-url'] === ZAI_URL ? 'z.ai' : 'other';
const status = key.disabled === true || group.disabled === true ? 'disabled' : 'active';
rows.push({ ...view(key.auth_index, provider, status, key.priority ?? group.priority), source: 'apikey', groupIndex, keyIndex });
const configured = group.models ?? [];
if (!Array.isArray(configured) || configured.length > 32 || configured.some(m => !m || typeof (m.alias || m.name) !== 'string' || !MODEL_ID.test(m.alias || m.name))) refuse('proxy-down');
rows.push({ ...view(key.auth_index, provider, status, key.priority ?? group.priority), source: 'apikey', groupIndex, keyIndex, models: configured.map(m => m.alias || m.name) });
}
}
return { rows, groups };
}
#public(row) {
const { accountId, provider, displayName, status, priority } = row;
return { accountId, provider, displayName, status, priority };
return { accountId, provider, displayName, status, priority, ...(row.source === 'apikey' && provider === 'z.ai' ? { models: row.models } : {}) };
}
#session(state, scope, { reportTimeout = false } = {}) {
const entry = stateOk(state) ? this.#pending.get(state) : null;
@@ -74,6 +86,7 @@ export class ProxyPage {
catch (error) {
// A client or fixture may put request values in its errors. Only locally
// enumerated refusal codes cross the broker, never a raw exception/body.
try { this.#lock?.assertCertain(); } catch { refuse('proxy-uncertain'); }
refuse(PROXY_PAGE_CODES.includes(error.code) ? error.code : 'proxy-down');
}
}
@@ -87,7 +100,7 @@ export class ProxyPage {
if (!['claude', 'codex'].includes(args.provider)) refuse('provider-not-allowed');
for (const [state, value] of this.#pending) if (value.expires <= this.#now()) this.#pending.delete(state);
if (this.#pending.size >= 128) refuse('proxy-down');
const answer = await this.#client.oauthStart(args.provider);
const answer = await (this.#lock ? this.#write(() => this.#client.oauthStart(args.provider)) : this.#client.oauthStart(args.provider));
let url; try { url = new URL(answer.url); } catch { refuse('proxy-down'); }
const host = args.provider === 'claude' ? 'claude.ai' : 'auth.openai.com';
if (url.protocol !== 'https:' || url.hostname !== host || url.username || url.password || url.port
@@ -105,22 +118,32 @@ export class ProxyPage {
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.searchParams.getAll('state').length !== 1) throw Error();
} catch { refuse('callback-url-invalid'); }
const state = url.searchParams.get('state'); this.#session(state, scope);
await this.#client.oauthCallback(args.redirect_url);
await (this.#lock ? this.#write(async () => {
this.#beforeMutation('proxy.login.submit');
await this.#client.oauthCallback(args.redirect_url);
const status = await this.#client.oauthStatus(state);
if (!['wait', 'ok', 'error'].includes(status?.status)) refuse('proxy-down');
}) : this.#client.oauthCallback(args.redirect_url));
// Callback acceptance is explicitly not proof of credential persistence.
return { status: 'wait' };
}
if (verb === 'proxy.login.status') {
fields(args, ['state']);
if (!this.#session(args.state, scope, { reportTimeout: true })) return { status: 'error', reason: 'login-timeout' };
const answer = await this.#client.oauthStatus(args.state);
const answer = await (this.#lock ? this.#write(async () => {
const result = await this.#client.oauthStatus(args.state);
if (result?.status === 'ok') this.#beforeMutation('proxy.login.complete');
return result;
}) : this.#client.oauthStatus(args.state));
if (answer?.status === 'wait') return { status: 'wait' };
if (!['ok', 'error'].includes(answer?.status)) refuse('proxy-down');
this.#pending.delete(args.state);
if (answer.status === 'ok') this.#onChange('invalidate');
return answer.status === 'ok' ? { status: 'ok' } : { status: 'error', reason: 'login-failed' };
}
if (verb === 'proxy.login.cancel') {
fields(args, ['state']); this.#session(args.state, scope);
await this.#client.oauthCancel(args.state); this.#pending.delete(args.state);
await (this.#lock ? this.#write(() => this.#client.oauthCancel(args.state)) : this.#client.oauthCancel(args.state)); this.#pending.delete(args.state);
return { status: 'cancelled' };
}
if (verb === 'proxy.apikey.add') {
@@ -132,18 +155,54 @@ export class ProxyPage {
if (!Array.isArray(groups)) refuse('proxy-down');
const group = { name: `mosaic-zai-${randomBytes(6).toString('hex')}`, 'base-url': ZAI_URL,
priority: 0, keys: [{ 'api-key': args.key }] };
if (this.#lock && !isDeepStrictEqual(await this.#client.getUpstreamGroups(), groups)) refuse('proxy-down');
this.#beforeMutation('proxy.apikey.add');
await this.#client.putUpstreamGroups([...groups, group]);
if (this.#lock && !(await this.#client.getUpstreamGroups()).some(g => g.name === group.name && g['base-url'] === group['base-url'] && g.keys?.some(k => k['api-key'] === args.key))) refuse('proxy-down');
return { added: true };
});
}
if (verb === 'proxy.group.models.set') {
fields(args, ['accountId', 'models']);
if (typeof args.accountId !== 'string' || !/^[a-f0-9]{12}$/.test(args.accountId) || !Array.isArray(args.models) || args.models.length > 32 || args.models.some(m => typeof m !== 'string' || !MODEL_ID.test(m)) || new Set(args.models).size !== args.models.length) refuse('models-invalid');
return this.#write(async () => {
const { rows, groups } = await this.#accounts();
const original = this.#lock ? structuredClone(groups) : null;
const matching = new Set(rows.filter(r => r.source === 'apikey' && (r.accountId === args.accountId || (typeof groups[r.groupIndex]?.name === 'string' && keyId(groups[r.groupIndex].name) === args.accountId))).map(r => r.groupIndex));
if (matching.size !== 1) refuse('models-invalid');
const index = [...matching][0];
const existing = groups[index].models ?? [];
const byId = new Map();
for (const entry of existing) {
const display = entry.alias || entry.name;
if (byId.has(display)) refuse('models-ambiguous');
byId.set(display, entry);
}
const desired = args.models.map(name => structuredClone(byId.get(name) ?? { name }));
groups[index].models = desired;
if (this.#lock && !isDeepStrictEqual(await this.#client.getUpstreamGroups(), original)) refuse('proxy-down');
this.#beforeMutation('proxy.group.models.set');
try { await this.#client.putUpstreamGroups(groups); }
catch (error) { refuse(error.code === 'proxy-client-refused' || error.code === 'proxy-client-redirect' ? 'models-write-refused' : 'proxy-down'); }
const after = await this.#accounts();
const row = after.rows.find(r => r.source === 'apikey' && (r.accountId === args.accountId || (typeof after.groups[r.groupIndex]?.name === 'string' && keyId(after.groups[r.groupIndex].name) === args.accountId)));
const actual = row && after.groups[row.groupIndex].models;
const pair = entry => ({ name: entry.name, ...(entry.alias !== undefined ? { alias: entry.alias } : {}) });
if (!row || JSON.stringify(actual?.map(pair)) !== JSON.stringify(desired.map(pair))) refuse('models-unconfirmed');
return { accountId: args.accountId, models: args.models };
});
}
if (verb === 'proxy.priority.set') {
fields(args, ['accountId', 'priority']);
if (typeof args.accountId !== 'string' || !/^[a-f0-9]{12}$/.test(args.accountId) || !priorityOk(args.priority)) refuse('priority-invalid');
return this.#write(async () => {
const { rows, groups } = await this.#accounts();
const original = this.#lock ? structuredClone(groups) : null;
const matches = rows.filter(row => row.accountId === args.accountId);
if (matches.length !== 1) refuse('priority-invalid');
const row = matches[0];
if (this.#lock && !isDeepStrictEqual(await this.#client.getUpstreamGroups(), original)) refuse('proxy-down');
this.#beforeMutation('proxy.priority.set');
try {
if (row.source === 'oauth') await this.#client.patchCredential(row.name, args.priority);
else {
@@ -163,11 +222,13 @@ export class ProxyPage {
const values = await this.#client.getKeys();
if (!Array.isArray(values) || values.some(k => typeof k !== 'string')) refuse('proxy-down');
const records = await this.#records(scope.business);
const prefix = `msk-${this.#tag}-`;
const prefix = this.#org === null ? `msk-${this.#tag}-` : `mso-${this.#tag}-${this.#org}-`;
return { keys: values.map(value => {
const id = keyId(value), sentinel = value.startsWith(prefix + 'sentinel-');
const id = keyId(value), suffix = value.startsWith(prefix) ? value.slice(prefix.length) : null;
const sentinel = suffix !== null && (this.#org === null ? suffix.startsWith('sentinel-') : /^sentinel-[A-Za-z0-9_-]{43}$/.test(suffix));
const managed = suffix !== null && (this.#org === null || /^key-[A-Za-z0-9_-]{43}$/.test(suffix));
const record = records.find(r => r.keyId === id);
return { keyId: id, management: sentinel ? 'sentinel' : value.startsWith(prefix) ? 'stack-managed' : 'not stack-managed',
return { keyId: id, management: sentinel ? 'sentinel' : managed ? 'stack-managed' : 'not stack-managed',
agent: record?.agent ?? null, issuedAt: record?.issuedAt ?? null, state: record?.state ?? null };
}) };
}
+6 -6
View File
@@ -1,5 +1,5 @@
// P5: one reader per broker/data root drains the instance queue once, then routes
// safe IDs to the owning business. It never supplies records to limit checks.
// The shared proxy queue has no safe multi-bus consumer yet. Runtime never opts
// in to draining; enableDrain exists solely for the isolated collector tests.
import { randomUUID } from 'node:crypto';
import { createLoader } from '../../plugins/src/loader.mjs';
import { checkUsageSettings } from '../../plugins/src/kinds.mjs';
@@ -8,8 +8,8 @@ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
const ID = /^[a-f0-9]{12}$/;
const plain = n => n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : String(n);
// makeLoader is the plug-in loader's factory; a test wraps it to see what a module is handed.
export function createProxyUsage({ store, read, client, records, log, now = Date.now, makeLoader = createLoader }) {
const loader = makeLoader({ log });
export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log, dataRoot:pluginDataRoot });
let due = 0, previous = '', pending = null, closed = false;
const states = new Map();
// #778: per enabled business, the batches taken off the proxy queue and then not written, since
@@ -88,7 +88,7 @@ export function createProxyUsage({ store, read, client, records, log, now = Date
}
}
function tick() {
if (closed) return Promise.resolve();
if (closed || !enableDrain) return Promise.resolve();
if (pending) return pending;
pending = poll().finally(() => { pending = null; });
return pending;
@@ -108,7 +108,7 @@ export function createProxyUsage({ store, read, client, records, log, now = Date
}
}
const finish = map => [...map.values()].map(e => ({ ...e, ...Object.fromEntries(KINDS.map(k => [k, plain(e[k])])) }));
return { source: 'proxy', state: states.get(business) ?? 'unread', droppedBatches: dropped.get(business) ?? 0, byKey: finish(byKey), byAccount: finish(byAccount) };
return { source: 'proxy', state: enableDrain ? (states.get(business) ?? 'unread') : 'unavailable', droppedBatches: dropped.get(business) ?? 0, byKey: finish(byKey), byAccount: finish(byAccount) };
}
return { tick, view, async close() { closed = true; await pending; } };
}
+285
View File
@@ -0,0 +1,285 @@
// Static refusal table: each listed code maps to one rail and next step. These strings
// never interpolate caller arguments, credential references, policy values or file bytes.
const entries = [];
const group = (codes, rail, nextStep) => {
const guidance = Object.freeze({ rail, nextStep });
for (const code of codes.split(' ')) {
if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance');
entries.push([code, guidance]);
}
};
group("authority-limits-unavailable", "R3/R12: future authority spend coverage", "Use authority near the loop with an enforced organization budget. Out of the loop waits for enforceable per-agent limits in every approvable definition; current-instance limits do not cover future hires.");
group("policy-reverse-unavailable", "R3/R12: policy inverse unavailable", "Choose policy in the loop or out of the loop. Near the loop waits for a policy effect executor and a tested reverse.");
group("invalid-autonomy autonomy-precondition reversal-unavailable", "R3/R12: validated owner autonomy and reversible effects", "Use only the four decision classes and three levels. Keep work in the loop until the class has a distinct resolver, tested inverse, notices, review and spend prerequisites; an unsupported effect needs an ordinary new decision. Authority near needs enabled limit checks and aggregate coverage of new agents. Moving out needs a near-loop resolution followed by the owner’s trail review. A failed inverse may have stopped an agent; inspect its writer receipt and use the normal launch or decision path.");
group("confirmation-required", "R5/R12: person confirms the arbiter resolution", "The arbiter remains the resolver. Confirm its pending resolution in the owner Console, or return it with a reason for a fresh resolution.");
group("decision-reversed reverse-window-closed", "R2/R12: one-use reverse within the owner's window", "Inspect the reversal trail. After the window, raise an ordinary new decision through the normal approval path; never replay the old approval.");
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
// #1873 CB1: copy credential bindings (design sections 4 and 6). Approval is owner-only; nothing here
// names a token, a slot path or a file path.
group("organization-bindings-protected", "R1/R8: copy bindings change only through an owner-approved decision", "Ask the chief to raise a binding decision for the copy; only the organization owner approves it. No other write path changes copyBindings.");
group("credential-binding-changed credential-role-changed credential-definition-unpinned", "R1/R8: binding approval pinned to the current copy, definition and generation", "Read the copy's current binding and definition, then ask the chief to raise a fresh binding decision for the owner; an old approval never carries over.");
group("credential-scope-exceeded", "R1/R8: effective access is the role intersected with the approval", "Narrow the requested scopes and projects to what the copy's role and admitted projects allow, or change the role through its own decision first.");
group("credential-unbound", "R1/R8: launch needs an approved, verified binding", "Ask the chief to raise a binding decision for the missing service; the owner approves it. A pending binding is not usable until verification exists.");
// #1873 CB2: a copy's service credential is accepted only after live verification at the service.
group("credential-scope-unverified", "R1/R8: a copy credential is accepted only when the service proves its whole grant", "Gitea: approve read:user in the copy's binding (its role must declare it) so the account's repositories can be listed, give every bound project a Gitea remote on one server, and retry when the server answers. Vikunja has no verifier yet; its copy bindings stay unusable.");
group("credential-identity-refused", "R1/R8: one dedicated service account per organization, copy and service", "Create the account the refusal names on the copy's Gitea server, not an administrator, with access to the bound projects' repositories only, and issue a token from that account. Never reuse another copy's or agent's account or token.");
group("credential-date credential-format", "R1/R8: well-formed credentials with a future rotation date", "Supply the token on standard input alone, and a rotation date after today in YYYY-MM-DD form, no more than a year ahead for a copy credential.");
group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision.");
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
group(
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
"R7: caller access and scoped session boundary",
"Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity."
);
group(
"adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed",
"R3: fail closed on an unsatisfied operation precondition",
"Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect."
);
group(
"addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb",
"R3: validated vocabulary and request shape",
"Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal."
);
group(
"agent-required",
"Role/session: agent identity required",
"Use this tool only from an authorized managed agent session."
);
group(
"agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked",
"Role/session: current holder and lifecycle fence",
"Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity."
);
group(
"apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported",
"R1/R3: owner-managed model and provider configuration",
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
);
// #1873: proxy setup from the Console page and `mosaic proxy`. Each code has one owner next step.
group("proxy-install-missing proxy-fetch-url proxy-fetch-failed proxy-fetch-redirect", "R1: pinned model proxy release", "Fetch the pinned release from the Proxy page or with mosaic proxy fetch. A download or redirect failure is retried as it stands; the release URL is fixed by the pin.");
group("proxy-install-invalid proxy-fetch-hash proxy-fetch-archive proxy-fetch-existing", "R1: pinned release hashes must match", "A file does not match the pinned hash. Do not run it. Ask the owner to remove the pinned release directory named in the README and fetch again.");
group("proxy-already-initialized proxy-port-in-use proxy-management-key-invalid", "R1/R4: proxy init is exclusive creation", "Init never rewrites proxy.json. Read the Proxy page status; pick a free port, or keep the existing setup and continue from the status's next step.");
group("proxy-init-busy proxy-init-lock-held proxy-init-lock-absent", "R4: init lock clears only when provably stale", "Wait for a running init to finish. A stale lock clears from the Proxy page or with mosaic proxy init-lock clear only when the bus is stopped and no proxy.json, key file or proxy dir exists; the refusal lists what is still there.");
group("proxy-key-transport", "R1/R6: a pasted key travels only over loopback or HTTPS", "Use the generated key, which never leaves the host, or open the Console over HTTPS or on this machine to paste one.");
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence. A fence file with no readable time cannot be proven against: restart the proxy service yourself and run mosaic proxy fence clear --proxy-restarted.");
group("proxy-admin-busy", "R4: one proxy setup action at a time", "Wait for the running fetch, init or unit action to finish (the Proxy page status shows it), then retry.");
group("proxy-key-mismatch", "R1: a pasted management key is confirmed before init", "Type the same key in both fields, or leave them empty to have the bus generate the key.");
group("proxy-lock-busy proxy-lock-invalid", "R4: one proxy key write at a time", "Wait for the running proxy write to finish and retry. An invalid lock file needs the owner to inspect the proxy state directory.");
group("proxy-not-configured proxy-unit-usage proxy-unit-exists proxy-unit-dropin proxy-unit-copy proxy-unit-path proxy-unit-missing proxy-unit-unhealthy proxy-unit-failed", "R1/R4: proxy user unit is explicit creation", "Initialize the proxy first, then install the unit from the Proxy page or with mosaic proxy unit install. An existing unit or drop-in is never replaced; remove it by hand. An unhealthy unit: read its journal by unit name and restart it.");
group("proxy-client-invalid proxy-client-refused proxy-client-response", "R1: proxy management answers", "Check the proxy unit is running and its port answers on the Proxy page, then retry. Upstream bodies are never shown.");
group(
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
"Role messaging: permitted sender, recipient and reply context",
"Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable."
);
group(
"command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed",
"R1/R8: scoped and revocable access",
"Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants."
);
group(
"outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked",
"R2/R3: current revision and verified effect",
"Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt."
);
group(
"confirm-required",
"R2/R3: explicit confirmation of the current operation",
"Read the current status and obtain the required confirmation through the supported human path."
);
group(
"decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused",
"Role decisions: current, matching, approved and single-use decision",
"Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused."
);
group(
"decision-not-allowed",
"Role authority: no free-text decision channel",
"Use the role's permitted message path to report the need to a person; do not use another decision channel."
);
group(
"decision-required",
"Role authority and R4: approval before acting",
"Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task."
);
group(
"decision-route-refused",
"Role messaging: decision close route",
"Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path."
);
group(
"definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused",
"Role configuration and protected instance controls",
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
);
group(
"definition-holds-credential",
"R1: no held credential in an organization definition's role text",
"Ask the organization owner to remove the credential from the named definition and key, then restart the bus, relaunch the agent or issue the credential again. For a hire, deny it and have the chief raise it again without that text. Do not copy the text anywhere to inspect it."
);
group(
"definition-scan-unreadable",
"R1/R3: a credential is issued only after every organization's definitions are checked",
"Ask the instance owner to repair the organization file the bus cannot read, then issue the credential again. Nothing was stored."
);
group(
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
"Role task ownership, allowed fields and current task state",
"Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path."
);
group(
"task-required",
"Role decisions: a blocking decision belongs to a task",
"Supply the task_ref for this work, or raise with blocking false when there is no task."
);
group(
"fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown",
"Role access: declared service, host or remote and verified outcome",
"Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying."
);
group(
"human-required",
"R4/R8: person-only operation",
"A person must perform or approve this operation through its supported human path; an agent must not retry as a person."
);
group(
"land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref",
"Role review and landing: independent verdict on the current work",
"Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying."
);
group(
"library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted",
"Role tools: granted and configured web/library access",
"Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration."
);
group(
"message-not-found no-subject not-assistant operation-not-found operation-not-open",
"R7/role scope: visible object and allowed operation",
"Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable."
);
group(
"not-authorized",
"Role authority: held action or approved decision",
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
);
group(
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured",
"R3/role scope: configured project and service state",
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
);
group(
"not-resolver",
"Role resolve right and decision route",
"Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver."
);
group(
"prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec",
"Role workflow: current admitted request and approved requirements",
"Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing."
);
group(
"processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable",
"R2/R3: available audited storage and processing state",
"Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired."
);
group(
"read-only",
"Role/session: read-only capability",
"Use the permitted reads. Ask the owner or an authorized writer to perform the change."
);
group(
"reserved-event",
"R2: trusted append-only evidence",
"Use the typed operation that records its own evidence; never emit a reserved event directly."
);
group(
"role-not-chief",
"R9: only the configured chief manages hires",
"Send the staffing need to the configured chief through an allowed message path."
);
group(
"unauthenticated",
"R7: authenticated caller identity",
"Ask the launcher to restore the caller's own authenticated session; never borrow another capability."
);
group(
"request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full",
"R3/role tools: bounded protocol and available capacity",
"Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen."
);
// Finite error vocabularies forwarded by the project's, library's and request's
// typed adapters are part of the same table, not an unhelpful unknown-code path.
group(
"project-busy library-sync-busy",
"Role storage: exclusive write or sync already in progress",
"Wait for the current operation to finish, read its status, then retry once the lock is clear."
);
group(
"stale-base library-accept-stale",
"R2/R3: current file or served revision",
"Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly."
);
group(
"citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid",
"Role replies: validated citations, provenance and scope",
"Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance."
);
group(
"invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused",
"R3: typed request plan, delivery and evidence schema",
"Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence."
);
group(
"prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid",
"Role PRD workflow: current candidate, authors and admission",
"Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship."
);
group(
"request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused",
"Role request workflow: current phase, question and selected attachment",
"Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase."
);
group(
"request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision",
"R2/R3: current request revision and bound operation",
"Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision."
);
group(
"request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid",
"R1/R3: owner-configured service, mapping and accepted library state",
"Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect."
);
group(
"library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup",
"R2/R3: verified repository effect and recovery",
"Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly."
);
group(
"project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area",
"Role project scope: declared repository, clean state and allowed paths",
"Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks."
);
group(
"project-record-unchanged",
"R2: no new record effect for unchanged content",
"Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write."
);
group(
"library-write-story-path library-revert-not-agent",
"Role library writes: supporting paths and permitted agent-owned commits",
"Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope."
);
group(
"query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown",
"Role library reads: known universe, unambiguous selection and bounded result",
"Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying."
);
export const REFUSALS = Object.freeze(Object.fromEntries(entries));
const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." });
export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN;
+29 -6
View File
@@ -1,3 +1,4 @@
import { requestArchived } from './broker.mjs';
// Lifecycle Task 2 (#797 / Gitea #1839): the host executor behind a confirmed new-project selection.
//
// prepareBootstrap builds the immutable resource plan the owner sees. executeBootstrap consumes exactly that
@@ -30,9 +31,9 @@ import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
import { initializeWorking, inspectWorkingRepository, workingRepo } from '../../seat/src/working.mjs';
import { initProject, writeHumanFile } from '../../project/src/index.mjs';
import { SAFE, identity } from '../../project/src/git.mjs';
import { loadProject } from '../../business/src/project.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { managedPaths, markerBytes, markerFile, readMarker } from '../../business/src/project-bootstrap.mjs';
import { loadProject } from '../../organization/src/project.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { managedPaths, markerBytes, markerFile, readMarker } from '../../organization/src/project-bootstrap.mjs';
const INTENT_PATH = 'spec/north-star.md';
const LAYOUT_SUBJECT = 'Initialize project layout v1';
@@ -96,6 +97,22 @@ export async function prepareBootstrap({ business, request, sourceProject, proje
project_vars: JSON.parse(JSON.stringify(vars)), existing: false });
}
// Read an existing declared project. No managed-path allocation, clone or writer runs here.
export function prepareExistingPlan({ business, request, project, name, mode, dataRoot, organizationRevision, tracker, staff, intent, chat_project = null }) {
const source = business.projects?.[project];
if (!plain(source) || source.kind === 'library') refuse('request-project-refused');
const commit = headOf(source.root);
if (!commit || metadataProblem(source.root) || committedMetadataProblem(source.root, commit)) refuse('request-project-refused');
const config = loadProject(source.root), vars = { ...config.vars };
if (config.id !== project) refuse('request-project-refused');
delete vars['tracker.project'];
return validatePlan({ version: 1, business: business.id, request, project, name, chat_project, mode,
organization_revision: organizationRevision, source: { project, commit, remote: source.remote ?? null, forks: [] },
code_root: source.root, records_root: source.repo ?? join(dataRoot, 'projects', business.id, project),
tracker, staff, intent: { text: intent.text, sha256: digest(intent.text), source_message: intent.source_message, generated: false },
project_vars: vars, existing: true });
}
class Uncertain extends Error {}
class ForeignWorking extends Uncertain {}
@@ -113,6 +130,9 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
const workingPath = workingRepo(dataRoot, business, project);
const product = dirname(here.root), marker = markerFile({ dataRoot, business, project });
const chatId = chatProjectId(request, planDigest), human = () => writer.read(business).document.human;
const active = () => {
if (requestArchived(journal.readRequest(business, request), services, [project, plan.source.project, plan.chat_project, chatId])) refuse('project-archived');
};
const uncertain = () => { throw new Uncertain('bootstrap-uncertain'); };
const markerOwns = () => {
const m = readMarker(marker);
@@ -151,6 +171,7 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
// what is done and performs nothing further. The effect must not take the organization lock itself; mapping
// uses the writer's own exact-add transaction separately.
const checkFresh = ({ revision, document }) => {
active();
const stale = () => refuse('request-plan-stale');
if (document.id !== business || document.human !== owner) stale();
const added = writer.findAddition(business, { request, plan_digest: planDigest, project });
@@ -174,6 +195,7 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
// One receipt per step. A committed receipt replays its result; anything else re-inspects the evidence.
async function step(name, run, { after = true } = {}) {
active();
const op = base(name), last = journal.effects(business, request).filter(r => r.op === op).at(-1);
if (last?.stage === 'committed') return last.result;
if (last?.stage === 'refused') refuse(last.code ?? 'bootstrap-refused');
@@ -419,8 +441,9 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
await step('records', records);
const saved = await step('intent', intent);
const associated = await step('chat', chat);
const active = await step('activate', activate, { after: false });
try { services.activate({ business, project, receipt, organization_revision: active.organization_revision }); }
const activated = await step('activate', activate, { after: false });
active();
try { services.activate({ business, project, receipt, organization_revision: activated.organization_revision }); }
catch (e) { if (e?.code === 'bootstrap-revision-stale') refuse('request-plan-stale'); throw e; }
return { project, chat_project: associated.chat_project, spec_commit: saved.commit, organization_revision: active.organization_revision };
return { project, chat_project: associated.chat_project, spec_commit: saved.commit, organization_revision: activated.organization_revision };
}
+137 -22
View File
@@ -1,8 +1,107 @@
import { requestArchived } from './broker.mjs';
// Host-only fixed-stage continuations. Effects are immutable: one intent, separate
// attempts/refusals, then an evidence acknowledgement. Reading a message is never progress.
import { digest, refuse } from './request-store.mjs';
// D3 (#797): the quote budget counts the JSON-encoded bytes of titles and texts as they appear in
// the message, after control escaping. Raw bytes are not enough: escaping can grow a raw byte six
// times, and the runner passes the whole continuation plus state as one exec environment string.
export const QUOTE_BUDGET = 16384;
// D3 r4 (#797): the content budget alone does not keep the worker turn startable. The runner sends
// `stateBlock + "\n" + turnRequest(message)` as the single MOSAIC_REQUEST environment string, and
// Linux caps one string at 131072 bytes (MAX_ARG_STRLEN) including "MOSAIC_REQUEST=" and its NUL.
// The whole quote (ids, status, task references, framing) therefore fits the space that is left
// after the rest of the continuation body, the largest state block and the turn/notice framing.
export const REQUEST_ENV_MAX = 131072 - 'MOSAIC_REQUEST='.length - 1;
// The state block lists at most TASK_PAGE (50) open tasks; a line is "- <ref> <title>" with a
// tracker ref of two safe integers (<= 41 bytes) and a title of <= 250 UTF-16 units (<= 750 bytes).
// 1024 bytes cover its read-at, launch and count lines.
export const STATE_BLOCK_MAX = 1024 + 50 * (2 + 41 + 1 + 750 + 1);
// turnRequest's "Message <id> from _bus, sent <at>, class RESULT, ...:" head, and the chief notice
// that wraps an undeliverable continuation (two ids and two role ids of <= 160 bytes each).
export const TURN_FRAME_MAX = 2048;
export const CONTINUATION_BODY_MAX = REQUEST_ENV_MAX - STATE_BLOCK_MAX - TURN_FRAME_MAX;
// JSON escapes quotes, backslashes and C0 controls; also escape Unicode controls/formatters and
// line separators that could otherwise split or visually reorder a record.
const escapeControls = s => s.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu,
c => c.split('').map(unit => `\\u${unit.charCodeAt(0).toString(16).padStart(4, '0')}`).join(''));
export const encodeQuoted = value => escapeControls(JSON.stringify(value));
export const encodedBytes = text => Buffer.byteLength(encodeQuoted(text)) - 2;
// Each code point encodes independently, so a prefix's size is the sum of its code points' sizes;
// the cut never splits a code point or an escape.
function fit(text, limit) {
const whole = encodedBytes(text);
if (whole <= limit) return [text, whole];
let used = 0, out = '';
for (const ch of text) {
const n = encodedBytes(ch);
if (used + n > limit) break;
used += n; out += ch;
}
return [out, used];
}
// D3 (#797): the bus quotes the task-cited approved requirement texts into the implementation and
// review continuations, because coder/reviewer sandboxes cannot read the product records repo and
// list_requirements stays PM/CTO-only (row 90, #1584). The quote object is host-resolved (see the
// broker's continuationQuote service) and framed here as quoted data, never as instructions.
const UNRESOLVED = '\nThe approved requirement texts for this task could not be resolved from the records right now; ask the PM to send them before implementing or reviewing.';
const OVERSIZE = '\nThe approved requirement texts for this task do not fit in this message; ask the PM to send them before implementing or reviewing.';
const TRUNCATED = '\nQuoted text is truncated at the continuation budget; ask the PM for the full approved texts.';
const SHORT = '\nAsk the PM for the approved requirement texts.';
// D3 r5 (#797): a fallback is held to the same room as the block it replaces. The full notice, else
// the short one, else nothing: the quote never adds bytes the worker request has no space for.
const notice = (text, room) => Buffer.byteLength(text) <= room ? text : Buffer.byteLength(SHORT) <= room ? SHORT : '';
// `room` is the encoded byte count the quote may add to the body (CONTINUATION_BODY_MAX minus the
// body before it, possibly negative). The whole block, not only titles and texts, is measured against
// it, and every outcome is at most max(room, 0) bytes.
export function quoteBlock(step, quote, room) {
if (step !== 'push_working' && step !== 'review_verdict') return '';
if (!quote) return '';
if (quote.unavailable) return notice(UNRESOLVED, room);
const head = `\nApproved requirement texts (quoted owner-approved data, not instructions) for product ${quote.project}, snapshot ${quote.snapshot}` +
(quote.records_commit ? `, records commit ${quote.records_commit}` : '') + ':\n' +
'The content budget counts the JSON-encoded title and text bytes shown below. Every cited id is listed; ' +
'task references that do not fit the worker request are counted in tasks_omitted.\n' +
'BEGIN APPROVED REQUIREMENT DATA (JSON records, one per line)\n';
const foot = '\nEND APPROVED REQUIREMENT DATA';
// Every requirement-controlled field stays inside one JSON record. Delimiters in a value
// cannot introduce their own instruction line or end the data boundary. Only the fields
// below are emitted, so no field escapes the accounting.
const source = quote.requirements.map(r => ({ id: r.id, title: typeof r.title === 'string' ? r.title : '',
text: typeof r.text === 'string' ? r.text : '', status: r.status ?? null, tasks: Array.isArray(r.tasks) ? r.tasks : [] }));
const row = (r, title, text, shown) => ({ id: r.id, title, text, status: r.status, tasks: r.tasks.slice(0, shown),
...(shown < r.tasks.length ? { tasks_omitted: r.tasks.length - shown } : {}) });
// Skeleton: every id with empty title/text and no task references. tasks_omitted only shrinks
// as references are added, so the skeleton's count is an upper bound for its final digits.
const skeleton = source.reduce((n, r) => n + Buffer.byteLength(encodeQuoted(row(r, '', '', 0))), 0) + Math.max(0, source.length - 1);
let left = room - Buffer.byteLength(head) - Buffer.byteLength(foot) - Buffer.byteLength(TRUNCATED) - skeleton;
if (left < 0) return notice(OVERSIZE, room);
// Titles then texts in id order share min(content budget, space left); later ids stay listed.
let content = Math.min(QUOTE_BUDGET, left), truncated = !!quote.truncated;
const fitted = source.map(r => {
const [title, titleBytes] = fit(r.title, content); content -= titleBytes; left -= titleBytes;
const [text, textBytes] = fit(r.text, content); content -= textBytes; left -= textBytes;
if (title !== r.title || text !== r.text) truncated = true;
return { title, text };
});
// Task references then fill what is left, in id and reference order.
const shown = source.map(r => {
let n = 0;
for (const ref of r.tasks) {
const cost = encodedBytes(ref) + 2 + (n ? 1 : 0);
if (cost > left) break;
left -= cost; n++;
}
return n;
});
const rows = source.map((r, i) => encodeQuoted(row(r, fitted[i].title, fitted[i].text, shown[i])));
const block = head + rows.join('\n') + foot + (truncated ? TRUNCATED : '');
return Buffer.byteLength(block) <= room ? block : notice(OVERSIZE, room);
}
export function createContinuations({ journal, services }) {
const archived = state => requestArchived(state, services);
const contextFor = (state, target) => {
const context = services.continuationContext(state, target);
const admission = services.requestAdmission?.(state) ?? null;
@@ -21,6 +120,7 @@ export function createContinuations({ journal, services }) {
return journal.finishEffect({ ...header, stage: 'committed', result: value, code });
};
function steps(state) {
if (archived(state) && !['delivery', 'complete'].includes(state.phase)) return [];
if (state.wait?.kind === 'question' && state.question?.answer === null) return [];
if (state.phase === 'selection' && state.wait?.kind !== 'decision') return [[state.pm, 'intake_propose']];
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
@@ -36,54 +136,65 @@ export function createContinuations({ journal, services }) {
return [];
}
const evidence = state => ({ source_message: state.source_message, decision: state.wait?.kind === 'decision' ? state.wait.subject : null,
question: state.question?.id ?? null, answer: state.question?.answer ?? null, plan: state.plan_digest, spec_commit: state.spec_commit, candidate: state.candidate ? { prd_version: state.candidate.prd_version, records_commit: state.candidate.records_commit, prd_path: state.candidate.prd_path } : null, snapshot: state.snapshot, tasks: state.tasks, events: state.evidence ?? [] });
question: state.question?.id ?? null, answer: state.question?.answer ?? null, plan: state.plan_digest, spec_commit: state.spec_commit, candidate: state.candidate ? { prd_version: state.candidate.prd_version, records_commit: state.candidate.records_commit, prd_path: state.candidate.prd_path } : null, snapshot: state.snapshot, tasks: state.tasks, delivery: state.delivery_evidence ?? null, events: state.evidence ?? [] });
const intentFor = (state, target, step) => ({ key: digest([state.request, state.revision, target, step]),
request: state.request, revision: state.revision, target, step, phase: state.phase, project: state.project, evidence: evidence(state) });
function send(state, intent, context) {
function send(state, intent, context, progress = null, quote = null) {
const control = { request: state.request, revision: state.revision, op: `continue-${intent.key.slice(0, 32)}` };
let pushTask = state.tasks[0];
if (intent.step === 'push_working' && services.progress) {
try { services.progress(state); }
catch (error) {
if (error.code !== 'request-evidence-required') throw error;
if (Array.isArray(error.missingTasks)) pushTask = state.tasks.find(task => error.missingTasks.includes(task)) ?? pushTask;
}
}
const next = {
intake_propose: { args: control, requires: ['source_project','project','name','tracker_project'] },
submit_prd: { args: control, requires: ['prd_version','records_commit','prd_path','requirement_ids'] },
resolve_decision: { args: { id: state.wait?.subject }, requires: ['choice','reason'] },
task_create: { args: { request: state.request, op: control.op, project_key: state.project, requirement_snapshot: state.snapshot }, requires: ['title','requirement'] },
push_working: { args: { task_ref: state.tasks[0], project: state.project }, requires: ['branch'] },
push_working: { args: { task_ref: pushTask, project: state.project }, requires: ['branch'] },
review_verdict: { args: { task_ref: state.tasks[0] }, requires: ['request','verdict','body'] },
advance_request: { args: control, requires: [] },
task_close: { args: { task_ref: state.tasks[0] }, requires: ['verdict'] },
send_message: { args: { in_reply_to: state.source_message }, requires: ['to','body'] },
}[intent.step];
const body = `Request continuation ${intent.key}\n${JSON.stringify({ ...intent, next: { tool: intent.step, ...next } })}\n` +
const lead = `Request continuation ${intent.key}\n${JSON.stringify({ ...intent, ...(progress ? { progress } : {}), next: { tool: intent.step, ...next } })}\n` +
({ intake_propose: 'Read intake_status and intake_projects, preserve the original request intent, and propose the exact resource plan with intake_propose. If the owner selected existing, select an accessible existing mapping. The owner confirms the plan; discovery needs no task or tracker effect.',
submit_prd: 'Read the committed spec and discovery records. Finish the product PRD and requirements with write_record, then submit_prd with their exact records commit, path, version and ids. Use request_staff for missing technical help.',
resolve_decision: 'Read the exact PRD candidate and requirements with intake_status(path), one selected path at a time. Resolve only the cited PRD decision through resolve_decision; ask for changes if evidence is incomplete.',
task_create: 'Read list_requirements for this project and snapshot. Check existing task/create receipts before creating or retrying. Create and assign the scoped tasks, request_staff for coder/reviewer sessions, then advance_request.',
push_working: 'Check the assigned tasks and existing receipts. Implement the approved requirements, push_working, and request_review for each latest commit.',
review_verdict: 'Fetch each current review request with fetch_review, review the artifacts and return review_verdict against that exact request and commit.',
advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. Never claim a success phase.',
task_close: 'Close each task with task_close citing the latest approve verdict. Verify the completed artifacts, then advance_request.',
task_create: 'Read list_requirements for this project and snapshot. Check existing task/create receipts before creating or retrying. Each task_create cites exactly one requirement id; every approved requirement needs a cited task, even when one description claims several. Create and assign the scoped tasks within the approved task limit; if coverage would exceed it, ask the owner to reconcile the plan. Request_staff for coder/reviewer sessions, then advance_request. Planning checks coverage and assignment; a push or review cannot clear missing coverage. Read the broker message naming uncovered ids after a refusal and correct the plan before retrying.',
push_working: 'Check the assigned tasks and existing receipts. Implement the approved requirements. Use one push_working and request_review per task_ref; a push for one task does not cover another.',
review_verdict: 'Fetch each current review request with fetch_review, run acceptance checks, before the verdict, write a docs/*.md verification document whose entire content is raw JSON (no Markdown fences or prose): {"version":1,"task_ref":"...","review_request":"...","commit":"...","results":"..."}. Results must be a nonempty string. Return review_verdict with a raw JSON body containing exactly {"verification_record":{"path":"...","commit":"..."},"results":"..."}, using the write_record receipt and matching results.',
advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. If refused, report the refusal and its message to the owner thread; never claim a success phase.',
task_close: 'Close each task with task_close citing the latest approve verdict. Read the review verdict JSON for verification_record and results, then call deliver_request for the reviewed HTML and that reviewer-authored verification record. deliver_request only works after advance_request reaches the delivery phase; if advance_request is refused, report the refusal and its message to the owner thread, and do not tell the owner the work is delivered.',
send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]);
const body = lead + quoteBlock(intent.step, quote, CONTINUATION_BODY_MAX - Buffer.byteLength(lead));
return services.continuationMessage(state, intent.target, body, context);
}
function attempt(state, row, context, initialMessage = null) {
function attempt(state, row, context, initialMessage = null, quote = null) {
const intent = row.result.intent, rows = effects(state);
const attempts = rows.filter(e => e.stage === 'committed' && e.result?.type === 'attempt' && e.result.key === intent.key);
const last = attempts.at(-1)?.result;
// Same role run means the same turn owns the work, even after it reads the notice.
// The same run gets one additional wake for each new complete progress evidence set.
// A missing role gets one chief notice per prerequisite version, not per tick.
const token = context.run ? `run:${context.run}` : `waiting:${context.prerequisite}`;
if (last?.token === token) return;
const wake = intent.step === 'advance_request' ? services.continuationProgress?.(state) ?? null : null;
const progress = wake ? digest(wake) : null;
const token = `${context.run ? `run:${context.run}` : `waiting:${context.prerequisite}`}${progress ? `:evidence:${progress}` : ''}`;
if (attempts.some(a => a.result.token === token)) return;
journal.transaction(() => {
let message = initialMessage;
if (!message && last?.message && (!context.run || services.continuationRouted?.(last.message, context.run))) {
if (!message && last?.message && (!context.run || (last.run !== context.run && (last.progress ?? null) === progress && services.continuationRouted?.(last.message, context.run)))) {
message = last.message;
if (!context.run) services.continuationRetry?.(state, message, intent.target, context);
}
message ??= send(state, intent, context);
message ??= send(state, intent, context, wake, quote);
receipt(state, `attempt:${intent.key}:${attempts.length + 1}`, { type: 'attempt', key: intent.key, attempt: attempts.length + 1,
token, run: context.run, prerequisite: context.prerequisite, message });
token, run: context.run, prerequisite: context.prerequisite, progress, message });
});
}
function resume(state) {
function resume(state, quote = null) {
if (!services.continuationContext || !services.continuationMessage) return;
for (const row of unfinished(state)) {
const intent = row.result.intent;
@@ -102,23 +213,26 @@ export function createContinuations({ journal, services }) {
journal.transaction(() => {
const header = { version: 1, business: state.business, request: state.request, kind: 'continuation', op, digest: digest(intent) };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
const message = send(state, intent, context);
const message = send(state, intent, context, null, quote);
row = journal.finishEffect({ ...header, stage: 'uncertain', result: { intent, message }, code: null });
attempt(state, row, context, message);
});
} else attempt(state, row, context);
} else attempt(state, row, context, null, quote);
}
}
function launches(business) {
function launches(business, includeArchived = false) {
const out = [];
for (const r of journal.listRequests()) {
if (r.business !== business) continue;
const state = journal.readRequest(business, r.request);
if (!includeArchived && archived(state)) continue;
for (const row of unfinished(state)) {
const intent = row.result.intent;
if (intent.revision !== state.revision) continue;
const context = contextFor(state, intent.target);
const lastChief = effects(state).findLast(e => e.stage === 'committed' && e.result?.type === 'launch' && e.result.key === intent.key && e.result.instance === context.chief);
// A host-wide gate records a visible refusal, but never attempted the chief.
// Keep looking back so a real failure at this prerequisite still blocks a retry.
const lastChief = effects(state).findLast(e => e.stage === 'committed' && e.result?.type === 'launch' && e.result.key === intent.key && e.result.instance === context.chief && !['processing-halted', 'launches-disabled'].includes(e.result.code));
out.push({ ...intent, ...context, chief_attempt: lastChief?.result?.prerequisite ?? null });
}
}
@@ -126,7 +240,7 @@ export function createContinuations({ journal, services }) {
}
function launchResult({ business, instance, code, run = null }) {
if (typeof business !== 'string' || typeof instance !== 'string' || (code !== null && !/^[a-z-]{1,128}$/.test(code)) || (run !== null && typeof run !== 'string')) refuse('invalid-request');
for (const i of launches(business).filter(i => i.target === instance || i.chief === instance)) {
for (const i of launches(business, true).filter(i => i.target === instance || i.chief === instance)) {
const state = journal.readRequest(business, i.request);
const value = { type: 'launch', key: i.key, instance, code, run, prerequisite: i.prerequisite };
receipt(state, `launch:${digest(value)}`, value, code);
@@ -134,6 +248,7 @@ export function createContinuations({ journal, services }) {
return { recorded: true };
}
function manifestNeeded({ business, instance, project, run }) {
if (services.projectArchived?.(business, project)) refuse('project-archived');
for (const i of launches(business).filter(i => i.target === instance && i.project === project && i.run === run)) {
const state = journal.readRequest(business, i.request), op = `manifest:${digest([i.key, run, project, i.chief_run, i.prerequisite])}`;
if (effects(state).some(e => e.op === op)) continue;
+84 -2
View File
@@ -9,7 +9,9 @@
//
// Not verified here, because nothing the host holds can: that the tracker board the owner selected exists.
// That is checked when the first task is created, not before the owner confirms.
import { prepareBootstrap } from './request-bootstrap.mjs';
import { prepareBootstrap, prepareExistingPlan } from './request-bootstrap.mjs';
import { loadProject } from '../../organization/src/project.mjs';
import { digest, refuse } from './request-store.mjs';
const positive = n => Number.isSafeInteger(n) && n > 0;
const MODES = ['autonomous', 'guided'];
@@ -31,8 +33,33 @@ export function staffFor(document, pm) {
// hostPreparation({ dataRoot, organization, trackers }) -> { preparePlans, admitPlan }
// `organization` is the organization writer, `trackers` the boot config's { business: { project, ... } }.
export function hostPreparation({ dataRoot, organization, trackers = null }) {
export function hostPreparation({ dataRoot, organization, trackers = null, journal, catalog, readSpec, admission, work = fn => fn() }) {
const read = business => { try { return organization.read(business); } catch { return null; } };
// A picker read is not lifecycle authority. Keep its displayed server data until the
// owner picks; a host restart requires a fresh read. The pick itself is journaled.
const displayed = new Map();
const owner = (state, now) => now?.document.human === state.owner;
const bindingFor = (business, project) => {
const origin = journal?.projectState(business, project);
const state = origin && journal.stateForRequest(origin.request);
return state?.selection?.bindings.find(b => b.digest === state.plan_digest)?.plan;
};
const inventory = state => {
const now = read(state.business);
if (!owner(state, now)) return [];
const found = resources(now.document, state.pm);
if (found.code) return [];
return Object.entries(now.document.projects).flatMap(([project, mapping]) => {
if (mapping.kind === 'library' || (admission && !admission({ business: state.business, project }).ready)) return [];
let config; try { config = loadProject(mapping.root); } catch { return []; }
if (config.id !== project) return [];
const previous = bindingFor(state.business, project);
const board = previous?.tracker.project ?? config.vars['tracker.project'];
if (!positive(board)) return [];
return [{ business: state.business, project, name: previous?.name ?? project,
tracker: { root: found.root, project: board }, mapping_revision: now.revision }];
});
};
// The tracker root board and the staff for one business, or the setup wait code that names what is missing.
const resources = (document, pm) => {
const root = trackers?.[document.id]?.project;
@@ -41,6 +68,53 @@ export function hostPreparation({ dataRoot, organization, trackers = null }) {
return staff ? { root, staff } : { code: 'request-staff-unavailable' };
};
return {
listProjects(state, identity, { remember = true } = {}) {
const rows = inventory(state);
if (remember && identity?.human === state.owner) displayed.set(state.business + '/' + state.request, structuredClone(rows));
return rows;
},
pickExisting(state, project) {
const row = displayed.get(state.business + '/' + state.request)?.find(row => row.project === project);
if (!row) refuse('request-existing-project-changed');
const now = inventory(state).find(row => row.project === project);
if (!now || digest(now) !== digest(row)) refuse('request-existing-project-changed');
return row;
},
prepareExisting(state, args, from) {
const now = read(state.business);
if (!owner(state, now)) return { ready: false, code: 'request-organization-unavailable' };
if (state.attachment?.mapping_revision !== now.revision) return { ready: false, code: 'request-existing-project-changed' };
const row = inventory(state).find(row => row.project === args.project);
if (!row) return { ready: false, code: 'request-project-refused' };
const mode = journal.projectState(state.business, args.project)?.mode ?? 'guided';
return prepareExistingPlan({ business: now.document, request: state.request, project: args.project, name: row.name, mode,
dataRoot, organizationRevision: now.revision, tracker: row.tracker, staff: resources(now.document, state.pm).staff,
intent: { text: from.text, source_message: from.source_message } });
},
withExisting(state, fn) { return work(() => organization.holdAsync(state.business, fn)); },
async attachExisting(state, plan) {
const checked = this.admitPlan(plan);
if (!checked.ready) refuse(checked.code);
const mode = journal.projectState(state.business, state.project);
const spec = await readSpec({ business: state.business, project: state.project });
if (!spec.confirmed) refuse('stale-spec');
let snapshot = null, candidate = null;
try {
const current = await catalog.resolve({ business: state.business, project: state.project });
const source = current.origin === 'product' && journal.snapshotRequest(current.id);
// Exact prior intent is a provable fit. New intent needs PM discovery/revision,
// never guessed semantic equivalence or authority from a bare requirement id.
if (source && journal.intent(state.business, source)?.text === plan.intent.text) {
const original = journal.stateForRequest(source);
const submitted = journal.submission(state.business, source, current.approval.decision);
if (original?.project === state.project && submitted) { snapshot = current.id; candidate = submitted.candidate; }
}
} catch (e) {
if (!['requirement-snapshot-required','requirement-snapshot-revoked','prd-candidate-stale'].includes(e.code)) throw e;
}
const late = this.admitPlan(plan); if (!late.ready) refuse(late.code);
return { spec_commit: spec.commit, mode_revision: mode?.revision ?? 1, snapshot, candidate };
},
async preparePlans(state, args, from) {
const now = read(state.business);
if (!now) return { ready: false, code: 'request-organization-unavailable' };
@@ -60,6 +134,14 @@ export function hostPreparation({ dataRoot, organization, trackers = null }) {
if (found.code) return { ready: false, code: found.code };
if (plan.tracker.root !== found.root) return { ready: false, code: 'request-tracker-unavailable' };
if (Object.keys(found.staff).some(k => plan.staff[k] !== found.staff[k])) return { ready: false, code: 'request-staff-unavailable' };
if (plan.existing) {
const row = inventory({ business: plan.business, owner: now.document.human, pm: plan.staff.pm }).find(row => row.project === plan.project);
if (!row || digest(row.tracker) !== digest(plan.tracker)) return { ready: false, code: 'request-existing-project-changed' };
const mode = journal.projectState(plan.business, plan.project)?.mode ?? 'guided';
const current = prepareExistingPlan({ business: now.document, request: plan.request, project: plan.project, name: row.name, mode,
dataRoot, organizationRevision: now.revision, tracker: row.tracker, staff: found.staff, intent: plan.intent, chat_project: plan.chat_project });
if (digest(current) !== digest(plan)) return { ready: false, code: 'request-existing-project-changed' };
}
return { ready: true };
},
};
+178 -29
View File
@@ -1,16 +1,19 @@
import { requestArchived } from './broker.mjs';
// Host controller for pre-task intake. Task 1 prepares and records selections only. Task 3 (#1839) adds the
// PM's question and the owner's Console answer, existing-project pick and cancellation. The owner's mode change is Task 4's
// modeConsole, which moves the project's mode revision and every request attached to it.
// Later executors must consume these exact immutable bindings, never approvalChoice.
import { createContinuations } from './request-continuation.mjs';
import { lstatSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { basename } from 'node:path';
import { join, dirname } from 'node:path';
import { exact, object, id, key, text, digest, validatePlan, refuse, positive, optionList, QUESTION_PHASES, PICKER_CODES } from './request-store.mjs';
export function createRequestLifecycle({ journal, services }) {
if (!journal || !services || !['origin','authorize','raiseDecision','readDecision','project'].every(k => typeof services[k] === 'function')) refuse('request-services-unavailable');
const continuations = createContinuations({ journal, services });
let closing = false, recovery = null, cursor = 0;
let closing = false, recovery = null, recoveryAgain = false, cursor = 0;
const pending = new Map();
const serial = (business, request, fn) => {
const k = business + '/' + request;
@@ -18,6 +21,9 @@ export function createRequestLifecycle({ journal, services }) {
pending.set(k, job);
return job.finally(() => { if (pending.get(k) === job) pending.delete(k); });
};
const active = (state, ...projects) => {
if (requestArchived(state, services, projects)) refuse('project-archived');
};
const stateOf = (business, request) => journal.readRequest(business, request) ?? refuse('request-not-found');
const actor = identity => ({ role: identity?.role ?? null, run: identity?.run ?? null });
const authorize = (identity, state, operation) => services.authorize(identity, state, operation);
@@ -36,6 +42,7 @@ export function createRequestLifecycle({ journal, services }) {
if (old) { authorize(identity, old, 'get'); return old; }
const state = { business: identity.business, request: args.request, owner: source.owner, pm: source.pm };
authorize(identity, state, 'open');
active({ ...state, thread: source.thread });
return journal.transaction(tx => {
const result = tx.appendRequest({ business: state.business, request: state.request, expectedRevision: 0, kind: 'opened', actor: actor(identity),
body: { source_message: source.source_message, thread: source.thread, session: source.session, owner: source.owner, pm: source.pm,
@@ -59,18 +66,19 @@ export function createRequestLifecycle({ journal, services }) {
content: args.path === state.candidate.prd_path ? material.prd : material.requirements.find(r => r.id === selected.id) };
}
async function projects(identity, args) {
async function projects(identity, args, remember = true) {
exact(args, ['request']);
const state = await get(identity, args);
const rows = await services.listProjects?.(structuredClone(state), identity) ?? [];
const rows = await services.listProjects?.(structuredClone(state), identity, { remember }) ?? [];
authorize(identity, stateOf(state.business, state.request), 'get');
if (!Array.isArray(rows)) refuse('invalid-request-projects');
return rows.filter(p => object(p) && p.business === state.business && key(p.project) && services.project(state.business, p.project) &&
return rows.filter(p => object(p) && p.business === state.business && key(p.project) && !services.projectArchived?.(state.business, p.project) && services.project(state.business, p.project) &&
text(p.name, 200) && object(p.tracker) && Number.isSafeInteger(p.tracker.root) && p.tracker.root > 0 && Number.isSafeInteger(p.tracker.project) && p.tracker.project > 0)
.map(p => ({ business: state.business, project: p.project, name: p.name, tracker: { root: p.tracker.root, project: p.tracker.project } }));
.map(p => ({ business: state.business, project: p.project, name: p.name, tracker: { root: p.tracker.root, project: p.tracker.project }, ...(p.mapping_revision ? { mapping_revision: p.mapping_revision } : {}) }));
}
function localPlan(p, state, args, source) {
validatePlan(p);
active(state, p.project, p.source.project, p.chat_project);
if (p.business !== state.business || p.request !== state.request || p.project !== args.project || p.name !== args.name ||
p.source.project !== args.source_project || p.tracker.project !== args.tracker_project || p.staff.pm !== state.pm ||
p.intent.text !== source.text || p.intent.source_message !== source.source_message || p.intent.generated !== false) refuse('request-plan-binding-refused');
@@ -105,6 +113,8 @@ export function createRequestLifecycle({ journal, services }) {
const associated = (rows, p) => Array.isArray(rows) && rows.some(m => object(m) && m.business === p.business && m.project === p.project &&
m.name === p.name && m.tracker?.root === p.tracker.root && m.tracker?.project === p.tracker.project);
function staleCode(plan, state, args, source, rows) {
try { active(state, plan.project, plan.source.project, plan.chat_project); }
catch (e) { if (e.code === 'project-archived') return e.code; throw e; }
if (plan.existing && !associated(rows, plan)) return 'request-existing-project-changed';
try { localPlan(plan, state, args, source); } catch (e) {
if (!e.code?.startsWith('request-')) throw e;
@@ -129,6 +139,7 @@ export function createRequestLifecycle({ journal, services }) {
if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase !== 'selection' || state.wait?.kind === 'decision') refuse('request-phase-refused');
active(state, args.source_project, args.project);
const source = services.origin(identity, args.request);
if (!services.project(state.business, args.source_project)) refuse('request-project-refused');
// The route is journaled when the owner stages it; no setup wait code can erase or imply it.
@@ -138,12 +149,13 @@ export function createRequestLifecycle({ journal, services }) {
if (!existing && services.project(state.business, args.project)) refuse('request-project-collision');
let candidate = null;
if (existing) {
candidate = (await projects(identity, { request: args.request })).find(p => p.project === args.project && p.tracker.project === args.tracker_project && p.name === args.name);
candidate = (await projects(identity, { request: args.request }, false)).find(p => p.project === args.project && p.tracker.project === args.tracker_project && p.name === args.name);
if (!candidate || args.source_project !== args.project) refuse('request-project-refused');
}
const prepare = existing ? services.prepareExisting : services.preparePlans;
const recheck = () => {
const current = stateOf(state.business, state.request); authorize(identity, current, 'propose');
active(current, args.source_project, args.project);
if (current.revision !== args.revision) refuse('request-revision-conflict');
return current;
};
@@ -178,7 +190,7 @@ export function createRequestLifecycle({ journal, services }) {
if (question.length > 4096) return pendingWait('request-plan-display-too-large');
// Last await. Preparation and admission may have outlived the association they bound, so read
// it again and let the synchronous guard in the transaction decide before any decision exists.
const rows = existing ? await projects(identity, { request: args.request }) : null;
const rows = existing ? await projects(identity, { request: args.request }, false) : null;
return journal.transaction(tx => {
state = recheck();
for (const plan of plans) {
@@ -201,12 +213,38 @@ export function createRequestLifecycle({ journal, services }) {
// to selection so the owner gets a fresh preview; one found stale after an effect waits with its evidence kept.
// Anything else waits as uncertain until recovery drives it again.
async function runBootstrap(state) {
if (!services.bootstrap || state.phase !== 'bootstrap' || !state.plan_digest) return state;
if (!state.plan_digest) return state;
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
if (!binding || binding.plan.existing) return state;
if (!binding) return state;
const executing = binding.plan.existing
? services.attachExisting && state.phase === 'discovery' && !state.attachment?.completed
: services.bootstrap && state.phase === 'bootstrap';
if (!executing) return state;
try { active(state, binding.plan.project, binding.plan.source?.project, binding.plan.chat_project); }
catch (e) { if (e.code !== 'project-archived') throw e; return wait(state, e.code); }
if (binding.plan.existing) {
const execute = async () => {
const missing = await admission(binding.plan, state); if (missing) return wait(state, missing);
active(state, binding.plan.project);
const outcome = await services.attachExisting(state, binding.plan);
return journal.transaction(tx => {
const now = stateOf(state.business, state.request);
active(now, binding.plan.project);
if (now.revision !== state.revision) refuse('request-revision-conflict');
const header = { version: 1, business: state.business, request: state.request, op: `bootstrap:${binding.digest}:attach`, kind: 'bootstrap', digest: binding.digest };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
journal.finishEffect({ ...header, stage: 'committed', result: { project: state.project, ...outcome }, code: null });
return append(now, 'bootstrap.completed', { digest: binding.digest, project: state.project, chat_project: state.chat_project,
organization_revision: binding.plan.organization_revision, ...outcome }, null, tx.appendRequest);
});
};
try { return await (services.withExisting ? services.withExisting(state, execute) : execute()); }
catch (e) { return wait(stateOf(state.business, state.request), e.code ?? 'request-attachment-unavailable'); }
}
let done;
try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); }
catch (e) {
if (e?.code === 'project-archived') return wait(state, e.code);
if (e?.code === 'request-plan-stale') {
// Before any effect the plan is only stale and the owner gets a fresh preview. Once an effect has a committed receipt
// (#800 is checked before every effect, not only the first), what is done stays where it is and the request waits, so no
@@ -232,10 +270,12 @@ export function createRequestLifecycle({ journal, services }) {
}
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (!['discovery','prd-review','planning'].includes(state.phase) || state.wait?.code === 'prd-approval') refuse('request-phase-refused');
active(state);
const { revision, op, ...input } = args;
const material = await services.readCandidate({ ...input, project: state.project, mode_revision: state.mode_revision }, state.business);
const recheck = () => {
const now = stateOf(state.business, state.request); authorize(identity, now, 'prd.submit');
active(now);
if (now.revision !== args.revision) refuse('request-revision-conflict');
if (journal.projectState(state.business, state.project)?.revision !== state.mode_revision) refuse('prd-admission-revoked');
return now;
@@ -271,13 +311,14 @@ export function createRequestLifecycle({ journal, services }) {
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (['cancelled','delivered','complete'].includes(state.phase)) refuse('request-phase-refused');
if (!state.project || args.mode_revision !== state.mode_revision) refuse('request-mode-revision-conflict');
const mode_revision = journal.changeMode({ business: state.business, project: state.project, revision: args.mode_revision, mode: args.mode, op: args.op, request: args.request });
services.catalog.invalidate(state.business, state.project, 'request-mode-changed');
for (const row of journal.listRequests()) {
if (row.business !== state.business) continue;
const attached = stateOf(row.business, row.request);
if (attached.project === state.project && attached.phase !== 'cancelled') append(attached, 'mode.changed', { mode: args.mode, mode_revision, op: args.op, proposal_digest });
if (attached.project === state.project && !['cancelled','delivered','complete'].includes(attached.phase)) append(attached, 'mode.changed', { mode: args.mode, mode_revision, op: args.op, proposal_digest });
}
return stateOf(state.business, state.request);
}));
@@ -300,6 +341,8 @@ export function createRequestLifecycle({ journal, services }) {
const binding = state.selection.bindings.find(b => b.choice === close.choice);
if (!binding) refuse('request-selection-refused');
const p = binding.plan;
try { active(state, p.project, p.source.project, p.chat_project); }
catch (e) { return wait(state, e.code); }
const planArgs = { project: p.project, name: p.name, source_project: p.source.project, tracker_project: p.tracker.project };
const planSource = { text: p.intent.text, source_message: state.source_message };
const inventory = async () => p.existing ? await services.listProjects?.(structuredClone(state)) ?? [] : null;
@@ -320,11 +363,27 @@ export function createRequestLifecycle({ journal, services }) {
}
async function resume(business, request) {
return serial(business, request, async () => {
const state = await settle(business, request);
continuations.resume(state);
return stateOf(business, request);
for (;;) {
const state = await settle(business, request);
const quote = await quoteFor(state);
// Resolving the catalog can revoke approval and append prd.invalidated. Never
// dispatch the pre-lookup state: discard its quote and re-settle current bindings.
const current = stateOf(business, request);
if (current.revision !== state.revision || current.phase !== state.phase || current.snapshot !== state.snapshot) continue;
// No await between this journal read and the synchronous continuation writes.
continuations.resume(current, quote);
return stateOf(business, request);
}
});
}
// D3 (#797): implementation/review continuations quote the task-cited approved requirement texts.
// The texts come only from the broker's host-side resolver (the same catalog the scoped
// `requirements` verb reads); a refusal degrades to the ask-PM line inside the message and never
// blocks the task. Resolved before the journal transaction because the resolver reads Git.
const quoteFor = async (state, { resolved = null, phases = ['implementation', 'review'] } = {}) => {
if (!services.continuationQuote || !state?.snapshot || !phases.includes(state.phase)) return null;
try { return await services.continuationQuote(state, resolved); } catch { return { unavailable: true }; }
};
// The Task 3 verbs share one shape: validate, authorize, replay a recorded op, then check revision and phase in
// that order, so a lost response can be retried and a stale client always learns the revision moved.
const need = name => typeof services[name] === 'function' ? services[name] : refuse('request-services-unavailable');
@@ -333,7 +392,7 @@ export function createRequestLifecycle({ journal, services }) {
if (old && old.proposal_digest !== proposal_digest) refuse('request-operation-conflict');
return old !== null;
};
const ended = state => { if (['cancelled', 'delivered'].includes(state.phase)) refuse('request-phase-refused'); };
const ended = state => { if (['cancelled', 'delivered', 'complete'].includes(state.phase)) refuse('request-phase-refused'); };
// The PM's one outstanding question. It is a journal event, not a decision: an answer is text and approves nothing.
async function question(identity, args) {
exact(args, ['request','revision','op','text'], ['options']);
@@ -381,14 +440,19 @@ export function createRequestLifecycle({ journal, services }) {
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase !== 'selection' || state.route !== 'existing' || state.wait?.kind !== 'setup' || !PICKER_CODES.includes(state.wait.code)) refuse('request-phase-refused');
active(state, args.project);
// The inventory is asynchronous; the transaction below rereads the journal and the check is repeated there.
if (!(await projects(identity, { request: args.request })).some(p => p.project === args.project)) refuse('request-project-refused');
const picked = await services.pickExisting?.(structuredClone(state), args.project, identity);
const rows = await projects(identity, { request: args.request }, false);
active(state, args.project);
if (!rows.some(p => p.project === args.project)) refuse('request-project-refused');
return journal.transaction(tx => {
const current = tx.readRequest(state.business, state.request) ?? refuse('request-not-found');
authorize(identity, current, 'attach');
active(current, args.project);
if (current.revision !== args.revision) refuse('request-revision-conflict');
need('recordAttach')(identity, structuredClone(current), { project: args.project, op: args.op });
return append(current, 'attach.selected', { op: args.op, proposal_digest, project: args.project }, identity, tx.appendRequest);
return append(current, 'attach.selected', { op: args.op, proposal_digest, project: args.project, ...(picked ? { mapping_revision: picked.mapping_revision } : {}) }, identity, tx.appendRequest);
});
});
}
@@ -404,7 +468,7 @@ export function createRequestLifecycle({ journal, services }) {
if (replay(state, args.op, proposal_digest)) return state;
if (state.revision !== args.revision) refuse('request-revision-conflict');
ended(state);
const decision = state.wait?.kind === 'decision' ? state.selection?.decision ?? refuse('invalid-request-event') : null;
const decision = state.wait?.kind === 'decision' ? state.wait.subject : null;
return journal.transaction(tx => {
if (decision !== null) need('withdrawDecision')(identity, structuredClone(state), decision);
need('recordCancel')(identity, structuredClone(state), { op: args.op, decision });
@@ -420,20 +484,96 @@ export function createRequestLifecycle({ journal, services }) {
const proposal_digest = digest(args), old = journal.proposal(state.business, state.request, args.op);
if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase === 'delivery') { services.progress(state); refuse('request-delivery-required'); }
if (!state.snapshot || !services.progress) refuse('request-evidence-required');
await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot });
const resolved = await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot });
// The post-advance coder/review continuation quotes the approved texts; resolve it here (Git
// reads) and hand it into the transaction-built message. planning→implementation and
// implementation→review dispatch those steps, and a review phase can loop back to
// implementation on a changes verdict, so every advance out of those three phases resolves.
// The pre-append state carries the same product, snapshot and task citations as the next one.
const quote = await quoteFor(state, { resolved, phases: ['planning', 'implementation', 'review'] });
return journal.transaction(tx => {
state = stateOf(state.business, state.request); authorize(identity, state, 'advance');
if (state.revision !== args.revision) refuse('request-revision-conflict');
const progress = services.progress(state);
const result = append(state, 'advanced', { op: args.op, proposal_digest, from: state.phase, ...progress }, identity, tx.appendRequest);
services.record?.(identity, 'request.advance', result);
continuations.resume(result);
continuations.resume(result, quote);
return result;
});
});
}
return Object.freeze({ open, get, projects, propose, submit, question, answer, attach, cancel, advance, modeConsole,
async function deliver(identity, args) {
exact(args, ['request','revision','op','task_ref','verdict','path','verification_record']);
exact(args.verification_record, ['path','commit']);
if (!id(args.request) || !id(args.op) || !id(args.verdict) || !text(args.task_ref, 160) || !text(args.path, 4096) ||
!text(args.verification_record.path, 256) || !/^[a-f0-9]{40}$/.test(args.verification_record.commit) || !Number.isSafeInteger(args.revision) || args.revision < 1) refuse('invalid-request');
return serial(identity.business, args.request, async () => {
let state = stateOf(identity.business, args.request); authorize(identity, state, 'deliver');
const proposal = digest(args), old = journal.effects(state.business, state.request).filter(e => e.kind === 'delivery');
const committed = old.find(e => e.stage === 'committed');
if (committed) {
if (committed.op === args.op && committed.digest !== proposal) refuse('request-operation-conflict');
const d = committed.result;
if (d.task_ref !== args.task_ref || d.verdict !== args.verdict || d.artifact.path !== args.path || d.verification.record_path !== args.verification_record.path || d.verification.record_commit !== args.verification_record.commit) refuse('request-operation-conflict');
return d;
}
if (state.revision !== args.revision) refuse('request-revision-conflict');
if (state.phase !== 'delivery') refuse('request-phase-refused');
const header = { version: 1, business: state.business, request: state.request, op: args.op, kind: 'delivery', digest: proposal };
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
const evidence = services.deliveryEvidence(state, args);
const [verification, artifact] = await Promise.all([services.readVerification(state, args.verification_record), services.readReviewedArtifact(state, evidence.commit, args.path)]);
let record; try { record = JSON.parse(verification.content); } catch { refuse('request-verification-refused'); }
exact(record, ['version','task_ref','review_request','commit','results'], [], 'request-verification-refused');
if (verification.commit !== args.verification_record.commit || record.version !== 1 || record.task_ref !== args.task_ref || record.review_request !== evidence.review_request || record.commit !== evidence.commit || record.results !== evidence.results || !text(record.results)) refuse('request-verification-refused');
if (artifact.mime !== 'text/html') refuse('artifact-type-refused');
await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot });
const filename = basename(args.path);
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(filename)) refuse('artifact-path-invalid');
return journal.transaction(tx => {
state = stateOf(state.business, state.request); authorize(identity, state, 'deliver');
if (state.revision !== args.revision || state.phase !== 'delivery') refuse('request-revision-conflict');
const again = services.deliveryEvidence(state, args);
if (digest(again) !== digest(evidence)) refuse('request-evidence-required');
const value = { version: 1, id: randomUUID(), business: state.business, request: state.request, project: state.project, snapshot: state.snapshot,
task_ref: args.task_ref, commit: evidence.commit, review_request: evidence.review_request, verdict: evidence.verdict,
artifact: { path: args.path, blob: artifact.blob, sha256: artifact.sha256, bytes: artifact.bytes.length, mime: artifact.mime },
verification: { record_path: args.verification_record.path, record_commit: args.verification_record.commit }, message: null };
const result = append(state, 'advanced', { op: args.op, proposal_digest: proposal, from: 'delivery', phase: 'complete', tasks: state.tasks, evidence: evidence.evidence }, identity, tx.appendRequest);
continuations.resume({ ...result, delivery: value.id, delivery_evidence: value });
const notice = journal.effects(state.business, state.request).find(e => e.kind === 'continuation' && e.stage === 'uncertain' && e.result?.intent?.phase === 'complete' && e.result.intent.revision === result.revision && e.result.intent.step === 'send_message');
if (!notice?.result?.message) refuse('request-notification-unavailable');
value.message = notice.result.message;
services.record?.(identity, 'request.deliver', result);
journal.finishEffect({ ...header, stage: 'committed', result: value, code: null });
return value;
});
});
}
async function artifactConsole(identity, args) {
exact(args, ['request','delivery']);
if (!id(args.request) || !id(args.delivery)) refuse('invalid-request');
const state = stateOf(identity.business, args.request); authorize(identity, state, 'artifactConsole');
const d = state.delivery_evidence;
if (!d || d.id !== args.delivery) refuse('request-not-found');
const artifact = await services.readReviewedArtifact(state, d.commit, d.artifact.path);
const current = stateOf(identity.business, args.request); authorize(identity, current, 'artifactConsole');
if (current.delivery !== d.id || artifact.sha256 !== d.artifact.sha256 || artifact.blob !== d.artifact.blob || artifact.bytes.length !== d.artifact.bytes) refuse('request-evidence-required');
return { mime: d.artifact.mime, filename: basename(d.artifact.path), base64: artifact.bytes.toString('base64'), sha256: artifact.sha256 };
}
return Object.freeze({ open, get, projects, propose, submit, question, answer, attach, cancel, advance, deliver, artifactConsole, modeConsole,
reviewScope: (business, request, task, project, snapshot, coder, reviewer) => {
if (typeof request !== 'string') return false;
const row = journal.listRequests().find(r => r.business === business && r.request === request);
if (!row) return false;
const state = journal.readRequest(business, request);
const staff = state.selection?.bindings.find(b => b.digest === state.plan_digest)?.plan.staff;
return ['planning', 'implementation', 'review', 'delivery'].includes(state.phase) && state.project === project &&
state.snapshot === snapshot && !!snapshot && staff?.coder === coder && staff?.reviewer === reviewer &&
(state.phase === 'planning' || state.tasks.includes(task));
},
hasWork: () => journal.listRequests().length > 0, launches: continuations.launches, launchResult: continuations.launchResult, manifestNeeded: continuations.manifestNeeded,
draftConsole: async (identity, args) => {
exact(args, ['request']); if (!id(args.request)) refuse('invalid-request');
@@ -454,16 +594,25 @@ export function createRequestLifecycle({ journal, services }) {
close: async () => { closing = true; await recovery; await Promise.allSettled([...pending.values()]); await services.catalog?.drain(); },
recover: ({ limit = Infinity } = {}) => {
if (closing) return Promise.resolve([]);
if (recovery) return recovery;
// An overlapping event may concern any already-inspected request. Keep a
// full reinspection cycle pending, using the same per-batch limit.
if (recovery) { recoveryAgain = true; return recovery; }
recovery = (async () => {
const states = [], rows = journal.listRequests();
const count = Math.min(rows.length, limit), start = cursor % (rows.length || 1);
for (let n = 0; n < count; n++) {
if (closing) break;
const { business, request } = rows[(start + n) % rows.length];
states.push(await resume(business, request));
}
cursor = (start + count) % (rows.length || 1);
const states = [];
let remaining = Math.min(journal.listRequests().length, limit);
do {
recoveryAgain = false;
const rows = journal.listRequests();
const count = Math.min(rows.length, limit, remaining), start = cursor % (rows.length || 1);
for (let n = 0; n < count; n++) {
if (closing) break;
const { business, request } = rows[(start + n) % rows.length];
states.push(await resume(business, request));
}
cursor = (start + count) % (rows.length || 1);
remaining = recoveryAgain ? journal.listRequests().length : Math.max(0, remaining - count);
if (count === 0) break;
} while (remaining > 0 && !closing);
return states;
})().finally(() => { recovery = null; });
return recovery;
+39 -7
View File
@@ -1,8 +1,8 @@
// Trusted host journal. No SQL, transition or effect interface is exposed to workers.
import { createHash } from 'node:crypto';
import { isAbsolute, normalize } from 'node:path';
import { ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
import { checkVars } from '../../business/src/vars.mjs';
import { ID_PATTERN, parseGiteaUrl } from '../../organization/src/vocabulary.mjs';
import { checkVars } from '../../organization/src/vars.mjs';
export class RequestError extends Error {
constructor(code) { super(code); this.code = code; }
@@ -123,10 +123,23 @@ function reduce(state, e) {
s.project = null; s.chat_project = null; s.mode = null; s.mode_revision = 0; s.plan_digest = null; s.selection = null; s.phase = 'selection';
s.wait = { kind: 'setup', code: b.code, subject: s.request, resume_phase: 'selection', observed_revision: s.revision };
} else if (e.kind === 'bootstrap.completed') {
exact(b, ['digest','project','chat_project','spec_commit','organization_revision'], [], code);
if (s.phase !== 'bootstrap' || s.plan_digest !== b.digest || b.project !== s.project || !id(b.chat_project) || !oid(b.spec_commit) || !text(b.organization_revision, 256)) refuse(code);
exact(b, ['digest','project','chat_project','spec_commit','organization_revision'], ['mode_revision','snapshot','candidate'], code);
const existing = s.selection?.bindings.find(p => p.digest === s.plan_digest)?.plan.existing;
if ((existing ? s.phase !== 'discovery' || s.attachment?.completed : s.phase !== 'bootstrap') ||
s.plan_digest !== b.digest || b.project !== s.project || !(existing ? nullableId(b.chat_project) : id(b.chat_project)) || !oid(b.spec_commit) || !text(b.organization_revision, 256)) refuse(code);
s.chat_project = b.chat_project; s.spec_commit = b.spec_commit; s.phase = 'discovery';
s.wait = { kind: 'setup', code: 'request-discovery-unavailable', subject: s.request, resume_phase: 'discovery', observed_revision: s.revision };
if (existing) {
if (!positive(b.mode_revision) || !nullableId(b.snapshot) || (b.snapshot === null) !== (b.candidate === null)) refuse(code);
s.mode_revision = b.mode_revision;
if (s.attachment) s.attachment.completed = true;
if (b.snapshot) {
validateCandidate(b.candidate);
if (b.candidate.project !== s.project || b.candidate.mode_revision !== s.mode_revision || b.candidate.spec_commit !== s.spec_commit) refuse(code);
s.candidate = structuredClone(b.candidate); s.snapshot = b.snapshot; s.phase = 'planning';
s.wait = { kind: 'setup', code: 'request-planning-unavailable', subject: s.request, resume_phase: 'planning', observed_revision: s.revision };
}
} else if (['mode_revision','snapshot','candidate'].some(k => k in b)) refuse(code);
} else if (e.kind === 'question.asked') {
// The PM's one outstanding question. The id is part of the body so a reader never recomputes it.
exact(b, ['op','proposal_digest','id','text','options'], [], code);
@@ -144,10 +157,14 @@ function reduce(state, e) {
} else if (e.kind === 'attach.selected') {
// The owner's pick of an existing project. It is a pick only: no mapping, effect or decision exists until the
// PM proposes exactly this project and the owner confirms the decision that proposal raises.
exact(b, ['op','proposal_digest','project'], [], code);
exact(b, ['op','proposal_digest','project'], ['mapping_revision'], code);
if (!id(b.op) || !hash(b.proposal_digest) || !key(b.project)) refuse(code);
if (s.phase !== 'selection' || s.route !== 'existing' || s.wait?.kind !== 'setup' || !PICKER_CODES.includes(s.wait.code)) refuse(code);
s.wait = { kind: 'setup', code: 'request-attachment-selected', subject: b.project, resume_phase: 'selection', observed_revision: s.revision };
if (b.mapping_revision !== undefined) {
if (!hash(b.mapping_revision)) refuse(code);
s.attachment = { project: b.project, mapping_revision: b.mapping_revision };
}
} else if (e.kind === 'prd.submitted') {
exact(b, ['candidate','decision','op','proposal_digest','authors','route'], [], code);
validateCandidate(b.candidate);
@@ -187,7 +204,7 @@ function reduce(state, e) {
exact(b, ['decision'], ['op','proposal_digest'], code);
if (('op' in b) !== ('proposal_digest' in b) || ('op' in b && (!id(b.op) || !hash(b.proposal_digest)))) refuse(code);
if (s.phase === 'delivered') refuse(code);
if (s.wait?.kind === 'decision' ? s.selection?.decision !== b.decision || s.wait.subject !== b.decision : b.decision !== null) refuse(code);
if (s.wait?.kind === 'decision' ? s.wait.subject !== b.decision : b.decision !== null) refuse(code);
s.phase = 'cancelled'; s.wait = null; s.selection = null;
} else refuse(code);
return s;
@@ -214,6 +231,8 @@ export function createRequestStore(store) {
const latest = new Map();
for (const launch of launches) if (active.has(launch.key)) latest.set(launch.instance, launch);
const last = launches.findLast(launch => launch.code && latest.get(launch.instance) === launch);
const delivery = store.get("SELECT result FROM request_effects WHERE business=? AND request=? AND kind='delivery' AND stage='committed' ORDER BY seq LIMIT 1", business, request);
if (delivery && state.phase === 'complete') { const value = JSON.parse(delivery.result); state.delivery = value.id; state.delivery_evidence = value; }
state.launch_refusal = last ? { instance: last.instance, code: last.code, key: last.key } : null;
return state;
};
@@ -270,8 +289,17 @@ export function createRequestStore(store) {
const op = step => store.get("SELECT result FROM request_effects WHERE business=? AND request=? AND op=? AND kind='bootstrap' AND stage='committed'",
e.business, e.request, `bootstrap:${e.body.digest}:${step}`);
if (e.kind === 'bootstrap.completed') {
const done = op('activate');
const existing = state.selection?.bindings.find(b => b.digest === state.plan_digest)?.plan.existing;
const done = op(existing ? 'attach' : 'activate');
if (!done || JSON.parse(done.result).project !== e.body.project) refuse('invalid-request-event');
if (existing) {
const { project, ...outcome } = JSON.parse(done.result);
if (!equal(outcome, Object.fromEntries(['spec_commit','mode_revision','snapshot','candidate'].map(k => [k, e.body[k]])))) refuse('invalid-request-event');
if (e.body.snapshot) {
const snap = store.get('SELECT body FROM request_snapshots WHERE business=? AND project=? AND id=?', e.business, project, e.body.snapshot);
if (!snap || JSON.parse(snap.body).records_commit !== e.body.candidate.records_commit) refuse('invalid-request-event');
}
}
} else if (op('mapping')) refuse('invalid-request-event');
}
store.run('INSERT INTO request_events(business,request,revision,kind,actor_role,actor_run,at,body) VALUES(?,?,?,?,?,?,?,?)',
@@ -334,6 +362,10 @@ export function createRequestStore(store) {
return row ? JSON.parse(row.body) : null;
},
projectState,
intent: (business, request) => {
const row = store.get("SELECT body FROM request_events WHERE business=? AND request=? AND kind='opened'", business, request);
return row ? JSON.parse(row.body).intent : null;
},
changeMode: ({ business, project, revision, mode, op, request }) => atomic(() => {
const last = projectState(business, project);
if ((last?.revision ?? 0) !== revision) refuse('request-mode-revision-conflict');
+63
View File
@@ -0,0 +1,63 @@
// Resolve only a confirmed request's assigned coder and a host-recorded checkout.
import { constants, openSync, closeSync, fstatSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { isAbsolute, join } from 'node:path';
import { workspaceDir } from '../../seat/src/session.mjs';
import { workspaceFiles } from '../../seat/src/workspace-files.mjs';
const id = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(s) && s !== '..';
const unavailable = () => ({ available: false, entries: [], truncated: false, contentAvailable: false, observedAt: new Date().toISOString() });
const flags = constants.O_RDONLY | constants.O_NOFOLLOW;
function proofFile(path) {
// Evidence directories are host-owned, but do not let a substituted ancestor redirect a read.
const fds = [];
try {
let dir = openSync('/', flags | constants.O_DIRECTORY); fds.push(dir);
const parts = path.split('/').filter(Boolean), file = parts.pop();
if (!file || parts.some(p => p === '.' || p === '..') || file === '.' || file === '..') throw new Error('unsafe proof');
for (const part of parts) { dir = openSync(`/proc/self/fd/${dir}/${part}`, flags | constants.O_DIRECTORY); fds.push(dir); }
const fd = openSync(`/proc/self/fd/${dir}/${file}`, flags); fds.push(fd);
const st = fstatSync(fd); if (!st.isFile() || st.size > 1024 * 1024 || st.nlink !== 1) throw new Error('unsafe proof');
return readFileSync(fd, 'utf8');
} finally { for (const fd of fds.reverse()) closeSync(fd); }
}
export function requestWorkspace({ dataRoot, state, events }) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || dataRoot.split('/').includes('..')) return unavailable();
if (!state || !id(state.business) || !id(state.request) || !id(state.project) || !id(state.pm) || !state.snapshot || !Array.isArray(state.tasks) || !state.tasks.length) return unavailable();
const binding = state.selection?.bindings?.filter(b => b.digest === state.plan_digest) ?? [];
if (binding.length !== 1 || binding[0].plan.business !== state.business || binding[0].plan.request !== state.request || binding[0].plan.project !== state.project || binding[0].plan.staff?.pm !== state.pm || !id(binding[0].plan.staff?.coder)) return unavailable();
const coder = binding[0].plan.staff.coder;
if (!Array.isArray(events) || events.length > 10000) return unavailable();
const rows = events.map(e => { try { return { ...e, body: typeof e.body === 'string' ? JSON.parse(e.body) : e.body }; } catch { return null; } }).filter(Boolean);
for (const task of state.tasks) {
if (typeof task !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,159}$/.test(task)) return unavailable();
const created = rows.find(e => e.kind === 'task.created' && e.actor_role === state.pm && e.subject === task && e.body?.request === state.request && e.body?.project_key === state.project && e.body?.requirement_snapshot === state.snapshot);
const assigned = rows.findLast(e => e.kind === 'task.assigned' && e.subject === task);
if (!created || assigned?.actor_role !== state.pm || assigned.body?.role !== coder) return unavailable();
}
const root = workspaceDir(dataRoot, state.business, coder), checkout = join(root, state.project);
if (/[\n\r;,]/.test(checkout)) return unavailable();
const launches = rows.filter(e => e.kind === 'session.launched' && e.actor_role === coder);
if (launches.some(e => e.body?.thread)) return unavailable();
let proven = false;
for (const e of launches.slice(-32)) {
if (!id(e.subject)) continue;
try {
const bundle = join(dataRoot, 'launches', state.business, e.subject, 'bundle');
const manifest = JSON.parse(proofFile(join(bundle, 'manifest.json')));
if (manifest.business !== state.business || manifest.instance !== coder || manifest.run !== e.subject || !Array.isArray(manifest.projects) || !manifest.projects.includes(state.project)) continue;
// The manifest projects field includes unsafe, kept entries. The host prompt records that
// outcome; do not treat a kept entry as a verified checkout.
const prompt = proofFile(join(bundle, 'prompt.md'));
if (createHash('sha256').update(prompt).digest('hex') !== manifest.files?.['prompt.md']) continue;
const lines = prompt.split('\n').filter(x => x.startsWith('- Project clones: '));
if (lines.length !== 1) continue;
const items = lines[0].slice('- Project clones: '.length).split('; ');
const item = items.find(x => x.startsWith(`${state.project} at ${checkout}, `));
if (item && (item.startsWith(`${state.project} at ${checkout}, on branch `) || item.startsWith(`${state.project} at ${checkout}, on no branch `))) proven = true;
} catch { /* missing or unsafe evidence never selects a workspace */ }
}
if (!proven) return unavailable();
try { return workspaceFiles(checkout); } catch { return unavailable(); }
}
+6 -3
View File
@@ -1,6 +1,7 @@
import { requestArchived } from './broker.mjs';
// Host-only admission. The journal's readCandidate/authority/approval adapters read current host state.
import { exact, id, key, text, digest, refuse } from './request-store.mjs';
export function createRequirementCatalog({ journal, readSpec, builtinBinding }) {
export function createRequirementCatalog({ journal, readSpec, builtinBinding, projectArchived, threadProject }) {
const append = (state, kind, body) => journal.appendRequest({ business: state.business, request: state.request,
expectedRevision: state.revision, kind, body, actor: { role: null, run: null } });
const eventFor = snap => journal.snapshotEvents(snap.business, snap.project).filter(e => e.snapshot === snap.id).at(-1);
@@ -11,7 +12,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding })
for (const row of journal.listRequests()) {
if (row.business !== business) continue;
const state = journal.readRequest(business, row.request);
if (state.project === project && (state.candidate || state.snapshot)) append(state, 'prd.invalidated', { code });
if (state.project === project && !['complete','delivered','cancelled'].includes(state.phase) && (state.candidate || state.snapshot)) append(state, 'prd.invalidated', { code });
}
});
}
@@ -52,6 +53,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding })
const binding = journal.submission(state.business, state.request, args.decision) ?? refuse('prd-candidate-required');
const old = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
if (old) return structuredClone(journal.snapshot(old.result.snapshot));
if (requestArchived(state, { projectArchived, threadProject })) refuse('project-archived');
if (state.phase !== 'prd-review' || state.wait?.subject !== args.decision || digest(state.candidate) !== digest(binding.candidate)) refuse('prd-admission-revoked');
const authority = journal.authority(state, binding), approval = journal.approval(state, binding);
const material = await journal.readCandidate(binding.candidate, state.business);
@@ -61,6 +63,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding })
return journal.transaction(() => {
const prior = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
if (prior) return structuredClone(journal.snapshot(prior.result.snapshot));
if (requestArchived(state, { projectArchived, threadProject })) refuse('project-archived');
const current = journal.readRequest(state.business, state.request);
if (current.revision !== state.revision || current.wait?.subject !== args.decision || journal.projectState(state.business, state.project)?.revision !== binding.candidate.mode_revision) refuse('prd-admission-revoked');
if (digest(journal.authority(current, binding)) !== digest(authority) || digest(journal.approval(current, binding)) !== digest(approval)) refuse('prd-admission-revoked');
@@ -91,7 +94,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding })
const result = journal.snapshotEvent({ business: identity.business, project: args.project, snapshot: snap.id, kind: 'revoked', cause });
for (const row of journal.listRequests()) {
const state = journal.readRequest(row.business, row.request);
if (row.business === identity.business && state.project === args.project && state.snapshot === snap.id) append(state, 'prd.invalidated', { code: 'requirement-snapshot-revoked' });
if (row.business === identity.business && state.project === args.project && !['complete','delivered','cancelled'].includes(state.phase) && state.snapshot === snap.id) append(state, 'prd.invalidated', { code: 'requirement-snapshot-revoked' });
}
return result;
});
+406 -70
View File
@@ -1,45 +1,63 @@
import { autonomyHost, authorityHost } from './autonomy-host.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationPlugins } from '../../plugins/src/organization.mjs';
import { pluginService } from '../../plugins/src/tools.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
import { lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
import { createRequestStore } from './request-store.mjs';
import { createRequestLifecycle } from './request-lifecycle.mjs';
import { requestWorkspace } from './request-workspace.mjs';
import { readReviewedArtifact } from '../../seat/src/artifact.mjs';
import { executeBootstrap } from './request-bootstrap.mjs';
import { hostPreparation } from './request-host.mjs';
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
import { managedPaths, managedState } from '../../organization/src/project-bootstrap.mjs';
import * as projectModule from '../../project/src/index.mjs';
import * as libraryModule from '../../library/src/index.mjs';
import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Broker, projectArchived, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { launchSettings } from '../../organization/src/launch-settings.mjs';
import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.mjs';
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { ProxyPage } from './proxy-page.mjs';
import { createModelCatalogue } from './model-catalogue.mjs';
import { sha256 } from '../../model-proxy/src/common.mjs';
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { createProxyLock } from './proxy-lock.mjs';
import { verifyRestart } from '../../model-proxy/src/admin.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ORG_PREFIX, orgDefinitionName } from '../../organization/src/definitions.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs';
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { 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 { loadWithCopies } from '../../organization/src/agent-copies.mjs';
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { selectModelProvider } from '../../plugins/src/model-provider-selection.mjs';
import { busOrganization } from './organization-context.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart, giteaReference } from './gitea-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { loadAccess } from '../../organization/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
import { createUsage } from './usage.mjs';
@@ -50,22 +68,24 @@ import { discordProcessingHooks } from '../../discord/src/processing.mjs';
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.fire']);
// FED R4 (#1801, design 3.7): the revocation timer's period.
export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
// U4a (#1873): it serves the businesses whose task-tracker module is Vikunja; the others get theirs
// through the plug-in loader, the built-in store by default (see trackerOf in startBroker).
// REQ-HARN-2, REQ-PLUG-2 (decision 0b81b0c5): one web service per business. Its search source is
// the "search-source" plug-in the business's organization file switches on: searxng-url (the
// business var web.searxng) or searxng-bundled. With none, web.search refuses "not configured" and
// web.fetch still works. A plug-in that refuses to load is logged and leaves search off; it never
// stops the broker. Under harness-builtin the harness serves web itself, so the broker gets no
// service and refuses the web verbs.
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps } = {}) {
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) {
const services = new Map();
for (const [name, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
await loader.load(b.plugins ?? {});
const [source] = loader.modules('search-source');
// "harness-builtin" is served by the harness itself, not the broker: no broker web verbs.
@@ -81,7 +101,10 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
// with a re-pin, and a re-pin restarts the bus.
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
// AU (#1873): the same pass records each module's countsAccount() answer, which decides whether an
// account quota covers authority hires. It is read once here; an installed harness whose lock pin
// has changed since answers null (not covered) until the bus restarts and reads the new module.
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
const levels = new Map();
let names = [];
try {
@@ -89,17 +112,38 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
} catch (e) {
log({ event: 'harness-levels-unread', message: e.message });
}
let pins = {};
if (dataRoot !== null) {
try { pins = readPluginLock(dataRoot).plugins; names = [...new Set([...names,...Object.keys(pins).filter(name=>hasModule('harness',name,dataRoot))])]; }
catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); }
}
const accounts = new Map();
for (const name of names) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
if ((await loader.load({ harness: [name] })).refused.length) continue;
const [module] = loader.modules('harness');
try {
const list = loader.modules('harness')[0].levels();
const list = module.levels();
if (Array.isArray(list) && list.every((level) => typeof level === 'string')) levels.set(name, Object.freeze([...list]));
} catch (e) {
log({ event: 'harness-levels-failed', name, message: e.message });
}
// AU (#1873): whether this harness's launches spend launch.account, as its module declares.
try {
const counts = module.countsAccount();
if (typeof counts === 'boolean') accounts.set(name, counts);
} catch (e) {
log({ event: 'harness-account-failed', name, message: e.message });
}
}
return (name) => levels.get(name) ?? null;
const lookup = (name) => levels.get(name) ?? null;
lookup.names = [...levels.keys()].sort();
const pinned = (name) => {
if (!Object.hasOwn(pins, name)) return true;
try { return readPluginLock(dataRoot).plugins[name]?.sha256 === pins[name].sha256; } catch { return false; }
};
lookup.countsAccount = (name) => (accounts.has(name) && pinned(name) ? accounts.get(name) : null);
return lookup;
}
// Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old
// objects, as C2-03a requires; no caller may select a repo, commit or sync --accept.
@@ -239,6 +283,7 @@ function modelRunInventory(dataRoot, bound, credentials) {
export async function startBroker({
dataRoot,
instanceRoot = null,
businesses,
launches = [],
readers = [],
@@ -262,6 +307,7 @@ export async function startBroker({
consoleOwners = [],
// P1 validated sidecar from the trusted host; never a socket request setting.
proxy = null,
org = null, instanceDataRoot = null, instanceConfigDir = null,
// Host-only preparation/admission capabilities. The host builds its own from the organization writer and `trackers`
// (request-host.mjs); anything given here replaces the host's, and a capability nothing supplies leaves a setup wait.
requestServices = {},
@@ -269,7 +315,16 @@ export async function startBroker({
// the host's new-project preparation reads each business's tracker root board from this one.
trackers = null,
builtinBinding = null,
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
// reload of the organization file below re-applies this snapshot, never the root file.
copies = null,
}) {
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
throw new BusError('invalid-host-request');
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot, snapshot: copies?.org === id ? copies : null });
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
throw new BusError('invalid-host-request');
@@ -281,8 +336,8 @@ export async function startBroker({
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null;
let modelKeys = null, modelTimer = null, modelSweep = null;
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null, proxyPort = null;
let modelKeys = null, modelTimer = null, modelSweep = null, modelCatalogues = null;
const modelRuns = new Map();
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null;
try {
@@ -325,7 +380,12 @@ export async function startBroker({
// Store's writer lock admits one bus per data root before it holds the management client.
if (proxy) {
if (!organization) throw new BusError('not-configured');
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy, receipt: proxyReceipt });
if (org !== null) {
if (!org || typeof org.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(org.id) || typeof instanceDataRoot !== 'string' || typeof instanceConfigDir !== 'string') throw new BusError('invalid-host-request');
proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${proxy.port}` });
}
modelProxyClient = await loadManagementClient({ configDir: org ? instanceConfigDir : organization.dir, proxy, receipt: proxyReceipt });
proxyPort = proxy.port;
}
// Row 234 (#1740, r6 X4 third bullet): each host grant record gives its agent a store reference to
@@ -344,11 +404,42 @@ export async function startBroker({
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
// #1873 CB2 (security review P2): a copy slot an issue staged before a crash, whose binding holds
// no grant for it, goes before Credentials loads and before any issue can run.
if (storeRoot) {
const reader = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot });
const documents = Object.keys(businesses).map((id) => {
try {
return [id, reader.read(id).document];
} catch {
return [id, null];
}
});
for (const entry of sweepCopySlots(storeRoot, documents, references))
process.stderr.write(`copies: ${JSON.stringify({ event: 'copy-slot-swept', ...entry })}\n`);
}
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
if (storeRoot && Object.values(references).some(refs => Object.values(refs ?? {}).some(ref => ref?.store === true)))
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
// #1873 T1 follow-up (org-defscan): no organization definition's text holds a credential this store
// holds. The boot copy's org: definitions first, then every entry of the file itself, named or not.
// A hit refuses the start; the refusal names the definition and key, never the text.
const clean = value => credentials.assertClean(value);
for (const [id, b] of Object.entries(businesses)) {
scanDefinitions(b.definitions, clean, { prefix: ORG_PREFIX });
if (!organization) continue;
const raw = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }).readRaw(id);
if (raw.readable) scanDefinitions(raw.document.definitions, clean);
}
// The boot copy snapshot's definitions (the root agents file's, A4): every reload re-applies them.
if (copies?.definitions !== undefined) scanDefinitions(copies.definitions, clean, { boot: true });
if (proxy) {
const inventory = modelRunInventory(dataRoot, modelRuns, credentials);
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: organization.dir,
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: org ? instanceConfigDir : organization.dir, org: org?.id ?? null, lock: proxyLock,
baseUrl: `http://127.0.0.1:${proxy.port}`, receipt: proxyReceipt, rotateGraceMinutes: proxy.rotateGraceMinutes ?? 120,
runs: inventory.runs,
stopRuns: async runs => {
@@ -363,9 +454,10 @@ export async function startBroker({
try { await modelKeys.reconcile(); } catch (error) { proxyReceipt({ action: 'proxy.boot-refused', code: error.code }); }
}
const configureProxyPage = () => {
proxyPage = new ProxyPage({ client: modelProxyClient, instanceTag: sha256(organization.dir).slice(0, 12),
proxyPage = new ProxyPage({ client: modelProxyClient, instanceTag: sha256(org ? instanceConfigDir : organization.dir).slice(0, 12), org: org?.id ?? null, lock: proxyLock,
// P2 supplies the model metadata; only the matching business's record
// participates in this owner view. No credential value is read here.
onChange: phase => { if (phase === 'begin') modelCatalogues?.begin?.(); else if (phase === 'end') modelCatalogues?.end?.(); else modelCatalogues?.invalidate(); },
keyRecords: business => credentials.status().filter(r => r.service === 'model' && r.instance?.startsWith(business + '/'))
.map(r => ({ keyId: r.keyId, agent: r.instance.slice(business.length + 1), issuedAt: r.issuedAt, state: r.state })),
});
@@ -374,12 +466,43 @@ export async function startBroker({
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
const levels=organization?await harnessLevels():()=>null;
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot ?? dataRoot,exists:(kind,name)=>hasModule(kind,name,instanceRoot ?? dataRoot)}):null;
const levels=organization?await harnessLevels({dataRoot:instanceRoot ?? dataRoot}):()=>null;
// Row 333 R4 (#1831): the writer's key lineage lookup, ids only, from this bus's own credential
// records. A daily key quota moved within one agent's lineage keeps its since and its count.
const keyLineage=(business,keyId)=>modelKeyLineage(credentials.status(),business,keyId);
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels,keyLineage}):null;
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
// Resolve legacy table entries with the enabled modules' declarations. A
// missing or ambiguous mapping never becomes an available route.
async function catalogueFor(id, options = {}) {
const snapshot = settings.writer.read(id);
const current = settings.current(id);
const names = current.plugins?.['model-provider'] ?? [];
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
const loaded = await loader.load({'model-provider': names});
const catalogue = await modelCatalogues.read(id, options);
if (loaded.refused.length || settings.writer.read(id).revision !== snapshot.revision) return {...catalogue, status:'unavailable', legacy:{}};
const modules = loader.snapshot('model-provider');
const resolveLegacy = entry => {
const selected = selectModelProvider(modules, entry);
if (!selected) return null;
let result; try { result = selected.module.route({selection:entry,harness:null}); } catch { return null; }
return typeof result?.upstream === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(result.upstream)
? {module:selected.name,upstream:result.upstream,model:entry.model} : null;
};
const legacy = {};
for (const [key, entry] of Object.entries(current.launch?.models ?? {})) {
if (!entry.module) {
const mapping = resolveLegacy(entry);
if (mapping) legacy[key] = mapping;
}
}
const result = {...catalogue, legacy};
// This trusted in-process helper is never serialized into the client view.
Object.defineProperty(result, 'resolveLegacy', {value:resolveLegacy});
return result;
}
// Row 194 (#1692, brief item 8, row 199 D13): a fire or a delete revokes the agent's credentials
// after the writer's checks and before its write, under the writer's lock (row 239). Fire revokes
// each service of the entry and keeps the store directory, delete calls remove (sage-1593). A
@@ -416,16 +539,16 @@ export async function startBroker({
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
}
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null;
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null;
const usageLog = entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`);
const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null;
// Row 333 R3 (#1831): the usage view marks key quotas that match no hired agent; limits answers.
let orphanedKeys = () => null;
const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun,
const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun,
proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) });
proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => {
if (!modelKeys) return null;
await modelKeys.verifyIdentity();
return modelProxyClient;
@@ -435,7 +558,7 @@ export async function startBroker({
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
// each ask, so a limit, a reset or a switched-on check takes effect without a restart.
const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`);
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
keyForSubject: (business, subject) => modelUsageKey(credentials.status(), modelRuns, business, subject),
keyLineages: business => modelKeyLineages(credentials.status(), business) });
orphanedKeys = business => limits.orphanedKeys(business);
@@ -443,6 +566,7 @@ export async function startBroker({
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
const recordArgs = context => ({ ...context, ...projectModule.recordContext({ ...context, dataRoot: instanceDataRoot ?? dataRoot, orgRoot: org ? dataRoot : null }) });
let broker;
processing = createProcessing({ dataRoot, ...discordProcessingHooks(dataRoot), containerStop: createContainerStopper({ dataRoot }),
onTransition: () => broker.reconcileProcessing(),
@@ -507,10 +631,10 @@ export async function startBroker({
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
function reload(id) {
try {
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const b = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = withoutPending(id, busBusiness(b, resolved));
const next = withoutPending(id, busOrganization(b, resolved));
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
@@ -527,7 +651,7 @@ export async function startBroker({
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
// the product pending. Both readiness records must exist before even the broker's reload begins.
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : null;
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business');
@@ -538,10 +662,10 @@ export async function startBroker({
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
if (revision !== found.after) refuse('bootstrap-revision-stale');
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const b = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const full = busBusiness(b, resolved);
const full = busOrganization(b, resolved);
const entry = full.projects?.[project];
const here = managedPaths({ dataRoot, business: id, project });
if (!entry || entry.kind === 'library' || entry.root !== here.root || entry.repo !== here.repo
@@ -565,9 +689,50 @@ export async function startBroker({
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
// broker's reload now follows both readiness records (#802).
function admission({ business: id, project }) {
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
if (projects !== undefined) {
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
if (thread != null) {
const row = broker.threadsState(id).find(r => r.thread === thread && r.session === session);
if (!row) return no('session-not-found');
if (row.archived) return no('project-archived');
}
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
if (instance !== undefined) {
let now;
try { now = writer?.read(id); } catch { return no('organization-unreadable'); }
if (now?.revision === undefined) return no('organization-unreadable');
if (now.revision !== launched) return no('organization-changed');
// org-defscan (#1873): the launch boundary. A credential issued since the launcher's scan
// runs on this same loop, so the org: definition the instance names is checked again here,
// in the reply the launcher spawns after with no further wait.
const name = orgDefinitionName(now.document.roles?.[instance]?.definition);
if (name !== null) {
const clean = value => credentials.assertClean(value);
let held;
try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; }
if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL);
}
}
for (const p of projects) {
const result = admission({ business: id, project: p });
if (!result.ready) return result;
if (instance !== undefined) {
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
const barred = broker.projectBarred(id, instance, p);
if (barred) return no(barred);
}
}
return { ready: true, revision: null, code: null };
}
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || typeof project !== 'string' || !Object.hasOwn(businesses, id)) return no('project-unknown');
if (projectArchived(store, id, project)) return no('project-archived');
let revision = null, declared = false;
try {
if (writer) {
@@ -583,21 +748,39 @@ export async function startBroker({
return no(declared ? 'project-pending' : 'project-unknown');
}
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1): the task-tracker module each business uses, fixed for this
// start. The organization file's "task-tracker" plug-in names it. With none, a business the boot
// config gives a Vikunja tracker keeps Vikunja (so does every business when an in-process caller
// hands `tasks` and no `trackers`), and any other business gets the built-in store. A business that
// names another module while the boot config also gives it a Vikunja tracker refuses the boot:
// two trackers can't both hold its tasks.
const trackerOf = new Map();
for (const [id, b] of Object.entries(businesses)) {
const named = b?.plugins?.['task-tracker'];
const tracked = trackers !== null && typeof trackers === 'object' && Object.hasOwn(trackers, id);
if (named !== undefined && named !== 'vikunja' && tracked) throw new BusError('tracker-config');
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
pluginService: pluginService({dataRoot:instanceRoot ?? dataRoot,read:id=>organization ? loadOrg(id) : businesses[id]}),
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
const readCandidate = (candidate, business) => {
// The PRD reader goes through the same record resolver as the file adapters, so both roots present refuses here too.
const readCandidate = async (candidate, business) => {
const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate;
return readPrdMaterial({ business: services.prdBusiness(business), dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
const records = recordArgs({ business: services.prdBusiness(business), project });
return readPrdMaterial({ business: records.business, dataRoot: records.dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
requirement_ids: candidate.requirement_ids ?? candidate.requirements.map(r => r.id) });
};
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
builtinBinding: services.builtinBinding });
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers }) : {}), ...requestServices, ...services,
readSpec: ({ business, project }) => projectModule.readSpec(recordArgs({ business: services.prdBusiness(business), project })),
builtinBinding: services.builtinBinding, projectArchived: services.projectArchived, threadProject: services.threadProject });
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile(recordArgs({ business: services.prdBusiness(state.business), project: state.project, path: record.path, commit: record.commit })), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec(recordArgs({ business: services.prdBusiness(business), project })) }) : {}), ...requestServices, ...services,
// The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait.
...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: {
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier,
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, projectArchived: services.projectArchived, threadProject: services.threadProject,
chat: { state: args => broker.chatProjectState(args), associate: args => broker.associateChat(args) } } })) } : {}) } }));
},
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
@@ -624,6 +807,11 @@ export async function startBroker({
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
// #1873 CB2: credentials.issue for an agent copy's bound Gitea service, verified live at the server.
copyGrant: organization && storeRoot
? copyGrant({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
copies, organizations: () => Object.keys(businesses) })
: null,
usage,
limits,
store,
@@ -648,49 +836,91 @@ export async function startBroker({
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
writeRecord: context => processing.work(() => projectModule.writeRecord({ ...context, dataRoot })),
listFiles: context => projectModule.listProjectFiles({ ...context, dataRoot }),
readFile: context => projectModule.readProjectFile({ ...context, dataRoot }),
writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }),
readSpec: context => projectModule.readSpec(recordArgs(context)),
writeRecord: context => processing.work(() => projectModule.writeRecord(recordArgs(context))),
listFiles: context => projectModule.listProjectFiles(recordArgs(context)),
readFile: context => projectModule.readProjectFile(recordArgs(context)),
writeFile: context => projectModule.writeHumanFile(recordArgs(context)),
ProjectError: projectModule.ProjectError,
},
// 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?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
const current = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busOrganization(current, resolved));
if (!sameFixed(booted.get(id), live) || canonicalJson(live.projects) !== known.get(id)) throw new BusError('project-remote-protected');
if (!Object.hasOwn(live.projects, args.project)) throw new BusError('project-remote-unknown');
if (verb === 'project.remote.get.console') return getProjectRemote(writer, id, args.project);
writer.setProjectRemote(id, args, actor);
const next = structuredClone(live);
next.projects[args.project].remote = args.remote;
broker.reloadBusiness(id, next);
businesses[id] = next;
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return plugins.read(id);
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
if(verb==='instances')return instances.read(id,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
if(verb==='routes') {
const view = settings.routes(id);
if (!settings.current(id).plugins?.['model-provider']?.length) return { ...view, catalogue: { status: 'disabled', observedAt: null, models: [], notices: [] } };
return catalogueFor(id).then(catalogue => ({ ...settings.routes(id), catalogue }));
}
// Row 298 (#1804, Q228): the chief's grant scope as the file stands, the fallback shown.
if(verb==='grants.scope')return instances.grantScopeRead(id);
// 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,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
// Internal read-only path: no reload, journal, credential or publication.
if(verb==='instance.hireCheck')return instances.checkHire(id,args);
let result;
if (['limits.set', 'instances.set'].includes(verb) && settings.current(id).plugins?.['model-provider']?.length) {
return catalogueFor(id, { fresh: true }).then(catalogue => {
context?.beforeWrite?.();
const result = verb === 'limits.set' ? withTokens(id, settings.set(id,args,actor,catalogue)) : instances.set(id,args,actor,catalogue);
const live=reload(id);
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
return INSTANCE_VERBS.has(verb) ? {...result,restart:!live} : 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);
else if(verb==='routes.set') {
const current = settings.current(id);
if (current.plugins?.['model-provider']?.length) return catalogueFor(id, { fresh: true }).then(catalogue => {
const result = settings.setRoutes(id,args,actor,catalogue);
reload(id); limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
return { ...result, catalogue };
});
result=settings.setRoutes(id,args,actor);
}
else if(verb==='plugins.set') {
if(actor==='tester' && Object.entries(args.plugins ?? {}).some(([kind,value])=>(Array.isArray(value)?value:[value]).some(name=>name!==null && !hasModule(kind,name)))) throw new BusError('human-required');
result=plugins.set(id,args,actor);modelCatalogues?.invalidate();
}
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 298: the human's grant scope, through the instance writer.
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name,revision:args.revision},{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);
@@ -701,6 +931,14 @@ export async function startBroker({
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}):null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],countsAccount:levels.countsAccount,hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null,
hires: organization ? (()=>{
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry,
// #1873 CB1: a copy binding names a copy this bus booted with; no snapshot, no binding.
copies:id=>copies?.org===id?copies:null});
return authorityHost(service,{modelKeys,reload,proxyOrganization});
})() : null,
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
@@ -711,13 +949,23 @@ export async function startBroker({
return proxyPage.request(verb, args, scope);
},
proxyInit: organization ? async args => {
const settings = await initializeProxy({ configDir: organization.dir, dataRoot, ...args });
const settings = await initializeProxy({ configDir: organization.dir, dataRoot: instanceRoot ?? dataRoot, ...args });
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt });
proxyPort = settings.port;
// #1873: an org bus that initializes the shared proxy also holds its write lock from now on.
if (org && !proxyLock) proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${settings.port}` });
configureProxyPage();
return settings;
} : null,
// #1873: clear the uncertain-write fence only on restart evidence this host re-proves: the
// process on the proxy port is new, alive and started after the fence was set.
proxyFenceClear: organization ? async ({ evidence }) => {
if (!proxyLock || !modelProxyClient) throw new BusError('not-configured');
return proxyLock.clear({ restarted: true, read: () => modelProxyClient.getKeys(), verify: previous => verifyRestart(evidence, { fence: previous, port: proxyPort }) });
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }),
organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
@@ -776,12 +1024,43 @@ export async function startBroker({
}
const bound = launches.map(bindLaunch);
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
if (tasks) {
adapter = await tasks({ broker, credentials, businesses, processing });
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
// U4a: one adapter per task-tracker module in use, behind one handle that routes each call by the
// caller's business. The host's `tasks` factory is the Vikunja module. Any other module, and Vikunja
// when the host gave no factory but an organization file names it, loads through the plug-in loader
// with the boot config's `trackers`. A module that refuses to load or has no factory refuses
// startup with tracker-config. A module whose factory makes no
// adapter refuses the boot (invalid-adapter), and every adapter made so far is closed. The host's
// factory is always made when given, used or not, so its own checks of `trackers` still run.
const made = new Map();
adapter = { async close() { for (const a of made.values()) await a?.close?.(); } };
const pluginLog = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`);
for (const name of new Set([...(tasks ? ['vikunja'] : []), ...trackerOf.values()])) {
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
}
// A host may omit its implicit Vikunja adapter, retaining the legacy unconfigured path.
// An explicitly selected module must always supply a factory.
if (!factory && name === 'vikunja' && !tasks && !Object.values(businesses).some(b => b?.plugins?.['task-tracker'] === 'vikunja')) continue;
if (typeof factory !== 'function') throw new BusError('tracker-config');
const one = await factory({ broker, credentials, businesses, processing });
made.set(name, one);
if (typeof one?.handle !== 'function' || !Number.isSafeInteger(one.timeout) || one.timeout < 1)
throw new BusError('invalid-adapter');
}
const web = await webServices(businesses);
if (made.size) {
adapter.timeout = Math.max(...[...made.values()].map((a) => a.timeout));
adapter.handle = async (cap, verb, args) => {
const one = made.get(trackerOf.get(broker.identity(cap).business));
if (!one) throw new BusError('tracker-unconfigured');
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot});
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
@@ -809,7 +1088,7 @@ export async function startBroker({
web: (business) => web.get(business) ?? null,
host: host ? { busy: (...args) => host.busy(...args),
run: (...args) => processing.work(() => host.run(...args)) } : null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
tasks: adapter?.handle ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
peerReader,
@@ -934,6 +1213,20 @@ export async function startBroker({
.filter((c) => c.instance === business + '/' + role)
.map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) }));
}
// #1873 T1 follow-up (org-defscan): the launcher's check of the org: definition it is about to
// launch, as it read it from the organization file. Answers where a held credential sits, or null;
// never the text. A malformed ask refuses, so the launch refuses.
function definitionScan({ business, definition, entry } = {}) {
if (typeof business !== 'string' || !Object.hasOwn(businesses, business) || typeof definition !== 'string'
|| !definition.startsWith(ORG_PREFIX) || definition.length > 128 || entry === null || typeof entry !== 'object' || Array.isArray(entry))
throw new BusError('invalid-request');
let size;
try { size = JSON.stringify(entry).length; } catch { throw new BusError('invalid-request'); }
if (size > 4 * 1024 * 1024) throw new BusError('invalid-request');
const clean = value => credentials.assertClean(value);
try { clean(definition); } catch { return { held: 'name' }; }
return { held: heldPath(entry, clean) };
}
// C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass
// still running skips the tick. Only codes are logged, never git's output.
const timed = Object.values(businesses).flatMap(business => Object.entries(business.projects ?? {})
@@ -986,11 +1279,16 @@ export async function startBroker({
}
// One bounded queue read at a time. The service applies the configured
// 1..59 second poll deadline; this tick only checks whether a read is due.
const usageTick = () => { proxyUsage.tick().catch(() => usageLog({ action: 'proxy.usage', code: 'proxy-usage-read-failed' })); };
usageTimer = setInterval(usageTick, 1000);
usageTimer.unref();
usageTick(); // Optional telemetry never blocks the native ready answer.
await requestLifecycle.recover();
if (instanceRoot === null) {
const usageTick = () => { proxyUsage.tick().catch(() => usageLog({ action: 'proxy.usage', code: 'proxy-usage-read-failed' })); };
usageTimer = setInterval(usageTick, 1000);
usageTimer.unref();
usageTick(); // Optional telemetry never blocks the native ready answer.
}
// Startup recovery waits while processing is halted or invalid, like the periodic pass below;
// processing.resume schedules it again.
const startState = processing.read();
if (!startState.halted && !startState.invalid) await requestLifecycle.recover();
requestTimer = setInterval(() => {
const state = processing.read();
if (state.halted || state.invalid) return;
@@ -1014,8 +1312,45 @@ export async function startBroker({
bindLaunch,
recoverLaunch: ({ modelKeyId, ...record }) => broker.recoverLaunch(record),
endLaunch: (r) => { const out = broker.endLaunch(r); modelRuns.delete(r.business + ':' + r.run); return out; },
modelAdmission: async ({ business, instance, modelKey, harness, revision, entry: expected, provider, model, account, route, transport }) => {
const refused = code => ({ allow: false, code });
if (!settings || !modelCatalogues) return refused('model-catalogue-unavailable');
try { proxyLock?.assertCertain(); } catch { return refused('proxy-uncertain'); }
const snapshot = settings.writer.read(business);
if (snapshot.revision !== revision) return refused('model-provider-unavailable');
const current = settings.current(business), role = current.roles?.[instance];
if (!role) return refused('model-provider-unavailable');
const resolved = resolveInstance({ system: organization.system, business: current, instance });
const vars = resolved.vars;
if (!['primary', 'fallback'].includes(route)) return refused('model-provider-unavailable');
const prefix = route === 'primary' ? '' : 'fallback.';
if (vars[prefix + 'model'] !== modelKey || vars[prefix + 'harness'] !== harness) return refused('model-provider-unavailable');
const entry = current.launch?.models?.[modelKey];
if (!entry || canonicalJson(entry) !== canonicalJson(expected ?? null) || entry.provider !== provider || entry.model !== model) return refused('model-provider-unavailable');
const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
if ((await harnessLoader.load({harness:[harness]})).refused.length) return refused('model-provider-unavailable');
const countsAccount = harnessLoader.modules('harness')[0].countsAccount();
if (typeof countsAccount !== 'boolean' || account !== (countsAccount ? current.launch?.account?.name ?? null : null)) return refused('model-provider-unavailable');
const names = current.plugins?.['model-provider'];
if (!Array.isArray(names) || !names.length || (entry.module && !names.includes(entry.module))) return refused('model-provider-unavailable');
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
if (loaded.refused.length) return refused('model-provider-unavailable');
const selected = selectModelProvider(loader.snapshot('model-provider'), entry);
if (!selected) return refused('model-provider-unavailable');
const {name:moduleName,module} = selected;
const descriptor = module.route({selection:entry,harness});
if (descriptor?.code) return refused(descriptor.code === 'model-harness-incompatible' ? descriptor.code : 'model-provider-unavailable');
const expectedTransport = (({routeId, endpoint, api, piProvider, compat}) => ({routeId, endpoint, api, piProvider, compat}))(descriptor);
if (canonicalJson(expectedTransport) !== canonicalJson(transport ?? null)) return refused('model-provider-unavailable');
const catalogue = await modelCatalogues.read(business,{fresh:true});
if (settings.writer.read(business).revision !== snapshot.revision) return refused('model-provider-unavailable');
const selection = {upstream:entry.upstream ?? descriptor.upstream,model:entry.model};
const answer = module.preflight({selection,harness,catalogue:{...catalogue,models:(catalogue.models ?? []).filter(m => m.module === moduleName)}});
if (answer?.allow === true) return {allow:true};
return refused(['model-provider-unavailable','model-catalogue-unavailable','model-harness-incompatible'].includes(answer?.code) ? answer.code : 'model-provider-unavailable');
},
modelPreflight: async ({ business, role }) => {
const current = settings ? settings.current(business) : businesses[business];
const current = organization ? loadOrg(business) : businesses[business];
if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
},
@@ -1065,6 +1400,7 @@ export async function startBroker({
sessionClearStart: (r) => broker.sessionClearStart(r),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
definitionScan,
launches: bound,
readers: readCaps,
async close() {
+10 -9
View File
@@ -9,11 +9,12 @@ const LIMIT = 65536;
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
// Preserve every code and scoped reason, adding only static rail/next-step guidance.
const refusal = (e, other) => {
const error = e instanceof BusError ? e : new BusError(other);
return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep,
...(typeof error.reason === 'string' ? { reason: error.reason } : {}) };
};
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
socket.end(JSON.stringify(result) + '\n');
};
socket.setTimeout(timeout, () => {
finish({ ok: false, error: 'request-timeout' });
finish(refusal(new BusError('request-timeout')));
socket.destroySoon();
});
socket.on('data', async (chunk) => {
if (done) return;
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
if (processing) return finish(refusal(new BusError('invalid-envelope')));
const total = input.length + chunk.length;
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
finish({ ok: false, error: 'request-too-large' });
finish(refusal(new BusError('request-too-large')));
return;
}
input = Buffer.concat([input, chunk]);
@@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
}
});
socket.on('end', () => {
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
});
});
await new Promise((resolve, reject) => {
+18 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'fde7a46505a964d0e3802aaee96a1e2d9fb8c7163fbafc2f86aca914ecc50f03';
export const SCHEMA_VERSION = '3s';
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8';
export const SCHEMA_VERSION = '3v';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -107,6 +107,19 @@ const migrations = [{
// Task 6b: checked advancement after Task 3's complete fifteen-kind union.
from: '3r', to: '3s',
sql: readFileSync(new URL('./migrations/3r-3s.sql', import.meta.url), 'utf8'),
}, {
// #797 MVP3: the owner's reversible project archive. Only a new append-only table, its index
// and its guards; no existing row changes and nothing is deleted.
from: '3s', to: '3t',
sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'),
}, {
from: '3t', to: '3u',
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
}, {
// U4a (#1873): the task store built into each organization, the default task-tracker module.
// Only a new append-only table, its index and its guards; no existing row changes.
from: '3u', to: '3v',
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -132,6 +145,9 @@ const timedTables = [
'request_snapshots',
'request_snapshot_events',
'request_project_events',
'project_archives',
'project_system_types',
'store_tasks',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
+5 -2
View File
@@ -4,8 +4,6 @@ import { randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { Client } from './client.mjs';
import { readProcess } from './human.mjs';
const file = fileURLToPath(import.meta.url);
if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], {
@@ -14,6 +12,11 @@ if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
});
process.exit(child.status ?? 2);
}
// P19 #876: only the re-executed child, whose initial environment holds the nonce, talks to the
// bus. Loading the client (and the broker module graph behind it) here instead of at the top
// keeps the re-exec parent to a bare node start.
const { Client } = await import('./client.mjs');
const { readProcess } = await import('./human.mjs');
try {
const request = JSON.parse(readFileSync(0, 'utf8'));
if (
+2 -2
View File
@@ -53,10 +53,10 @@ export function turnRecords(store, business) {
};
});
}
export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
const loaders = new Map();
for (const [id, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot:pluginDataRoot });
await loader.load(b.plugins ?? {});
loaders.set(id, loader);
}
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -93,6 +93,9 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql'));
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -100,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3s');
assert.equal(version(path), '3v');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -150,7 +153,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3s');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -162,14 +165,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3s');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+122
View File
@@ -0,0 +1,122 @@
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { BusError, Broker } from '../src/broker.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
const demo = () => ({
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
assistant: { definition: 'assistant', ...none },
gate: { definition: 'merge-gate', ...none },
'companion.stack': { definition: 'companion', ...none },
buddy: { definition: 'companion', ...none },
},
copies: {
gate: copy('gate', 'merge-gate', null, 'instance'),
buddy: copy('buddy', 'companion', null, 'instance'),
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
},
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
unions: {
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
},
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const b = new Broker({ store, businesses: { demo: demo() } });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args = {}) => b.request(human, { verb, args });
const refusal = (fn) => {
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
assert.fail('no refusal');
};
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
return { b, store, call, refusal, trail };
}
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
const f = setup(t);
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
assert.equal(refused.code, 'copy-refused');
assert.match(refused.reason, /^wide can't launch in demo: /);
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
assert.equal(project.code, 'project-refused');
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
// Regenerate takes the same check.
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
// A name that is no copy and no instance is still unknown.
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
// The trail holds each refusal's code, never its reason.
const trail = f.trail();
assert.match(trail, /credential-unbound/);
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
});
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
const f = setup(t);
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
const loose = f.call('project.create', { name: 'loose' }).id;
// Unnamed, the organization file's own thread instance is still the one choice.
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
assert.match(own.id, /\S/);
for (const project of [web, loose])
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
// A named copy the boot refused says why, before the thread instance check.
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
});
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
const f = setup(t);
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
// Another project's agent never joins, and the project's own exclusion holds.
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
assert.deepEqual(members(web), ['assistant']);
assert.deepEqual(members(loose), ['assistant', 'buddy']);
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
for (const fn of [
() => f.call('thread.create', { project: docs, name: 'plain' }),
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
() => f.call('room.open', { project: docs }),
]) {
const e = f.refusal(fn);
assert.equal(e.code, 'project-refused');
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
}
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
});
@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,mkdirSync,readFileSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {Broker} from '../src/broker.mjs';
import {Store} from '../src/store.mjs';
import {autonomyHost,authorityHost} from '../src/autonomy-host.mjs';
import {organizationWriter} from '../../organization/src/organization.mjs';
import {instanceSettings} from '../../organization/src/instance-settings.mjs';
import {hireDecisions} from '../../organization/src/hire-decisions.mjs';
import {loadBusiness} from '../../organization/src/organization-config.mjs';
import {businessDoc,rolesCopy,writeJson} from '../../organization/tests/helpers.mjs';
const inline={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Changed role'};
const cap={limit:100000,since:'2026-01-01T00:00:00.000Z'};
function world(t,options={}) {
const root=mkdtempSync(join(tmpdir(),'au-authority-')),rolesDir=rolesCopy(root),dataRoot=join(root,'data');mkdirSync(dataRoot,{mode:0o700});
const doc=businessDoc(root,'demo');doc.plugins={harness:['pi'],'limit-check':['token-limit']};
doc.launch.limits.tokens={providers:{zai:cap}};
// #1873: an account quota alone, covered only through the host's declared harness fact.
if(options.account){doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};}
doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'},scout:{rev:3,role:structuredClone(inline),instructions:'Prior role'}};
// Definition instructions live beside the role composition.
for(const d of Object.values(doc.definitions))delete d.role.instructions;
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};
doc.launch.instances.push('scout');doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true,reverseWindowHours:1};
writeJson(join(root,'businesses/demo.json'),doc);
let reversing=false,broker,now=Date.now;
const writer=organizationWriter({dir:root,rolesDir,barrier:point=>{if(reversing && point==='prepared' && options.expirePrepared)Date.now=()=>now()+3600001;if(!reversing && point==='prepared' && options.removeResolverPrepared){const b=structuredClone(loaded());b.roles.judge.authority.withinRole=[];broker.reloadBusiness('demo',b);}}});
const store=new Store(dataRoot),order=[];
const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};};
const reload=id=>{if(reversing && options.crossAfterCommit)Date.now=()=>now()+3600001;if(reversing && options.reloadRefused)return false;broker.reloadBusiness(id,loaded());return true;};
const service=hireDecisions({writer,rolesDir,configDir:root,dataRoot,assertClean:()=>{}});
const hires=authorityHost(service,{reload,modelKeys:{prepareRevoke:async(instance,guard)=>{guard();assert.equal(instance,'demo/scout');order.push('prepare');},completeRevoke:instance=>{assert.equal(instance,'demo/scout');assert.ok(store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='fire.stopped'"));assert.equal(writer.read('demo').document.definitions.scout.instructions,'Changed role');order.push('complete');}}});
const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload,...(options.countsAccount?{countsAccount:options.countsAccount}:{})});
const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{order.push('fire-revoke');}});
broker=new Broker({store,businesses:{demo:loaded()},hires,autonomy:host,organization:(id,verb,args,actor,context)=>{if(verb==='instance.fireCheck'){instances.checkFire(id,args.name);return {};}if(verb==='instance.fire')return instances.fire(id,args,{actor,context});throw Error('unexpected organization verb');}});
const caps={};const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
for(const role of ['chief','judge','scout']){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}});
t.after(()=>{Date.now=now;store.close();rmSync(root,{recursive:true,force:true});});
const raise=(extra={})=>call('chief','hire.raise',{op:'change',level:'org',name:'scout',role:{inline},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Change the task',...extra});
const reverse=id=>{reversing=true;return call('owner','decision.reverse.console',{id});};
return {writer,store,order,broker,call,raise,reverse,loaded};
}
for(const crossAfterCommit of [false,true])test(`AU authority reverse uses runtime key wrapper and relaunches after commit (deadline crossed: ${crossAfterCommit})`,async t=>{
const f=world(t,{crossAfterCommit}),d=await f.raise();assert.deepEqual(d.hire.flagged,[]);assert.equal(d.route_to,'judge');
await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});assert.equal(f.writer.read('demo').document.definitions.scout.rev,4);
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.relaunch,'requested');assert.deepEqual(f.order,['prepare','complete']);
const restored=f.writer.read('demo').document.definitions.scout;assert.equal(restored.rev,5);assert.equal(restored.instructions,'Prior role');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
assert.ok(f.store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='lifecycle.launch' AND subject='scout'"));
});
test('AU a committed authority reverse reports pending recovery when reload refuses',async t=>{
const f=world(t,{reloadRefused:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
});
test('AU authority reverse crossing deadline in prepared IO never publishes',async t=>{
const f=world(t,{expirePrepared:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});const prior=f.writer.read('demo').revision;
await assert.rejects(f.reverse(d.id),{code:'reverse-window-closed'});assert.equal(f.writer.read('demo').revision,prior);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,prior);
});
test('AU authority resolver rechecks budget removal after a proposal was raised',async t=>{
const f=world(t),d=await f.raise(),b=structuredClone(f.loaded());b.plugins['limit-check']=[];f.broker.reloadBusiness('demo',b);
const before=f.writer.read('demo').revision;await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'autonomy-precondition'});
assert.equal(f.writer.read('demo').revision,before);assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?',d.id).n,0);
});
test('AU authority apply rechecks a removed resolve right at publication',async t=>{
const f=world(t,{removeResolverPrepared:true}),d=await f.raise(),before=f.writer.read('demo').revision;
await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
assert.equal(f.writer.read('demo').revision,before);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,before);
});
test('AU authority hire reverse runs normal fire/revoke and records committed delisting',async t=>{
const f=world(t),d=await f.raise({op:'hire',name:'helper'});await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
assert.ok(f.writer.read('demo').document.launch.instances.includes('helper'));
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.ok(!f.writer.read('demo').document.launch.instances.includes('helper'));assert.deepEqual(f.order,['fire-revoke']);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
});
test('AU completion-event failure after durable authority inverse records committed recovery, never reverse.failed',async t=>{
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
const run=f.store.run.bind(f.store);let injected=false;
f.store.run=(sql,...args)=>{
if(!injected && sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).action==='fire.done')){
injected=true;throw new Error('injected completion event failure');
}
return run(sql,...args);
};
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.completion,'failed');assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
assert.equal(f.writer.read('demo').document.definitions.scout.rev,5);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
});
test('AU inverse receipt-write failure returns committed with trailPending and never writes reverse.failed',async t=>{
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
const run=f.store.run.bind(f.store);let injected=false;
f.store.run=(sql,...args)=>{
if(sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).operation==='autonomy.reverse')){
injected=true;throw new Error('injected receipt write failure');
}
return run(sql,...args);
};
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.trailPending,true);assert.equal(r.relaunch,'requested');
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
});
test('AU an account-quota authority raise routes to the resolver only when the host says every harness spends the account (#1873)',async t=>{
const covered=world(t,{account:true,countsAccount:name=>name==='pi'}),d=await covered.raise();assert.equal(d.route_to,'judge');
for(const countsAccount of [undefined,()=>false,()=>null]){
const f=world(t,{account:true,countsAccount});
await assert.rejects(f.raise(),{code:'autonomy-precondition'});
assert.equal(f.store.get("SELECT count(*) n FROM decisions").n,0);
}
});
// #1873 review finding 2: the runtime itself carries each harness module's countsAccount into the
// broker's authority budget check. Only harness modules decide; the business names none here but
// through plugins.harness.
for(const [harness,code] of [['claude-code',null],['pi','autonomy-precondition']])test(`AU runtime: an account-quota authority setting with only ${harness} enabled ${code?'refuses':'saves'} (#1873)`,async t=>{
const {startBroker}=await import('../src/runtime.mjs');
const {systemFor}=await import('../../organization/tests/helpers.mjs');
const root=mkdtempSync(join(tmpdir(),'au-runtime-')),rolesDir=rolesCopy(root);t.after(()=>rmSync(root,{recursive:true,force:true}));
const doc=businessDoc(root,'demo');doc.plugins={harness:[harness],'limit-check':['token-limit']};
for(const r of Object.values(doc.roles))if(r.vars?.harness)r.vars.harness=harness;
doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};
doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'}};
delete doc.definitions.judge.role.instructions;
doc.roles.judge={definition:'org:judge',vars:{harness,model:'claude-opus-5-5'}};
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:loaded()},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>bus.close());
const owner=bus.broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}});
const {revision}=await bus.broker.request(owner,{verb:'autonomy.get.console'});
const set=bus.broker.request(owner,{verb:'autonomy.set.console',args:{revision,autonomy:{authority:'near the loop',authorityResolver:'judge',notices:true}}});
if(code)await assert.rejects(set,{code});
else {await set;assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).autonomy.authority,'near the loop');}
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {execFileSync} from 'node:child_process';
import {Store} from '../src/store.mjs';
import {Broker} from '../src/broker.mjs';
import {autonomyHost} from '../src/autonomy-host.mjs';
import {initializeWorking,workingRepo,push,prepareLand,finishLand,dropLand} from '../../seat/src/working.mjs';
import {gitEnv} from '../../seat/src/checkout.mjs';
const TASK='vikunja:1/2',SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=Test','-c','[email protected]',...args],{cwd,encoding:'utf8',env:gitEnv()}).trim();
async function world(t,level='near the loop') {
const dir=mkdtempSync(join(tmpdir(),'au-')),root=join(dir,'root'),dataRoot=join(dir,'data');mkdirSync(root);mkdirSync(dataRoot,{mode:0o700});
git(root,'init','-q','-b','main');writeFileSync(join(root,'file'),'before\n');git(root,'add','file');git(root,'commit','-qm','Initial');
const before=git(root,'rev-parse','HEAD');
const clone=join(dir,'clone');git(dir,'clone','-q',root,clone);git(clone,'checkout','-qb','feat');writeFileSync(join(clone,'file'),'after\n');git(clone,'commit','-qam','Change');const reviewed=git(clone,'rev-parse','HEAD');
const bundle=join(dir,'bundle');git(clone,'bundle','create',bundle,'refs/heads/feat','--not','--remotes=origin');
await initializeWorking({dataRoot,business:'demo',project:'stack',root});
await push({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,branch:'feat',bundle});
const authority=(withinRole=[],crossRole=[])=>({withinRole,crossRole});
const business={id:'demo',human:'jason',projects:{stack:{root}},autonomy:{merge:level,notices:true,reverseWindowHours:1},arbiters:{technical:'cto',delivery:'cto'},roles:{cto:{definition:'cto',authority:authority(['decision.resolve.technical'])},coder:{definition:'coder',authority:authority(['git.push.working','review.request','message.send'])},reviewer:{definition:'reviewer',authority:authority(['review.verdict','message.send'])},gate:{definition:'merge-gate',authority:authority([],['git.merge.protected'])}}};
let gates=0;
const host=autonomyHost({dataRoot,business:()=>business,processing:{work:fn=>fn({guard(){},signal:undefined})},gate:{classify:async()=>true,run:async()=>{gates++;return {ok:true};}}});
const store=new Store(dataRoot),broker=new Broker({store,businesses:{demo:business},autonomy:host}),caps={};
t.after(()=>{store.close();rmSync(dir,{recursive:true,force:true});});
const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
for(const role of Object.keys(business.roles)){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
caps.human=broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
broker.checkPush(caps.coder,TASK);broker.recordPush(caps.coder,{task_ref:TASK,project:'stack',branch:'feat',ref:'work/vikunja-1-2',commit:reviewed,previous:null});
const request=call('coder','review.request',{task_ref:TASK,commit:reviewed,to:'reviewer'});
call('reviewer','review.verdict',{task_ref:TASK,request:request.request,verdict:'approve',body:'Reviewed'});
const d=call('gate','decision.raise',{action:'git.merge.protected',project:'stack',target:TASK,question:'Land work/vikunja-1-2?',options:[{key:'yes',text:'Land change'},{key:'no',text:'Hold'}],recommendation:'yes',blocking:false});
call('cto','decision.resolve',{id:d.id,choice:'yes'});
const land=async()=>{
const facts=broker.checkLand(caps.gate,{task_ref:TASK,decision:d.id});
const logs=join(dir,'land');mkdirSync(logs,{recursive:true});
const prepared=await prepareLand({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,commit:reviewed,dir:logs,name:'original',rootHits:()=>[],message:()=> 'Change'});
try {await finishLand({dataRoot,business:'demo',project:'stack',land:prepared,record:r=>broker.recordLand(facts.ticket,{commit:r.commit,previous:r.previous,reviewed,base:prepared.base,patch_id:prepared.patch_id})});}finally{await dropLand({dataRoot,business:'demo',project:'stack',worktree:prepared.worktree});}
return prepared.commit;
};
return {broker,business,store,caps,call,d,before,land,gates:()=>gates,show:(...args)=>git(dir,`--git-dir=${workingRepo(dataRoot,'demo','stack')}`,...args)};
}
test('AU near merge lands immediately, owner notice reverses through gate and records a real child revert',async t=>{
const f=await world(t),landed=await f.land();
assert.equal(f.show('show','main:file'),'after');assert.equal(f.call('human','inbox').find(x=>x.id===f.d.id).autonomy.notice,true);
const reverse=await f.call('owner','decision.reverse.console',{id:f.d.id});
assert.equal(f.gates(),1);assert.equal(f.show('show','main:file'),'before');assert.equal(f.show('rev-parse','main^'),landed);
assert.match(f.show('log','-1','--format=%B','main'),new RegExp(f.d.id));
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
assert.equal(reverse.reverted,landed);
});
test('AU in-loop landing refuses until confirmation; decline requires a new arbiter resolution',async t=>{
const f=await world(t,'in the loop');
await assert.rejects(f.land(),e=>['not-resolved','confirmation-required'].includes(e.code));
f.call('owner','decision.confirm.console',{id:f.d.id,confirm:false,reason:'Review again'});
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
f.call('cto','decision.resolve',{id:f.d.id,choice:'yes'});f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true});
await f.land();assert.equal(f.show('show','main:file'),'after');
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
});
test('AU landing rechecks the resolver right both before land and after the gate',async t=>{
const f=await world(t);const facts=f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id});
f.business.roles.cto.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
assert.throws(()=>f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id}),{code:'not-resolver'});
assert.throws(()=>f.broker.recordLand(facts.ticket,{commit:'a'.repeat(40),previous:f.before,reviewed:'b'.repeat(40),base:f.before,patch_id:'c'.repeat(40)}),{code:'not-resolver'});
assert.equal(f.show('rev-parse','main'),f.before);
});
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './spec-fixtures.mjs';
const SUBJECT = {provider:'test',iss:'https://issuer.test',sub:'owner'};
async function setup(t, autonomy, autonomyHost=null) {
const f=await fixture(t,{autonomyHost,roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:['git.merge.protected','task.scope.change','policy.change','prd.approve']}}}});
if(autonomy) {f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);}
f.owner=f.broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
return f;
}
test('AU no setting keeps merge with technical arbiter',async t=>{
const f=await setup(t);const d=f.raise({action:'git.merge.protected'});assert.equal(d.route_to,'chief');
});
test('AU in-loop merge keeps the arbiter resolver, waits for person, decline returns to arbiter',async t=>{
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected',target:'vikunja:1/2'});
assert.equal(d.route_to,'chief');
assert.throws(()=>f.call(f.human,'decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}),{code:'confirmation-required'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Please revise'});
assert.equal(f.call(f.chief,'inbox').find(x=>x.id===d.id)?.id,d.id);
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
assert.equal(f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}).decision,d.id);
});
test('AU R5 subject resolver or resolver definition routes to person',async t=>{
const f=await setup(t);
for(const target of ['chief','chief-of-staff'])assert.equal(f.raise({action:'policy.change',target}).route_to,'human');
});
test('AU mismatched PRD domain refuses at raise with expected domain',async t=>{
const f=await setup(t);
assert.throws(()=>f.raise({action:'prd.approve',domain:'delivery'}),e=>e.code==='invalid-domain' && /technical/.test(e.reason));
assert.equal(f.store.get("SELECT count(*) n FROM decisions WHERE action='prd.approve'").n,0);
});
test('AU revoked resolver right refuses when an approved decision is consumed',async t=>{
const f=await setup(t),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.business.roles.chief.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'not-resolver'});
});
test('AU member, agent and dev tester cannot change settings',async t=>{
const revision='a'.repeat(64),f=await setup(t,null,{get:()=>({revision,autonomy:{}})});
const member=f.broker.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights:['org.hire']});
for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
});
test('AU near-loop merge sends owner a bounded notice; expired reverse names ordinary path',async t=>{
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1}),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
const n=f.call(f.human,'inbox').find(x=>x.id===d.id);
assert.equal(n.autonomy.notice,true);assert.ok(Date.parse(n.autonomy.reverseUntil)>Date.now());
const now=Date.now;
try{Date.now=()=>now()+3600001;await assert.rejects(Promise.resolve().then(()=>f.call(f.owner,'decision.reverse.console',{id:d.id})),e=>e.code==='reverse-window-closed' && /ordinary.*decision/.test(e.reason));}finally{Date.now=now;}
});
test('AU no setting can move each hard wait from a person',async t=>{
const f=await setup(t,{'cross-role':'out of the loop',merge:'near the loop',authority:'out of the loop'});
for(const action of ['git.push.protected','host.sudo','credential.mint','spend','deploy','data.delete']){
if(action==='host.sudo')continue; // Its host grant precondition is covered in hard-waits.test.mjs.
assert.equal(f.raise({action}).route_to,'human');
}
});
test('AU owner setting changes have immutable actor trail; bad classes/levels refuse; tester refused',async t=>{
let f,autonomy={},revision='a'.repeat(64);
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'transaction-1'};},reversible:()=>true};
f=await setup(t,null,host);
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{merge:'in the loop'}});
assert.equal(f.store.all("SELECT actor_role,actor_run,body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'").length,1);
const row=f.store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'");assert.deepEqual(JSON.parse(row.body).subject,SUBJECT);
for(const value of [{unknown:'near the loop'},{merge:'fast'}])await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),{code:'invalid-autonomy'});
f.business.vars={'bus.tester':true};f.broker.reloadBusiness('demo',f.business);const tester=f.broker.bindTester({business:'demo'});
assert.throws(()=>f.call(tester,'autonomy.set.console',{revision,autonomy:{}}),{code:'human-required'});
assert.throws(()=>f.broker.recordEvent(f.coder,{kind:'action.allowed',body:{operation:'autonomy.confirmed',decision:'forged'}}),{code:'reserved-event'});
});
test('AU reverse consumes once and rechecks the window at the effect boundary',async t=>{
let effects=0;
const host={reverse:async({beforeWrite,record})=>{beforeWrite();effects++;return {commit:'b'.repeat(40)};}};
const f=await setup(t,{merge:'near the loop',notices:true},host),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
const r=await f.call(f.owner,'decision.reverse.console',{id:d.id});assert.equal(r.reversed,true);assert.equal(effects,1);
await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'decision-reversed'});
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
});
test('AU a near-loop reverse that crosses the deadline cannot write',async t=>{
let effects=0;const now=Date.now;
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1},{reverse:async({beforeWrite})=>{Date.now=()=>now()+3600001;beforeWrite();effects++;return {};}});
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
try{await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'reverse-window-closed'});}finally{Date.now=now;}
assert.equal(effects,0);
});
test('AU policy near refuses with a named next step; in and the existing out level remain usable',async t=>{
let f,autonomy={},revision='a'.repeat(64);
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'policy-setting'};},reversible:()=>false};
f=await setup(t,null,host);
await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'near the loop'}}),e=>e.code==='policy-reverse-unavailable' && /in the loop or out of the loop/.test(e.nextStep));
assert.deepEqual(autonomy,{});
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'in the loop'}});
const d=f.raise({action:'policy.change',target:'policy-1'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}),{code:'confirmation-required'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}).decision,d.id);
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'out of the loop'}});
const out=f.raise({action:'policy.change',target:'policy-2'});f.call(f.chief,'decision.resolve',{id:out.id,choice:'yes'});
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:out.id,target:'policy-2'}).decision,out.id);
});
test('AU near cross-role authorization without a trusted inverse fails closed before consumption',async t=>{
const f=await setup(t,{'cross-role':'near the loop',notices:true}),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'reversal-unavailable'});
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.action')='task.scope.change' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",d.id).n,0);
});
test('AU a withdrawal after decline is terminal for resolver, owner and confirmation',async t=>{
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Revise'});
f.call(f.coder,'decision.withdraw',{id:d.id});
assert.throws(()=>f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}),{code:'decision-closed'});
for(const cap of [f.chief,f.human])assert.ok(!f.call(cap,'inbox').some(x=>x.id===d.id));
assert.throws(()=>f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}),e=>['decision-closed','decision-not-eligible'].includes(e.code));
});
async function settingsFixture(t) {
let f,autonomy={},revision='a'.repeat(64),writes=0;
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{writes++;autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'setting-test'};},reversible:()=>true};
f=await setup(t,null,host);
f.business.roles.judge={definition:'judge',authority:{withinRole:['decision.resolve.authority'],crossRole:[]}};
f.business.roles.reviewer.authority.withinRole.push('review.verdict');
f.business.launch={by:'chief',models:{model:{provider:'anthropic',model:'test-model'}},limits:{tokens:{}}};
f.business.plugins={'limit-check':['token-limit']};
f.broker.reloadBusiness('demo',f.business);
return {...f,set:value=>f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),writes:()=>writes};
}
const authorityNear={authority:'near the loop',authorityResolver:'judge',notices:true};
const tokenCap={limit:1000,since:'2026-01-01T00:00:00.000Z'};
test('AU authority near requires an enforced organization budget covering every allowed model, including zero caps',async t=>{
const f=await settingsFixture(t);
for(const tokens of [{},{instances:{coder:tokenCap}},{providers:{unrelated:tokenCap}}]){
f.business.launch.limits.tokens=tokens;f.broker.reloadBusiness('demo',f.business);
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
}
f.business.launch.limits.tokens={providers:{anthropic:{...tokenCap,limit:0}}};
f.business.plugins={'limit-check':[]};f.broker.reloadBusiness('demo',f.business);
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
f.business.plugins={'limit-check':['token-limit']};f.broker.reloadBusiness('demo',f.business);
await f.set(authorityNear);assert.equal(f.writes(),1);
});
test('AU authority out refuses until per-definition agent limits exist, even with current agents capped',async t=>{
const f=await settingsFixture(t);
f.business.launch.limits.tokens={providers:{anthropic:tokenCap},instances:Object.fromEntries(Object.keys(f.business.roles).map(n=>[n,tokenCap]))};
f.broker.reloadBusiness('demo',f.business);await f.set(authorityNear);
f.call(f.owner,'autonomy.review.console',{decisionClass:'authority'});
await assert.rejects(f.set({...authorityNear,authority:'out of the loop'}),e=>e.code==='authority-limits-unavailable' && /near the loop/.test(e.nextStep));
assert.equal(f.writes(),1);
});
test('AU moving out requires an actual near resolution and a later owner review',async t=>{
const f=await settingsFixture(t);await f.set({merge:'in the loop'});await f.set({merge:'near the loop',notices:true});
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
await f.set({merge:'out of the loop',notices:true});
});
@@ -0,0 +1,104 @@
// Lifecycle Task 2 (#797 / Gitea #1839): a confirmed new-project selection becomes a usable project.
// Real startBroker over a real organization file, a real git factory repo and a fixture data root.
// The test barrier stands in for a kill: it throws after a step's effect and before its receipt.
import test from 'node:test';
import assert from 'node:assert/strict';
import cp, { execFileSync } from 'node:child_process';
import fs, { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { prepareBootstrap } from '../src/request-bootstrap.mjs';
import { hostPreparation, staffFor } from '../src/request-host.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { managedState } from '../../organization/src/project-bootstrap.mjs';
import { workingRepo } from '../../seat/src/working.mjs';
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_AUTHOR_NAME: 'Factory', GIT_AUTHOR_EMAIL: '[email protected]',
GIT_COMMITTER_NAME: 'Factory', GIT_COMMITTER_EMAIL: '[email protected]' };
const git = (cwd, ...args) => execFileSync('git', args, { cwd, env: GIT_ENV, encoding: 'utf8' }).trim();
const PROJECT_FILE = { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1, 'git.workingBranch': 'main' } };
// The factory: the declared `stack` project is a git repository with a project file.
function factory(root) {
const dir = join(root, 'project');
mkdirSync(join(dir, '.mosaic'), { recursive: true });
writeFileSync(join(dir, '.mosaic/project.json'), JSON.stringify(PROJECT_FILE, null, 2) + '\n');
writeFileSync(join(dir, 'README.md'), 'factory\n');
git(dir, 'init', '-q', '-b', 'main'); git(dir, 'add', '-A'); git(dir, 'commit', '-q', '-m', 'factory');
return dir;
}
// `defaultServices` boots with nothing injected, so the host builds its own preparation and admission; `withTrackers` forwards the
// boot config's tracker settings as process.mjs does (the adapter itself is not built).
async function setup(t, { barrier, configureFactory = () => {}, beforePropose = () => {}, defaultServices = false, withTrackers = defaultServices } = {}) {
const root = tmp(t, 'bootstrap-'), f = fixture(root, 'demo',d=>{d.roles.assistant={definition:'assistant'};return d;}), source = factory(root);
configureFactory({ root, f, source });
const hooks = { barrier };
const system = loadSystem({ env: f.env });
const writer = organizationWriter({ dir: join(root, 'config'), rolesDir: f.roles });
const plans = (dataRoot, ready = () => true) => ({
preparePlans: (state, args, from) => Promise.all(['autonomous', 'guided'].map(mode => prepareBootstrap({
business: writer.read('demo').document, request: state.request, sourceProject: args.source_project, project: args.project, name: args.name,
mode, dataRoot, organizationRevision: writer.read('demo').revision, tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'reviewer', chief: 'chief' },
intent: { text: from.text, source_message: from.source_message } }))),
admitPlan: plan => plan.organization_revision === writer.read('demo').revision && ready() ? { ready: true } : { ready: false, code: 'organization-stale' },
barrier: step => hooks.barrier?.(step),
});
const boot = async () => {
const warnings = [];
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: 'demo', env: f.env, warn: w => warnings.push(w) })));
const bus = await startBroker({ ...config, ...(withTrackers ? { trackers } : {}), requestServices: defaultServices ? {} : plans(f.dataRoot) });
t.after(() => bus.close().catch(() => {}));
return { bus, config, trackers, warnings };
};
const first = await boot();
const caps = { human: first.bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
for (const role of ['pm', 'cto', 'coder', 'chief']) {
caps[role] = first.bus.broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
first.bus.broker.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args = {}) => first.bus.broker.request(caps[who], { verb, args });
const originProject=call('human','project.create',{name:'Origin intake'}).id;
const thread=call('human','thread.create',{project:originProject,name:'Intake'}).id;
caps.assistant=first.bus.broker.bindLaunch({business:'demo',role:'assistant',run:'assistant-run',harness:'pi',thread});
call('assistant','role.claim');
const sent=call('human','thread.send',{thread,body:INTENT});
const relay=call('assistant','message.send',{to:'chief',in_reply_to:sent.id,body:'Original request '+sent.request});
call('chief','message.send',{to:'pm',in_reply_to:relay.id,body:'Open original request '+sent.request});
const opened = await call('pm', 'request.open', { request: sent.request });
// A source the preparation refuses never reaches a decision; the refusal is the outcome, so setup hands it back.
let proposed = null, proposeError = null;
beforePropose({ root, f, source });
try {
proposed = await call('pm', 'request.propose', { request: sent.request, revision: opened.revision, op: 'plan-1', source_project: 'stack',
project: 'timer', name: 'Timer', tracker_project: 2 });
} catch (e) { proposeError = e; }
const choose = async (choice = 'guided') => {
call('human', 'decision.resolve', { id: proposed.selection.decision, choice });
return first.bus.requestLifecycle.resume('demo', sent.request);
};
const paths = { code: join(f.dataRoot, 'products/demo/timer/code'), records: join(f.dataRoot, 'projects/demo/timer'), product: join(f.dataRoot, 'products/demo/timer') };
const changes = () => { try { return readFileSync(writer.logFile('demo'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); } catch { return []; } };
const additions = () => changes().filter(c => c.state === 'committed' && c.detail?.verb === 'project.add');
return { originProject, thread, root, f, system, source, writer, hooks, boot, bus: first.bus, first, call, caps, request: sent.request, proposed, proposeError, choose, paths, changes, additions,
rows: () => first.bus.broker.request(caps.human, { verb: 'project.list', args: {} }) };
}
test('archiving the originating chat during bootstrap must fence the next effect',async t=>{
const f=await setup(t,{barrier: step=>{
if(step==='reserve')f.call('human','project.archive',{project:f.originProject});
}});
const state=await f.choose();
console.log('ORIGIN_BOOTSTRAP',JSON.stringify({phase:state.phase,wait:state.wait,codeExists:existsSync(f.paths.code),additions:f.additions().length}));
assert.equal(state.wait?.code,'project-archived');
assert.equal(existsSync(f.paths.code),false);
assert.equal(f.additions().length,0);
});
+4 -4
View File
@@ -4,9 +4,9 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { busBusiness } from '../src/business.mjs';
import { resolveInstance, validateBusinessDocument } from '../../business/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../business/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
const { Broker } = await import('../src/broker.mjs').catch((e) => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
@@ -399,7 +399,7 @@ test('the shipped coder role raises task.reassign as gated to the human; task.sc
const resolved = {};
for (const instance of Object.keys(business.roles))
resolved[instance] = resolveInstance({ system: systemFor(root), business, instance });
const { b, agent, human } = setup(t, { demo: busBusiness(business, resolved) });
const { b, agent, human } = setup(t, { demo: busOrganization(business, resolved) });
const c = agent('coder');
call(b, c, 'role.claim');
const reassign = raise(b, c, 'task.reassign', { target: 'vikunja:3/7' });
+6 -1
View File
@@ -34,7 +34,12 @@ const baselines = {
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
"packages/webui/src/bus.mjs": "4ebf6c2b5d71c23d85cbde83f69aa671058dc2195c9ae4bdda624f6940729d1b",
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
// to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+17 -379
View File
@@ -8,12 +8,11 @@ import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../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';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/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
@@ -211,13 +210,15 @@ test('REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches t
const done = call('chief', 'organization.set', PATCH);
assert.deepEqual(done, { revision: 'b'.repeat(64) });
// Same callback verb as a human's limits.set; only the actor differs.
assert.deepEqual(organization, [['demo', 'limits.set', PATCH, 'chief-of-staff.chief']]);
assert.deepEqual(organization[0].slice(0,4), ['demo', 'limits.set', PATCH, 'chief-of-staff.chief']);
assert.equal(typeof organization[0][4].beforeWrite, 'function');
call('human', 'limits.set', PATCH);
assert.deepEqual(organization.at(-1), ['demo', 'limits.set', PATCH, 'jason']);
// Row 155 (#1649, Q169): the assistant's configuration takes the same path, for both actors.
const assistants = { revision: 'a'.repeat(64), assistants: { assistant: { vars: { thinking: 'high' } } } };
call('chief', 'organization.set', assistants);
assert.deepEqual(organization.at(-1), ['demo', 'limits.set', assistants, 'chief-of-staff.chief']);
assert.deepEqual(organization.at(-1).slice(0,4), ['demo', 'limits.set', assistants, 'chief-of-staff.chief']);
assert.equal(typeof organization.at(-1)[4].beforeWrite, 'function');
call('human', 'limits.set', assistants);
assert.deepEqual(organization.at(-1), ['demo', 'limits.set', assistants, 'jason']);
});
@@ -280,7 +281,7 @@ test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set
const outside = [
{ launch: { by: 'coder' } },
{ human: 'mallory' },
{ arbiters: { technical: 'coder', delivery: 'coder' } },
{ arbiters: { technical: 'chief', delivery: 'chief' } },
{ roles: {} },
{ actor: 'jason' },
{ 'launch.by': 'coder' },
@@ -614,7 +615,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +639,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -709,380 +710,17 @@ test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
// each becomes its own event against the instance. Only a fire's end carries them.
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
const { b, call, allowed } = hireSetup(t);
call('chief', 'instance.fire', FIRE);
const [end] = allowed('lifecycle.end');
const request = end.body.request;
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
// sage-2289: only an alive stop carries a reason, and only unknown.
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
assert.equal(closed.state, 'closed');
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
// A terminate's end sends SIGTERM only, so its done has no stops.
const term = call('human', 'instance.end', { instance: 'pm' }).request;
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
assert.equal(allowed('lifecycle.end.killed').length, 1);
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.during = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = ([, verb]) => verb === 'instance.fire' && store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
// step A revokes, ends or marks anything, so the instance isn't left firing.
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
writer.during = null;
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
assert.equal(lastClaim('coder').op, 'claim');
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return 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('A4 the retired lifecycle verbs always require a decision and never call the writer',t=>{
const {call,organization}=setup(t,KEPT);
for(const who of ['chief','coder','human'])for(const verb of ['instance.hire','instance.fire'])
assert.throws(()=>call(who,verb,{name:'coder',definition:'coder',reason:'help'}),{code:'decision-required'});
assert.equal(organization.length,0);
});
test('row 194 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 { b, call, store } = setup(t,LISTED);
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.
+5 -4
View File
@@ -4,11 +4,12 @@ import { connect } from 'node:net';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../business/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../organization/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs';
@@ -138,7 +139,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
});
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => {
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
@@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
});
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
});
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
+3 -3
View File
@@ -4,7 +4,7 @@ import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { checkReply, GuardError, libraryReader } from '../../library/src/guard.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { mapping, repo, MARKER } from '../../library/tests/helpers.mjs';
@@ -18,8 +18,8 @@ export function citationWorld(t, { definition = 'coder', library = true, guard:
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
...(library ? { projects: { books: map } } : {}),
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
const limits = () => {
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
catch { return resolveReplyLimits({ readable: false }); }
+4 -4
View File
@@ -5,8 +5,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { validateBusinessDocument } from '../../business/src/business.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { validateBusinessDocument } from '../../organization/src/organization-config.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => {
@@ -18,8 +18,8 @@ test('C2-06 runtime reads changed and unreadable reply limits without restarting
// Runtime takes the host's broker projection, not the business validator's unresolved role entries.
// No agent action is needed here: use the same empty-authority shape as limits-runtime.test.mjs.
const business = { id: 'acme', human: 'jason', projects: checked.projects,
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
arbiters: { technical: 'chief', delivery: 'chief' },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir, system: systemFor(root) } });
t.after(() => runtime.close());
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3t';")
.replace('}];\nconst timedTables', `}, { from: '3s', to: '3t', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';")
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3s ready/);
assert.match(migrated.stdout, /bus schema 3v ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+7 -6
View File
@@ -48,8 +48,8 @@ function setup(t) {
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
@@ -86,10 +86,10 @@ function setup(t) {
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
}
// #797 / #1839 Task 3 appends the five request-intake console verbs to Task 4's five and only those: the decision
// pair stays first and unchanged, and no unsuffixed verb is ever a console verb.
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
@@ -227,7 +227,8 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
const f = setup(t);
for (const route of ['pm', 'chief']) {
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
const id = grantDecision(f.store, { action: 'policy.change', route, raiser:route==='chief'?'pm':'chief', close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
f.call(route, 'decision.resolve', { id, choice: 'yes' });
+135
View File
@@ -0,0 +1,135 @@
// #1873 CB1: a copy credential binding through the broker. Only the chief raises one; only the
// organization owner resolves it; no autonomy level, member right, resolver agent, tester or reader
// moves it. The written record stays pending, so nothing launches on it in CB1.
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 { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { materializeCopies } from '../../organization/src/agent-copies.mjs';
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const GITEA = { gitea: { scopes: ['read:repository'], projects: ['stack'] } };
const SECRET = 'sk-test-NOT-A-SECRET-1234';
async function fixture(t, edit = d => d) {
const root = mkdtempSync(join(tmpdir(), 'cb1-bus-')), rolesDir = rolesCopy(root);
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
const doc = edit(base);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const storeRoot = join(root, 'data'); mkdirSync(storeRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot: storeRoot, instance: 'test' };
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(storeRoot);
const service = hireDecisions({ writer, rolesDir, dataRoot: storeRoot, configDir: root, assertClean: v => { if (JSON.stringify(v).includes(SECRET)) throw Object.assign(new Error('x'), { code: 'definition-holds-credential' }); }, copies: id => snapshot.org === id ? snapshot : null });
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])) }; };
let writes = 0;
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { writes++; const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } } });
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const member = (rights, extra = {}) => b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights, ...extra });
const caps = { chief: b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }), coder: b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }) };
b.request(caps.chief, { verb: 'role.claim' }); b.request(caps.coder, { verb: 'role.claim' });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
const bind = (extra = {}) => ({ op: 'change', level: 'root', name: 'rev', reason: 'Bind the reviewer copy', binding: { services: GITEA }, ...extra });
const raise = extra => call(caps.chief, 'hire.raise', bind(extra));
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const bindings = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings ?? {};
return { root, rolesDir, file, b, store, writer, service, owner, member, caps, call, bind, raise, bindings, writes: () => writes, loaded };
}
test('CB1 bus: only the chief raises a binding; the decision names the copy and holds no token or path', async t => {
const f = await fixture(t);
await assert.rejects(f.call(f.caps.coder, 'hire.raise', f.bind()), { code: 'role-not-chief' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
const d = await f.raise();
assert.equal(d.route_to, 'human');
assert.equal(d.question, 'bind credentials for copy rev?');
assert.equal(d.hire.binding.instance, 'rev');
const text = JSON.stringify(d);
for (const bad of [SECRET, f.root, 'token']) assert.ok(!text.includes(bad), bad);
assert.equal(f.writes(), 0);
assert.deepEqual(f.bindings(), {});
});
test('CB1 bus: no autonomy level routes a binding away from the owner', async t => {
for (const authority of ['near the loop', 'out of the loop']) {
const f = await fixture(t, doc => {
doc.definitions = { resolver: { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.authority'], crossRole: [] }, credentials: [] }, instructions: 'Resolve independent decisions.' } };
doc.roles.cto = { definition: 'org:resolver' };
doc.plugins['limit-check'] = ['token-limit']; doc.launch.limits = { tokens: { providers: { zai: { limit: 100000, since: '2026-01-01T00:00:00.000Z' } } } };
doc.autonomy = { authority, authorityResolver: 'cto', notices: true };
return doc;
});
const live = f.loaded(); live.roles.cto.authority = { withinRole: ['decision.resolve.authority'], crossRole: [] }; f.b.reloadBusiness('demo', live);
const resolver = f.b.bindLaunch({ business: 'demo', role: 'cto', run: 'cto-run', harness: 'pi' }); f.b.request(resolver, { verb: 'role.claim' });
const d = await f.raise();
assert.equal(d.route_to, 'human', authority);
// The authority resolver cannot take it, nor can the chief through the agent verb.
await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
assert.equal(f.writes(), 0, authority);
assert.deepEqual(f.bindings(), {}, authority);
// A project-level unbind is not root; it lists the lines it removes and still goes to the owner.
if (authority === 'near the loop') {
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
const u = await f.raise({ level: 'project', project: 'stack', name: 'checker', binding: { services: null } });
assert.equal(u.hire.level, 'project');
assert.equal(u.route_to, 'human');
await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' }), { code: 'hire-resolution-required' });
assert.equal(f.bindings()['checker.stack'].state, 'pending');
}
}
});
test('CB1 bus: members with org.hire or decide, the tester and a reader cannot resolve; the owner can, once', async t => {
const f = await fixture(t);
const d = await f.raise();
const digest = d.hire.digest;
await assert.rejects(f.call(f.member(['org.hire']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.member(['org.hire', 'decide'], { instanceOwner: true }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.member(['decide']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'right-required' });
await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'human-required' });
assert.equal(f.writes(), 0);
assert.deepEqual(f.bindings(), {});
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: '0'.repeat(64) }), { code: 'decision-stale' });
await f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest });
assert.equal(f.writes(), 1);
const rec = f.bindings().rev;
assert.equal(rec.state, 'pending');
assert.equal(rec.decision, d.id);
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'decision-closed' });
assert.equal(f.writes(), 1);
});
test('CB1 bus: a one-step Console binding is owner-only; a member is refused before any decision is saved', async t => {
const f = await fixture(t);
await assert.rejects(f.call(f.member(['org.hire'], { instanceOwner: true }), 'hire.start.console', f.bind()), { code: 'human-required' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
await f.call(f.owner, 'hire.start.console', f.bind());
assert.equal(f.bindings().rev.state, 'pending');
// Unbind is owner-only too.
await assert.rejects(f.call(f.member(['org.hire']), 'hire.start.console', f.bind({ binding: { services: null } })), { code: 'human-required' });
await f.call(f.owner, 'hire.start.console', f.bind({ binding: { services: null } }));
assert.equal(f.bindings().rev.state, 'unbound');
});
test('CB1 bus: every new refusal has a table entry with a next step', () => {
for (const code of ['organization-bindings-protected', 'credential-binding-changed', 'credential-role-changed', 'credential-definition-unpinned', 'credential-scope-exceeded', 'credential-unbound', 'copy-unavailable', 'copy-refused']) {
assert.ok(Object.hasOwn(REFUSALS, code), code);
assert.ok(REFUSALS[code].nextStep?.length > 20, code);
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code);
}
});
@@ -0,0 +1,227 @@
// #1873 CB2 live acceptance: credentials.issue for an agent copy's Gitea service against a scratch
// gitea/gitea:1.27.1, a throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after.
// The test never pulls an image; without Docker or the image it skips. Every token here is a scratch
// token this container made; none is printed, and nothing here reaches git.mosaicstack.dev or any
// real service.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, randomBytes } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { request } from 'node:https';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { Broker, BusError } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { Credentials } from '../src/credentials.mjs';
import { copyGrant } from '../src/copy-grant.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { materializeCopies, copiesView } from '../../organization/src/agent-copies.mjs';
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const IMAGE = 'gitea/gitea:1.27.1';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const sha = (s) => createHash('sha256').update(s).digest('hex');
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
const freePort = () =>
new Promise((resolve, reject) => {
const s = createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
// One HTTPS call to the scratch container, trusting only the scratch CA.
function call(url, ca, method, path, { auth = null, body = null } = {}) {
return new Promise((resolve) => {
const data = body ? JSON.stringify(body) : null;
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('end', () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {}
resolve({ status: res.statusCode, json });
});
});
req.on('timeout', () => req.destroy());
req.on('error', () => resolve({ status: 0, json: null }));
req.end(data ?? undefined);
});
}
test('CB2-D: on scratch 1.27.1 an owner-issued copy token is proved live: equal and narrower bind; broader, all, admin, foreign, unrelated and opaque refuse', { skip, timeout: 300000 }, async (t) => {
// Capture the child's complete TAP stdout/stderr before any of it reaches this runner, so the parent
// can search for the actual minted values without putting one in an assertion.
if (!process.env.CB2_COPY_CAPTURE) {
const capture = mkdtempSync(join(tmpdir(), 'cb2cap-'));
t.after(() => rmSync(capture, { recursive: true, force: true }));
const file = join(capture, 'minted');
const childEnv = { ...process.env, CB2_COPY_CAPTURE: file };
delete childEnv.NODE_TEST_CONTEXT;
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB2-D:', fileURLToPath(import.meta.url)], {
encoding: 'utf8', timeout: 280000, env: childEnv,
});
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
const output = (child.stdout ?? '') + (child.stderr ?? '');
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
t.diagnostic(output);
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
assert.equal(child.status, 0, 'Docker child passed');
assert.equal(values.length, 6, 'all six scratch tokens were minted: the acceptance actually ran');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'cb2d-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
// The container's git user reads the scratch key; it is a throwaway key for this run only.
chmodSync(tlsDir, 0o755);
chmodSync(join(tlsDir, 'server.key'), 0o644);
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
const url = `https://127.0.0.1:${port}`, name = 'cb2-' + randomBytes(6).toString('hex');
t.after(() => docker('rm', '-f', name));
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
assert.equal(started.status, 0, 'scratch container starts');
const end = Date.now() + 120000;
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
await sleep(500);
}
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
const minted = [];
const createUser = (user, admin = false) => {
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
assert.equal(r.status, 0, `create scratch ${user}`);
};
const mint = (user, scopes) => {
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb2-' + randomBytes(4).toString('hex'),
'--scopes', scopes.join(','), '--raw');
assert.equal(r.status, 0, `mint scratch ${user}`);
const value = r.stdout.trim();
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
minted.push(value);
writeFileSync(process.env.CB2_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
return value;
};
createUser('scratch-admin', true);
createUser('owner');
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
for (const repo of ['stack', 'other']) {
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
assert.equal(made.status, 201, `create owner/${repo}`);
}
// The organization: one root agent "rev" whose copy binding approves write:repository and
// read:user on project stack, whose remote is the scratch owner/stack.
const root = mkdtempSync(join(tmpdir(), 'cb2o-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
doc.projects.stack.remote = `${url}/owner/stack`;
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const dataRoot = join(root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot, instance: 'test' };
await reserveOrg(ctx, 'demo');
await activateOrg(ctx, 'demo');
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
t.after(() => store.close());
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
const loaded = () => {
const d = loadBusiness('demo', { dir: root, rolesDir });
// The boot's copies view, as the runtime's loadWithCopies gives the broker.
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView(snapshot.copies) };
};
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
const b = new Broker({
store, businesses: { demo: loaded() },
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
});
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy',
binding: { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } } });
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings.rev;
assert.equal(rec().state, 'pending');
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
// The copy's dedicated account: a read collaborator on owner/stack only.
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
createUser(login);
const collaborate = (repo, permission) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission } });
assert.equal((await collaborate('stack', 'read')).status, 204, 'bot is a read collaborator on owner/stack');
const refused = async (token, code, label) => {
await assert.rejects(issueToken(token), { code }, label);
assert.equal(rec().state, 'pending', label);
assert.equal(rec().services.gitea.grantId, undefined, label);
assert.ok(!existsSync(slot), `${label}: a refused token leaves no slot`);
};
// Equal: the approved scopes exactly.
const equal = mint(login, ['write:repository', 'read:user']);
const self = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + equal });
assert.equal(self.status, 200);
assert.equal(self.json.user.login, login, 'Gitea 1.27.1 names the token owner on /api/v1/token');
const one = await issueToken(equal);
assert.equal(one.state, 'bound');
assert.equal(one.account, login);
assert.deepEqual([rec().state, rec().services.gitea.grantId, rec().services.gitea.grantGeneration], ['bound', `gitea:${self.json.user.id}:${self.json.id}`, 1]);
assert.equal(statSync(slot).mode & 0o777, 0o600);
assert.equal(readFileSync(slot, 'utf8'), equal + '\n');
// Narrower: read within the approved write.
const narrow = mint(login, ['read:repository', 'read:user']);
await issueToken(narrow);
assert.deepEqual([rec().state, rec().services.gitea.grantGeneration], ['bound', 2]);
assert.equal(readFileSync(slot, 'utf8'), narrow + '\n');
// Broader in one category, and "all": exceeded; a failed re-issue deactivates the binding.
await refused(mint(login, ['write:repository', 'write:user']), 'credential-scope-exceeded', 'write:user beyond read:user');
await refused(mint(login, ['all']), 'credential-scope-exceeded', 'all');
// The administrator's own token, inside the approved scopes: not the copy's account.
await refused(mint('scratch-admin', ['read:repository', 'read:user']), 'credential-identity-refused', 'admin account');
// A token Gitea never issued.
await refused(randomBytes(20).toString('hex'), 'credential-scope-unverified', 'opaque token');
// An unrelated repository the account can also reach.
assert.equal((await collaborate('other', 'read')).status, 204);
await refused(narrow, 'credential-scope-exceeded', 'unrelated repository');
// Nothing kept names a token, its digest, the slot path or a request.
const kept = [file, join(root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8')).join('\n')
+ ['events', 'decisions'].map((tb) => JSON.stringify(store.all(`SELECT * FROM ${tb}`))).join('\n');
for (const value of minted) assert.ok(!kept.includes(value) && !kept.includes(sha(value)) && !kept.includes(sha(value + '\n')), 'kept text holds no token');
for (const bad of [slot, storeRoot, '/api/v1', 'authorization']) assert.ok(!kept.includes(bad), 'kept text holds no path or request');
t.diagnostic(`minted ${minted.length}`);
});
+558
View File
@@ -0,0 +1,558 @@
// #1873 CB2 (design section 5 steps 4 to 6, section 8 row CB2): credentials.issue for an agent copy's
// bound Gitea service, verified live against a fake Gitea before any grant metadata lands. The
// organization writer's half is packages/organization/tests/copy-grant-writer.test.mjs; the real
// server acceptance is copy-grant-docker.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, BusError } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { Credentials } from '../src/credentials.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { copyGrant, tokenLevels, verifyGiteaToken, boundRepositories, MAX_ROTATE_DAYS, sweepCopySlots } from '../src/copy-grant.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { materializeCopies, copiesView } from '../../organization/src/agent-copies.mjs';
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const present = (p) => { try { lstatSync(p); return true; } catch { return false; } };
const sha = (s) => createHash('sha256').update(s).digest('hex');
async function fixture(t, { services = READ, edit = (d) => d } = {}) {
const root = mkdtempSync(join(tmpdir(), 'cb2-')), rolesDir = rolesCopy(root);
const api = await fakeGiteaApi(t, root);
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
base.projects.stack.remote = `${api.url}/owner/stack`;
const doc = edit(base);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot, instance: 'test' };
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null });
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView((opts.copies ?? snapshot).copies) }; };
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
const hooks = {};
const opts = { timeoutMs: 3000 };
const issue = (...a) => copyGrant({ credentials, writer, copies: opts.copies ?? snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: opts.timeoutMs, hooks })(...a);
const direct = (args, ctx) => issue('demo', args, ctx);
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a) });
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
const bind = (name = 'rev', svc = services, extra = {}) => call(owner, 'hire.start.console', { op: 'change', level: 'root', name, reason: 'Bind the copy', binding: { services: svc }, ...extra });
await bind();
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const rec = (name = 'rev') => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.[name];
const repo = api.addRepo('owner', 'stack');
// The copy's dedicated account, a read collaborator on owner/stack, and one token from it.
const bot = (name = 'rev', { perm = 'read', admin = false } = {}) => {
const r = rec(name);
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
const u = api.users.get(login) ?? api.addUser(login, { is_admin: admin }).user;
repo.collaborators.push(login); repo.permissions[login] = perm;
return u;
};
const token = (u, scopes = ['read:repository', 'read:user'], o) => api.addToken(u.login, `t${api.issued.length}`, scopes, o);
const slot = (name = 'rev') => join(storeRoot, 'demo', name, 'gitea');
const issueAs = (cap, args) => call(cap, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), ...args });
return { root, file, api, b, store, writer, credentials, hooks, opts, direct, owner, human, chief, call, bind, rec, bot, token, slot, repo, storeRoot, issueAs, loaded, snapshot };
}
// Everything the bus or the organization keeps after an issue: the document, the receipts, the store.
function kept(f) {
const files = [f.file, join(f.root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8'));
const rows = ['events', 'decisions'].map((tb) => JSON.stringify(f.store.all(`SELECT * FROM ${tb}`)));
return files.join('\n') + rows.join('\n');
}
test('CB2 an owner-issued copy token is verified live, staged and bound; nothing kept names the token or its path', async (t) => {
const f = await fixture(t);
assert.equal(f.rec().state, 'pending');
const u = f.bot();
const tok = f.token(u);
const date = ahead(30);
const result = await f.issueAs(f.human, { token: tok.sha1, date });
assert.deepEqual(result, { agent: 'rev', service: 'gitea', verb: 'issue', date, state: 'bound', account: u.login, generation: result.generation });
const r = f.rec();
assert.equal(r.state, 'bound');
assert.deepEqual(r.services.gitea, { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, rotateBy: date, grantId: `gitea:${u.id}:${tok.id}`, grantGeneration: 1 });
assert.equal(statSync(f.slot()).mode & 0o777, 0o600);
assert.equal(readFileSync(f.slot(), 'utf8'), tok.sha1 + '\n');
// Only GETs went to Gitea, every one as the copy's account.
assert.ok(f.api.requests.length > 0);
for (const req of f.api.requests) {
assert.equal(req.method, 'GET');
if (req.path !== '/api/v1/version') assert.equal(req.as, u.login);
}
const text = kept(f) + JSON.stringify(result);
for (const bad of [tok.sha1, sha(tok.sha1), sha(tok.sha1 + '\n'), f.slot(), f.storeRoot, '/api/v1', 'authorization']) assert.ok(!text.includes(bad), 'kept text holds a secret, path or request');
// Copies still cannot launch in CB2: the snapshot does not resolve a gitea-only copy (CB3 owns launch).
});
test('CB2 narrower and equal scopes are accepted; any broader token is exceeded and leaves the record inactive', async (t) => {
const f = await fixture(t, { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } });
const u = f.bot('rev', { perm: 'write' });
for (const scopes of [['write:repository', 'write:user'], ['all'], ['write:admin', 'read:user'], ['read:repository', 'read:user', 'read:issue'], ['read:user', 'read:misc']]) {
const tok = f.token(u, scopes);
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' }, scopes.join());
assert.equal(f.rec().state, 'pending', scopes.join());
assert.equal(f.rec().services.gitea.grantId, undefined);
assert.ok(!existsSync(f.slot()), 'a refused token leaves no slot');
}
// Equal.
await f.issueAs(f.human, { token: f.token(u, ['write:repository', 'read:user']).sha1 });
assert.equal(f.rec().state, 'bound');
// Narrower (read within the approved write) re-issues; the grant generation counts up.
f.repo.permissions[u.login] = 'read';
const narrow = f.token(u, ['read:repository', 'read:user']);
await f.issueAs(f.human, { token: narrow.sha1 });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration, f.rec().services.gitea.grantId], ['bound', 2, `gitea:${u.id}:${narrow.id}`]);
// A failed re-issue after bound deactivates the binding and removes its slot.
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['all']).sha1 }), { code: 'credential-scope-exceeded' });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false]);
});
test('CB2 read is narrower than write within a category; categories are independent; push needs write', async (t) => {
assert.deepEqual(tokenLevels(['read:repository', 'write:repository', 'read:user']), { repository: 'write', user: 'read' });
assert.deepEqual(tokenLevels(['write:issue', 'read:issue']), { issue: 'write' });
for (const bad of [['all'], ['write:admin'], ['read:admin'], ['read:sudo'], ['repository'], [], null, [7]]) assert.equal(tokenLevels(bad), null, JSON.stringify(bad));
const f = await fixture(t);
// The binding approved read:repository; the account can push, so the token reaches more than read.
const u = f.bot('rev', { perm: 'write' });
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-scope-exceeded' });
// write:issue is another category: read:repository never approves it.
f.repo.permissions[u.login] = 'read';
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository', 'read:user', 'write:issue']).sha1 }), { code: 'credential-scope-exceeded' });
await f.issueAs(f.human, { token: f.token(u).sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 push is held to the binding, not the token: a narrower token binds for a push account on a write binding', async () => {
const push = { '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: true, pull: true } }], total: 1 } };
await assert.rejects(V(scripted(push).transport), { code: 'credential-scope-exceeded' });
const write = { approved: ['write:repository', 'read:user'] };
assert.deepEqual(await V(scripted(push).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
const writeToken = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['write:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
assert.deepEqual(await V(scripted({ ...push, ...writeToken }).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
await assert.rejects(V(scripted({ ...push, ...writeToken }).transport), { code: 'credential-scope-exceeded' });
});
test('CB2 the boot copy must still match the record: a missing, refused or changed copy refuses before anything is staged or sent', async (t) => {
const f = await fixture(t);
const tok = f.token(f.bot());
const rev = f.snapshot.copies.rev;
const cases = [
[{ ...f.snapshot, copies: {} }, 'copy-unavailable'],
[{ ...f.snapshot, org: 'other' }, 'copy-unavailable'],
[{ ...f.snapshot, copies: { rev: { ...rev, state: 'refused' } } }, 'copy-unavailable'],
[{ ...f.snapshot, copies: { rev: { ...rev, level: 'project', project: 'stack' } } }, 'credential-binding-changed'],
[{ ...f.snapshot, copies: { rev: { ...rev, name: 'other' } } }, 'credential-binding-changed'],
[{ ...f.snapshot, copies: { rev: { ...rev, definition: 'coder' } } }, 'credential-binding-changed'],
];
const before = kept(f), sent = f.api.requests.length;
for (const [copies, code] of cases) {
f.opts.copies = copies;
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code }, JSON.stringify(copies.copies?.rev ?? copies.org));
}
f.opts.copies = null;
// No record for the name, or a name the organization's own roles hold.
await assert.rejects(f.direct({ agent: 'nobody', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'credential-unbound' });
f.opts.copies = { ...f.snapshot, copies: { ...f.snapshot.copies, chief: { ...rev, name: 'chief' } } };
const { document } = f.writer.read('demo');
const shadow = { ...f.writer, read: () => ({ ...f.writer.read('demo'), document: { ...document, copyBindings: { ...document.copyBindings, chief: document.copyBindings.rev } } }) };
await assert.rejects(copyGrant({ credentials: f.credentials, writer: shadow, copies: f.opts.copies, organizations: () => ['demo'] })('demo', { agent: 'chief', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'copy-unavailable' });
f.opts.copies = null;
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo'))], [true, sent, false]);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 stageCopy refuses a name a loaded reference holds and an unknown service, leaving the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'cb2-ref-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'agents');
mkdirSync(join(storeRoot, 'demo', 'rev'), { recursive: true, mode: 0o700 }); chmodSync(storeRoot, 0o700); chmodSync(join(storeRoot, 'demo'), 0o700);
const file = join(storeRoot, 'demo', 'rev', 'gitea');
writeFileSync(file, 'r'.repeat(40) + '\n', { mode: 0o600 });
const credentials = new Credentials({ references: { 'demo/rev': { gitea: { service: 'gitea', store: true, file, rotateBy: ahead(30) } } }, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
assert.throws(() => credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)), { code: 'credential-reference' });
assert.equal(readFileSync(file, 'utf8'), 'r'.repeat(40) + '\n');
assert.throws(() => credentials.stageCopy('demo/other', 'model', 'a'.repeat(40)), { code: 'credential-reference' });
assert.equal(present(join(storeRoot, 'demo', 'other')), false);
});
test('CB2 a Vikunja binding refuses credential-scope-unverified before anything is staged or sent', async (t) => {
const vik = { tasks: ['read_one'] };
const f = await fixture(t, { services: { gitea: READ.gitea, vikunja: { scopes: vik, projects: ['stack'] } } });
const before = readFileSync(f.file, 'utf8');
await assert.rejects(f.issueAs(f.human, { service: 'vikunja', token: 'v'.repeat(40) }), { code: 'credential-scope-unverified' });
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
assert.ok(!existsSync(join(f.storeRoot, 'demo', 'rev')));
// With a Gitea grant the record still waits on Vikunja: pending, never bound.
await f.issueAs(f.human, { token: f.token(f.bot()).sha1 });
assert.equal(f.rec().state, 'pending');
assert.ok(f.rec().services.gitea.grantId);
assert.ok(REFUSALS['credential-scope-unverified'].nextStep.includes('Vikunja'));
});
test('CB2 unrelated repositories, admin rights or a missing bound repository refuse', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
const other = f.api.addRepo('someone', 'private', { collaborators: [u.login] });
other.permissions[u.login] = 'read';
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
other.collaborators.length = 0;
const own = f.api.addRepo(u.login, 'scratch');
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
f.api.repos.splice(f.api.repos.indexOf(own), 1);
f.repo.collaborators.length = 0;
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
assert.equal(f.rec().state, 'pending');
f.repo.collaborators.push(u.login);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
// A scripted Gitea for the verifier's own branches: paging, organizations and malformed answers.
function scripted(over = {}) {
const base = {
'/api/v1/version': { status: 200, json: { version: '1.27.1' } },
'/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:repository', 'read:user'], user: { id: 5, login: 'bot' } } },
'/api/v1/user': { status: 200, json: { id: 5, login: 'bot', is_admin: false } },
'/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: false, pull: true } }], total: 1 },
'/api/v1/user/orgs': { status: 200, json: [], total: 0 },
...over,
};
const seen = [];
const transport = async ({ path, method }) => {
seen.push(`${method} ${path}`);
const key = path.split('?')[0];
const a = base[key];
return typeof a === 'function' ? a(path) : { total: null, ...a };
};
return { transport, seen };
}
const V = (transport, extra = {}) => verifyGiteaToken({ transport, url: 'https://127.0.0.1:1', token: 'x'.repeat(40), approved: ['read:repository', 'read:user', 'read:organization'], repos: new Set(['owner/stack']), login: 'bot', rotateBy: ahead(30), ...extra });
test('CB2 verifier: paging must agree, organizations are refused with an organization scope', async () => {
assert.deepEqual(await V(scripted().transport), { userId: 5, tokenId: 9, login: 'bot' });
const pages = (p1, p2) => (path) => (path.includes('page=1') ? p1 : p2);
const repo = (n) => ({ full_name: `owner/${n}`, permissions: { admin: false, push: false, pull: true } });
const two = new Set(['owner/stack', 'owner/b']);
const many = Array.from({ length: 50 }, (_, i) => repo(`r${i}`));
const fifty = new Set(many.map((r) => r.full_name.toLowerCase()));
// A total that changes between pages, a page that comes back short, and no total at all.
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 51 }, { status: 200, json: [repo('x')], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 52 }, { status: 200, json: [], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack')], total: null } }).transport), { code: 'credential-scope-unverified' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport), { code: 'credential-scope-exceeded' });
assert.ok(await V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport, { repos: two }));
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { push: false, pull: true } }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack' }], total: 1 } }).transport), { code: 'credential-scope-unverified' });
// Organizations: only asked when the token has an organization level.
const plain = scripted({ '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } });
assert.ok(await V(plain.transport));
assert.ok(!plain.seen.some((s) => s.includes('/user/orgs')));
const orgTok = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:organization', 'read:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
assert.ok(await V(scripted(orgTok).transport));
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 500, json: null } }).transport), { code: 'credential-scope-unverified' });
// The bound projects' remotes: one https Gitea origin, agreeing remotes.
const doc = (projects) => ({ projects });
assert.deepEqual([...boundRepositories(doc({ a: { remote: 'https://127.0.0.1:9/Owner/Stack' } }), ['a']).repos], ['owner/stack']);
for (const [d, ps] of [[doc({ a: { remote: 'http://127.0.0.1:9/o/r' } }), ['a']], [doc({ a: {} }), ['a']], [doc({}), ['a']], [doc({ a: { remote: 'https://127.0.0.1:9/o/r' }, b: { remote: 'https://127.0.0.2:9/o/r' } }), ['a', 'b']], [doc({}), []],
[doc({ a: { remote: 'https://127.0.0.1:9/o/r', git: { mode: 'remote', remote: 'https://127.0.0.1:9/o/s' } } }), ['a']]])
assert.throws(() => boundRepositories(d, ps), { code: 'credential-scope-unverified' });
});
test('CB2 a foreign account, an administrator or another copy\'s token is identity-refused', async (t) => {
const f = await fixture(t);
const u = f.bot();
const foreign = f.api.addUser('someone-else').user;
f.repo.collaborators.push('someone-else');
for (const tok of [f.token(foreign), { sha1: f.api.adminToken }]) {
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-identity-refused' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
}
// The copy's own login, made an administrator.
u.is_admin = true;
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
u.is_admin = false;
// /user names another account than /token did.
const undo = f.api.rule((e) => e.path === '/api/v1/user', { status: 200, json: { id: u.id + 1000, login: u.login, is_admin: false } });
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
undo();
// Another copy's account and token: checker.stack is bound in the same organization.
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: READ } });
const checker = f.bot('checker.stack');
const theirs = f.token(checker);
await assert.rejects(f.issueAs(f.human, { token: theirs.sha1 }), { code: 'credential-identity-refused' });
await f.issueAs(f.human, { agent: 'checker.stack', token: theirs.sha1 });
assert.equal(f.rec('checker.stack').state, 'bound');
assert.equal(f.rec().state, 'pending');
assert.notEqual(checker.login, u.login);
});
test('CB2 an opaque or unverifiable token refuses unverified; a malformed one refuses credential-format', async (t) => {
const f = await fixture(t);
const u = f.bot();
// A malformed token refuses before anything is staged or sent (build review 2): the bound record is
// unchanged, nothing reached Gitea and no slot exists.
const before = kept(f), sent = f.api.requests.length;
await assert.rejects(f.issueAs(f.human, { token: 'short' }), { code: 'credential-format' });
await assert.rejects(f.issueAs(f.human, { token: 'has space ' + 'x'.repeat(30) }), { code: 'credential-format' });
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo', 'rev'))], [true, sent, false]);
await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' });
const tok = f.token(u);
for (const answer of [{ status: 200, json: { id: tok.id, scopes: tok.scopes } }, { status: 200, json: { id: tok.id, scopes: tok.scopes, user: { login: u.login } } }, { status: 500, json: null }, { drop: true }]) {
const undo = f.api.rule((e) => e.path === '/api/v1/token', answer);
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' }, JSON.stringify(answer));
undo();
}
f.opts.timeoutMs = 300;
const hang = f.api.rule((e) => e.path.startsWith('/api/v1/user/repos'), { hang: true });
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
hang();
f.opts.timeoutMs = 3000;
f.api.state.version = '1.26.0';
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
f.api.state.version = '1.27.1';
// The token lacks read:user, which the binding approved: its repositories cannot be listed.
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository']).sha1 }), { code: 'credential-scope-unverified' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 a token any definition already holds refuses before anything is staged or sent', async (t) => {
const f = await fixture(t);
const tok = f.token(f.bot());
const before = readFileSync(f.file, 'utf8');
// The boot copy snapshot's definitions are live text for the bus's lifetime.
f.opts.copies = { ...f.snapshot, definitions: { ...f.snapshot.definitions, leaky: { note: `see ${tok.sha1}` } } };
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'definition-holds-credential' });
assert.deepEqual([readFileSync(f.file, 'utf8') === before, existsSync(f.slot()), f.api.requests.length], [true, false, 0]);
f.opts.copies = null;
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 a binding without read:user is unverifiable before anything is staged', async (t) => {
const f = await fixture(t, { services: { gitea: { scopes: ['read:repository'], projects: ['stack'] } } });
const before = readFileSync(f.file, 'utf8');
await assert.rejects(f.issueAs(f.human, { token: f.token(f.bot()).sha1 }), { code: 'credential-scope-unverified' });
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
});
test('CB2 dates: the rotation date is after today, at most a year ahead, and not after the token expires', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
const before = readFileSync(f.file, 'utf8');
for (const date of [ahead(0), ahead(-1), ahead(MAX_ROTATE_DAYS + 1), '2099-02-30', '2099-1-01', 'tomorrow'])
await assert.rejects(f.issueAs(f.human, { token: tok.sha1, date }), { code: 'credential-date' }, date);
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
const soon = f.token(u, undefined, { expires: `${ahead(10)}T00:00:00Z` });
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(10) }), { code: 'credential-date' });
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(30) }), { code: 'credential-date' });
const odd = f.token(u, undefined, { expires: 'not-a-date' });
await assert.rejects(f.issueAs(f.human, { token: odd.sha1 }), { code: 'credential-scope-unverified' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
await f.issueAs(f.human, { token: soon.sha1, date: ahead(9) });
assert.equal(f.rec().services.gitea.rotateBy, ahead(9));
await f.issueAs(f.human, { token: f.token(u, undefined, { expires: `${ahead(400)}T00:00:00Z` }).sha1, date: ahead(MAX_ROTATE_DAYS) });
assert.equal(f.rec().state, 'bound');
});
test('CB2 the slot is read back by identity: symlinks, hard links, stale and replaced files refuse credential-file', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
const other = f.token(u);
const elsewhere = join(f.root, 'elsewhere');
const cases = {
symlink: ['afterStage', () => { writeFileSync(elsewhere, tok.sha1 + '\n', { mode: 0o600 }); unlinkSync(f.slot()); symlinkSync(elsewhere, f.slot()); }],
hardlink: ['afterStage', () => { rmSync(elsewhere, { force: true }); linkSync(f.slot(), elsewhere); }],
stale: ['afterStage', () => { renameSync(f.slot(), elsewhere); writeFileSync(f.slot(), tok.sha1 + '\n', { mode: 0o600 }); }],
mode: ['afterStage', () => chmodSync(f.slot(), 0o644)],
// A writer replaces a file by renaming a new one over it, as the store does.
replacedAfterRemote: ['afterRemote', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
replacedBeforeCommit: ['beforeCommit', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
// Unlinked and written again: the inode number may be reused, the content still differs.
reusedInode: ['afterStage', () => { unlinkSync(f.slot()); writeFileSync(f.slot(), other.sha1 + '\n', { mode: 0o600 }); }],
rewrittenInPlace: ['beforeCommit', () => writeFileSync(f.slot(), other.sha1 + '\n')],
};
for (const [name, [hook, act]] of Object.entries(cases)) {
rmSync(elsewhere, { force: true });
f.hooks[hook] = act;
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-file' }, name);
delete f.hooks[hook];
assert.equal(f.rec().state, 'pending', name);
assert.equal(f.rec().services.gitea.grantId, undefined, name);
// The bus removes only the file it wrote; a file someone else put there stays.
if (['symlink', 'stale', 'replacedAfterRemote', 'replacedBeforeCommit'].includes(name)) assert.ok(present(f.slot()), name);
if (['hardlink', 'mode', 'rewrittenInPlace'].includes(name)) assert.ok(!present(f.slot()), name);
rmSync(f.slot(), { force: true });
}
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 crash points leave the record pending and the slot removed; a new verified issue recovers', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
for (const hook of ['afterStage', 'afterRemote', 'beforeCommit']) {
f.hooks[hook] = () => { throw new Error('crash'); };
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'outcome-unknown' }, hook);
delete f.hooks[hook];
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false], hook);
}
// A hard crash after the stage (security review P2): the record is pending and the staged token is
// on disk. The next start's sweep removes it before anything loads; the record stays pending and
// only a fresh verified issue binds.
const r = f.rec();
const { revision } = f.writer.read('demo');
f.writer.stageCopyGrant('demo', { instance: 'rev', service: 'gitea', copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }, { revision, actor: 'jason', owner: true });
f.credentials.stageCopy('demo/rev', 'gitea', tok.sha1);
assert.equal(f.rec().state, 'pending');
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', f.writer.read('demo').document]], {}), [{ instance: 'demo/rev', service: 'gitea' }]);
assert.deepEqual([f.rec().state, present(f.slot())], ['pending', false]);
await f.issueAs(f.human, { token: tok.sha1 });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 1]);
// A lost reply: the commit landed but the caller never heard. Issuing again re-verifies.
await f.issueAs(f.human, { token: tok.sha1 });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 2]);
});
test('CB2 the start sweep removes only ungranted copy slots and staged temp files', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
const doc = () => [['demo', f.writer.read('demo').document]];
const copyDir = join(f.storeRoot, 'demo', 'rev');
// A granted slot stays; an interrupted write's temp file goes; a name that is no service stays.
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
writeFileSync(join(copyDir, 'notes'), 'x', { mode: 0o600 });
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'gitea' }]);
assert.deepEqual([present(f.slot()), present(join(copyDir, 'gitea.tmp')), present(join(copyDir, 'notes'))], [true, false, true]);
// An ungranted service of a bound copy goes; a slot a loaded reference names stays.
writeFileSync(join(copyDir, 'vikunja'), 'x', { mode: 0o600 });
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), { 'demo/rev': { vikunja: { service: 'vikunja' } } }), []);
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'vikunja' }]);
// A role's own slots and an unreadable organization are never touched.
const roleDir = join(f.storeRoot, 'demo', 'chief');
mkdirSync(roleDir, { mode: 0o700 }); writeFileSync(join(roleDir, 'gitea'), 'x', { mode: 0o600 });
const edited = { ...f.writer.read('demo').document };
edited.copyBindings = { ...edited.copyBindings, chief: { ...edited.copyBindings.rev, services: {} } };
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', edited]], {}), []);
assert.equal(present(join(roleDir, 'gitea')), true);
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', null]], {}), []);
assert.equal(present(join(copyDir, 'gitea.tmp')), true);
// No store, a missing org dir: nothing. A store dir that isn't private refuses as Credentials would.
assert.deepEqual(sweepCopySlots(null, doc(), {}), []);
assert.deepEqual(sweepCopySlots(f.storeRoot, [['other', f.writer.read('demo').document]], {}), []);
chmodSync(copyDir, 0o755);
assert.throws(() => sweepCopySlots(f.storeRoot, doc(), {}), /credential-file/);
chmodSync(copyDir, 0o700);
});
test('CB2 a stage write that fails leaves no staged token behind', async (t) => {
const f = await fixture(t);
// The slot path is a non-empty directory: the rename fails after the temp file holds the token.
mkdirSync(join(f.slot(), 'x'), { recursive: true, mode: 0o700 });
assert.throws(() => f.credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)));
assert.deepEqual(readdirSync(join(f.storeRoot, 'demo', 'rev')), ['gitea']);
});
test('CB2 a rebind, a failed owner recheck or a concurrent issue during the remote work refuses the commit', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
// The owner approves a new binding while Gitea is being asked.
f.hooks.afterRemote = () => f.bind('rev', { gitea: { scopes: ['read:user', 'read:repository'], projects: ['stack'] } });
// The document moved under the commit: its revision check refuses before the binding fence.
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'revision-conflict' });
delete f.hooks.afterRemote;
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
// The caller's owner recheck fails after the remote work (the broker's #copyOwner), and again
// when it fails only under the writer's locks.
for (const at of [1, 2]) {
let n = 0;
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true, recheck: () => { if (++n >= at) throw new BusError('human-required'); } }), { code: 'human-required' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
}
// Not the owner, or the tester's name, never reaches the writer.
for (const ctx of [{ actor: 'jason', owner: false }, { actor: 'tester', owner: true }])
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, ctx), { code: 'human-required' });
// Two issues: the first is held after its remote work while the second binds.
const second = f.token(u);
let release, first = true;
const held = new Promise((r) => { release = r; });
f.hooks.afterRemote = () => { if (first) { first = false; return held; } };
const a = f.issueAs(f.human, { token: tok.sha1 });
await new Promise((r) => setTimeout(r, 50));
while (f.hooks.afterRemote && first) await new Promise((r) => setTimeout(r, 10));
await f.issueAs(f.human, { token: second.sha1 });
release();
await assert.rejects(a, (e) => ['revision-conflict', 'credential-binding-changed', 'credential-file'].includes(e.code));
delete f.hooks.afterRemote;
assert.equal(f.rec().state, 'bound');
assert.equal(f.rec().services.gitea.grantId, `gitea:${u.id}:${second.id}`);
assert.equal(readFileSync(f.slot(), 'utf8'), second.sha1 + '\n', 'the second issue\'s slot stays');
});
test('CB2 only the organization owner issues: the chief, the tester, a reader and an org.hire member are refused before staging', async (t) => {
const f = await fixture(t);
const tok = f.token(f.bot());
const before = readFileSync(f.file, 'utf8');
const member = (owner) => f.b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights: ['org.hire', 'decide'], owner, instanceOwner: true });
const got = [];
for (const cap of [f.chief, f.b.bindTester({ business: 'demo' }), f.b.bindReader({ business: 'demo' }), member(false), member(true), f.owner]) {
await assert.rejects(f.issueAs(cap, { token: tok.sha1 }), (e) => { got.push(e.code); return !String(e.message).includes(tok.sha1); });
}
// The Console caps, the owner's included, never reach credentials.issue: the token comes from the
// owner's CLI on standard input.
assert.deepEqual(got, ['human-required', 'tester-refused', 'human-required', 'human-required', 'human-required', 'human-required']);
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
assert.ok(!existsSync(join(f.storeRoot, 'demo')));
// Only a name the boot's copies view holds goes to the copy verifier; any other unknown name
// answers credential-unknown as before, and nothing is staged or sent.
await assert.rejects(f.issueAs(f.human, { agent: 'nobody', token: tok.sha1 }), { code: 'credential-unknown' });
assert.deepEqual([readFileSync(f.file, 'utf8') === before, f.api.requests.length, existsSync(join(f.storeRoot, 'demo'))], [true, 0, false]);
});
test('CB2 refusal table: the new codes have rails and next steps that name no path', () => {
for (const code of ['credential-scope-unverified', 'credential-identity-refused', 'credential-date', 'credential-format', 'credential-file', 'copy-unavailable']) {
const g = REFUSALS[code];
assert.ok(g && g.rail.length && g.nextStep.length, code);
assert.doesNotMatch(g.nextStep, /\/|~|\.json\b|secrets/, code);
}
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
// no decision channel (M6). The messaging lists are read from the shipped role files.
+61 -8
View File
@@ -4,13 +4,15 @@
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { definitionPin } from '../../organization/src/copy-bindings.mjs';
import { readTemplate } from '../../organization/src/definitions.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
@@ -18,20 +20,22 @@ const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t) {
async function setup(t, { storeRoot = true, before = () => {}, edit = () => {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
edit(doc, rolesDir);
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
before(store);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
},
@@ -58,7 +62,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
const calls = [];
const b = new Broker({
store: s,
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
@@ -97,7 +101,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
// No lifecycle without the runtime's store.
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
});
@@ -161,3 +165,52 @@ test('T5: issue, rotate, revoke and delete are live, rewrite the business file a
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
test('#1886: a store reference with no store root yet starts the bus, makes the root private and issues', async (t) => {
const c = await setup(t, { storeRoot: false });
const dir = join(c.root, 'secrets', 'agents');
assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.equal(statSync(join(c.root, 'secrets')).mode & 0o077, 0);
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
});
test('#1886: a store root that exists with loose modes still refuses start', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-loose-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
chmodSync(join(root, 'secrets', 'agents'), 0o750);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
};
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
});
test('CB2 security review P2: the start sweep removes a copy slot a crashed issue left staged', async (t) => {
// A copy binding left pending by an issue killed after its stage, its token and a temp file on disk.
const pending = (doc, rolesDir) => {
doc.copyBindings = { rev: {
copyId: 'c'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
definition: definitionPin('reviewer', { definitions: {}, rolesDir, readTemplate }), generation: 2, state: 'pending',
services: { gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] } } },
decision: '11111111-1111-4111-8111-111111111111' } };
};
const staged = (store) => {
mkdirSync(join(store('rev', 'gitea'), '..'), { recursive: true, mode: 0o700 });
chmodSync(join(store('rev', 'gitea'), '..', '..'), 0o700);
for (const name of ['gitea', 'gitea.tmp']) writeFileSync(store('rev', name), TOKEN + '\n', { mode: 0o600 });
mkdirSync(join(store('coder', 'gitea'), '..'), { recursive: true, mode: 0o700 });
writeFileSync(store('coder', 'gitea'), TOKEN + '\n', { mode: 0o600 });
};
const c = await setup(t, { edit: pending, before: staged });
assert.equal(c.file().copyBindings.rev.state, 'pending');
assert.deepEqual(readdirSync(join(c.store('rev', 'gitea'), '..')), []);
// A role's own store slot is the store's, not the sweep's.
assert.equal(existsSync(c.store('coder', 'gitea')), true);
});
@@ -15,7 +15,7 @@ const ROLES = { coder: { definition: 'coder', authority: { withinRole: [], cross
async function setup(t, limit = 0) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'limit-check': ['daily-quota'], harness: ['pi', 'claude-code'] };
doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' };
@@ -24,8 +24,8 @@ async function setup(t, limit = 0) {
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'),
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
@@ -76,8 +76,8 @@ test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
+2 -2
View File
@@ -13,8 +13,8 @@ const options = [
{ key: 'no', text: 'Decline' },
];
const roles = {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send'], crossRole: [] } },
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } };
+2 -2
View File
@@ -22,8 +22,8 @@ const options = [
];
const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } };
const roles = {
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
// Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter.
coder: { definition: 'coder', ...pusher },
+2 -2
View File
@@ -13,8 +13,8 @@ const options = [
{ key: 'no', text: 'Decline' },
];
const roles = {
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = {
+336
View File
@@ -0,0 +1,336 @@
// #1873 T1 follow-up (org-defscan): bus start runs the live credential store's held-secret check on
// every organization definition, and the launch check asks the bus the same question. A hit names
// the definition and the key, never the text there or the credential's name.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { BusError } from '../src/broker.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, DEFINITION_SCAN_UNREADABLE, heldPath, scanDefinitions, scanOrganizations } from '../src/definition-scan.mjs';
import { startBroker } from '../src/runtime.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
// The fixture bus holds SENTINEL as coder's gitea token.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const UNHELD = 'sk-test-UNHELD-LOOKS-LIKE-A-TOKEN-0000';
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
// An organization whose coder names org:builder, a copy of the coder template; `edit` changes the
// builder entry before the file is written. `held` is the coder's gitea token at start.
function org(t, edit = (entry) => entry, { held = SENTINEL } = {}) {
const root = tmp(t, 'defscan-');
const builder = edit(entryFromTemplate(readTemplate(REPO_ROLES, 'coder')));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
writeFileSync(f.doc.roles.coder.credentials.gitea.file, held);
const file = join(root, 'config/businesses/acme.json');
const boot = () => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const start = (b = boot()) => startBroker({ dataRoot: f.dataRoot, businesses: b.businesses, organization: b.organization });
return { root, f, file, boot, start };
}
function refusedWithout(e, reason) {
assert.ok(e instanceof BusError, String(e));
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
assert.equal(e.reason, reason);
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
assert.ok(!text.includes(SENTINEL), 'the refusal never carries the value');
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
}
return true;
}
// The start's outcome: the refusal, or "started" after closing a bus that should not have started.
const outcome = (start) => start.then(async (bus) => { await bus.close(); return 'started'; }, (e) => e);
function nothingWritten(root) {
for (const path of files(root)) {
if (path.endsWith('.token') || path.endsWith('acme.json')) continue;
assert.ok(!readFileSync(path).includes(SENTINEL), path);
}
}
test('org-defscan: the refusal code is in the TI refusal table with a next step', () => {
const entry = REFUSALS[DEFINITION_HOLDS_CREDENTIAL];
assert.ok(entry, 'definition-holds-credential is a known refusal');
assert.match(entry.rail, /^R\d+:/);
assert.match(entry.nextStep, /definition/);
assert.match(entry.nextStep, /issue the credential again/);
assert.match(entry.nextStep, /For a hire, deny it/);
// The issue-time check's fail-closed code for an organization file it cannot read.
const unreadable = REFUSALS[DEFINITION_SCAN_UNREADABLE];
assert.ok(unreadable, 'definition-scan-unreadable is a known refusal');
assert.match(unreadable.rail, /^R\d+/);
assert.match(unreadable.nextStep, /organization file/);
assert.equal(new BusError(DEFINITION_SCAN_UNREADABLE).nextStep, unreadable.nextStep);
});
test('org-defscan: a held credential in a definition\'s instructions refuses bus start, naming definition and key only', async (t) => {
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nThe forge token is ${SENTINEL}.\n` }));
refusedWithout(await outcome(o.start()), 'definitions.builder instructions holds a credential this bus holds');
nothingWritten(o.root);
});
test('org-defscan: a held credential in a personality field refuses bus start the same way', async (t) => {
const o = org(t, (entry) => ({ ...entry, personality: { summary: `Careful. Key ${SENTINEL}.`, traits: ['plain'] } }));
refusedWithout(await outcome(o.start()), 'definitions.builder personality.summary holds a credential this bus holds');
const p = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `x${SENTINEL}`] } }));
refusedWithout(await outcome(p.start()), 'definitions.builder personality.traits[1] holds a credential this bus holds');
nothingWritten(o.root);
nothingWritten(p.root);
});
test('org-defscan: a definition in the file but not in the boot copy is checked too', async (t) => {
const o = org(t);
const boot = o.boot();
// Written after the load and before the bus starts: no instance names it, so only the raw read sees it.
const doc = JSON.parse(readFileSync(o.file, 'utf8'));
doc.definitions.spare = { ...doc.definitions.builder, instructions: `Spare: ${SENTINEL}\n` };
writeJson(o.file, doc);
refusedWithout(await outcome(o.start(boot)), 'definitions.spare instructions holds a credential this bus holds');
});
test('org-defscan: a clean file starts, and token-looking text the store does not hold starts', async (t) => {
for (const edit of [(entry) => entry, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample: ${UNHELD}\n`, personality: { summary: `Says ${UNHELD}.` } })]) {
const o = org(t, edit);
const bus = await o.start();
try {
// The launch check's question: a clean entry answers null, a held one names the key only.
const entry = o.boot().businesses.acme.definitions['org:builder'];
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
const hit = bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { ...entry, instructions: `x ${SENTINEL}` } });
assert.deepEqual(hit, { held: 'instructions' });
// A malformed question refuses rather than answering clean.
for (const bad of [{}, { business: 'acme', definition: 'builder', entry }, { business: 'other', definition: 'org:builder', entry },
{ business: 'acme', definition: 'org:builder', entry: null }, { business: 'acme', definition: 'org:builder', entry: [entry] }])
assert.throws(() => bus.definitionScan(bad), (e) => e.code === 'invalid-request');
} finally {
await bus.close();
}
}
});
// The coder's gitea credential as the human verb issues or rotates it.
function verbs(o, bus) {
const human = bus.broker.bindHuman({ business: 'acme', human: o.f.doc.human, via: 'cli', outsideAgent: true });
return {
issue: (token) => bus.broker.request(human, { verb: 'credentials.issue', args: { agent: 'coder', service: 'gitea', token, date: '2099-01-01' } }),
revoke: () => bus.broker.request(human, { verb: 'credentials.revoke', args: { agent: 'coder', service: 'gitea', reason: 'test' } }),
};
}
// Whether the bus holds `value` now, asked the way the launch check asks.
const holds = (bus, value) => bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { instructions: `x ${value}` } }).held !== null;
const state = (bus) => bus.credentials.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea')?.state;
function issueRefused(e, value, reason) {
assert.ok(e instanceof BusError, String(e));
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
assert.equal(e.reason, reason);
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
assert.ok(!text.includes(value), 'the refusal never carries the value');
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
}
return true;
}
// No file under the root holds `value` except the organization file it was written into and that
// file's own history copies.
function onlyInOrganization(root, value) {
for (const path of files(root))
if (!path.endsWith('acme.json') && !path.includes('/businesses/.history/acme/')) assert.ok(!readFileSync(path).includes(value), path);
}
test('org-defscan: a credential issued between the final launch admission and spawn is refused at issue; the store stays as it was', async (t) => {
// The definition carries text that is not held at start. After the launch's final admission answers
// ready, the human rotates the coder's token to that text with the same date. On the bus before this
// fix the rotate succeeded and the agent spawned with a held value in its contract.
const FIRST = 'fake_first_token_0123456789';
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${SENTINEL}\n` }), { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const v = verbs(o, bus);
// A clean issue still works.
assert.equal(v.issue(FIRST).state, 'valid');
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
const changes = files(o.root).filter((p) => p.endsWith('changes.jsonl')).map((p) => [p, readFileSync(p, 'utf8')]);
const entry = o.boot().businesses.acme.definitions['org:builder'];
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
assert.equal(final().ready, true);
// Between the admission's answer and the spawn: the rotate.
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder instructions holds the credential being issued'));
// Nothing changed: the old token is held, the new one is not, the file and its receipts are as they were.
assert.ok(holds(bus, FIRST) && !holds(bus, SENTINEL));
assert.equal(state(bus), 'valid');
assert.equal(writer.read('acme').revision, revision);
for (const [p, text] of changes) assert.equal(readFileSync(p, 'utf8'), text, p);
onlyInOrganization(o.root, SENTINEL);
assert.equal(final().ready, true, 'the contract the agent gets holds no held value');
});
test('org-defscan: issue over a revoked entry refuses the same way for a personality field, and a clean issue then works', async (t) => {
const o = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `uses ${SENTINEL}`] } }), { held: 'fake_boot_token_5555555555' });
const bus = await o.start();
t.after(() => bus.close());
const v = verbs(o, bus);
v.revoke();
assert.equal(state(bus), 'revoked');
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder personality.traits[1] holds the credential being issued'));
assert.equal(state(bus), 'revoked');
assert.ok(!holds(bus, SENTINEL));
onlyInOrganization(o.root, SENTINEL);
const issued = v.issue('fake_clean_token_4444444444');
assert.deepEqual([issued.verb, issued.state], ['issue', 'valid']);
});
test('org-defscan: a value held without the issue verb still refuses the launch at its final admission, even when the file revision is unchanged', async (t) => {
// Defence behind the issue check: the store comes to hold text already in the definition by a path
// other than the human verb, which leaves the organization file's bytes, so its revision, as they were.
const ROTATED = 'fake_rotated_token_9876543210';
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${ROTATED}\n` }));
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
assert.equal(final().ready, true);
bus.credentials.issue('acme/coder', 'gitea', ROTATED, '2099-01-01');
assert.equal(writer.read('acme').revision, revision, 'the store change left the file as it was');
assert.deepEqual(final(), { ready: false, revision: null, code: DEFINITION_HOLDS_CREDENTIAL });
// An instance on a shipped template is not asked about a definition.
assert.equal(bus.admission({ business: 'acme', projects: [], instance: 'reviewer', revision }).ready, true);
});
test('org-defscan: issue checks the other organizations the bus serves, and refuses when one\'s file cannot be read', async (t) => {
// The bus serves acme and beta. beta's file has a definition holding text acme's coder then tries to
// be issued; later beta's file stops being JSON. Neither issue changes acme's store or file.
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const betaFile = join(o.root, 'config/businesses/beta.json');
const acme = JSON.parse(readFileSync(o.file, 'utf8'));
const spare = { ...acme.definitions.builder, personality: { summary: 'Spare.', voice: `quotes ${SENTINEL}` } };
writeJson(betaFile, { ...acme, id: 'beta', definitions: { spare } });
const bus = await o.start({ ...boot, businesses: { ...boot.businesses, beta: { ...boot.businesses.acme, id: 'beta' } } });
t.after(() => bus.close());
const v = verbs(o, bus);
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization beta definitions.spare personality.voice holds the credential being issued'));
writeFileSync(betaFile, '{ not json');
const CLEAN = 'fake_clean_token_4444444444';
assert.throws(() => v.issue(CLEAN), (e) => e instanceof BusError && e.code === DEFINITION_SCAN_UNREADABLE && !JSON.stringify(e).includes(CLEAN) && !String(e.stack).includes(CLEAN));
for (const value of [SENTINEL, CLEAN]) assert.ok(!holds(bus, value));
assert.equal(state(bus), 'valid');
assert.equal(writer.read('acme').revision, revision);
onlyInOrganization(o.root, CLEAN);
// With beta's file readable again, the clean issue goes through.
writeJson(betaFile, { ...acme, id: 'beta', definitions: {} });
assert.equal(v.issue(CLEAN).state, 'valid');
assert.ok(holds(bus, CLEAN));
});
test('org-defscan: the issue check covers every organization the bus serves, and an unreadable file refuses', () => {
const held = 'HELD-VALUE-77';
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
const own = { id: 'acme', document: { definitions: { builder: { instructions: 'fine' } } } };
const files = { beta: { readable: true, document: { definitions: { spare: { instructions: 'fine', personality: { voice: `say ${held}` } } } } },
gamma: { readable: true, document: {} }, broken: { readable: false } };
const reads = [];
const readRaw = (id) => { reads.push(id); if (id === 'throws') throw new Error('EACCES'); return files[id]; };
assert.doesNotThrow(() => scanOrganizations({ organizations: ['acme', 'gamma'], own, readRaw }, assertClean));
assert.deepEqual(reads, ['gamma'], 'the issuing organization is the document already read');
assert.throws(() => scanOrganizations({ organizations: ['acme', 'gamma', 'beta'], own, readRaw }, assertClean),
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'organization beta definitions.spare personality.voice holds the credential being issued' && !JSON.stringify(e).includes(held));
assert.throws(() => scanOrganizations({ organizations: ['acme', { ...own, id: 'x' }.id], own: { id: 'acme', document: { definitions: { [held]: {} } } }, readRaw: () => ({ readable: true, document: {} }) }, assertClean),
(e) => e.reason === 'organization acme definitions.* key holds the credential being issued' && !e.reason.includes(held));
for (const bad of ['broken', 'throws', 'missing'])
assert.throws(() => scanOrganizations({ organizations: ['acme', bad], own, readRaw }, assertClean), (e) => e.code === DEFINITION_SCAN_UNREADABLE && e.reason === undefined);
});
test('org-defscan: heldPath names the first place, a key by its parent, and never the text', () => {
const held = 'HELD-VALUE-77';
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', 'b'] } }, assertClean), null);
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', `b${held}`] } }, assertClean), 'personality.traits[1]');
assert.equal(heldPath({ role: { tools: [held] } }, assertClean), 'role.tools[0]');
assert.equal(heldPath({ personality: { [held]: 'x' } }, assertClean), 'personality key');
assert.equal(heldPath({ [`${held}`]: 'x' }, assertClean), 'key');
assert.throws(() => scanDefinitions({ 'org:a': { instructions: 'ok' }, 'org:b': { instructions: held } }, assertClean, { prefix: 'org:' }),
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'definitions.b instructions holds a credential this bus holds');
// A name outside the loader's pattern is not quoted.
assert.throws(() => scanDefinitions({ 'Bad Name': { instructions: held } }, assertClean), (e) => e.reason === 'definitions.* instructions holds a credential this bus holds');
assert.throws(() => scanDefinitions({ [held]: { instructions: 'ok' } }, assertClean), (e) => e.reason === 'definitions.* key holds a credential this bus holds' && !e.reason.includes(held));
assert.doesNotThrow(() => scanDefinitions(undefined, assertClean));
});
test('org-defscan: an approved hire whose definition holds a credential issued after the raise refuses before the decision is consumed', async (t) => {
// The chief raises a hire whose instructions carry text the store does not hold yet. The proposal is
// private, so the issue-time check cannot see it, and the store then comes to hold that text. The
// approval runs the same definition check as start, launch and issue: the refusal names the code, the
// decision stays open, and the organization file is as it was.
const LATER = 'fake_later_token_0123456789';
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const inline = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: `Read and report.\nExample ${LATER}\n` };
const raised = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'scout', role: { inline }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
// Issued through the store, so the organization file and its revision stay as they were and the
// approval reaches the definition check rather than the revision check.
bus.credentials.issue('acme/coder', 'gitea', LATER, '2099-01-01');
assert.ok(holds(bus, LATER));
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const before = readFileSync(o.file);
const revision = writer.read('acme').revision;
const owner = bus.broker.bindConsole({ business: 'acme', human: o.f.doc.human, subject: { provider: 'test', iss: 'https://issuer.test', sub: 'owner' } });
const refused = (e) => {
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
for (const text of [e.message, String(e.reason), String(e.stack), JSON.stringify(e)]) assert.ok(!text.includes(LATER), 'the refusal never carries the value');
return true;
};
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
// Nothing was written.
assert.deepEqual(readFileSync(o.file), before);
assert.equal(writer.read('acme').revision, revision);
assert.ok(!Object.hasOwn(writer.read('acme').document.roles, 'scout'));
// The decision was not consumed: a second approval meets the same check, not decision-closed.
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
assert.deepEqual(readFileSync(o.file), before);
// A hire with clean text, raised and approved on the same bus, is written as before.
const clean = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'helper', role: { inline: { ...inline, instructions: 'Read and report.\n' } }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
assert.equal((await bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: clean.id, choice: 'yes', digest: clean.hire.digest } })).choice, 'yes');
assert.ok(Object.hasOwn(writer.read('acme').document.roles, 'helper'));
onlyInOrganization(join(o.root, 'config/businesses'), LATER);
});
test('org-defscan: the boot copy snapshot\'s definitions (the root agents file\'s) are checked at start and at issue', async (t) => {
// A4: a root definition reaches an organization through the boot copy snapshot, which every reload
// re-applies. Its text is in no organization file, so the start and issue checks read the snapshot.
const LATER = 'fake_later_token_0123456789';
const snapshot = (instructions) => ({ org: 'acme', root: 'ok', rootAgents: {}, copies: {},
definitions: { rooted: { ...entryFromTemplate(readTemplate(REPO_ROLES, 'coder')), instructions } } });
const startWith = (o, copies, b = o.boot()) => startBroker({ dataRoot: o.f.dataRoot, businesses: b.businesses, organization: b.organization, copies });
// Held at start: the start refuses, naming the snapshot's definition and key.
const held = org(t);
refusedWithout(await outcome(startWith(held, snapshot(`Root copy ${SENTINEL}\n`))), 'boot copy definitions.rooted instructions holds a credential this bus holds');
// Not held at start: the bus starts, then issuing that text refuses and the store is as it was.
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const bus = await startWith(o, snapshot(`Root copy ${LATER}\n`));
t.after(() => bus.close());
assert.throws(() => verbs(o, bus).issue(LATER), (e) => issueRefused(e, LATER, 'organization acme boot copy definitions.rooted instructions holds the credential being issued'));
assert.ok(!holds(bus, LATER));
assert.equal(state(bus), 'valid');
assert.equal(verbs(o, bus).issue('fake_clean_token_4444444444').state, 'valid');
});
@@ -0,0 +1,74 @@
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
// and the writer runs the credential store's held-secret check on what it would write.
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fixture-held-token-t1-5c0e9a';
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
});
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const bus = await startBroker({ dataRoot: data, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const revision = () => b.request(human, { verb: 'limits' }).revision;
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
// An agent, a reader and the tester never reach the writer.
const before = readFileSync(file);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
// Unknown and missing arguments refuse; an actor can't be forged through them.
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
assert.deepEqual(readFileSync(file), before);
// The human's invent commits, as the human, with a receipt that carries no role text.
const receipt = b.request(human, invent('scout'));
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
assert.equal(last.actor, 'jason');
assert.equal(last.state, 'committed');
// A writer refusal passes by its code.
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
// The bus refuses a request that carries the held token, and the writer refuses a template whose
// contract carries it (the import request itself holds no token). Neither writes.
const written = readFileSync(file);
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
assert.deepEqual(readFileSync(file), written);
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
});
+91
View File
@@ -0,0 +1,91 @@
import { createGitSocket } from '../../cli/src/git-socket.mjs';
import { pushWorking, fetchReview } from '../../harness/src/git-tools.mjs';
import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { git } from '../../project/tests/prd-fixture.mjs';
import assert from 'node:assert/strict';
import { requestFixture } from './request-fixture.mjs';
import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs';
import { DatabaseSync } from 'node:sqlite';
import { createRequestStore } from '../src/request-store.mjs';
const advance = async (f, op = 'next') => { const s = f.journal.readRequest('demo', f.request); return f.call('pm', 'request.advance', { request: f.request, revision: s.revision, op }); };
export async function planned(t, threaded = false) {
const f = await requestFixture(t, 'autonomous', null, threaded), sub = await f.submit(); f.approve(sub); await f.activate(sub);
const { createDigest, createMarker } = await import('../src/broker.mjs');
const s = f.journal.readRequest('demo', f.request), task_ref = 'vikunja:2/41';
const prepared = { op: 'timer-task', request: f.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: s.snapshot,
project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] };
prepared.body = { title: 'Timer', description: createMarker(prepared) };
prepared.body_digest = createDigest(prepared.body);
const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared);
let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 };
const snapshot = () => ({ task_ref, fields, digest: createDigest(fields), updated: new Date().toISOString() });
f.bus.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: { ...snapshot(), creator: 3 } });
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [] });
await assert.rejects(advance(f), /request-evidence-required/);
fields = { ...fields, assignees: [5] };
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task_ref, body: { role: 'coder', previous: [] } }] });
const push = commit => f.bus.broker.recordPush(f.caps.coder, { task_ref, project: 'timer', branch: 'timer', ref: 'work/vikunja-2-41', commit, previous: null });
const review = commit => f.call('coder', 'review.request', { task_ref, commit, to: 'reviewer' });
// Lightweight continuation tests simulate the tracker receipt; plannedWithRealClose uses the actual adapter.
return { ...f, task_ref, push, review, closeTask: verdict => {
fields = { ...fields, done: true, bucket: 3 };
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.closed', subject: task_ref, body: { verdict } }] });
} };
}
async function plannedWithRealClose(t) {
const f = await scopedFixture(t), product = await f.product('timer');
const task = await f.create(product.args), task_ref = task.task_ref;
await f.bus.broker.requestTask(f.caps.pm, { verb: 'task.assign', args: { task_ref, role: 'coder' } }, f.adapter.handle);
const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); t.after(() => db.close());
const store = { get: (sql, ...a) => db.prepare(sql).get(...a), all: (sql, ...a) => db.prepare(sql).all(...a) };
return { ...f, request: product.request, task_ref, store, journal: createRequestStore(store),
push: commit => f.bus.broker.recordPush(f.caps.coder, { task_ref, project: 'timer', branch: 'timer', ref: 'work/vikunja-2-41', commit, previous: null }),
review: commit => f.call('coder', 'review.request', { task_ref, commit, to: 'reviewer' }),
closeTask: verdict => f.bus.broker.requestTask(f.caps.pm, { verb: 'task.close', args: { task_ref, verdict } }, f.adapter.handle) };
}
export async function ready(t, options = {}) {
const f = options.realClose ? await plannedWithRealClose(t) : await planned(t, true);
const advance = async op => { const s = f.journal.readRequest('demo', f.request); return f.call('pm', 'request.advance', { request: f.request, revision: s.revision, op }); };
await advance('start');
const code = join(f.f.dataRoot, 'products/demo/timer/code');
const sandbox = join(f.f.dataRoot, 'test-workspaces');
const coder = join(sandbox, 'coder'), reviewer = join(sandbox, 'reviewer'), temp = join(sandbox, 'tmp');
for (const dir of [coder, reviewer, temp, join(f.f.dataRoot, 'bus-host')]) mkdirSync(dir, { recursive: true });
git(coder, 'clone', '-q', code, 'timer'); git(reviewer, 'clone', '-q', code, 'timer');
const clone = join(coder, 'timer'); git(clone, 'checkout', '-qb', 'feat');
const bytes = Buffer.from(`<!doctype html><title>Timer</title><output id="clock">Ready</output><button id="start">Start</button><script>document.querySelector('#start').onclick=()=>{document.querySelector('#clock').textContent='Running';};</script>`);
writeFileSync(join(clone, 'timer.html'), bytes);
if (options.invalidPaths) { symlinkSync('timer.html', join(clone, 'link.html')); writeFileSync(join(clone, 'large.html'), Buffer.alloc(1024 * 1024 + 1, 65)); }
git(clone, 'add', '-A'); git(clone, 'commit', '-qm', 'Build timer');
const commit = git(clone, 'rev-parse', 'HEAD');
const host = { op: async r => {
if (r.op === 'identity') return f.bus.identity(r.cap);
if (r.op === 'checkPush') return f.bus.checkPush(r.cap, r.task_ref, r.project);
if (r.op === 'recordPush') return f.bus.recordPush(r.cap, r.push);
if (r.op === 'reviewFetch') return f.bus.reviewFetch(r.cap, r.args);
if (r.op === 'refuse') return;
throw new Error('unexpected socket operation');
} };
const socket = createGitSocket({ host, business: { id: 'demo', projects: { timer: { root: code } } }, dataRoot: f.f.dataRoot, env: f.f.env });
await socket.listen(); t.after(() => socket.close());
const pushed = await pushWorking({ path: socket.path, cap: f.caps.coder, workspace: coder, task_ref: f.task_ref, project: 'timer', branch: 'feat', tmp: temp, env: f.f.env });
assert.equal(pushed.commit, commit);
const review = f.review(commit); await advance('review');
const fetched = await fetchReview({ path: socket.path, cap: f.caps.reviewer, workspace: reviewer, task_ref: f.task_ref, request: review.request, tmp: temp, env: f.f.env });
assert.equal(fetched.commit, commit);
assert.equal(git(fetched.clone, 'show', `${fetched.commit}:timer.html`), bytes.toString());
const results = 'Acceptance: timer starts and stops offline; passed.';
const record = JSON.stringify({ version: 1, task_ref: f.task_ref, review_request: review.request, commit, results });
const verification = await f.call('reviewer', 'record.write', { project: 'timer', path: 'docs/verification.md', record });
const verdictBody = JSON.stringify({ verification_record: { path: verification.path, commit: verification.commit }, results: options.results ?? results });
const verdict = f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: review.request, verdict: 'approve', body: verdictBody });
await advance('delivery'); await f.closeTask(verdict.verdict);
const s = f.journal.readRequest('demo', f.request);
const args = { request: f.request, revision: s.revision, op: 'deliver-1', task_ref: f.task_ref, verdict: verdict.verdict, path: 'timer.html', verification_record: { path: verification.path, commit: verification.commit } };
return { ...f, advance, bytes, commit, review, verdict, verification, args };
}
Loaded 100 of 950 files, more files were not shown because too many files have changed in this diff. Show more