1138 lines
99 KiB
Markdown
1138 lines
99 KiB
Markdown
# Bus and broker core — slice 1 S2
|
||
|
||
The broker owns `<dataRoot>/bus/` and is the only cooperative writer of
|
||
`bus.sqlite`. Consumers use the local socket. This package does not load or
|
||
write the system or business configuration, launch a harness, call a service,
|
||
or implement task verbs. S1 supplies validated/resolved definitions; S3 adds
|
||
task handlers; S6 supplies trusted launch records. No service credential is
|
||
returned to a client or written to the database.
|
||
|
||
Slice 1 shares an OS user. Files, capabilities, process ancestry checks and
|
||
hooks are **not a wall against a hostile same-UID process**. In particular,
|
||
The ancestry walk remains cooperative. Human/tester/user proof envelopes are now
|
||
bound to the connecting PID and uid using Linux SO_PEERCRED before dispatch. The trusted host
|
||
and well-behaved clients must follow this protocol. Containers remain later work.
|
||
|
||
## Start and stop
|
||
|
||
Requires Linux, Node 24 or 26, local disk and system Python >=3.10. The bus pins
|
||
the root-owned ELF resolved from /usr/bin/python3, never a PATH shim or home binary. The data root must
|
||
already exist and belong to the user. A broker creates `bus/` mode 0700 and the
|
||
DB/lock/socket mode 0600. It refuses symlinks or unsafe existing permissions.
|
||
|
||
The trusted host can import `startBroker` from `src/index.mjs`, or fork
|
||
`src/process.mjs` with an IPC channel. With `fork`, send exactly one boot message:
|
||
|
||
```js
|
||
{op: 'boot', config: {
|
||
dataRoot, businesses, launches, readers: ['business-id'], repoRoots: [repoRoot]
|
||
}}
|
||
```
|
||
|
||
`businesses` maps business IDs to normalized business definitions. Call
|
||
`busBusiness(business, resolvedByInstance)` to adapt S1's `loadBusiness` and
|
||
`resolveInstance` results. It copies resolved authority limits and credential
|
||
references into each role instance. It never expands an authority map or loads
|
||
files. Until S1 freezes its interface, callers may supply that normalized shape
|
||
explicitly: `{id, human, arbiters:{technical,delivery}, roles:{instance:{authority:
|
||
{withinRole:[],crossRole:[]}, credentials:{gitea?,vikunja?}}}, launch?}`. Empty
|
||
maps grant no actions; missing maps refuse. The runtime always excludes its own repository root and every declared project
|
||
root when loading credentials; `repoRoots` adds any other source checkouts. The host owns config/definition validation.
|
||
|
||
Launch records are `{business, role, run, harness, address?, pid, startTime}`.
|
||
`pid` and kernel `/proc/<pid>/stat` start time identify the managed process.
|
||
Harness is `pi` or `claude-code`. Bind only a launch the trusted host actually
|
||
performed. Binding appends `session.launched`; rebinding after broker restart
|
||
requires identical recorded identity. A changed role, harness, address or process
|
||
identity refuses. Never register an arbitrary client-supplied launch record.
|
||
The launcher must set `MOSAIC_RUN_ID` in every managed process's environment and
|
||
preserve it for descendants, including after the original process exits.
|
||
|
||
The boot reply is `{ok:true,path,launches:[{business,run,cap}],readers:
|
||
[{business,cap}]}`. Distribute each random capability only to its intended
|
||
run/reader, through an inherited private channel. It is a broker capability,
|
||
not a Gitea/Vikunja token. A capability binds business, role and run; socket
|
||
requests never declare those identities. Reconnect after restart requires a
|
||
new capability from the trusted host, not a socket re-registration.
|
||
|
||
For a later real launch the trusted host sends `{op:'bindLaunch',record}` over
|
||
its original IPC channel, receiving `{ok:true,launch:{business,run,cap}}`.
|
||
A refused binding returns `{ok:false,error}` and keeps the broker running.
|
||
The embedded runtime offers the same `bindLaunch(record)` wrapper, which also
|
||
updates the human ancestry registry. S6 must use that wrapper. Do not use the
|
||
underlying Broker's test-level binding API to bypass runtime process checks.
|
||
|
||
A rebind of a run that already has `session.ended` refuses with `run-ended`,
|
||
also after a restart, and a refused bind leaves the run unbound.
|
||
|
||
Row 65 (#1558): the record's `harness` is a plug-in module name (the loader's
|
||
pattern), else `invalid-harness`; the broker names no harness itself. A new
|
||
run binds only when `harnessOn(business, name)` says the organization file's
|
||
`plugins.harness` switches it on now, else `invalid-harness`. A run the store
|
||
already has binds again even after its harness is switched off, so it can be
|
||
ended. A broker built without `harnessOn` (the unit tests') lets every
|
||
module name through. The record may carry `transcript`, an absolute path
|
||
where the harness keeps the run's session files, for the usage reader; it
|
||
is held in memory only.
|
||
|
||
A request that carries a safe-integer `id` gets the same `id` on its reply,
|
||
refusals included, so the host can match each reply to its request
|
||
(`packages/cli/README.md`). A request without one gets a reply without one.
|
||
|
||
S6 adds launch ops on the same IPC channel, one reply each
|
||
(`{ok:true,result}` or `{ok:false,error}`), none of them socket verbs:
|
||
|
||
- `{op:'identity',cap}`: the business, role and run a capability names.
|
||
- `{op:'authorizeLaunch',cap,instance}`: `authorize(cap,'role.launch',
|
||
{target:instance})`; a refusal is also recorded as `action.refused`.
|
||
- `{op:'refuse',cap,code,subject?}`: records a refusal the host decided
|
||
itself (instance list, capacity, a failed spawn, a git socket check) as
|
||
`action.refused` against the caller, the code only. A task ref as
|
||
`subject` files the refusal on that task's trail, as a refused verb's
|
||
does; any other subject, or none, records null (row 93).
|
||
- `{op:'endLaunch',record}`: `{business,run,reason,exitCode?}` for a run
|
||
this broker bound. It writes `session.ended` `{reason,exitCode,released}`,
|
||
releases the run's claim if it still holds it, and drops every capability
|
||
of the run. A second end refuses with `run-ended`, an unbound run with
|
||
`unknown-run`. `reason` is an identifier the launcher picks
|
||
(`packages/cli/README.md`, "Sessions").
|
||
- `{op:'credentialStatus',business,role}`: the role's `{service,date,state}`
|
||
rows, never a token.
|
||
|
||
Row 93 (#1587) adds three ops for the host's git socket
|
||
(`packages/cli/src/git-socket.mjs`). A refusal from any of them is recorded
|
||
as `action.refused` on the task's trail.
|
||
|
||
- `{op:'checkPush',cap,task_ref}`: `checkPush(cap,task_ref)` checks the
|
||
caller's `git.push.working` authority before the host takes a push's
|
||
bundle. It records nothing unless it refuses.
|
||
- `{op:'recordPush',cap,push}`: `push` is `{task_ref,project,branch,ref,
|
||
commit,previous}` once the work ref moved. It checks the authority again
|
||
and writes the `action.allowed` for `git.push.working`, with the task as
|
||
subject and `{project,branch,ref,commit,previous}` in its body. That event
|
||
is the push record: the only `action.allowed` for `git.push.working` that
|
||
names a commit. The project must be one of the business's. The host moves
|
||
the ref back when this refuses.
|
||
- `{op:'reviewFetch',cap,args}`: `args` is `{task_ref,request}`. The caller
|
||
must hold `review.verdict` and be the role the `review.requested` event was
|
||
addressed to (`request-not-found` otherwise). Returns the request's
|
||
`{project,commit,ref}`, which the host bundles for the reviewer.
|
||
|
||
Row 137 (#1631) adds four ops for a landing on a working repository's
|
||
`main` through the same git socket. The decision checks are land-work's,
|
||
in `src/landing.mjs` (`readLanding`), which `scripts/land-work/read.mjs`
|
||
also calls. Each caller names its branch, `refactor` for land-work and
|
||
the task ref for an agent's landing, and the decision's target has to
|
||
match it. So a decision raised for one can't land the other. land-work
|
||
accepts only a decision the human resolved through the CLI (`human/cli`).
|
||
The broker passes `arbiter: true`, which also accepts a `cross-role`
|
||
decision its arbiter resolved (REQ-DEC-2). It refuses a decision of any
|
||
class but `cross-role` with `not-authorized` (REQ-LAND-1). That covers
|
||
`gated` and `within-role`: only a role granted `git.merge.protected`
|
||
cross-role lands, whoever resolved the decision.
|
||
|
||
- `{op:'checkLand',cap,args}`: `args` is `{task_ref,decision}`. The caller
|
||
must hold its claim and the decision: `git.merge.protected`, raised by
|
||
this role in this thread with the task ref as its target, of class
|
||
`cross-role` (`not-authorized` otherwise), resolved with its approval
|
||
option and not yet used (`decision-consumed` otherwise).
|
||
Then `readLanding`'s checks, and the verdict's commit must be the task's
|
||
latest push (`ref-moved`). A landing with no subject refuses
|
||
`no-subject`, since an agent has no `--subject` to pass. Returns a
|
||
ticket with what the host needs: the project, the work ref, the commit,
|
||
the verdict and the resolution, with its `via` (`cli` or `broker`). It
|
||
records nothing unless it refuses.
|
||
- `{op:'recordLand',ticket,land}`: `land` is `{commit,previous,reviewed,
|
||
base,patch_id}` once `main` moved. The checks run again inside the
|
||
transaction. It writes the `action.allowed` for `git.merge.protected`
|
||
on the task's trail, which spends the decision, and sends the role a
|
||
RESULT in the decision's thread. When it refuses, the host moves `main`
|
||
back.
|
||
- `{op:'landRefused',ticket,args}`: `args` is `{code,detail,notify}`. It
|
||
writes `action.refused` with the code on the task's trail and ends the
|
||
landing. With `notify` the role gets a RESULT too, since the git socket
|
||
had already answered `started`.
|
||
- `{op:'landHeld',record}`: `record` is `{business,role,thread}`. True when
|
||
the role, in that thread (null for a role run), holds a `cross-role`
|
||
merge decision a landing could use. The launcher binds the git socket's land op only
|
||
then; `checkLand` still checks everything.
|
||
|
||
A ticket lives in the broker's memory from `checkLand` until `recordLand`
|
||
or `landRefused` ends it, so either may run after the run ended. An
|
||
unknown ticket refuses `land-unknown`.
|
||
|
||
`{op:'close'}`, SIGTERM and SIGINT close the socket and DB and remove the owned
|
||
writer lock. Losing the host IPC channel closes with exit 2. Startup or host
|
||
protocol failures return a static error code and exit 2, never a raw exception.
|
||
Capabilities vanish with the process. A SIGKILL/crash leaves `writer.lock` and
|
||
possibly the socket: **there is no automatic stale-lock reclaim**. An operator
|
||
must establish that the old broker is gone, preserve the DB/WAL/SHM as evidence,
|
||
and explicitly remove only the stale lock/socket before restarting. Never delete
|
||
the database to get around an open-time refusal.
|
||
|
||
## Client protocol
|
||
|
||
Import `Client` from `@mosaic/bus/client` (or `src/client.mjs`).
|
||
`new Client({path,cap}).call(verb,args)` opens one Unix socket per request and
|
||
returns plain JSON or throws `BusError` with a static `.code`. One JSON line,
|
||
64 KiB request maximum, five-second timeout. There is no SQL verb and no
|
||
automatic retry. Responses are capped at 4 MiB by the client. Oversized reads
|
||
refuse; pagination is not part of this first core.
|
||
|
||
A disconnect or timeout reports `outcome-unknown`. A mutation may already have
|
||
committed. Callers must inspect its evidence before deciding what to do; they
|
||
must not resend automatically. `message.receive` records delivery when the
|
||
broker hands the batch to the connection, not when the harness acts on it.
|
||
A lost response leaves an uncertain delivery, never automatic replay to a new
|
||
holder. A holder that releases a role without reading what it received leaves
|
||
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). |
|
||
| `organization.set` | `{revision,limits?,models?,instances?,assistants?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `<definition>.<instance>`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits.set` stays human-only, and a tester gets `tester-refused` from `organization.set` (row 175, #1673). `assistants` (row 155, Q169) is described in the business README; a refusal from it passes through as its `organization-*` code. The chief of staff learns the revision from `organization.revision` (below); a stale one is `revision-conflict`. |
|
||
| `organization.revision` | No args (row 134, #1669). Answers `{revision}`, the organization file's current revision, and nothing else the human's `limits` read returns: no limits, models, instances or tokens. Needs `organization.change` in the caller's role file, with the same refusals as `organization.set` (`not-holder`, `read-only`, `tester-refused`, `agent-required` for the human, `decision-required` for a role whose only classification is cross-role; no shipped role is). A read never spends the grant and writes no receipt. Any argument is `invalid-request`. `limits` stays human-only, apart from the tester's read (row 175). |
|
||
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and an uncited gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
|
||
| `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). |
|
||
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
|
||
| `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. A human withdraw sends the raising role a DECISION message; the raising run's own withdraw sends none. |
|
||
| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`, plus `waiting` when no run holds the target (Launch notices, below). Human instruction's `request` is its `human.input` event ID. `to` is a role instance, its name (row 141, #1635, REQ-MSG-2), `human` or `tester`. A name resolves to its instance before any check; the message stores the instance in `to_role` and the name in `to_name`, null for a send by instance. An instance key, `human`, `tester` and a role definition the business knows win over a name equal to them, so an instance added later with a key some instance holds as a name still gets its messages, and a send to a later definition's name refuses `unknown-role`. A name the business doesn't hold refuses `unknown-role`. |
|
||
| `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. A human decision close's message carries the close's `human.input` answer event as its `request`; a role close's has `null`. |
|
||
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
||
| `name.set` | `{instance,name}`; action `instance.name`, routed under REQ-MSG-2 (row 141, #1635). The outside-agent human CLI may; an agent only when its role file grants `instance.name` within-role, which the chief of staff's does, and the write logs `action.allowed` with the instance as target. A tester may too (row 175, #1673): every name check runs, no authority is spent and `by` is `tester`. Any other class refuses `not-authorized`, so no decision sets a name. `name` matches the business ID pattern. It refuses `name-is-instance` when it equals an instance key, `human` or `tester`, `name-is-role` when it equals a role definition the loaded business knows (one an instance uses or one it defines), and `name-taken` when another instance of the business holds it now. The `instance_names` trigger backs up the last. Naming an instance again replaces its name. Returns `{instance,name}`. |
|
||
| `assistant.stop` | `{instance}`; outside-agent human CLI only (`human-required`), row 155 (#1649, Q169). `instance` must be a known instance (`unknown-role`) of `THREAD_DEFINITION` (`not-assistant`). Revokes the current claim of every thread of that instance the human made (REQ-USER-2), with `by` the human and reason `stopped by the human`; none held is `not-held`. Each run gets `not-holder` at its next bus call and exits. The launcher starts a run again only for a thread with pending messages or an open. Returns `{stopped:[{thread,run}]}`. |
|
||
| `names` | No args; human and agent sessions. Each named instance's current `{instance,name,at}`, by instance. |
|
||
| `staff.request` | `{task_ref,roles,note?,decision?}`; action `message.send` (row 140, #1634, REQ-COS-1). `roles` is 1 to 10 distinct `{role,count}` entries, `count` an integer 1 to 20; any other shape is `invalid-request`. Only an instance of the `pm` definition sends it (`staff-request-refused`), and it goes to the business's launcher, which must be an instance of `chief-of-staff` (`no-chief-of-staff`); the definitions' `messaging` lists apply as for `message.send`. Sends that instance a STAFF-REQUEST message naming the task and the roles with counts, and writes an `action.allowed` event with `operation: staff.request`, `staff_for` and `roles`. `message.send` and `thread.send` refuse the class `invalid-class`, so no other path writes one. It grants nothing: the chief of staff launches through `role.launch` within the organization's limits and replies to the message with the instances it launched or the refusal. Returns `{message,to}`. |
|
||
| `review.request` | `{task_ref,commit,to,note?,decision?}`; action `review.request` (row 93, #1587). `commit` must be the task's latest pushed commit: an older one refuses `stale-commit`, any other `unknown-commit`. `to` must be another role holding `review.verdict` (`unknown-role`, `self-review`, `not-a-reviewer`). Writes a `review.requested` event `{task_ref,project,ref,commit,to,message,held}` and sends `to` a REVIEW-REQUEST message naming it. `held` is the run id holding the `to` role when the request is routed (row 105, #1599), or `null` when no run holds it: the message then waits for a claim, and the requester should tell whoever launches roles. Returns `{request,message,commit,ref,held}`. |
|
||
| `review.verdict` | `{task_ref,request,verdict,body,decision?}`; action `review.verdict`. `verdict` is `approve` or `changes`. `request` must be a `review.requested` event on the task addressed to the caller's role (`request-not-found`), with no verdict yet (`verdict-exists`). Writes a `review.verdict` event `{task_ref,request,commit,verdict,message}` and sends the requesting role a REVIEW-RESULT in reply to the request's message. Returns `{verdict,message,commit}`. |
|
||
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
|
||
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
|
||
| `access.reload` | No args; the outside-agent human CLI only (row 295, #1801, FED R4): the tester and an agent get `human-required`, a reader `read-only`, a user `access-refused`. Reads the access file whatever its stamp and ends the lost claims of every business, since the file is shared, with `by` the human. Returns `{ended:[{thread,session,run}]}` for the caller's business only. `mosaic access grant` and `revoke` call it after the write. |
|
||
| `inbox` | No args; plain JSON projection scoped to capability's business. |
|
||
| `tasks` | Optional args, scoped to capability's business (row 89, #1583). None: every row of the tasks projection (below). `{task_ref}` alone: that one row, or `task-not-found`. Any of `{done,assignee,label,requirement,limit,after}`: `{tasks,total,next}`, the short form (below). An unknown arg or bad value refuses `invalid-request`. |
|
||
| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working`, `inputNeeded` and `stranded` (Agent state, below); a role session gets the claim rows only. |
|
||
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
|
||
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
|
||
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
|
||
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
|
||
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
|
||
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
|
||
| `hosts.status` | No args; the human, the tester or the business's chief (row 234, #1740). `[{host,address,port,user,sudo,state,hostKeys,installerFingerprint,checkedAt,hostLineLeft,revoking}]` from the `hosts` records, with host keys as fingerprints. Never a key. |
|
||
| `hosts.check` | `{host}`; same callers. Runs the host's `list <user>` as `mosaic-installer` with the installer key and the pinned host keys, then sets `state` `ready` and `checkedAt` and writes `grant.host.checked`. A nonzero exit is `host-failed`, a record that changed during the run `host-changed`, a record with no installer key `host-not-enrolled`. |
|
||
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
|
||
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
|
||
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
|
||
| `grants.scope` | `{}`; the proven human CLI only (row 298, #1804, Q228). Returns the organization revision, effective scope and fallback flag. `launch.grantScope` missing or invalid covers nothing. |
|
||
| `grants.scope.set` | `{revision,scope}`; the proven human CLI only, itself Approve for the scope change. Agents, testers, readers and Console caps refuse. Strict scope shape; optimistic organization revision, durable organization receipt, and `grant.scope` with human proof and old/new scope digests. General organization setters cannot write this setting. Scope matches per-method agent and every project, or agent/host and exact command arrays; host enrollment is excluded. In-scope requests by `launch.by` close via `scope` with protected `grant.scope.used` evidence, and run the existing grant executor without per-grant Approve. The raise returns the closed decision view to the chief; this shortcut sends no separate decision-close message. Outside scope waits as before. Scope narrowing before spend fails `scope-changed`; every issued grant keeps its records and revoke path. Empty default ships; the live value is Jason's to set. |
|
||
| `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. |
|
||
| `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. |
|
||
| `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). |
|
||
| `grants.sweep` | `{service:"gitea"}`; the human or the tester. Closes open revokes, settles open rotations again, and retries each `serviceLeft` entry. A fire retry (`tokens`, `login`) first checks the user's id as the revoke close does, and a gone or remade user is skipped with `gitea-user-gone` or `gitea-user-foreign`. Writes `grant.sweep` and `grant.swept {left,skipped,settled,closed}`. |
|
||
| `grants.admin.set`, `grants.admin.rotate`, `grants.admin.remove` | The human only (#436b r5 section 3). set takes `{service,url,account,rotateBy,token}`, checks C1 to C4 with the token, then writes the slot under `<configDir>/secrets/grants/<business>/gitea`; a slot already there is `grant-admin-exists`. rotate `{service,rotateBy}` mints a successor admin token and names the old one in `adminTokenLeft`; `{service,finish:true}` deletes it. Rotation voids the approved decisions not yet run, as their `adminSha256` no longer matches. remove drops the slot. Each writes `grant.admin` with no secret. A left admin token that set or remove drops from `adminTokenLeft`, or one shown as `unknown`, has to be deleted by hand at Gitea. A running executor, revoke close or sweep keeps the admin api it started with: rotate voids only decisions not yet run, and remove takes effect from the next run. |
|
||
|
||
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
|
||
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
|
||
a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
|
||
| `requirements` | Role sessions only, as `task.create` is (row 90, #1584). With `{project_key,requirement_snapshot?}`, returns the exact current approved product snapshot through the shared host `requirementsFor` catalog validator; stale/revoked/foreign snapshots refuse and product reads never fall back. With no args, preserves the legacy response. The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
|
||
| `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, 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. |
|
||
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
|
||
| `thread.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
|
||
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
|
||
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
|
||
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
|
||
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
|
||
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
|
||
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default,compactions}]`. `compactions` is `{count,known,reason,at}` from the host's ledger (row 270, #1775): `known` true gives the exact count of completed Pi compactions; otherwise `count` is a lower bound or null and `reason` is `harness-unsupported`, `not-collected`, `store-unreadable` or `store-incomplete`. `not-collected` passes: the next collection reads on. A launcher killed mid-write leaves a torn ledger line, and the count stays unknown (`store-incomplete`) for good, by design (sage-2806 condition 3). Writes nothing. |
|
||
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
|
||
|
||
### Projects and threads (row 121, #1615)
|
||
|
||
A thread is one conversation between a user and the business's thread instance, the role
|
||
whose definition is `assistant` (REQ-THREAD-1). Projects group threads (REQ-ORG-2). Schema
|
||
`3e` adds five append-only tables: `projects`, `threads`, `thread_claims`, `message_threads`
|
||
and `thread_opens`. A message's thread is a `message_threads` row written with the message,
|
||
so `messages` keeps its old columns.
|
||
|
||
A thread instance's run is for one thread. `bindLaunch` refuses a record for that role
|
||
without `thread` (`thread-required`) or naming a thread of another instance or business
|
||
(`thread-not-found`), and a record for any other role that names one (`thread-not-allowed`).
|
||
The run's `role.claim` writes a `thread_claims` row, not a `role_claims` row. One run holds
|
||
a thread at a time (`thread-already-held`), and a run holds at most one thread. An idle
|
||
thread has no run and holds nothing, so it counts against no launch limit (REQ-THREAD-2).
|
||
|
||
Every message to or from a thread instance names its thread (REQ-MSG-1):
|
||
|
||
- A thread run's own message is in its thread.
|
||
- A human reaches a thread instance only through `thread.send`. A `message.send` to it
|
||
refuses `thread-required-use-thread-send`.
|
||
- Any other role's message to a thread instance needs `in_reply_to`. The broker walks the
|
||
`in_reply_to` chain to the nearest message that has a thread, so the chief of staff's
|
||
reply returns to the thread that sent the work. The walk stops where the chain ends; there
|
||
is no depth constant. No threaded ancestor refuses `thread-required-reply-to-thread-message`,
|
||
which is the whole text a run sees, so it says what to do. A threaded ancestor in another
|
||
instance's thread refuses `thread-instance-mismatch`.
|
||
- A thread run's `message.receive` returns only its own thread's messages.
|
||
|
||
Triggers back the broker. `thread_one_holder` and `thread_one_per_run` refuse a second
|
||
holder and a second thread. `message_threads_match` refuses filing a message under a thread
|
||
of another business, or under a thread whose instance neither sent nor receives it.
|
||
`delivery_same_thread` refuses routing or delivering a threaded message to the instance to
|
||
any run but the thread's current holder.
|
||
|
||
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
|
||
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
|
||
another user's thread, unless it is shared with them (Thread shares, below).
|
||
|
||
### Thread shares (row 206, #1704)
|
||
|
||
Schema `3m` adds `thread_shares`, append-only: one row per `share` or `unshare`, and the latest
|
||
row per thread and target is the share's state. A thread's owner shares it read-only with a
|
||
user (`user:<id>`), the business human or everyone (`*`). The 3l→3m step only adds the table,
|
||
so every existing thread stays private to its owner.
|
||
|
||
A session may read a thread it doesn't own only when all four hold (row 193 design 5.4):
|
||
|
||
1. a share is in force to the caller's owner string or to `*`;
|
||
2. a user caller has a live grant, of any level, covering the thread's project and instance;
|
||
3. a thread owned by a user is still covered by that user's grant;
|
||
4. neither the caller nor the owner is the tester.
|
||
|
||
Only a human principal matches a share: the business human, a reader session as that human, or
|
||
a user. An agent session matches none, `*` included, even when it is bound to the thread.
|
||
A reader session reads as the business human. A sharee can't call `session.list` on the
|
||
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
|
||
access document as of that request: the broker stats the file each time and reads it again only
|
||
when the stat changed (row 295), so a removed, expired or unreadable grant or an unshare ends
|
||
reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
|
||
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
|
||
user's thread messages to the owner and readers while the check holds. The tester can't call
|
||
the share verbs (`tester-refused`), and a viewer is refused them `access-refused`; a viewer
|
||
reads a thread shared with it. Tests: `tests/thread-shares.test.mjs`.
|
||
|
||
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
|
||
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
|
||
messages to the instance not yet delivered, messages delivered to the current holder and
|
||
not yet read, the latest message, open and claim times, and the holding run or `null`. The
|
||
launcher reads it to start and stop thread sessions (`packages/cli/README.md`). A row adds
|
||
`revoked: true` when its thread's user owner has lost the grant (below); the launcher never
|
||
starts such a thread and sends a running one SIGTERM.
|
||
|
||
Revocation (row 295, #1801, FED R4, federation design 3.7). A user thread is lost when no grant
|
||
in the access file covers it at collaborator level, so a downgrade to viewer is a loss. One
|
||
transaction ends the held claim of every session of every lost thread, op `revoke`, reason
|
||
`access-revoked`; the run's bus calls refuse `thread-revoked` until it ends (row 306, so the runner
|
||
stops rather than exit as a lost session), and a new run of the thread refuses `thread-revoked` at
|
||
`bindLaunch` and at `role.claim`. A rebind of an earlier host's run is
|
||
allowed, so the launcher's recovery can end it. Four triggers end the claims, each with its `by`:
|
||
the runtime's timer, every 30 s (`access.intervalMs` sets it in process, as `librarySync.intervalMs`
|
||
does), which reads the file whatever its stamp (`access-timer`); a user's request or a
|
||
`threadsState` call after the file's stat changed (`access-change`); a `threadsState` row that is
|
||
lost with its claim still held, as when a grant expired with no write, from the same read
|
||
(`access-expiry`); and `access.reload` (the human). The timer calls `accessSweep()`, a trusted
|
||
broker method, not a verb. Tests: `tests/access-revoke.test.mjs`.
|
||
|
||
`threadIdle({business,thread,run})` is the launcher's idle stop, also trusted and not a
|
||
verb. It rechecks the thread and releases the run's claim in one call, and returns
|
||
`{released:true}`, or `{released:false,pending,unread}` when a message came in after the
|
||
launcher's snapshot. The launcher signals the run only after a release, so a message sent
|
||
after the snapshot gets a turn from the same run or waits as `pending` for the next one. It
|
||
refuses `not-holder` for a run that doesn't hold that thread and `run-ended` for an ended run.
|
||
After a release, and until the run ends, every call of the run's that needs the claim
|
||
(`message.receive`, `state`, `message.read`, `message.send` and every other verb that checks
|
||
the holder) refuses `launcher-released`, not `not-holder` (row 302). `role.claim` refuses it
|
||
too, before the claim, so the run can't claim again. A call
|
||
the broker answers after the release and before the signal is that stop, not a lost claim, so
|
||
the runner exits 0. The launch-landing pass's `landRelease` (`packages/cli/README.md`) answers
|
||
the same way for the role run it releases. Any other lost claim (a stop, a revoke, a clear, a
|
||
limit, a newer holder) still refuses `not-holder`.
|
||
|
||
A message delivered to a thread run that then ended without reading it is stranded, as
|
||
`stranded` (Agent state, below) describes for a role. It counts as neither `pending` nor
|
||
`unread`, so it neither starts a session nor keeps a later one from idling out. The user
|
||
sends it again.
|
||
|
||
Row 155 (#1649) makes `role.revoke` reach thread runs. A thread instance has no `role_claims`
|
||
row, so a decision to revoke it records a null holder run, and the check compares it with the
|
||
current holder read as null too. The chief of staff's reason path and a decision the human
|
||
resolved through the CLI both get past the assistant's protection (Q169), and the end then writes
|
||
a `revoke` row in `thread_claims`, with its decision or reason, for every thread of the instance
|
||
whose latest claim is held. The human does the same from the Console with `assistant.stop`, for
|
||
the threads they made. A revoked run can't claim its thread again (`run-revoked`). A stuck
|
||
thread run can still be stopped another way: the launcher stops it once the thread is idle, the
|
||
human stops it with `mosaic stop <run>`, and the run's end releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
|
||
|
||
### Sessions (row 264a, #1769)
|
||
|
||
Schema `3l` puts sessions below a thread. `sessions` and `session_defaults` are new and
|
||
append-only. `thread_claims`, `message_threads` and `thread_opens` each gain a `session`
|
||
column, and `thread_claims` gains `generation`, the claim's number in its session counting
|
||
from 1. A release or revoke carries its claim's number.
|
||
|
||
Every thread has a default session, the latest `session_defaults` row (D3). `thread.create`
|
||
writes one with a random id. The 3k→3l step gives each existing thread a default session whose
|
||
id is the thread's own, named `default` with op `legacy-3l`, and files every old claim, message
|
||
and open under it. A thread verb, or a `bindLaunch` record, that names no `session` works the
|
||
default, so a client from before 3l keeps working. A `session` from another thread refuses
|
||
`session-not-found`, and a non-thread role that names one refuses `thread-not-allowed`.
|
||
|
||
The thread rules above hold per session. One run holds a session at a time, and a run holds at
|
||
most one session in its business, while sibling sessions of a thread may each have a holder.
|
||
A thread run receives and replies in its own session, and the chief of staff's reply returns
|
||
to the session that sent the work. `threadsState` and `threadIdle` keep one row per thread,
|
||
computed on its default session; 264b's launcher starts the others.
|
||
|
||
A thread run's claim fences by generation (T2). A run acts only while its session's latest claim
|
||
row has the generation of the run's own latest claim. After a release or revoke, once another run
|
||
claims the session, the old run's `decision.withdraw`, `message.receive` and `message.read`
|
||
refuse `not-holder` and write nothing.
|
||
|
||
`thread.create` writes the default session with op `default`, so a `session.create` retry naming
|
||
`default` with op `default` answers with it. A migrated thread's default has op `legacy-3l`, and
|
||
the same retry there refuses `session-exists`.
|
||
|
||
The triggers check that a session has its thread's business, instance and owner, that its
|
||
predecessor and any default, claim, message or open name a session of the same thread, and that
|
||
a claim's generation is the one its session expects. `delivery_same_session` replaces
|
||
`delivery_same_thread`. `sessions_id_not_thread` and `threads_id_not_session` keep a session id
|
||
from naming another thread and a thread id from naming a session. A 3k engine refuses a 3l
|
||
store with `schema-mismatch`.
|
||
|
||
### Agent state (row 59, #1548)
|
||
|
||
For a human or reader session, `agents` derives each held role's state from
|
||
records the bus already keeps. Nothing new is written and no schema changed.
|
||
|
||
- `working` is `{messages, since}` or null: messages to the role that the
|
||
current holder received and hasn't marked read, and when the oldest was
|
||
received. The runner receives a batch, takes one turn per message, replies
|
||
and marks it read, so working means a turn is under way or queued.
|
||
- `inputNeeded` lists the role's open decisions routed to the human, gated
|
||
first and then oldest, as the Inbox orders them. Each item is
|
||
`{id, question, class, blocking, task_ref, at}`. Seen is still open.
|
||
- `state` is `working` when `working` is set, else `waiting` when
|
||
`inputNeeded` is nonempty, else `idle`. Working wins over waiting, as on
|
||
the control board.
|
||
- `stranded` lists messages to the role that an earlier holder was delivered
|
||
and never read, as `{id, from, run, at}`: the sender (`human` for the CLI),
|
||
the run it was delivered to, and when. Oldest first, at most 50, `[]` when
|
||
none. The bus neither redelivers, routes nor marks these read, and the
|
||
released run can no longer read them, so they stay listed. A person who
|
||
still needs one sends it again as a new message.
|
||
|
||
Why the bus and not a seat-to-role map: the business file's `holder` is a
|
||
display name, and the claim's holder is a run, not a seat, so no committed
|
||
data joins a seat to a role. The bus already has the claim, the deliveries
|
||
and the decisions.
|
||
|
||
Limits: a message routed but not yet received shows idle. A reply that fails
|
||
for a reason other than a gone session leaves its message unread, so the role
|
||
stays working; `since` shows how long. No error or offline state is derived.
|
||
A new holder doesn't inherit the previous holder's unread deliveries;
|
||
decisions stay with the role. Unheld and released roles aren't listed, so a
|
||
message stranded for a role nobody holds shows once a run claims the role.
|
||
|
||
Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
|
||
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
|
||
`thread.claims` takes no arguments and lists the held thread claims in the
|
||
business, oldest thread first, as `{thread, instance, holder_run, harness,
|
||
at}`. A claim is held while its session's latest `thread_claims` row is a
|
||
claim (row 264a), so a released or revoked session drops out and a thread
|
||
with two held sessions lists twice. Human and reader sessions
|
||
only: a role session refuses `human-required` and the tester
|
||
`tester-refused`. The result carries the thread id but never its name, which
|
||
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
|
||
after the roles; `mosaic agents --json` and `--tester` print `agents` alone.
|
||
|
||
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
|
||
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
|
||
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
|
||
RESULT, INFO, DECISION, REACTION and BLOCK (row 143). `staff.request` also writes STAFF-REQUEST (row 140);
|
||
`message.send` and `thread.send` refuse that class. References cannot cross businesses.
|
||
|
||
A BLOCK answers a spec notice (Spec review notices, below). `message.send` accepts it only from a
|
||
pm or cto instance (`block-sender`), only to the chief-of-staff instance (`block-recipient`; the
|
||
assistant may hear only from the user and the chief of staff, REQ-ASSIST-2), and only with
|
||
`in_reply_to` naming a notice the bus sent that sender (`block-reply-required`). The BLOCK sits in no
|
||
thread, because a message may join only a thread whose instance sent or received it. When the chief of
|
||
staff relays to the assistant in reply to the BLOCK (or to anything on a chain back to the notice), the
|
||
reply chain reaches the notice, and the relay is filed in the user's most recently opened thread of the
|
||
notice's project whose instance is the assistant. With no such thread the relay is refused
|
||
(`thread-required-reply-to-thread-message`). `thread.send` refuses the class.
|
||
|
||
Routing is immutable at raise: within-role closes atomically with `choice`,
|
||
cross-role routes to the configured delivery arbiter (or technical arbiter with
|
||
`domain:'technical'`). A gated raise that cites the project's current spec commit routes the same way,
|
||
to the arbiter for its domain (PRD 1.5 REQ-DEC-2, row 139, #1633). If that arbiter is the raiser,
|
||
resolution routes to human while the decision keeps its class. An uncited gated raise routes to human.
|
||
Unlisted actions are gated. The hard waits (`HARD_WAITS`, REQ-WAIT-1: `spend`, `data.delete`,
|
||
`network.expose`, `account.create`, `credential.mint`, `message.external`, `git.push.protected`,
|
||
`deploy`) always route to human, cited or not, and cannot appear in authority maps. An action is a hard
|
||
wait unless the broker's `ROUTED` list names it (REQ-WAIT-3), and both lists match the business
|
||
vocabulary. `git.merge.protected`, `policy.change`, `prd.approve` and `role.revoke` left the gated-only
|
||
list in 1.5, so a role file may grant them. Under Jason's ruling Q169 (PRD 1.6), a `role.revoke` whose
|
||
target's definition is in `END_PROTECTED` (the assistant) is still gated at every class and routes to
|
||
human, cited or not, so no arbiter resolves it. A blocking decision
|
||
requires a strict task ref. `role.launch` is gated unless the business launch
|
||
block's `by` names the acting role and refused when launch state is revoked. S6 still owns launch allowlists,
|
||
capacity and actual spawning.
|
||
|
||
Whoever closes a decision messages the raising role, so a cross-role or gated raise also needs the
|
||
route to be allowed to message the raiser under the role definitions' messaging lists (row 133,
|
||
#1627). In practice the raiser's `receiveFrom` must list the arbiter's definition, or `human` for a
|
||
gated route. Otherwise the raise refuses `decision-route-refused`, logs `action.refused` and stores
|
||
nothing. A within-role raise is exempt. The close itself isn't checked again; the business validator
|
||
refuses an arbiter whose role definition declares messaging lists.
|
||
|
||
`decision.raise` checks its choices in this order, and each of these codes names one field:
|
||
|
||
| Code | Field | Refused when |
|
||
|---|---|---|
|
||
| `invalid-options` | `options` | not an array, fewer than 2 or more than 9 options, an option whose `key` isn't 1 to 160 letters, digits or `_.:/-` starting with a letter or digit, or whose `text` isn't a string of 1 to 1024 characters with no NUL, or duplicate keys |
|
||
| `invalid-recommendation` | `recommendation` | the value isn't exactly one option key; `"yes: a reason"` refuses, so the reason goes in `question` |
|
||
| `invalid-approval-choice` | `approvalChoice` | the value, or the default `yes` when it is omitted, isn't an option key |
|
||
|
||
The options come first, so a bad option list refuses `invalid-options` even when the recommendation also misses.
|
||
An option that isn't an object with exactly `key` and `text` refuses `invalid-request`, like any other malformed argument.
|
||
|
||
Authorization requires the resolved option identified by `approvalChoice`
|
||
(default `yes`, which must exist), the same raiser role instance, action and
|
||
target. Row 136 (#1630): the approval binds to the decision, the business and
|
||
the role instance (`raised_by_role`, the key in the business's `roles` map),
|
||
not to the run that raised it, so it outlives a restart and any run of that
|
||
instance holding its claim may consume it. A decision raised by a thread run
|
||
binds to that run's thread too: only a run holding the same thread consumes
|
||
it, and a decision a thread instance raised before row 121 matches no thread
|
||
run. Another instance, or the same instance in another thread, refuses
|
||
`decision-mismatch`. `role.revoke` still requires the target's holder run
|
||
recorded at the raise.
|
||
Inbox projections expose `{action,target,approvalChoice}` as `authorization`:
|
||
**S4 must display this context when offering resolution**, not infer approval
|
||
from recommendation or option position. Any other option is a refusal, even if
|
||
the decision is resolved. Every decision-backed authorization is single-use,
|
||
including cross-role approval (lead decision 64). `authorize`, `message.send`
|
||
and `role.revoke` share one authority-and-consumption helper. Each caller keeps
|
||
the consumption check, `action.allowed` event and any broker-owned effect in one
|
||
transaction.
|
||
A later use through any of those paths, by any run and including after
|
||
restart, refuses with `decision-consumed`. The `decision.raise` context event does not consume approval.
|
||
A failed transaction rolls consumption back. A mismatch between the decision's
|
||
recorded class and current policy refuses with `decision-mismatch` before use,
|
||
in either direction. Within-role `authorize` calls without a decision retain
|
||
their existing behavior; explicitly supplying a decision to `authorize` subjects
|
||
it to the same checks.
|
||
|
||
Every agent `message.send` writes `action.allowed`, including within-role sends
|
||
without a decision; its subject is the recipient role. On a within-role send,
|
||
`decision` is a citation: the broker checks that it exists in the same business
|
||
and stores it in `messages.decision`, but omits it from `action.allowed`. An open,
|
||
unresolved or already-consumed decision can be cited repeatedly, without class
|
||
matching or consumption. If sending is not within-role, `decision` supplies
|
||
authority and the full matching, resolution and single-use checks still apply.
|
||
|
||
A consumed approval is not an exactly-once external service operation receipt;
|
||
an uncertain external outcome must not be retried with that approval. S3/S6
|
||
must implement their own external-operation identity and uncertain-outcome
|
||
handling. Raising/superseding and resolving are transactional. `supersedes` names an
|
||
open decision raised by the same role instance, in the same thread when it was
|
||
raised in one, by this run or an earlier one; otherwise the raise refuses
|
||
`supersede-refused` and withdraws nothing.
|
||
|
||
### Decision close messages (row 115, #1609)
|
||
|
||
A `decision.resolve` or `decision.withdraw` by anyone but the raising run sends
|
||
the raising role one message, written in the same transaction as the close, so
|
||
a refused close sends nothing. It goes to `raised_by_role` with class
|
||
`DECISION`, `decision` set to the decision's id, and no `in_reply_to` or
|
||
`corrects`. A human close is sent `from_role 'human'`, `from_run 'human-cli'`;
|
||
a role close is sent from the closing role and run. It is routed like
|
||
`message.send`'s: to the role's current holder, or with no holder until the
|
||
next claim routes it. The body is
|
||
|
||
```
|
||
Decision <id> on <action> was resolved by <by>: <choice> (<option text>).
|
||
Decision <id> on <action> was withdrawn by <by>.
|
||
```
|
||
|
||
where `<by>` is the close's `decision_events.by` (the human's name or the
|
||
closing run). A close with a note adds `Note: <note>` on its own line.
|
||
|
||
No message goes out for `decision.seen`, for the raising run's own withdraw, for
|
||
a supersede (a withdraw by the raising role instance), or for a within-role decision,
|
||
which closes inside `decision.raise`. `message.receive` gives a human close's
|
||
message the close's `human.input` answer event as its `request`, so the role's
|
||
turn cites it as it cites a human instruction; a role close's `request` is
|
||
`null`. The message also appears in the decision's `trail`.
|
||
|
||
### Launch notices (row 146, #1640)
|
||
|
||
A `message.send` whose target no run holds tells the business's `launch.by`
|
||
instance, once, in the same transaction as the message. The notice is an
|
||
ordinary message: `from_role '_bus'`, `from_run 'bus'`, to `launch.by`, class
|
||
`RESULT`, `in_reply_to` the waiting message, no `decision`. It is routed like
|
||
any other, so a stopped `launch.by` gets it at its next claim. The body is
|
||
|
||
```
|
||
Message <id> from <sender> to <role> waits: <role> has no running session. Launch <role> within the launch block's limits, or tell <sender> why not with message.send in_reply_to <id>.
|
||
```
|
||
|
||
When the role isn't one of `launch.instances` (row 152, #1646), `launch.by`
|
||
can't launch it, and the body says so:
|
||
|
||
```
|
||
Message <id> from <sender> to <role> waits: <role> has no running session and isn't in the launch block's instances, so a launch of <role> is refused. Tell <sender> why not with message.send in_reply_to <id>.
|
||
```
|
||
|
||
`launch.by` is still told in that case. The message stays stored and reaches
|
||
the role at a later claim, and the Console shows the notice as a reply to the
|
||
human's message; it doesn't read the `waiting` field.
|
||
|
||
`_bus` can't be a role: an instance name matches the business `ID_PATTERN`,
|
||
which starts with `[a-z0-9]`. So `message.send` to `_bus` refuses
|
||
`unknown-role`, and a caller can't set `from_role` or `from_run` (unknown keys
|
||
are `invalid-request`). The class is `RESULT` because the runner sends an
|
||
automatic RESULT reply to every other class, and the bus would refuse a reply
|
||
to `_bus`; the runner would log it and skip the `message.read`. On a RESULT
|
||
the runner takes one turn, sends nothing and marks it read.
|
||
|
||
The result's `waiting` is `{role,launcher,told,launcherRunning,launchable}`:
|
||
the target, `launch.by` (`null` with no launch block), whether the notice went
|
||
out, whether a run holds `launch.by` (`null` with no launch block), and whether
|
||
the target is one of `launch.instances` (`null` with no launch block; `false`
|
||
for `launch.by` itself, which `instances` can't list). `launchable` came with
|
||
row 152; a reader that ignores it sees row 146's shape. No `waiting`
|
||
field means a run held the target, or the message went to the human, the
|
||
tester, or a thread instance inside its thread (row 121's thread launcher starts
|
||
that session). `told` is `false` and no notice goes out for a message to
|
||
`launch.by` itself, a message from `launch.by` (the result tells it), or a
|
||
tester's (a PM test authorizes nothing).
|
||
|
||
Only `message.send` sends a notice, and only for the message it stores, so a
|
||
re-route at a later claim, a broker restart or a release and claim sends none.
|
||
A run that is launched but hasn't claimed counts as no holder, so the notice
|
||
can go out while it starts; the launcher's `instance-running` refusal stops a
|
||
second launch. The Console's replies under a human's sent message include the
|
||
notice, since it is `in_reply_to` that message. `review.request`,
|
||
`thread.send` and a decision close's message send no notice.
|
||
|
||
## Human and reader paths
|
||
|
||
`src/human-cli.mjs <socket>` is S4's transport shim, not another product CLI.
|
||
It reads `{business,verb,args}` from stdin and prints JSON. It re-execs once with
|
||
a fresh nonce in its launch environment; the broker checks its exact script
|
||
path, UID, PID/start time, nonce and ancestry on every request. Missing process
|
||
proof, ancestry loops/disappearance, a known launch ancestor, or inherited
|
||
Mosaic/Pi/Claude/Codex run markers refuse. No claimed human name is accepted from
|
||
the socket. The configured business human is bound only after these checks.
|
||
An ancestor whose environment returns EACCES skips only the environment-marker
|
||
check: command and launch-registry checks still apply. All other read failures
|
||
refuse, and the CLI itself must have a readable nonce environment.
|
||
Reading `/proc` is Linux-specific. Reparenting and malicious same-UID PID/nonce
|
||
impersonation remain within the explicit cooperative trust limit.
|
||
Lead decision 62 accepts this limit for slice 1: the proof refuses a direct
|
||
agent invocation, but deliberate detachment and environment clearing (as in
|
||
Darkwing's `setsid -f env -i` probe) can obtain human authority. S6 must close
|
||
this gap for managed agents with their own PID namespace or user and no human
|
||
socket mounted. That isolation is not supplied by this S2 package. T3 seats
|
||
must not invoke the human CLI or work around its proof.
|
||
|
||
Human resolutions and launch toggles append `human.input`. Agent capabilities
|
||
and read-only WebUI capabilities cannot reach these operations. Reader
|
||
capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies/messages for one
|
||
business; they do not confer mutation or resolution authority.
|
||
|
||
`decisions` (row 57, #1546) is for human and reader sessions only; a role
|
||
session gets `human-required`, so it can't list another role's open
|
||
decisions. It takes `{list}`, nothing else. `roles` lists the business's open
|
||
decisions routed to a role, never to the human, oldest first. `recent` lists
|
||
its last `RECENT` (50) closed decisions of every route, newest close first,
|
||
each with `closed`, the closing `decision_events` row. Every row is the
|
||
`inbox` view of the decision: the `decisions` row `trail` gives the same
|
||
session for its id, with `blocking` as a boolean and `authorization` from the
|
||
raise's `action.allowed` event, which `trail` also gives. So the lists add no
|
||
data a reader couldn't already read one id at a time; they add the listing.
|
||
|
||
`credentials` (row 60, #1549) is also for human and reader sessions only; a
|
||
role session gets `human-required`. It takes no args. Each row is one
|
||
credential the running host loaded for the session's business, in load order
|
||
(role instances, then the tracker sync identity):
|
||
|
||
- `service`: `gitea` or `vikunja`.
|
||
- `account`: the role's `tracker.bot` for Vikunja, the sync identity's `bot`
|
||
for its row, `null` for Gitea.
|
||
- `role`: the instance, or `tracker sync` for `business/@sync`.
|
||
- `dateKind` and `date`: `rotateBy` for Gitea, `expires` for Vikunja.
|
||
- `state`: `credentials.status()` against the host's clock at the request:
|
||
`valid`, `expiring` (under 7 days), `expired`, or `rotation-due` for Gitea.
|
||
|
||
The broker gets only that metadata function from the runtime, never
|
||
`credentials.use`, and the result passes the known-token check like every
|
||
other read. The rows match the Business view's credentials in the WebUI
|
||
(`packages/webui/src/reads.mjs`), which reads the business file instead: the
|
||
file says what the host should load, this read says what it did load. The
|
||
host takes the dates and accounts from the business file at startup, and
|
||
only the state moves with the clock. So the two differ only when the
|
||
business file changes after startup: a date, an account, or a credential
|
||
added or removed. Rotating a token file without editing its date changes
|
||
neither.
|
||
|
||
`replies` (row 81, #1575) is also for human and reader sessions only; a role
|
||
session gets `human-required`. It takes `{id}`, nothing else, and returns
|
||
the business's messages whose `in_reply_to` is that id, in broker write
|
||
order, at most `REPLIES` (50). Each row is the `messages` row `trail` gives
|
||
the same session for the reply's id. An id that names no message in the
|
||
session's business refuses `message-not-found`, as `message.send` does for
|
||
`in_reply_to`; `trail` returns an empty list for such an id instead. The
|
||
read writes no `deliveries` row, so a reply the WebUI's Sent receipt lists
|
||
still reaches the human's next `message.receive` (`mosaic talk`). It is a
|
||
separate verb rather than a `trail` field so `trail`'s shape and its
|
||
empty-list answer for unknown subjects stay as they are.
|
||
|
||
`messages` (row 94, #1588) is a read for human and reader sessions only.
|
||
It takes `{limit?, after?}`; `limit` defaults to 50 and must be an integer
|
||
from 1 to 200. It returns `{messages, total, unread, next}`. Messages are
|
||
only those addressed to `human` in this capability's business, newest
|
||
first by immutable sequence. Each message adds `read`, true only when a
|
||
read receipt exists. Counts cover the entire feed. `next` is the last
|
||
returned message id if another page exists, otherwise null; pass it as
|
||
`after` to read older messages. A cursor that is not in this human feed
|
||
refuses `message-not-found`; malformed arguments refuse `invalid-request`.
|
||
Roles get `human-required`. A successful read records nothing, so Console
|
||
history reads never prevent a later `message.receive` from delivering it.
|
||
|
||
`instances` and `instances.set` (row 165, #1662, Q171, REQ-ROLE-3) are the human's and,
|
||
since row 175 (#1673), the tester's. A role session and a reader get `human-required`, and the chief of staff
|
||
does too: neither verb is in `ORGANIZATION_KEYS`, so `organization.set` refuses the instance
|
||
keys with `invalid-request` before the writer runs (sage-0918). `instances` takes no args and
|
||
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
|
||
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
|
||
loaded at start, so the change counts only after a restart that needs Mos's go or Jason's
|
||
hand. Tests: `packages/bus/tests/instances-verb.test.mjs`.
|
||
|
||
`routes` and `routes.set` (row 65, #1558, mos-0840) are human-only the same way, except that a
|
||
tester may send `routes.set` (row 175), logged with actor `tester`; its `routes` read refuses
|
||
`tester-refused`. Both are outside
|
||
`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 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
|
||
or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mjs`.
|
||
|
||
A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs <socket>` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`. Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`, `limits`, `plugins`, `instances`, `project.list` and `thread.list`, each answering what the human's answers (`thread.list` lists the tester's own threads since row 177), and the writes `name.set` (`by` `tester`), `project.create` (`created_by` `tester`), `plugins.set`, `instances.set` and `routes.set`, each with every check the human's write runs and the organization receipt's actor `tester`. `TESTER_VERBS` and `TESTER_ORGANIZATION_VERBS` in `src/broker.mjs` list them; every other verb refuses `tester-refused`, among them `limits.set`, `organization.set`, `organization.revision`, `routes`, `decision.resolve`, `launch.revoke`, `launch.restore`, the credential verbs and `project.file.save`. Tests: `tests/tester-writes.test.mjs`. It sends only to a role, with any body and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`.
|
||
|
||
Row 177 (#1675, Q173) gives the tester threads of its own. `thread.create`, `thread.open`, `thread.send`, `thread.messages` and `assistant.stop` join `TESTER_VERBS`. A tester's thread has owner `tester` and its opens `by` `tester`; the human and the tester each reach only their own threads (`thread-not-owned`), and each `thread.list` lists only its own, so the human's list never shows a tester thread. Row 182 (#1680) holds a tester's `message.send` to the same rule. Its thread comes from the `in_reply_to` chain, or from a spec notice on that chain (row 143), and when that thread isn't the tester's the send refuses `thread-not-owned` and stores nothing. The code names a thread the tester never named, so the place to look is `in_reply_to`. A role's reply up a chain into a tester's thread still lands there, which is how the chief's answer comes back. A business whose `human` is `tester` refuses `invalid-business` at start, since the two would share threads. A tester's `thread.send` is stored `from_role 'tester'`, `from_run 'tester-cli'`, and returns `request: null` with no `human.input`, as its `message.send` does. Row 120's lists can't name the tester (`invalid-messaging`), so in a thread owned by `tester` the broker reads the tester as `human`: the tester's `thread.send` passes when the instance would hear the human, and the thread's run answers `to: 'tester'` (with `in_reply_to`, as above) when it could answer the human. The run's own `message.send` to `human` refuses `tester-thread-human`, and so does its `decision.raise` when the route comes out `human` (an uncited gated raise or a hard wait), before anything is stored. So neither its replies nor its decisions reach the human's inbox; the human sees its messages in the trail as `tester` traffic. That covers the run's own sends and raises only: the assistant's lists still let it message the chief of staff, a raise routed to the chief as arbiter stays allowed, and the chief can message the human. That is no new reach, since the chief has no lists and a tester's `message.send` already reaches it. The tester's `assistant.stop` revokes the runs of its own threads only, recorded `by` `tester`; the human's stop never touches them. Thread names are unique per project, not per owner, so a tester's `thread.create` with a name the human already used in that project answers `thread-exists`; that tells the tester the name exists and nothing more. A tester reads a reply with `message.receive` (what `talk --tester` and `follow --tester` use) or, in its own thread, `thread.messages`. `inbox --tester --json` prints decisions only, and the `messages` view reads the human's address, so neither shows a reply to the tester (sage-1148, #1673 comment 29334). Tests: `tests/tester-threads.test.mjs`.
|
||
|
||
## Shared reads and adapter boundary
|
||
|
||
```js
|
||
import {Client} from './src/client.mjs';
|
||
import {views} from './src/views.mjs';
|
||
const read = views(new Client({path,cap}));
|
||
await read.inbox(); await read.tasks(); await read.agents();
|
||
await read.trail('vikunja:3/41');
|
||
await read.decisions('roles'); await read.decisions('recent');
|
||
await read.replies(messageId);
|
||
await read.messages({limit: 50});
|
||
```
|
||
|
||
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
|
||
The tasks projection reports the schema’s `task_current` view, including tombstones; the caller
|
||
must not pretend a missing/inaccessible task is open or completed. Each row adds two keys,
|
||
computed at each read and never stored. `priority` is the current row's `fields.priority`, null
|
||
for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
|
||
first `task.created` for the same Vikunja task id in the business, so a task moved to another
|
||
project keeps it; null for a task the stack didn't create. Scoped rows also carry
|
||
`project_key` and `requirement_snapshot` from that immutable creation evidence;
|
||
these are optional filters on `tasks` and appear in the task's trail. No scope is
|
||
invented for older tasks. `checkPush(cap,task_ref,project)` checks that binding
|
||
before bundle admission; `recordPush` repeats it, as do review/export consumers.
|
||
|
||
A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full
|
||
projection is too large for a tool reply on a real board. It leaves out tombstones and pages in
|
||
`task_ref` order (as text). Each task is `{task_ref,title,done,bucket,assignees,labels,priority,
|
||
requirement,changed_at}`: no description; `assignees` are the roles whose tracker bot is
|
||
assigned, sorted, so a person's assignment shows as none; `changed_at` is the row's `at`, as the
|
||
Console shows it. Filters: `done` (boolean), `assignee` (a role of the business), `label` (a
|
||
label id), `requirement` (`REQ-[A-Z]+-[1-9][0-9]*`). `limit` is 1 to `TASK_PAGE_MAX` (200),
|
||
`TASK_PAGE` (50) by default. `after` is a task ref, which needn't be on the board; the page holds
|
||
the matches past it. `total` counts every match, `next` is the `after` for the following page,
|
||
null on the last.
|
||
|
||
Trail joins
|
||
human request, messages/deliveries, decisions/resolutions, task snapshots and
|
||
session/claim evidence. Broker timestamps advance monotonically across all
|
||
its writes, including after reopen; per-table `seq` stays the primary table
|
||
order. Timestamps can run slightly ahead of wall time during bursts. S3's
|
||
service `updated` and `read_at` remain observations, not broker write order.
|
||
|
||
Trusted in-process adapters can call `broker.authorize(cap,action,{decision,
|
||
target})` and `broker.recordEvent(cap,{kind,body,subject})`. These APIs are never
|
||
socket verbs. `authorize` verifies the current holder and appends action evidence;
|
||
it is not itself an external service executor. An adapter must not treat a prior
|
||
check as authority after an await/holder change. Task/credential/poll registration
|
||
and serialized external operations are S3's integration work, not a generic
|
||
user-defined-handler endpoint in S2. `recordEvent` stamps identity, uses a
|
||
transaction, and cannot emit human input or human launch control events,
|
||
or the review loop's `review.requested` and `review.verdict` events, or an
|
||
`action.allowed` for `git.push.working`. Only the verbs and host ops above
|
||
write those. The review loop adds no event kind and no schema change.
|
||
|
||
`authorize(cap,'task.close',{target,verdict})` checks the verdict on a task
|
||
with a recorded push, inside the same transaction (row 93): it
|
||
must be the id of a `review.verdict` event on that task
|
||
(`verdict-not-found`), with verdict `approve` (`verdict-not-approve`), for
|
||
the task's latest pushed commit (`verdict-stale`). A refusal rolls the
|
||
`action.allowed` back. On a task with no push the verdict stays a citation
|
||
the broker doesn't check.
|
||
Task refs in action/review bodies require matching `subject`; all task events
|
||
are also guarded by the pinned schema. `task.created.body.request` must name an
|
||
existing same-business human input, with a requirement ID.
|
||
|
||
Credentials are keyed by `business/instance` in the runtime; the distinct read-only
|
||
tracker sync identity uses `business/@sync` and has no agent capability. Trusted service
|
||
adapters use `runtime.credentials.use('business/instance','gitea', callback)`;
|
||
only that in-process callback receives the token, never the socket client.
|
||
Callbacks must not spawn children with it, log it or persist it. Errors are
|
||
replaced with `service-failed`. Known-token content in returned values, keys,
|
||
request payloads and broker evidence refuses. This is not general-purpose secret
|
||
redaction or protection against a malicious adapter encoding/exfiltrating values.
|
||
|
||
File refs are absolute, regular non-symlink files, owned, exactly 0600, outside
|
||
repo roots and dataRoot, bounded to 16 KiB. Open uses O_NOFOLLOW and verifies
|
||
inode/device before reading. Environment refs are supported; neither kind is
|
||
forwarded to agents. Tokens use the opaque ASCII alphabet `[A-Za-z0-9._~-]`,
|
||
with a minimum of 16 characters and at most one terminal LF. Dates are strict YYYY-MM-DD: Gitea `rotateBy`,
|
||
Vikunja `expires`. Vikunja use refuses at midnight UTC on expiry. Gitea rotation
|
||
due is a warning state, not an expiry; `credentials.status()` reports metadata
|
||
only (valid, expiring, expired or rotation-due) for the S3 notifier. Tokens are read once
|
||
at startup and retained only in memory. Restart after a rotation. S3 owns live
|
||
scope probes and rotation/expiry notifications; S2 never probes real services.
|
||
|
||
## Schema and verification
|
||
|
||
`schema.sql` is v3m, SHA-256
|
||
`d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9`.
|
||
It has 23 tables, 90 triggers and five views. Source digest and a trusted full
|
||
sqlite_master digest are checked at open, alongside schema version `3m`, WAL,
|
||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||
|
||
`task_comment_cursors` holds append-only `(business, task_id, last_comment)` rows.
|
||
The latest sequence wins, and a database guard requires the ID to increase.
|
||
Trusted sync commits a new cursor and its comment event in one transaction;
|
||
agent snapshots and the public task digest/projection are unchanged. An empty
|
||
poll writes no cursor. Growth is at most one row per observed new comment, and
|
||
usually one per batch. IDs include bots, but bot comments never contribute to
|
||
the external-comment count. Task IDs survive project moves within a business.
|
||
|
||
Normal startup refuses a database at any earlier version. The explicit offline command is:
|
||
|
||
```sh
|
||
node packages/bus/src/migrate-schema.mjs /absolute/dataRoot
|
||
```
|
||
|
||
Mos runs it only with the bus stopped, in an approved downtime/re-pin window,
|
||
after a consistent backup. Development seats run it only against disposable
|
||
fixtures. It takes the same exclusive writer lock, checks the pinned source
|
||
schema and metadata, WAL, timestamps and integrity, then runs named migration
|
||
steps in order. Each step atomically adds its DDL, changes only schema version
|
||
and digest metadata, restores the metadata update guard, and verifies its target
|
||
digest. The 3b→3c step also records an immutable `comment_cursor_epoch`. The 3c→3d
|
||
step (row 123) replaces the `role_revoke_needs_decision` trigger: a revoke needs a resolved
|
||
decision or a reason. Storage accepts a revoke with a reason and no decision, so only the
|
||
broker decides who may revoke that way (`role.end`, in the `role.revoke` row above). The
|
||
3d→3e step (row 121) adds the project, thread and thread-claim tables and changes no row.
|
||
Messages from before 3e have no thread. A thread run receives only its own thread's
|
||
messages, so an undelivered one to a thread instance is never delivered. The step leaves
|
||
those messages as written and does not attach them to a thread. The command prints one
|
||
line per business and recipient role with the count of undelivered messages it kept,
|
||
before the ready line. The human re-sends any that were meant for a thread instance with
|
||
`thread.send`. Prior steps stay pinned under `src/migrations/`; later versions append a step. A completed
|
||
step remains valid if a later step refuses; rerunning resumes from that version.
|
||
Already-current migration is a validated no-op. Missing stores refuse.
|
||
|
||
Before a task has a cursor, comments at or after the epoch minus 60 seconds are
|
||
eligible (rounded down to tracker seconds). This deliberately favors a possible
|
||
cutover duplicate over dropping comments with up to one minute of tracker clock
|
||
lag. Older historical comments are baselined without events; a tracker clock
|
||
more than a minute behind may still lose initial comments, and a clock ahead can
|
||
replay already reported comments once at migration. Once an ID is stored, only
|
||
IDs govern freshness, including after a long outage. No other event semantics,
|
||
tracker client, or pruning command changes here.
|
||
|
||
Run `node --test packages/bus/tests/*.test.mjs`. Tests use private disposable
|
||
directories, fixture token files, fake process tables, real local child brokers,
|
||
real SQLite and real Unix sockets. The prototype cases are assertions, not
|
||
print-only observations. Crash recovery deliberately tests refusal, not automatic
|
||
repair. Positive human process classification uses synthetic process ancestry;
|
||
real `/proc` reading and real-process refusal are separate tests. There is no
|
||
claim that an agent's test run proves a live outside-agent human terminal.
|
||
|
||
Lead decision 60 also fixes timestamp precision. Before every commit (including
|
||
standalone trusted `Store.run` inserts) and on reopen, the store refuses a row
|
||
whose `at` or non-null `read_at` differs from SQLite's canonical millisecond UTC
|
||
form, `YYYY-MM-DDTHH:mm:ss.sssZ`. It rolls back the entire transaction, not just
|
||
the malformed row. This deliberately scans the timestamp columns in this first
|
||
core; an indexed/insert-scoped optimization requires equivalent checks and tests.
|
||
The schema itself remains byte-identical to v3b. S3 must normalize service read
|
||
start times to this form before storing snapshots. External service `updated`
|
||
is not a broker timestamp; its interpretation remains S3's responsibility.
|
||
|
||
## Project records and file-backed north-star specs (row 138)
|
||
|
||
The business's declared project resolves to its separate local records Git
|
||
repository (packages/project). `spec.read {project,commit?}` is available to a
|
||
held role instance, scoped to its business. It returns the committed
|
||
spec/north-star.md, its last file-changing commit, confirmation flag and up to
|
||
100 path-history entries with truncated=true if more exist. Optional commit is
|
||
a full reachable SHA-1; arbitrary revision expressions refuse. Uncommitted bytes
|
||
are ignored. Only author.name equal to business.human confirms a spec; a latest
|
||
agent-authored change is visible but unconfirmed, with no fallback to an older
|
||
human version. A later docs commit does not change the spec's file commit.
|
||
|
||
`decision.raise` optionally takes project, spec_path (spec/north-star.md) and
|
||
spec_commit together. The broker reads the current committed snapshot and
|
||
refuses a different commit (stale-spec), unconfirmed file (spec-unconfirmed),
|
||
or incomplete citation (invalid-spec-citation). Missing files and unsafe
|
||
repositories retain the project reader's refusal codes. A cited gated raise that
|
||
isn't a hard wait routes to the arbiter for its domain (REQ-DEC-2). Uncited raises
|
||
keep the existing synchronous validity and routing rules. Decisions store spec_path and
|
||
spec_commit; later Git commits never reopen or rewrite settled decisions.
|
||
|
||
`record.write {project,path,record,decision?}` uses the shared package writer.
|
||
It checks record.write authority, validates data and binds the author to the
|
||
session's definition.instance. No caller author/business identity is accepted.
|
||
Only docs Markdown and requirement/decision JSON are writable. spec/ always
|
||
refuses project-spec-human-only. The repository's exclusive writer lock refuses
|
||
concurrent writes with project-busy before they contend for Git's index lock.
|
||
All eleven v2 role definitions grant record.write within-role. Legacy researcher
|
||
(v1) and conductor-policy are unchanged. Narrowing limits still apply.
|
||
|
||
Git awaits run outside Store transactions. File commits and SQL receipts are
|
||
two effects: a commit followed by failure to record its authority receipt refuses
|
||
outcome-unknown and must be inspected, never blindly retried. The unchanged
|
||
socket request envelope is 64 KiB; package file validation remains 1 MiB. The server
|
||
awaits only spec.read, record.write and cited decision.raise and holds any
|
||
transient proof until completion. Other request paths stay synchronous.
|
||
|
||
Row 148 (#1642) adds three human-only verbs for the Console's spec and docs editor:
|
||
`project.files {project}`, `project.file {project,path}` and `project.file.save
|
||
{project,path,base,content}`. Each refuses an agent session and a reader
|
||
(`human-required`), refuses a tester `tester-refused` (row 175, #1673), and takes exactly those keys; the two file verbs refuse a path
|
||
other than spec/north-star.md or docs/NAME.md. `project.file` answers `{path,head,commit,content,
|
||
author,at}`; `project.file.save` answers `{path,commit}` after the project package's
|
||
`writeHumanFile` made its one commit, or a `stale-base` refusal that wrote nothing.
|
||
Every refusal is recorded as the others are. The save verb alone may carry a request
|
||
up to 1 MiB + 64 KiB (the client and the server both check it); every other verb
|
||
keeps the 64 KiB envelope. Tests: `packages/bus/tests/project-files.test.mjs`.
|
||
|
||
### Spec review notices
|
||
|
||
Row 143 (#1637, REQ-PROJ-4): a successful `project.file.save` of `spec/north-star.md` sends one
|
||
notice to every instance whose definition is `pm` or `cto`. It comes from `_bus` (run `bus`), class
|
||
RESULT, in no thread. The body begins `The human saved spec/north-star.md in project <key>: commit
|
||
<sha>, from the editor's base <sha>.` and asks the role to read the change with `read_spec`, check it
|
||
against settled decisions and work in flight, and answer the chief of staff once, in reply to the
|
||
notice: an INFO beginning "Accept", or a BLOCK naming each conflict. A BLOCK does not revert the commit.
|
||
|
||
Only that verb sends a notice. A save of a docs file, a refused or stale save, an agent's
|
||
`record.write` commit and a git commit made outside the Console send none. The notice is stored after
|
||
the commit; if storing it fails the save answers `outcome-unknown` and the commit stands. A notice to a
|
||
role no run holds waits and is delivered at the next claim. The broker launches nothing and does not
|
||
tell `launch.by`; the chief of staff's `launch` is how a pm or cto session starts. A business with
|
||
no pm or cto instance sends nothing. Tests: `packages/bus/tests/spec-review.test.mjs`.
|
||
|
||
The 3e→3f offline step adds nullable spec_path/spec_commit columns to decisions,
|
||
with nulls on existing rows. It changes no event or resolution and creates no
|
||
spec/confirmation tables. The 3e fixture is pinned from 26a0b0fab's tree; no live
|
||
store is used. Mos runs this step only in an approved stopped-bus/re-pin window,
|
||
as described in the candidate packet's MIGRATION.md.
|
||
|
||
The 3f→3g step (row 141, #1635, REQ-MSG-2) adds the append-only `instance_names` table,
|
||
its triggers, and a nullable `to_name` column on messages with nulls on existing rows. It
|
||
changes no other row. The 3f fixture, `tests/fixtures/schema-3f.sql`, is pinned from
|
||
4ebef2284's tree; no live store is used. The step runs only in its own stopped-bus/re-pin
|
||
window on Mos's go.
|
||
|
||
## Token accounting per run (row 124, REQ-LIMIT-4)
|
||
|
||
Schema 3h adds only the append-only usage_records table and its three protection
|
||
triggers. The 3g→3h step preserves all prior evidence; migration is explicit and
|
||
offline in Mos's re-pin window. The normal bus never auto-migrates an old store.
|
||
|
||
Schema 3i (row 126, #1620) drops the 3h reservation `account TEXT CHECK (account IS NULL)`
|
||
so a record can carry the run's account. SQLite cannot drop a CHECK, so the 3h→3i step
|
||
drops the three triggers, renames the table to usage_records_3h, creates usage_records
|
||
with text identical to schema.sql's, copies every row with its seq, carries the table's
|
||
sqlite_sequence value, drops the old table and recreates the three triggers. The rename
|
||
comes first because a renamed table's stored SQL quotes its new name, and the digest
|
||
compares that text. Every other column, CHECK and trigger is unchanged. The step runs in
|
||
one transaction; a failure leaves the store at 3h. It runs only through
|
||
`stack-release.sh activate … --migrate`, which backs up the store first; a 3h engine
|
||
refuses a 3i store. The 3h fixture, `tests/fixtures/schema-3h.sql`, is pinned from
|
||
81f26551b's tree; no live store is used.
|
||
|
||
Schema 3j (row 234, #1740) adds only new objects: the `hosts` and `host_grants` tables, which
|
||
hold enrolled hosts and host grants, and the `events_grant_actor` trigger. The trigger refuses an
|
||
`action.allowed` event whose `body.action` or `body.operation` matches `grant.*` unless its actor
|
||
is `actor_role` NULL with `actor_run` 'grant', the broker's grant writer (#436b section 7). The
|
||
step runs in one transaction, and a failure leaves the store at 3i. The 3i fixture,
|
||
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
|
||
covers the step and the trigger.
|
||
|
||
Schema 3k (row 236, #1742, #436b r5 section 7) adds only two tables: `gitea_users`, the Gitea
|
||
users the stack made (user, id and the nonce in their full name), and `gitea_grants`, the live Gitea
|
||
grants (user, token id and name, projects, rotate-by date and decision), whose key references its
|
||
`gitea_users` row. Each write goes in one transaction with a `grant.*` event. The step runs in one
|
||
transaction, and a failure leaves the store at 3j. `tests/gitea-migration.test.mjs` covers it.
|
||
|
||
The managed runner calls usage.begin before an actual harness turn and usage.end
|
||
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
|
||
The broker requires the bound role holder and its delivery, binds run identity,
|
||
and calls the runtime-injected accountant. These are runner protocol verbs,
|
||
not harness tools. The accountant calls configured usage sources only through
|
||
the plug-in loader. It durably baselines history, then appends one outcome per
|
||
source/run/turn. Replays return the recorded result without charging twice.
|
||
An absent/failed source, parse failure, missing baseline, transcript rewrite or
|
||
incomplete turn remains unknown. Unknown outcomes are logged with fixed codes,
|
||
never transcript text. A pending turn is unknown until its end arrives.
|
||
|
||
The business-scoped usage read is available to its human, reader and held role.
|
||
It returns total plus byRun, byInstance, byProvider, byModel and byAccount arrays.
|
||
Each total has input, output, cacheWrite, cacheRead and unknownTurns. Known
|
||
readings sum; unknown turns count distinctly by run and turn next to every total.
|
||
An exact aggregate beyond JavaScript's safe integer range is a decimal string,
|
||
not a rounded count or a policy ceiling. Row 126: a run's account is the one
|
||
the launcher bound it with (`launch.account.name` for a Claude Code run), kept
|
||
on the session and written on each usage record; a pi run's is null. The
|
||
account stays out of `session.launched`'s body. `mayLaunch` and `mustStop`
|
||
pass it to the limit checks in the subject. A session can edit its own transcript: this is a best reading, not proof.
|
||
No enforcement or resets are added. Settings reads through the same bus view.
|
||
|
||
## Human boundary (row 196)
|
||
|
||
A bounded Python stdlib helper receives only the accepted Unix socket fd and
|
||
returns pid, uid and gid. All three are validated as safe integers. Only pid and
|
||
uid select a principal. It runs for proof envelopes only, never cap traffic, with
|
||
a limit of 16 helpers, a one-second timeout and 256-byte result. Proof PID must
|
||
equal kernel peer PID. Human/user proofs retain the full agent ancestry walk.
|
||
Tester proofs deliberately admit agent seats and still refuse managed runs.
|
||
|
||
The trusted verifyPrincipal interface returns {kind,business,id} for one request.
|
||
User provenance requires the same-tree Console as direct parent and a distinct
|
||
user nonce. R3 supplies user mapping and checks the live grant on every request.
|
||
No role is accepted from a proof or cached. Caps are not returned to proof clients.
|
||
|
||
Every restart repeats interpreter safety checks and appends a 0600 startup receipt
|
||
to bus/peercred-starts.jsonl before listening. The receipt names the actual current
|
||
interpreter and copy. A safe OS replacement is accepted without comparing an old
|
||
activation hash. Unsafe interpreter or failed receipt append refuses startup.
|
||
Original activation evidence is never rewritten. The next authorized re-pin to a
|
||
different reviewed copy refreshes activation evidence. The worker Containerfile
|
||
does not run the bus and needs no Python change. Any future bus image must provide
|
||
this dependency or use a separately reviewed compiled helper.
|
||
|
||
Hostile processes on the instance uid remain outside this guarantee. In particular,
|
||
a detached agent can erase visible ancestry, and a same-uid process can tamper with
|
||
files, pipes or process memory where OS policy permits. Separate instance uids
|
||
narrow cross-instance reach, not reach within one instance.
|
||
|
||
The Console provenance option trusts verified code and process metadata, not an independent
|
||
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
|
||
|
||
## Durable instance processing control
|
||
|
||
`processing.status` is an authorized read. `processing.halt` ({}) and
|
||
`processing.resume` ({generation}) require the existing human proof before
|
||
validation or effects. They are management verbs independent of channels and
|
||
never tester or role grants. The strict state under the resolved dataRoot's
|
||
control/ directory is authoritative; reserved processing.halt/resume/stop
|
||
actions mirror its durable transitions and write-once stop receipts with
|
||
verified human and generation. Generic event writers cannot forge them. No
|
||
schema change is needed. Invalid state refuses work and leaves control reads
|
||
and human recovery reachable. Halt then resume repairs an invalid leaf; an
|
||
unsafe parent is not automatically repaired.
|
||
|
||
The bus remains a read/control service while new agent work, tracker polling,
|
||
notifications and launches pause. Narrow exit cleanup remains possible. Every
|
||
managed starter carries a generation and publishes identity before releasing
|
||
work. Stops inspect root/boot/start identity or generated container id/labels,
|
||
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.
|