Files
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

1398 lines
84 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLI
Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
`decide`, `tasks`, `agents` and `trail`, and the bus host `mosaic bus
start|stop|status` with its Discord notifier (row S4, #1521); the session
launcher in the host and `mosaic talk`, `stop` and `launches` (row S6,
#1523); and `mosaic` with no argument, the full-screen app (queue row 55,
#1544). Briefs: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6, and
`docs/plans/2026-10-10_design-implementation.md`, "Full-screen terminal
app". Rulings: lead decisions 70 and 77 in
`docs/plans/2026-09-26_lead-decisions.md`.
Every command reads through the broker. None opens the SQLite file.
```sh
scripts/mosaic # the app, on a terminal
scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
scripts/mosaic bus status
node --test 'packages/cli/tests/*.test.mjs'
```
## Human commands
```
mosaic inbox [--business <id>] [--json]
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
mosaic tasks edit task:<n> --expect <digest> [--title <text>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
mosaic agents [--business <id>] [--json] [--tester]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>]
mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
mosaic project list [--archived] [--business <id>] [--json] [--tester]
mosaic project archive <name-or-id> [--business <id>]
mosaic project unarchive <name-or-id> [--business <id>]
mosaic project north-star <project> [--business <id>] [--approve <draft-commit> --base <spec-commit>]
mosaic project interview <project>
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
mosaic thread create <project> <name> [--instance <key>] [--business <id>] [--tester]
mosaic thread open <thread> [--business <id>] [--tester]
mosaic thread read <thread> [--business <id>] [--json] [--tester]
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
mosaic follow --thread <thread> [--business <id>] [--tester]
mosaic name <instance> <name> [--business <id>] [--tester]
mosaic names [--business <id>] [--json] [--tester]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
```
- The business is `--business`, or else the business of the bus host running
on this data root. With neither, the command exits 4.
- The transport is `packages/bus/src/human-cli.mjs <socket>`, run as a child.
The command writes `{business, verb, args}` on its stdin and reads the JSON
reply on its stdout. The child proves a human shell: it re-executes itself
with a nonce, and the broker checks its process and ancestry for agent
markers. The command carries no capability and the bus is unchanged.
- Each command refuses with exit 3 before it touches the bus when an agent
marker is in its environment (REQ-DEC-3). The broker would refuse anyway;
this check only gives a clear message first. **Agent seats never run
these commands.** The tests use a stand-in transport.
- `inbox` lists the open decisions routed to the human, gated first, in
broker order. For a gated decision it prints what approving authorizes:
the action, its target, and the one choice that authorizes it. Text output
then lists all unread messages to the human, newest first, without marking
any delivered or read. `--json` keeps its existing decision-row array.
- `inbox --read <id>...` (row 150, #1644) marks unread messages read with
the broker's `message.read`. Each id is the 8-character one `inbox`
prints, or any longer prefix of exactly one unread message's id. A
shorter one refuses with `too-short`. A prefix that matches two or more
refuses with `ambiguous` and names them; one that matches none refuses
with `no-unread-match`, unless it is a full message id, which goes to the
broker as given. `--read-all` marks every message
`inbox` lists, oldest first. Each id prints `marked <id>` or `not marked
<id>: <code>` (`--read-all` prints only the refusals), then `marked N of
M`. Every id is tried; if any was refused the command exits 2 after the
rest. A message the human never received has to be received first, so
the command calls `message.receive` until it has that message. Every
message that call receives drops out of a later `follow`, like the ones
plain `talk` skips, and stays unread in `inbox` until marked. Neither flag
takes `--json` or `--tester` (exit 4).
- `agents` (row 277, #1782) prints the held roles, then a `thread` line for
each held thread claim from the bus verb `thread.claims`: thread id,
instance, run, harness and since when. It never prints a thread's name. If
the bus refuses `thread.claims`, the role lines still print, then `thread
claims: refused <code>`, and the command exits 0. `--json` prints the
`agents` array alone, and `--tester` makes only the `agents` call.
- `decide` takes the full id, or a unique prefix of at least 8 characters,
from the inbox. It prints the decision and whether the choice authorizes
or declines, then asks `[y/N]` on a terminal. Without a terminal it needs
`--yes`, else it exits 4. On `outcome-unknown` it does not resend: it
exits 1 and asks you to check `mosaic inbox` or `mosaic trail <id>`
first.
- Projects and threads (row 128, #1622) are the human's side of the bus's
`project.*` and `thread.*` ops. A thread project named for a business-file
repository must carry that same repo: `mosaic project create stack --repo stack`.
`project create stack` refuses with guidance to add `--repo stack` or choose
another name. An unresolved repository name explains how to create its thread
project, when the business file can be read. `project create` and `project list` work on
the bus record; `project init` and `project check` (`docs/TOOLS.md`, "Local
project records") work on the repository on disk and keep their arguments.
`project list` prints name, repo (`-` for none) and the first 8 characters of
the id; `--archived` includes archived projects, each marked `archived`.
`project archive <name-or-id>` (#797) is the owner's reversible retirement of
a project: it hides the project, refuses its new threads and messages, deletes
nothing, and `project unarchive <name-or-id>` restores it (resolution reads
the archived list too, so an archived project can be named). `threads` prints `<project>/<name>`, the instance and the short id of
the caller's threads, optionally for one project. `thread create` prints the
id and the instance the broker chose; `thread open` writes the open row the
launcher reads; `thread read` pages the thread's messages oldest first and
marks nothing read.
- A project is its name or id. A thread is its id, an id prefix of at least 8
characters, or `<project>/<name>`. The resolver reads `project.list` and
`thread.list` and refuses an unknown or ambiguous reference with exit 2
before anything is sent. A full id that `thread.list` doesn't show goes to
the broker as given, which answers `thread-not-owned` for another user's
thread and `thread-not-found` for none. The CLI adds no ownership,
uniqueness or instance check of its own; `thread-not-owned`,
`thread-not-found`, `project-not-found`, `project-exists`, `thread-exists`,
`unknown-repo`, `thread-instance-missing` and `thread-instance-ambiguous`
print as `mosaic: <code>` and exit 2.
- `talk --thread` sends `thread.send` as a REQUEST. Without `--follow` it
prints only the message whose `in_reply_to` is the sent id, like plain
`talk`, and leaves the rest unread. With `--follow` it prints every message
of that thread after the one it sent and marks read those addressed to the
human; other threads' messages and messages outside any thread stay unread
for `mosaic inbox`. `follow --thread` is the same loop without the send,
from the start of the thread. The loop reads the thread first, so a thread
that isn't the user's is refused before any message is received. A plain
`mosaic talk <role> <text>` to the threaded instance has no default
thread: it exits 2 and names `--thread` and `mosaic threads`.
- With `--tester` (row 184, #1682) `thread create`, `open` and `read`,
`talk --thread` and `follow --thread` run as the tester on the tester's own
threads, which the bus keeps apart from the human's (owner `tester`, row
177). `threads --tester` lists only those. A human thread's full id
refuses `thread-not-owned` and its `<project>/<name>` or prefix isn't in
the tester's list, as for another user's thread above. The reply comes
back through `message.receive` as the tester's message; `inbox --tester`
lists decisions only. Tests: `tests/tester-threads.test.mjs`.
- `trail` prints rows in the broker's order (at, table, seq). A decision's
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
does not pull in the task's rows.
- `talk` sends `<text>` to the role as a REQUEST, then polls the human's
inbox every 2 s for up to `--wait` seconds (default 900). It prints the
reply to its REQUEST, marks only that read, and stops. Any other message
that arrives meanwhile is not printed and stays unread; `mosaic inbox`
lists it. No reply in time exits 1; the reply may still come, and
`mosaic inbox` shows it. When no run holds the role (row 146, #1640),
a line after `sent` says so: `pm isn't running, chief has been told`
when the bus told `launch.by`; `pm isn't running, and the business names
no launch.by` with no launch block; `pm isn't running and isn't in the
launch block, so chief can't launch it` when the role isn't one of
`launch.instances` (row 152, #1646). A `--tester` message to such a role is
refused instead (see `--tester` below). When `launch.by` itself isn't
running, talk prints `<launch.by> isn't running` (or `isn't running
either` after the told line, but not after the launch-block line) and the
command that starts it:
`systemctl --user restart mosaic-bus@<business>`, which starts it only if
the unit runs with `--pm` (`scripts/bus-service.sh status <business>`
shows `pm: on`) and stops running sessions. A hand-started host takes
`mosaic bus stop`, then `mosaic bus start <business> --pm`. No verb
launches `launch.by` on a running host. `--wait 0` only sends. `talk --follow` keeps
printing and marking every message read until Ctrl-C, including messages
with no `in_reply_to`. `follow` runs that same loop without sending. `--wait`
and `--follow` together refuse. These commands consume the same human
inbox; run one receiving CLI at a time. Console only reads history, so a
reply to a Console send stays unread in `inbox` until it is marked read.
- `name` gives a role instance a name (row 141, #1635, REQ-MSG-2) and prints
`named <instance> <name>`. `talk` and every `message.send` then accept the
name where they took the instance. A name is unique in the business and
can't be an instance key, a role definition, `human` or `tester`; the bus
refuses with `name-taken`, `name-is-instance` or `name-is-role`. Naming an
instance again replaces its name and frees the old one. `names` lists each
named instance and its current name.
- `launches off` and `on` are the broker's `launch.revoke` and
`launch.restore`. While off, every `role.launch` refuses with
`launch-revoked`; running sessions keep running. Stop them with `stop`.
`bus start --pm` doesn't ask the broker (`launchPm` skips
`authorizeLaunch`): the human launches the PM, so `launches off` doesn't
stop it. The other checks in "Sessions" below still apply.
- `stop` and `launches list` read `<dataRoot>/bus-host/sessions.json`, not
the bus (see "Sessions" below). `stop` refuses inside an agent run;
`launches list` does not, because the file is readable to the same user
anyway.
- `--tester` (row 110, #1604) runs `inbox`, `tasks`, `agents`, `trail`,
`talk` and `follow` as the business's tester through
`packages/bus/src/tester-cli.mjs` instead of as the human. It is the one
exception to the agent check above: Mos or a T3 seat may run it for a PM
test, and the broker still refuses it under a managed run
(`tester-required`) or while the business var `bus.tester` isn't `true`
(`tester-off`), each exit 3. Like the human commands, `--tester` works
only from the tree the bus runs from ("One pinned copy" below).
Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`,
`limits`, `plugins`, `threads`, `project list` and `org instances` (old
spelling `business instances`), which print what the human's forms print,
and the writes `plugins set`, `org instance add` and `set` (old spelling
`business instance`), `name`, `project create` and
`project init` (below), each recorded as `tester`.
`routes.set` has no CLI form yet. `talk --tester` sends any text. When no run holds the role
(row 170, #1667), the bus refuses the message with
`tester-target-not-running` and stores nothing, and talk prints `pm isn't
running, so the tester's message was refused and nothing was stored` and
exits 3. A run launched but not yet claimed counts as not running. The
role's reply comes back to the tester, not to the human's inbox. `decide`, `stop`, `launches`,
`inbox --read` or `--read-all`, `limits set`, `reset` and `migrate`,
`project check` and `bus start` or `stop` take no `--tester` (exit 4).
Row 184 (#1682) adds the `thread` commands and `talk` and `follow
--thread` (above).
- `project init --tester` (row 175, mos-0872 Q2) may run inside an agent.
Before anything touches the disk it refuses a repository outside
`<dataRoot>/projects/<business>/`, or that directory itself, since a
repository there would hold the other projects' default ones
(`tester-project-area`, exit 2), then
binds the tester with one `agents` read, so `tester-off` and
`tester-required` refuse (exit 3), then commits the layout with `tester`
as author and committer. Tests: `tests/tester-reads.test.mjs`.
## The app
```
mosaic
```
With no argument, on a terminal, `mosaic` opens a full-screen app over the
same broker reads as the verbs: Inbox, Tasks, Agents, Trail and Help.
`src/app.mjs` handles keys and reads; `src/screens.mjs` draws the frames
and nothing else.
- In an agent run it exits 3 with the same refusal as the verbs, before
anything else. Without a terminal on both stdin and stdout it exits 4
and prints the seat usage (`mosaic launch`, `mosaic seat task`) and the
verbs above. A script never gets the app.
- The business is the one of the bus host on this data root. With no
host the app says so and `:business <id>` picks one.
- Keys: `c i t a r` switch views (Talk, Inbox, Tasks, Agents, Trail). `↑↓`
or `j k` move, PgUp and PgDn page, Enter opens the selection whole, `v`
opens its trail, `:` the command line, `?` help, Esc closes a box, `q`
or Ctrl-C quits. On the command line, Esc or Ctrl-C closes it and the
arrows type nothing. Commands: `:decide <decision> <option>`, `:trail
<task|decision>`, `:business <id>`, `:q`.
- Deciding: in the Inbox, digit `n` picks the selected decision's `n`th
option and runs `resolveDecision` in `src/cli.mjs`, the code `mosaic
decide` runs. The box shows the same lines and the same `[y/N]`
question. `y` or `Y` resolves; any other key answers No, and so does
quitting. Keys that arrive within 200 ms of the question being drawn
are type-ahead and are dropped: a paste such as `1 yes`, or keys typed
while a read held the screen, never answer it. After `outcome unknown` the app shows the CLI's sentence and
offers the trail (`v`) or a re-read (`r`). No key sends the choice
again.
- Reads happen on entering a view, after a decision, and every 30 s while
no box is open. A failed read keeps the last list on screen and marks
it stale. A refused read (exit 3 codes) says refused. Broker text goes
through `visible()` from `packages/conversation/src/terminal.mjs`, so
control characters and bidi overrides show as `^[` and `<U+202E>`.
- Talk (`c`) points to `mosaic talk` for now. The conversation pane and
`stop` come in a later row.
- 16 ANSI colours. Under `NO_COLOR` the only escape codes in a frame are
reverse video on the selected row (the design's tokens keep selection
visible without colour). Frames fit 80x24; the layout is designed at
120x36, and under 100 columns the nav shows keys only and the Inbox
drops RAISED BY. Below 40x10 the frame only says the terminal is too
small. If the decide question is open then, the frame says so, and any
key answers it No, `y` included. Widths count terminal cells: East
Asian Width W and F characters and emoji 2, a one-cell emoji base with
U+FE0F 2. ZWJ sequences and flags count more than they draw, so such a
row can come out short, never too wide. Wrapping breaks between
graphemes, so U+FE0F and combining marks stay with their base.
- A decide box taller than the screen cuts the end of the question and
nothing else: the decision line, the options, the recommendation, the
action and target, what the choice authorizes, the task, your choice
and the `[y/N]` question stay. The cut line points to `mosaic inbox
--json`. When even those lines don't fit, the box says only "Too small
to show this decision. Any key answers No. Resize, or use mosaic
decide.", and the status line says "Too small to confirm here." instead
of promising a `y`. `y` resolves only while the frame shows the whole
box apart from the cut, and the app checks the size at each key, so a
terminal shrunk after the question appeared answers No. Under 40
columns or 10 rows the app shows only the too-small screen, however
tall the terminal is. How many rows a box needs depends on its lines.
For example, a plain gated decision with a task needs 20 rows at 80
columns (21 with a cut question) and 29 rows at 40 columns; one with
longer lines needs more.
- The transport is the verbs' `spawnSync` child, so the screen does not
redraw while a read is in flight (up to its 30 s timeout).
- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
resize redraw included) the app restores the screen, the cursor and
cooked mode. After anything but an error it then prints
`(app closed with q; N decisions resolved this session)`; an error
goes to stderr instead.
## Bus host
```
mosaic bus start <business> [--pm]
the host; run by the unit, not by hand.
--pm also launches the business's PM
mosaic bus stop SIGTERM to the recorded host, then wait
mosaic bus restart stop, then start with the recorded options
mosaic bus recover clear a dead host's locks, with a receipt
mosaic bus status [--json] host state file, socket, writer.lock
```
`bus start`, `bus stop`, `bus restart` and `bus recover`, and `console start`,
`console stop` and `console restart`, refuse inside an agent run (exit 3,
agent-guard #1873). They check the environment they are given and their own
process environment. `bus status` and `console status` stay open to agents.
`bus start` does the following:
1. It reads the system config through `scripts/mosaic-config.mjs validate`,
the business file and the role files, and builds the broker's
`{op: 'boot'}` message (`src/config.mjs`). The message has `launches: []`
and `readers: [<business>]`. It gets `config.trackers` (below) when a
tracker is set.
2. It reads the notifier config (below).
3. It takes `<dataRoot>/bus-host/host.lock` (0600), which holds the same
state as `host.json` (step 6). It writes the state to a file of its own
and hard-links it in as `host.lock`. The link fails if the file exists,
so of two hosts started at once, one gets it. If a live host holds the
lock, or a live host started before `host.lock` existed holds
`host.json`, the start refuses with exit 3: `a bus host already runs for
<business> (pid <pid>)`. A lock whose host no longer runs is taken over
whatever its age: it is moved aside, checked to be the file that was
read, and removed; if another host's claim was moved aside in that race,
it goes back. Of three starts racing one stale lock, two can both claim
it, and then the broker's `writer.lock` refuses the second broker. Each
refusal below removes `host.lock` again, so a host that didn't start
leaves no file.
4. It forks `packages/bus/src/process.mjs` and waits up to 30 s for the boot
reply. If the broker answers `{ok: false, error}`, the host exits 3 and
prints `broker refused to start: <code>`. A timeout or an early exit
gives exit 1.
5. It forks `src/notifier-process.mjs` and hands it the reader capability
over IPC in `{op: 'start'}`. A notifier refusal closes the broker, and
the host exits 3.
6. It writes `<dataRoot>/bus-host/host.json` (0600): the pid, the process
start time, the business, the start time as text and the notifier
binding. The file holds no capability. `bus stop`, `bus status` and the
human commands' default business read it. It appears only once the
broker and notifier are up, so a reader never finds a booting host with
no broker socket.
7. It starts the session launcher (`src/launcher.mjs`, "Sessions" below)
and, with `--pm`, launches the business's `launch.by` instance. A PM
launch that refuses closes the host with the refusal's exit code.
No capability appears in argv, stdout, the environment or a file.
`startHost()` in `src/host.mjs` is also the in-process API the launcher
uses: `bindLaunch(record)` binds a launched run's process identity
(`pid`, `startTime`) and returns `{business, run, cap}`; `op(message)` sends
one of the broker process's launch ops (`identity`, `authorizeLaunch`,
`refuse`, `endLaunch`, `credentialStatus`, and for the git socket
`checkPush`, `recordPush`, `reviewFetch`); `beforeClose(fn)` runs `fn`
before the broker closes, so the launcher stops its sessions first.
Requests to the broker process go one at a time. Each carries an id, and
the broker echoes it on the reply. If a reply doesn't arrive within 10 s, or
arrives with an id no request is waiting for, the channel is broken: the
waiting request refuses with `broker-channel-broken`, so does every later
one, and the host stops with exit 1 for the unit to restart. A late reply
never answers a later request, and a launch waiting on one refuses.
SIGTERM or SIGINT stops the notifier after its poll in flight, then closes
the broker and removes `host.json`, then `host.lock`. If either child
dies on its own, the host goes down with exit 1. The unit then restarts
both. The host watches the children once both have started, and it
checks each child's exit state at that point too, so a broker that dies
while the notifier starts still takes the host down.
`bus stop` signals only a pid that still runs with the recorded start time
and whose command line is `…/packages/cli/src/cli.mjs bus start`.
`bus status` never removes a lock. A `writer.lock` whose pid is gone means
a broker was killed hard. Check, then remove the lock by hand
(`packages/bus/README.md`).
### Sessions
The launcher (`src/launcher.mjs`) starts managed role sessions
(`packages/seat/src/session.mjs`, `packages/harness`). It serves
`<dataRoot>/bus-host/launch.sock` (0600), which the PM's `launch` tool
calls through its runner. One request is one line,
`{"cap": "…", "instance": "<role instance>"}`, at most 4096 bytes; the reply
is `{"ok": true, "result": {run, instance, harness, model}}` or
`{"ok": false, "error": "<code>"}`. Launches run one at a time. In order:
| Check | Refusal |
|---|---|
| the request is one JSON line with a string instance | `invalid-request` |
| the capability is live and names a role of this business | `unauthenticated`, `not-a-role` |
| the business file has a `launch` block | `launch-not-configured` |
| `launch.instances` lists the instance (for `--pm`: it is `launch.by`) | `instance-not-listed` |
| the instance isn't running | `instance-running` |
| the instance resolves | `unsupported-harness` |
| it sets the `harness`, `model` and `thinking` vars, and all three `fallback.` vars if it sets any (row 65); the message names the missing var | `no-route` |
| each route's `model` is a `launch.models` key | `unsupported-harness` |
| its role has a contract file | `no-role-contract` |
| `plugins.harness` switches on the primary's harness and a stated fallback's | `harness-off` |
| the plug-in loader loads the primary's module; the message names it and the loader's code | `harness-refused` |
| the module applies the route's thinking level | `thinking-unsupported` |
| the module's preflight passes, else the stated fallback's harness passes the three checks above and its own preflight | the module's code (`no-session-credential`, `harness-binary-missing`, …); a module that throws is `harness-failed` |
| on a harness that counts the account, with `launch.account` set, its name is not the value of any session login variable the bus host sets, since usage records keep the name | `account-is-credential` |
| on that harness, the account names one of the login sets and the bus host holds all of it | `account-credential-missing` |
| on that harness, the bus host sets no other login variable, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` |
| bwrap runs here | `sandbox-unavailable` |
| total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` |
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
| the bundle builds and the runner starts | `launch-failed` |
Row 126 (#1620): only a launch on a harness whose module's `countsAccount()` is true spends `launch.account.name`. It
goes to the `mayLaunch` limit checks, to the bus with the run, onto the
run's usage records, and onto the `launch` record as `account`. Of the shipped
modules that is Claude Code. A pi launch
has account null: its records say null, no daily quota covers it, and the
two account checks above are skipped. Both account refusals name the
variables, never a value.
Row 65 (#1558): the route comes from the instance's vars only. The system's
`execution.adapter` and `execution.model` never fill a missing one; they stay
for `run-task.sh`. The harness is a module under `packages/plugins/harness/`
(`packages/plugins/README.md`, "Harness"), loaded through a fresh loader for
each launch, and the launcher names no harness itself. A fallback is taken
only when the primary's preflight fails: an off or refused primary, or one
whose thinking level it doesn't apply, refuses without trying it. If the
fallback's preflight fails too, the refusal carries its code and names both
reasons. The account, capacity and limit checks run on the route taken. The
manifest records `route` (`primary` or `fallback`) and `routeReason` (the
primary's preflight code, or null). The reply's `harness` and `model` are
the route's.
Every refusal is an `action.refused` event (the broker's `refuse`, or its
own refusal for the last broker check) and a line in the launch log. Then
the launcher reads the instance's credential status from the broker
(metadata only) and makes sure the instance's workspace has a clone of each
project in the business file (`packages/seat/README.md`, "The project
clones"). It makes a missing clone and fast-forwards a clean one. A clone
on another branch or dirty is fetched and not merged, a `checkout-fetched`
line in the launch log with the project and the reason (#1611). A clone it
leaves alone is a `checkout-kept` line with the project and the reason.
Either way the launch goes on. The bundle's prompt names
each clone's path. Then the launcher builds the bundle in the run
directory, spawns the runner in its bwrap sandbox (`packages/seat/README.md`,
"The sandbox"), binds the outer bwrap process's pid and start time, adds the
session to `sessions.json`, and writes the capability as one line on the
runner's stdin. The capability is never on disk, in argv or in the
environment. When the runner exits, the launcher sends `endLaunch` with the
end reason from its exit code (`packages/harness/README.md`), which writes
`session.ended`, releases the role and kills the run's capabilities.
A Claude Code session logs in with `CLAUDE_CODE_OAUTH_TOKEN`,
`ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL`
(a proxy's key), from the host's environment, which the allowlist passes
on. The bus unit loads it ("Unit" below). Nothing from the host's
`~/.claude` reaches a session, whose `HOME` is its own. With none of them
set, a Claude Code launch refuses `no-session-credential` before anything
starts. A Pi session gets the same variables through the allowlist. A Pi
session on provider `zai` logs in with `ZAI_API_KEY`, which the allowlist
passes. Like every allowlist variable, it reaches every session the host
starts, Claude Code ones included. A shell the harness opens, Claude Code's
`Bash` or Pi's `bash`, can read all of these variables.
A Pi session logs in only through the variables `ENV_ALLOW` passes. Its
`HOME` is the run's own, so Pi never reads the operator's `~/.pi/agent`
(`auth.json`, `models.json`, `settings.json`). A Pi launch has no
credential check: a Pi role on a provider whose login lives only in those
files, or whose key isn't an `ENV_ALLOW` variable, launches and then fails
at its first turn.
The manifest's `harnessVersion` is the module's `version()`: for pi the
repository's pin (the `@earendil-works/pi-coding-agent` dependency in the
root `package.json`), for Claude Code `--version` from the binary sessions
run, `MOSAIC_CLAUDE_BIN` when set (the unit pins it, below), else `claude`
on `PATH`.
On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
refusal and end; `workspaces/<business>/<instance>/` (0700) is kept across
launches and holds the instance's project clones; `bus-host/sessions.json`
(0600) lists the running sessions.
When the host closes, the launcher stops taking requests and drops every
open `launch.sock` connection, so a client that never ends its side can't
hold the close. It sends SIGTERM to
every runner (again each second, see "Limits"), waits up to 30 s, then
SIGKILLs what is left and ends those launches as `killed`.
A host that starts reads the `sessions.json` the last host left. An entry
for this business means that host died without ending the launch, and its
run would keep holding the role. Before the launch socket opens, the
launcher SIGKILLs each such session that still runs (same start time,
bwrap on its command line), binds the run again with the recorded
identity and ends it as `host-lost`, which releases the role. A
`sessions.json` that can't be read refuses the host start with exit 3; move
it aside after checking which sessions still run.
`mosaic stop <run>` checks that the runner in `sessions.json` still runs with
the recorded start time and that its command line is the runner's, then
sends SIGTERM until it exits (30 s). A stale entry prints "not running" and
exits 0; an unknown run exits 2.
### Thread sessions (row 121, #1615)
A thread instance is an instance whose role definition is `assistant`
(`packages/bus/README.md`, "Projects and threads"). It runs once per
thread and never on its own: a `launch` request or `--pm` for it refuses
`thread-required`, and a thread launch of any other instance refuses
`thread-not-allowed`. `instance-running` counts a thread instance per
thread, so two threads run at once when the limits have room.
Every `threadTickMs` (2 s) the launcher asks the broker for the business's
threads (`threadsState`) and acts on each one:
- A thread with no running session starts one when a message to its
instance waits undelivered, or when `thread.open` came after its last
claim (REQ-LAUNCH-1). The start runs the checks in the table above, as
the human, so a full limit refuses `capacity-full` and the next tick
tries again. A refusal is logged once per code per thread, not once per
tick. Each new message or open starts at most one session: a session
that ends without taking the work waits for the next message or open.
- A running thread session stops once the thread has had no message or
open for `launch.threadIdleSeconds` (`packages/organization/README.md`) and
the run is between turns. The broker's `threadIdle` decides that from
the store, not from the tick's snapshot: it releases the claim only with
nothing delivered and unread and nothing waiting. If a message came in
after the snapshot, nothing is released and the session keeps running.
After a release the launch log gets a `thread-idle` line, and the runner
gets SIGTERM, as `mosaic stop` sends it. An idle thread has no session,
so it counts against no limit (REQ-THREAD-2).
A thread run's session directory isn't in its run directory. It is
`threads/<business>/<thread>/session/` under the data root, kept across
runs (`packages/seat/README.md`, "Thread stores"), so the next run for the
thread resumes the harness session (REQ-THREAD-2). The launcher checks the
store before anything else; a thread id that isn't a plain id, or an unsafe
directory, refuses `thread-store-refused` and launches nothing. Launch,
refusal and end lines for a thread run carry `thread`, and so does its
`sessions.json` entry, so a host that starts after a crash binds the run
again with its thread and ends it as `host-lost`.
Thread runs of one instance share its workspace,
`workspaces/<business>/<instance>/`. The clone check runs at each start,
so it can fetch while another thread's session is running.
### Landing runs (row 156, #1651)
Every launch of a non-thread instance asks the broker's `landHolders`
(launch op) which approved, unspent merge decisions each non-thread
instance holds, with each decision's task ref. The instance's own entries
go into its `session.json` as `landing: [{decision, task_ref}]`, and the
same answer decides `land_work` and the git socket, so a landing entry
never comes without the tool. The key is left out when there is none. The
runner takes one turn per entry before its receive loop
(`packages/harness/README.md`). A thread launch still asks `landHeld` and
gets no `landing`.
After the thread pass, every tick asks `landHolders` again and acts on
each holder that `launch.instances` lists:
- A holder with no running session starts, through the checks in the
table above, as the human, once per decision. A run that ends without
landing isn't started again for that decision. A refused start is
logged once per code per decision as `decision <id> (<instance>):
landing start refused: <code>`, with a `refused` record that carries
`decision`, and the next tick tries again. A landing turn whose state
read fails is skipped (the runner logs it). The run still counts as
started for that decision, so this host doesn't start it again, and
the role-file steps below apply.
- A running holder whose `landing` doesn't name the decision started
before the resolution, so it has no `land_work` for it. The launcher
asks the broker's `landRelease` to end it. The broker releases the
role only between turns, with nothing delivered and unread, by
`land-relaunch`; a message still waiting stays undelivered for the next
run. A landing turn reads no message, so the broker counts a run as
past its landing turns only once it has asked `message.receive`, which
the runner first does after them. While a landing turn or a message
turn runs, or a delivered message is unread, nothing is released and a
later tick asks again. A refusal is logged once per code
per run as `run <run>: landRelease refused: <code>`. After a release the
launch log gets a `land-relaunch` record (`instance`, `run`,
`decision`) and the runner gets SIGTERM, as `mosaic stop` sends it. The
run's calls between the release and the signal refuse `launcher-released`,
so the runner exits 0 either way (row 302). The
next tick starts the instance with the decision in its `landing`.
The launcher keeps none of this on disk. A host that restarts ends the
earlier host's runs (`host-lost`), so its first tick starts each holder.
If the host doesn't start a landing run, row 149's steps in the role
files still apply: the chief of staff ends the raising run and launches.
### Git socket
The launcher also serves `<dataRoot>/bus-host/git.sock` (0600,
`src/git-socket.mjs`, row 93, #1587). A coder's runner pushes work to the
business's working repository through it, and a reviewer's runner fetches a
commit it was asked to review (`push_working` and `fetch_review`,
`packages/harness/README.md`). A role granted git.merge.protected
cross-role whose merge decision was approved, by its arbiter or the human,
lands its task on the repository's `main` (`land_work`, row 137, #1631). The sandbox binds the socket only for a role that holds
git.push.working or review.verdict, or one that holds a merge decision it
could land: `landHolders` lists it (a non-thread launch) or `landHeld` says
so (a thread launch). A session's `session.json` names it
as `gitSocket` only then.
Only a git bundle crosses. The host never runs git in a session's clone
and never opens a path in its workspace; `packages/seat/README.md` ("The
working repositories") has what it does with the bundle. A request is one
JSON line of at most 4096 bytes:
| Op | Request | Reply |
|---|---|---|
| push | `{cap, op: "push", task_ref, project, branch, size}` | `{ok: true, send: <size>}`; the runner sends exactly that many bundle bytes; then `{ok: true, result: {ref, commit, previous}}` |
| fetch | `{cap, op: "fetch", task_ref, request, have}` | `{ok: true, result: {project, ref, commit, size}}`, then that many bytes of a bundle whose one ref is `refs/mosaic/review` |
| land | `{cap, op: "land", task_ref, decision}` | `{ok: true, result: {landing: "started", commit, base, reviewed, patch_id}}` once every check before the gate passed; `commit` is the squash that will land |
The host checks the caller's identity (a role of this business), then the
request (task ref, project, branch name, a size of at most 32 MiB), then
the broker: `checkPush` for a push, `reviewFetch` for a fetch,
`checkLand` for a landing. Only then does it read bundle bytes. The broker records a push with
`recordPush` under the repository's lock once the ref moved; when it
refuses, the ref goes back. A refusal is `{ok: false, error: <code>}` and
ends the connection. The host's own refusals go through the broker's
`refuse` with the task ref as subject, so each is an `action.refused`
event on the task's trail. Eight connections are served at once; more
refuse `git-busy`. A connection holds its slot until it ends, so a push's
bundle has to come within a deadline: no gap over 30 s, and all of it
within 30 s plus 1 s per 256 KB (`bodyDeadline`, 161 s at the 32 MiB cap).
Past either it refuses `body-timeout`.
Codes a reviewer may see that aren't the broker's:
- `nothing-to-fetch`: the reviewer's clones already have the commit, by the
`have` list the runner sent, so there is no bundle to send. The commit is
already in one of its clones under `refs/remotes/`.
- `unknown-project`: the review names a project this business no longer
has.
- `fetch-aborted`: the reviewer's side closed the connection before the
whole bundle was sent. That is the reviewer's doing, not the host's, so
it isn't logged.
`git-failed` (an error in the host that has no code of its own) and
`rollback-failed` (a push the broker refused whose work ref couldn't go
back) are the host's own faults, and the host logs them as well.
A landing runs land-work's steps (`scripts/land-work.sh`) against the
working repository's `main`, never the project root:
1. The broker's `checkLand` checks the decision and the verdict and gives
the host a ticket (`packages/bus/README.md`).
2. `prepareLand` builds the squash in a worktree under
`bus-host/land/<id>/` (`packages/seat/README.md`, "Landing on main").
The message is land-work's. Its Resolved line names the channel from
the ticket's `via`: the human via the CLI, or the arbiter via the broker.
3. The project root has to be a mosaic-stack checkout with `node_modules`
(`isMosaicStack`), or `gate-missing`. The root's `node_modules` is
linked into the worktree.
4. The host answers `started`, and the gate runs in the background, in the
squash's worktree, with `gateEnv`'s variables only.
5. `finishLand` moves `main` and the broker's `recordLand` records it.
One landing runs at a time; another refuses `land-busy`. From the ticket
on, every refusal goes to the broker's `landRefused`, on the task's trail.
A refusal before the `started` answer is also the reply. After it, the role
learns the outcome as a RESULT message from the bus: the landing, or
`gate-failed` (naming the step and its log), `base-moved`, a broker
refusal, or `host-closing` when the launcher closed during the gate, in
which case nothing moved. The logs stay in `bus-host/land/<id>/`. The
worktree and the gate's Docker network, `<id>_default`, go on every path.
`createGitSocket({gate})` takes the gate's three parts (`LAND_GATE`:
`classify`, `run`, `dropNetwork`) so the tests can run a fake one.
A bundle file lives in `bus-host/` (0700) under a random name and is
deleted on every path. On close the launcher answers `host-closing` to a
connection still sending its request line, since nothing of it ran yet,
and drops every other connection. It waits for any push in flight to finish
under its lock before the broker stops, and stops a running gate, whose
landing is refused `host-closing`. It waits at most 2 s
(`CLOSE_WAIT_MS`) for its `host-closing` answers to leave, then drops those
connections too, so a peer that never reads can't hold the close.
### Trackers
`config.trackers[<business>]` is `{baseUrl, project, pollSeconds,
reconcileMinutes}`, taken from the `tracker.*` variables. `tracker.project` is
a project-layer variable, so the entry comes from the one declared project
whose `.mosaic/project.json` sets it. The resulting entry depends on the
variables:
| Variables | Result |
|---|---|
| No `tracker.baseUrl` | No entry and no task verbs |
| `tracker.baseUrl` set, no project setting `tracker.project` | A warning on stderr and no entry |
| Two projects setting `tracker.project` | Refusal with exit 3, because the boot shape holds one tracker project per business |
### Unit
`scripts/bus-service.sh render|install|uninstall|status` installs the
template `systemd/mosaic-bus.service.in` as the systemd user unit
`[email protected]`, one instance per business (`mosaic-bus@<business>`).
The template's file name has no `@` because queue candidate manifests refuse
it. It is modelled on `scripts/discord-service.sh`:
- `install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]` writes the unit through
a temp file and a rename, then runs `systemctl --user daemon-reload`. It
prints the pinned claude and its `--version`.
- `render [--copy ID [--copies DIR]] [--pm] [--claude PATH]` prints the unit without writing it.
- `uninstall` refuses while an instance is active.
- `status <business>` shows the unit's state and its `Environment`, which
includes the pinned claude.
The unit settings:
- `ExecStart=@REPO@/scripts/mosaic bus start %i`
- `Restart=on-failure`
- `RestartPreventExitStatus=2 3 4`, so refusals are never retried
- `KillSignal=SIGTERM`
- `TimeoutStopSec=60`
- `Environment=PATH=`: `/usr/local/bin:/usr/bin:/bin`, with node's directory
first when it isn't one of those
- `Environment=MOSAIC_CLAUDE_BIN=`: the Claude Code binary that managed
sessions and the manifest's version run
- `EnvironmentFile=`: the Claude Code session credential (below)
`MOSAIC_CLAUDE_BIN` comes from `--claude PATH`, or else `command -v claude`
in the installing shell. The path is kept as given, so the native install's
`~/.local/bin/claude`, a symlink into its versions directory, keeps
following its updates; reinstall to change it. It must be absolute, an
executable file, and only `A-Z a-z 0-9 / . _ + -`, because systemd expands
`%` and splits `Environment=` on spaces; anything else refuses with exit 1
and writes nothing. With no claude found, the unit gets a comment line
instead and sessions run `claude` from the unit's `PATH`, which in the
sandbox finds only a `claude` under `/usr`. A running host
keeps the environment it started with until it restarts.
The session credentials come from
`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`, the
one place the path is set. The file holds one line per variable, for
example `CLAUDE_CODE_OAUTH_TOKEN=<token>`, mode 0600. It holds every session
provider key, not only Claude's. The operator writes it;
nothing in the repository creates it, and only the Case 2 precheck reads
it, printing variable names, never values. No session sees the file
itself, only the variables. The leading `-` lets the unit start
without it, so Pi sessions still launch and a Claude Code launch refuses
`no-session-credential`. To change the credential, edit or swap the file
(or this line and this paragraph), then restart the unit; a running host
keeps the environment it started with.
A named stack instance (row 190) takes `--instance NAME` with any verb, in
any position. The unit is then `[email protected]`, it sets
`Environment=MOSAIC_CONFIG=` to `~/.config/mosaic-NAME/config.json`, its
Description ends in ` [NAME]`, and its `EnvironmentFile=` is
`secrets/claude-session.env` in that config directory, not mosaic-dev's.
Install's busy and drop-in checks cover only `mosaic-bus-NAME@*` units and
their `.d` directories, plus the prefix directories systemd applies to them:
`mosaic-bus-.service.d`, `mosaic-.service.d` and `service.d`. A data root
that another instance claims, or a named root with no `instance.json`, exits
3 before anything is read. Without the flag the unit renders byte for byte
as before. Selection, names and refusals are in
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
### One pinned copy (row 85, #1579)
The bus, Console and mosaic wrapper must run the same copy: the broker's
human proof accepts only its own human-cli entry path. stack-copy.sh
creates a pushed, read-only tree with pinned npm dependencies under
mosaic-stack/copies/<full id>. bus-service.sh render/install take --copy
ID and --pm alongside the existing --claude pin. Without --copy render
keeps today's checkout unit. A PM refusal exits 3 and the unit does not
retry it. Install refuses every busy instance (also --no-reload), failed
state reads and drop-ins overriding ExecStart or WorkingDirectory, including
inherited mosaic-.service.d/ and service.d/ in every unit lookup directory.
Status reads effective systemd properties, reports the tree/copy, PM flag,
refactor drift and drop-in files; it changes nothing. The temporary PM
ExecStart drop-in must be removed by Mos before installing the pinned unit.
The wrapper keeps the Q81 marker on line 2 and the final copy exec on the
last line. Comments name the copy root and explain that the Q81 lease probe
still answers at mosaic-old.
Copy npm ci may warn that @google/genai's preinstall, esbuild's postinstall
and protobufjs's postinstall are blocked. This is expected for the current
copy paths: Mosaic launches Pi's published bundled CLI, whose runtime does
not load those installed package entrypoints. @google/genai's preinstall
only echoes; esbuild's script installs/checks its build binary; protobufjs's
script only warns about dependency version schemes. The copy runs the
already published bundle and does not build it. No allowScripts change is
needed for these paths; adding a runtime loader requires a fresh audit.
`scripts/stack-release.sh` runs the update and the rollback (row 162, D1):
`adopt` records the running stack once, `activate ID` switches the bus,
Console and wrapper to a copy and gates it, `rollback` returns to the
previous copy from the log, and `status` reads. A failed gate rolls back to
the old copy. The pointer and the log live under `<dataRoot>/state/`. The
steps are in [TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
Each live run requires Mos's go; agents build and test fixtures only, and
`--plan` and `status` change nothing. Restarting the bus ends its sessions,
so no PM turn may be in flight. Queue writes stay on the canonical
scripts/mosaic.
On a host whose units are root's (row 232), `--units external` makes
stack-release.sh read the system units and start or stop none. `activate`
and `rollback --to` stage the copy and leave the `AWAITING-UNITS` marker;
root installs and starts the units, then `confirm` gates the start and
writes the pointer, `revert` undoes a staged run, and `gate --since` checks
a copy with no open activation. stack-copy.sh adds `copy --bundle FILE SHA`
for a host with no refactor checkout, `wrapper ID --create` for the first
wrapper, and `check --units external`. Details are in
[TOOLS.md, Root-owned units](../../docs/TOOLS.md#root-owned-units---units-external-row-232).
The human commands and `--tester` run only from the tree the bus runs from
(row 130, #1625). The broker checks the path of the calling human-cli.mjs or
tester-cli.mjs against its own. On a host with a pinned copy that tree is
the copy, and `mosaic` on PATH is the wrapper that runs it. The checkout's
`scripts/mosaic` exits 3 with `human-required` or `tester-required` and
prints one more line: "the bus accepts these commands only from the tree it
runs from; on a pinned host run `mosaic` on PATH". Queue writes still go
through the checkout's `scripts/mosaic`.
## Notifier
The notifier is a child of the host. Each poll (every 30 s) it reads the
inbox through the reader capability and does two things:
- **DMs.** It DMs each open blocking decision once. The DM holds the
question, the options, the recommendation, what approving authorizes, the
task, and `mosaic decide <short id> <option>`.
- **Digest.** It sends one digest a day at 08:00 America/Chicago. The hour
comes from the IANA zone, so daylight saving time is handled. If the host
starts after 08:00 and the day has no digest yet, the digest goes at once.
The digest lists the inbox. It marks each blocking decision with one of
three states: "DM sent", "DM pending" or "DM refused, not retried". An
empty inbox gets one line. Each message stays within Discord's 2000
characters.
The notifier only reads the bus. Its memory is the journal
`<dataRoot>/notify/<business>/sent.jsonl` (directory 0700, file 0600), with
one line per send attempt:
```json
{"at": "…", "kind": "dm", "decision": "<id>", "outcome": "confirmed", "messageId": "…"}
{"at": "…", "kind": "digest", "decision": null, "day": "2026-10-08", "outcome": "unknown", "messageId": null}
```
- A decision, or a day's digest, counts as sent once it has a `confirmed`
line.
- A `refused` or `unknown` send is retried. After an `unknown` outcome the
wait starts at 30 s and doubles up to 30 min. A duplicate costs less than
a miss. Every retry
carries the same Discord nonce, so a retry inside Discord's dedupe window
returns the first message. A DM's nonce comes from the decision id. A
digest's comes from the business and the day.
- A definite refusal is a DM refused with an HTTP 4xx other than 429. The
next attempt waits the full 30 min, counted from the refusal's `at` in
the journal, so a restart does not shorten it. After 5 of them for one
decision, at least 2 hours apart end to end, the notifier appends one
`gave-up` line, logs it once and never sends that DM again. The digest
marks the decision "DM refused, not retried". The count comes from the
journal, so a restart keeps it. An `unknown` outcome (network, 5xx or
429) retries without a limit (lead decisions 72 and 73).
- Recovery after a give-up is manual. Fixing the binding does not resend
the DM, which stays given up. The decision stays open in `mosaic inbox`,
the digest lists it, and the operator decides it with `mosaic decide`.
- On open, a final line without its newline is a torn write. The notifier
copies those bytes to `torn-<UTC stamp>.bin` in the same directory (0600,
a new file, synced), then truncates `sent.jsonl` to its last newline and
syncs it. It logs both steps. A crash between the two leaves the torn
tail in place, and the next open repairs it with a second copy. The next
append therefore starts on a line of its own.
- A malformed complete line refuses with exit 3 and changes nothing. Each
line is type-checked: `at` an ISO timestamp, `kind` `dm` or `digest`,
`decision` a string (required for `dm`, null for `digest`), `day` a real
`YYYY-MM-DD` date (required for `digest`), `outcome` one of `confirmed`,
`refused`, `unknown` or `gave-up` (`gave-up` only for `dm`), `messageId`
a string (required when confirmed) or null, `status` an integer when
present.
- The journal refuses with exit 3 when its directory is looser than 0700,
not yours, a symlink (dangling or not) or not writable, or cannot be
created (for example, a parent is a file). It also refuses when
`sent.jsonl` is a symlink, a directory or not writable, or is not a
regular 0600 file you own. The message names the path.
- No Discord channel or user id goes in the journal, a log line or an
error.
The Discord side is `packages/discord/src/notify.mjs`. It uses the
connector's REST client and the binding's `tokenFile`, and sends to the
binding's fixed `dmRecipient`, who must be one of the binding's `users`.
### Notifier config
`<dataRoot>/notify/<business>/notify.json`, mode 0600, owned by you. The
journal shares the directory, so create it 0700 first (`mkdir -m 0700 -p
<dataRoot>/notify/<business>`). A host with a binding refuses with exit 3 on
a looser directory.
```json
{"notifyVersion": 1, "binding": "sage-seat"}
```
`"binding": null` runs the host without DMs. A missing file refuses with
exit 3, so a host never starts until someone decides whether it DMs.
## Exit codes
| Code | Meaning |
|---|---|
| 0 | ok |
| 1 | failed, or outcome unknown: check before retrying |
| 2 | invalid input: unknown option, no such open decision, decision already closed, no such run, not a runner |
| 3 | refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start |
| 4 | usage |
## Limits
- **One broker per data root.** The bus store takes
`<dataRoot>/bus/writer.lock`. The unit is a template per business, but
only one instance can run on one data root.
- **`digest.sent` is unused.** The bus schema has this event kind, but
decision 70 puts the notifier's memory in the journal, and a reader
capability cannot write events.
- **The real human transport is untested here.** No test runs the real
`human-cli.mjs`, because its proof needs a human shell. The live run
covers it.
- **`stop` is same-UID process control.** It checks the start time and the
command line, not authority. Any process of the same user can signal a
runner directly.
- **Signals before the runner's handlers.** A session's runner is pid 1 of
its PID namespace, and pid 1 ignores a signal it has no handler for. The
runner installs its handlers first, but Node's startup leaves a short
window, so the launcher and `stop` resend SIGTERM every second.
- **If the host dies hard,** its sessions keep running until their broker
polls fail, then exit 23, and their launches stay open in the bus until
the next host starts and ends them as `host-lost` ("Sessions" above).
With no next host, nothing ends them.
- **`talk` and the launches verbs are tested only with the in-process
transport,** as the other human commands are.
- **Tracker boot is tested only without trackers.** The `trackers` boot case
is tested once S3's broker change lands.
## Not in this piece
The WebUI (S5).
## Agent limits and models
`mosaic limits [--business ID] [--json] [--tester]` reads the model table, each role's
selection, limits and running counts. `mosaic limits set --business ID
--file PATCH` submits JSON with the read's `revision` and any of `limits`,
`models`, `instances`. `limits` and `models` replace those entire objects;
`instances` maps role instance names to model-table keys. `assistants` maps
an assistant instance to any of its `vars`, `tracker` and `definition`; each
given field replaces that field, and `tracker: null` removes the tracker.
The definition stays an assistant one, and `holder` and `credentials` stay
hand edits (row 155, Q169). The chief of staff sends the same patch with
`change_organization` and reads the revision itself with
`organization_revision` (row 134). Both commands use
the existing human proof and broker; neither is available to a managed role.
A tester may read (row 175), but `limits set` refuses `--tester` with exit 4. The Console Settings form uses the same write path and requires
signed-in send mode, named Host, exact Origin and same-origin Fetch Metadata.
Unset limits mean unlimited; zero blocks new launches. Limits are checked
afresh at each launch in total, per provider and per exact provider/model.
Lowering one leaves running sessions alive and counted against their recorded
provider/model, including the PM. Capacity refusals log the scope, configured
limit and running count; launches log harness, provider and external model.
`limits` replaces the whole object, token limits included. Token limits
(row 125) sit under `limits.tokens`: per role instance at `instances.NAME`,
per provider at `providers.NAME`, per model at `models.PROVIDER.MODEL` and per
key at `keys.NAME`. Each is `{ "limit": N }`, a token count of 0 or more. The
writer adds `since`, the time the limit was set, and keeps it on later saves.
A patch that sends a different `since` is refused. Only these paths are token
limits; the agent limits above are never read as one, whatever their names.
`mosaic limits replies --file LIMITS [--business ID]` replaces the citation settings
through the human-only `library.limits.set` verb. LIMITS is a JSON object with optional
`requireCitation` (boolean, default false) and `citationsPerReply` (integer 1–1000,
default 20). Omitted keys use their defaults; unknown keys and invalid values refuse.
The next library reply uses the new settings without restarting the bus.
`mosaic limits reset WORD... [--business ID]` moves one token limit's `since`
to now, so its count restarts. The path is one word per level, as the plain
output prints it: `mosaic limits reset tokens models anthropic claude-opus-5-5`.
A path that isn't a token limit is refused. The reset is logged with its actor
in `changes.jsonl`, like any limit change.
A token limit is counted and enforced only by a switched-on `limit-check`
plug-in. The one that ships is `token-limit`
(`packages/plugins/limit-check/token-limit/README.md`). A limit that no
switched-on check counts reads `not enforced`, and so does every limit when
no check is switched on. An enforced limit refuses a launch whose subject is
at or over it, and stops a running session at the end of the turn that
reaches it. A check that fails, or that is switched on but not loaded,
refuses launches, stops sessions at the end of their turn and shows every
limit as `check failed` (fail closed).
Without `--json`, `mosaic limits`, `set` and `reset` print the read as JSON
without `tokens`, then a `token limits:` section with one line per token
limit: its path, limit and `since`, then either its count (with input, output,
cacheWrite, cacheRead and unknown turns), `not enforced` and any reason, or
`check failed` with the code and check. That output is not JSON as a whole;
scripts use `--json`, which keeps `tokens` as a list.
`mosaic limits migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]`
needs no bus. If a role inherits its model, it reads and validates the system
config and requires the mapping to preserve both provider and exact model.
Missing or invalid config refuses. It prints the whole converted document and never writes, including
on refusal. There is no `--write`. Mapping JSON supplies `models` (keys to
provider/model pairs), `families` (old family names to keys), and optional
`instances` or `selections` (old model strings to keys). Unknown or ambiguous
mappings refuse. Every held role must map too. A migrated file prints unchanged.
Jason installs the output himself in Mos's window with the PM run idle, and
the re-pin follows in the same window (decision 145).
## Role instances
Row 165 (#1662, Q171, REQ-ROLE-3).
```
mosaic org instances ORG [--json] [--tester]
mosaic org instance add ORG INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester]
mosaic org instance set ORG INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]
```
`mosaic business instances|instance|veto` is the old spelling, with the same
arguments. `scripts/mosaic` sends `business instances`, `business instance`
and `business veto` here and every other `business` command to
`packages/organization`; `org instances|instance|veto` come here with the rest
of `org`. Both spellings run `instancesCommand` (`src/instances.mjs`) over the
human-proof bus transport, so the principal checks, refusals, output and exit
codes are the same. Only a usage error differs: it prints the usage of the
spelling you used. These commands never use the dev-local-owner context that
`mosaic org create|show|update` use. `instances` prints the
file's revision, each instance with its definition and its marks (`launcher`,
`launched`, `protected`), the definitions `add` and `set` accept, and the
definitions that declare credentials. `add` and `set` send the bus's
human-only `instances.set` verb. `set` needs at least one of `--definition`,
`--vars` and a launch flag. `--vars FILE` is a JSON object that replaces the
instance's vars whole. `--launch` and `--no-launch` add the instance to
`launch.instances` or remove it.
The organization package's instance writer runs the checks `mosaic org config
validate` runs before it writes, and logs the change in `changes.jsonl` with
the human as actor. It refuses, and the file keeps its bytes:
| Code | When |
|---|---|
| `organization-credentials-out-of-scope` | the definition declares credentials, or a definition change on an instance that holds them |
| `organization-instance-exists` | `add` names an instance the file has |
| `organization-unknown-instance` | `set` names an instance the file lacks |
| `organization-definition-refused` | an assistant definition or instance; those change through `mosaic limits set` |
| `organization-launch-not-configured` | a launch flag when the file has no launch block |
| `organization-no-change` | the file already says that |
| `organization-project-mismatch`, `organization-unresolvable-instance` | the change would fail `mosaic org config validate` against a project file |
| `revision-conflict` | the file changed since `--revision` |
The bus sends a refusal's code alone, so the command adds the reason. A
refusal exits 2; an agent session or a non-human transport exits 3; a usage
error exits 4 before any bus call.
The commands bind no credentials. A definition that declares them needs
`credentials` and a `tracker` bot in the instance, and those, with `holder`,
stay hand edits of the business file. A vars or launch change on an instance
that holds credentials succeeds and leaves them as they are. There is no
delete.
An add or a definition change prints `restart needed`: the running bus keeps
the roles it loaded at start; this command does not restart it. The output
names no one; the restart itself needs Mos's go or Jason's hand. A vars or launch change
prints `no restart`, because the launcher reads it at its next launch check.
The Console Settings page has the same read and write (`packages/webui`).
## credentials (row 199)
`mosaic credentials [status [AGENT]] [--json] [--tester]` lists each agent's
credential kinds with date and state, never a value. `issue AGENT gitea
--rotate-by DATE` and `issue AGENT vikunja --expires DATE` read the token on
stdin (pipe it, or paste it and end input) and send it once to the bus's
`credentials.issue`; it is never an argument. `rotate` is the same over an
entry the bus holds now, and refuses before reading stdin when there is none.
`revoke AGENT [KIND] --reason TEXT` and `delete AGENT --reason TEXT` drop the
entries; `delete` refuses `agent-running` while the agent holds a claim. With
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
the old token in Gitea or Vikunja yourself; the bus only forgets it.
## hosts (row 234)
`mosaic hosts [status] [--json] [--tester] [--business ID]` prints one tab-separated line per
enrolled host: id, state, `user@address:port`, `sudo` or `no-sudo`, the last check time or
`unchecked`, and any host lines a failed step left. `--json` prints the bus rows. `check HOST`
runs the bus's `hosts.check` and `remove HOST` its `hosts.remove`, each printing the JSON result.
`revoke AGENT HOST --reason R` runs `hosts.revoke`, which takes the agent's grant on the host away
at once, and `sweep HOST` runs `hosts.sweep`, which removes the host lines a failure left (row 235).
A status line shows `revoking:` with the tags of a revoke still in progress.
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
command. A usage error exits 4 and a bus refusal 2.
## grants (row 236)
`mosaic grants status gitea [--json] [--tester]` prints the Gitea grant path's state: the admin
slot, each grant and open decision, settle-pending rotations, held agents, service left behind and
revokes in progress. `run DECISION` starts the executor of a Gitea grant the human approved.
`revoke AGENT gitea --reason R` takes the agent's Gitea token away and deletes it at Gitea. `sweep
gitea` retries what a failure left. `admin set gitea --url URL --account NAME --rotate-by DATE`
reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE`, `admin rotate
gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing
prints a token. A usage error exits 4 and a bus refusal 2.
`mosaic grants scope --business ID` reads the chief of staff's effective scope,
its organization revision, and whether a bad value fell back to empty (Q228,
row 298). `mosaic grants scope set --business ID --revision REV` reads scope JSON
from stdin. This human-shell command is Approve for the scope change; it refuses
agent callers through the same guard and human transport as `mosaic access`.
Neither command accepts `--tester`. No Console page or chief-raised scope-change
request exists. A stale revision refuses; malformed scope input writes nothing.
The setting is `launch.grantScope` in the business organization file. It defaults
to `{}`, which covers nothing; a missing or invalid value has the same effect.
Only Jason sets the live value. The supported shapes are:
```json
{
"gitea": { "agents": ["coder"], "projects": ["app"] },
"host": { "agents": ["ops"], "hosts": ["web1"], "commands": [["systemctl", "status", "nginx"]] }
}
```
Either method may be omitted. All lists are distinct and nonempty. Every target
project, agent, host and command must be listed for its method. Command arrays
match token for token with equal lengths: `["ls"]` covers neither `["lsblk"]`
nor `["ls", "-l"]`. Host enrollment always needs per-grant Approve.
An in-scope request from the enrolled chief is closed and issued by the protected
grant executor without an inbox approval; other requests wait for Approve.
All grants retain their records and revoke path. Narrowing the scope before a
grant is spent fails that grant with `scope-changed`; existing issued grants
remain revocable. Setting `{}` removes the setting and restores per-grant Approve.
Scope changes have organization receipts and a `grant.scope` event with the
human proof and old/new digests; each automatic approval has `grant.scope.used`.
## Access (FED R5, #414)
```
mosaic access list --business ID [--json]
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
mosaic access revoke --business ID --user ID [--grant GID] --revision N
mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N
mosaic access user remove ID --business ID --revision N
```
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
write runs the file's checks on the new document first, bumps the revision by one, and renames
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
`<dataRoot>/state/access-log.jsonl`.
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
writable by group or other. If it is not, or a business file fails to load, the command refuses
with exit 3, names the file and the key path, and writes nothing.
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
once. Removing a provider from `console.sso` takes effect only after a Console restart.
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
`user add` and `user remove` (row 193c, #1705) take the same proof, lock, revision check, file
checks and receipt. `user add` takes one sign-in subject, `PROVIDER:SUB`, split at the first colon;
the provider must be in `console.sso`. `user remove` refuses with `user-has-grants` while any grant,
at any business, names the user: revoke those first. `--business` names the business whose bus
takes the proof; the user list itself is instance-wide. Their receipt verbs are `user.add` and
`user.remove`. The bus has no `access.reload` yet (FED R4). As with grant and revoke, the broker
reads the file once per user request (`packages/bus/src/broker.mjs`), so the change acts at once.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
opening its database or transcripts. Text lists the total and run, instance,
provider/model, provider and account groups, with input/output/cacheWrite/
cacheRead and unknown turns beside every total. JSON retains the full view.
Account is unassigned today. Unknown turns can have used tokens: known sums
are a best reading of session-editable transcripts, not proof or a limit check.
### Private tester HTTP (row 196)
`node packages/cli/src/tester-http.mjs GET /api/config` selects the instance's
console/tester.sock from its dataRoot. POST reads one bounded JSON body from stdin.
The helper supplies the fixed logical origin, reports status/text/body as JSON,
and never retries an uncertain write. It cannot navigate a browser or upgrade the
tester to an owner. `mosaic ... --tester` continues using broker.sock directly.
See docs/TOOLS.md for explicit activation conversion and rollback behavior.
## Instance processing switch
`mosaic halt [--business ID]` and `mosaic resume [--business ID]` use the human
management path even when the CLI channel is disabled. The business chooses
proof context; the affected scope is every managed workload in the resolved
data root. Read `mosaic processing status --json` after an unknown or partial
result. Resume refuses unconfirmed stops and a stale generation. A restarted
`bus start --pm` keeps its control bus up while halted and skips the chief.
The switch does not clear other launch/Discord brakes or retry tasks. Native
diagnostic launch stays available. See packages/processing-control/README.md.
### Independent thread sessions (264e)
`mosaic session list <thread>` lists logical sessions and the saved default. `show`
reads metadata/count; `open <thread> <session>` requests that session's normal guarded
launch. Reads and `create <thread> <name> --op <key>` spend nothing and start no run.
Use `--json` for receipts and `--business <id>` as on the thread commands. A session
reference is its full ID, unambiguous ID prefix (8+ characters), or exact name in that
thread. Creating or opening a session never changes the saved default. Legacy thread
commands continue using that default.
`mosaic session clear <thread> <session> --yes --op <key>` requests safe context
replacement. The confirmation preserves old history and all old workspace files,
including dirty/untracked/unpushed work. The successor gets a fresh checkout at the
approved revision; old work is not silently copied. No forced stop is authorized.
The command waits up to 30 seconds by reading `session.operation`, then prints pending
if stop proof/capacity is outstanding. Keep the operation key: `mosaic session operation
<thread> <key> --json` reads the recorded outcome. A transport failure never triggers an
automatic second clear. Count/clear dependencies that are not installed report
unavailable; unsupported or incomplete count evidence reports unknown, never false zero.
The Console Chat thread pane offers the same chooser, New session and Clear context.
Links preserve the selected session; drafts, paging cursors and receipts belong only
to that session. Clear status can be checked with its operation key without another
write. Read-only listeners disable writes. Until the reviewed multi-user integration,
these operations retain the legacy human's own-thread authority; they grant neither
collaborator session writes nor organization-owner access to others' private context.
To address a nondefault session explicitly, use `mosaic talk --thread <thread>
--session <full-session-id> <text>`, `mosaic follow --thread <thread> --session <id>`,
or `mosaic thread read|open <thread> --session <id>`. Omitting `--session` preserves the
legacy saved-default behavior; opening a chooser session never redirects later sends.
`mosaic session cancel <thread> <clear-op-key> --yes` cancels a clear using its recorded
operation key. Before retirement this restores the old session to active without
replaying cancelled inputs. After retirement it cancels successor startup only; the
old session stays retired. A successor that already started refuses cancellation.
The Console's Check clear operation field and Cancel clear action use the same key
and require confirmation. Cancellation adds no force-stop or kill-switch authority.
Both before/after outcomes appear as `cancelled`, with `retired: null` or `true` in JSON.
If a routed message is stored but cancelled because its session is clearing/retired,
the CLI prints its ID, reports the cancellation and exits 3 without waiting for a reply.
### Organization commands
On a fresh named development instance, `mosaic org template render ID --fixture
local --output FILE`, `mosaic org validate FILE`, and `mosaic org create ID
--from FILE` prepare an organization. `mosaic org show ID [--json]` and
`mosaic org list [--json]` read it; `mosaic org update ID --from FILE
--if-revision N` requires the revision from show. Each configured project gets
its own derived root.
`mosaic org validate FILE` checks a draft by its path and prints `valid`; it
never looks up an installed organization. `mosaic org config validate ID` and
`mosaic org config resolve ID INSTANCE [--project ID]` read the installed
organization `ID` and print the same JSON as `mosaic business validate|resolve`
(`packages/organization/README.md`). A draft file in the working directory
named like an installed ID changes neither: each command reads only its own
kind of input. `mosaic org instances|instance|veto` are the role instance
commands under "Role instances" above. The config and instances commands
never use the dev-local-owner context, so they don't need a named development
instance.
`mosaic org queue init ID --local-only --accept-local-loss-risk` initializes
its checkout; `mosaic org project repo init ID PROJECT --local-only
--accept-local-loss-risk` initializes a project. For either command `--remote
URL` selects remote mode instead. These commands never push or overwrite an
existing repository. Local-only acceptance records the risk of losing the only
copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project
repo show ID PROJECT [--json]`. See `packages/organization/README.md` for the
layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
if both are supplied they must name the same ID.
`mosaic org delete ID --confirm ID --if-revision N [--json]` stops the
organization bus, moves its local organization document, history, runtime and
organization-specific secret directories into the instance's private
`deleted/` directory, and removes it from the registry. The ID may then be
reused. The deleted folder contains a receipt; `deleted/deleted.log` is an
append-only index. Remote repositories and remote credentials are untouched.
Purging deleted data and revoking remote credentials are separate operations and
are not provided by this command.
If deletion stops, the organization remains in `deleting` state and cannot
start a bus. Inspect the reported receipt and correct the refusal, then resume
with `mosaic org delete ID --confirm ID`; the revision is only required for the
first attempt. Completed moves are not rolled back. A held lifecycle lock can
mean a bus start is still in progress; if a process crashed and left its lock,
inspect it before removing it and resuming. Moves use rename and therefore
require the source and deleted folder to be on the same filesystem.
`mosaic org archive` and `mosaic org delete` stop the organization bus, so like
`bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after
their usage checks and before any config is read.
`mosaic org restore ID --if-revision N` makes the organization active and starts
its bus, as `mosaic bus start ID --dev-local-owner --detach` does (E1, P1-4). It
makes that verb's checks before the restore, so a stamped build or an instance
that refuses `--dev-local-owner` refuses the restore before anything changes
(exit 3). Starting a bus is a human action, so inside an agent run `org restore`
refuses like `bus start` (exit 3) before any config is read. The receipt's `bus`
names the outcome: `started` with the host pid, or `failed` with the message and
the verb that starts it (after `mosaic bus recover ID` when a host that died left a
stale lock). A bus that does not start leaves the restore in place;
the command prints the receipt and exits 3. `org restore ID --if-revision N
--no-bus` restores without starting the bus, as before, and stays open to agents.
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3):
```
mosaic org role-templates ORG [--json]
mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT [--json]
mosaic org fire ORG --name NAME [--level org|project|root] [--project P] --reason TEXT [--json]
mosaic org autonomy ORG [--json]
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]
mosaic org autonomy ORG review cross-role|merge|policy|authority [--json]
```
Each sends the page's own request to the running Console's org route, as
`org reload` does, so the Console runs the same owner, `--send`, origin, body
and session checks and the same bus verbs. `hire` and `change` read the role
templates first and send the chosen template's name and sha256. `autonomy set`
needs the 64-hex `--revision` that `mosaic org autonomy ORG` prints and sends every
setting the panel sends, changing only the named ones; a stale revision is
refused `revision-conflict`. `--authority-resolver ""` clears the resolver, as
emptying the panel's field does, and `--reverse-window-hours` takes 1 to 168.
These verbs need a named instance whose Console runs with `--dev-local-owner`;
the default instance is refused with exit 3. A Console refusal exits 3 with its code and
message, an answer that never came exits 1, an unknown template exits 2. All of
these verbs, reads included, refuse inside an agent run (exit 3) after their
usage checks (exit 4) and before any config is read.
`mosaic tasks create|edit|archive` are the task board's three writes. The
arguments pass the board's own check, then go to the bus as the person
(`tasks.create`, `tasks.edit`, `tasks.archive`), so the bus runs its human,
field, digest and archive checks. Edit and archive need the task's digest from
`mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict`
(exit 2) and nothing is written. They refuse inside an agent run.
System types are installed plug-ins. `mosaic org system-types installed` prints
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
ids. The owner changes the enabled list through the existing organization file
update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID`
shows selected, active and inactive ids. The owner replaces the selection with
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
empty `--types ''` clears it. Disabled selections stay recorded but become
inactive. Project settings changes are append-only and require human proof.
## Accounts
`mosaic accounts` administers the instance-level accounts store locally, as the
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
and takes no business selector or bus connection. It has no organization rights
verbs. Console OIDC sign-in re-reads these accounts on every request. A disabled
account or changed authentication generation loses account authority immediately;
a legacy access.json root grant is checked independently. Organization membership
evaluation follows in later Phase M slices.
```sh
mosaic accounts init --name Operator
mosaic accounts list --json
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
mosaic accounts user disable u-0123456789ab --revision 2
mosaic accounts user enable u-0123456789ab --revision 3
mosaic accounts user signout u-0123456789ab --revision 4
mosaic accounts user remove u-0123456789ab --revision 5
```
Init creates one operator account containing every configured `console.sso`
subject and refuses if accounts.json already exists. Add mints its id and defaults
to instance role `user`; `--role operator` creates another operator. Writes require
the file revision from list. Disable, enable and signout advance `authGen`, and
the last active operator cannot be disabled or removed. Removal scans ownership
while holding the registry then accounts locks, refuses if the account owns an
organization (including archived organizations) or any registry entry lacks its
owner metadata during creation, and removes its memberships in
the same write. List reports rollback rows needing repair; its JSON omits invite
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
Managed accounts instances use the system units from `scripts/case2-unit.sh`.
The selected uid must use a non-login shell, be the only member of its primary
group, and own its config directory and data root. The shared Case 1/Case 2
installer writes `<dataRoot>/.deployed` as mode 0444 naming the instance.
`webui-service.sh` refuses accounts and deployed instances because its user
manager runs under a human or development seat uid. The receipt uid identifies
the invoking process; it does not attribute a human or resist tampering by that uid.