A recommendation that isn't exactly one option key now refuses invalid-recommendation, checked after the options, instead of sharing invalid-options with bad option lists. The raise_decision tool text says recommendation is one key with the reason in question, and that approvalChoice defaults to yes. The bus README lists the three codes, the field each names and the check order; a malformed option object refuses invalid-request. No schema change. Candidate manifest 013c82c2fd99d23d92f6bed543a788aa04ff97b164019ccb15252ae808b4138e (Darkwing round 2, packetfee89d52f), BASE96656dd21. Approved by Filbert (comment 28383) and Sage (comment 28384). Sage gate rerun atf63fc20e4plus the candidate passed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
451 lines
32 KiB
Markdown
451 lines
32 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,
|
||
Linux `/proc` checks here are not SO_PEERCRED authentication. The trusted host
|
||
and well-behaved clients must follow this protocol. Containers remain later work.
|
||
|
||
## Start and stop
|
||
|
||
Requires Linux and Node 24 or 26, local disk, no dependencies. 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.
|
||
|
||
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.
|
||
|
||
`{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.
|
||
|
||
| Verb | Arguments and result |
|
||
|---|---|
|
||
| `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 human-approved gated decision for this exact target and holder incarnation. |
|
||
| `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 a 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. |
|
||
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
|
||
| `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. |
|
||
| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`. Human instruction's `request` is its `human.input` event ID. |
|
||
| `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. |
|
||
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
||
| `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. |
|
||
| `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. |
|
||
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). 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. |
|
||
|
||
### 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.
|
||
|
||
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 and REACTION. References cannot cross businesses.
|
||
|
||
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'`). If that arbiter is the raiser, resolution routes to human
|
||
while the decision keeps its cross-role class. Gated routes to human. Unlisted actions are gated and the
|
||
nine gated-only actions cannot appear in authority maps. 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.
|
||
|
||
`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/run, action and target.
|
||
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, 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.
|
||
|
||
## 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.
|
||
|
||
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`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) 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`.
|
||
|
||
## 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.
|
||
|
||
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 unchanged v3b, SHA-256
|
||
`179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65`.
|
||
It has eight tables, **36** triggers and five views. The prototype's displayed
|
||
35 is after deliberately dropping a guard. Source digest and a trusted full
|
||
sqlite_master digest are checked at open, alongside schema version `3b`, 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 every table.
|
||
No migration or pruning command ships 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.
|