Files
stack/packages/bus/README.md
T
jason.woltjeandClaude Opus 5.5 d27042faf2 feat(bus): a within-role message cites a decision without consuming it (row 44, S2c, rocko)
A within-role message.send stores its decision as a citation: the broker
checks it exists in the business, and doesn't class-match it, consume it
or put it in action.allowed (lead decision 65). Sends that aren't
within-role keep the S2b authority rules. The README wording on
within-role sends is fixed.

Candidate agents/rocko/work/slice1-s2c-r1, build.patch 2c4f8d9f,
manifest aa249ad9. Darkwing approved round 1 on #1526 (comment 26778).
Integration gate in a worktree on ac4a6499: every package green on
Node 26; bus 58/58 on Node 24; every scripts/test-*.sh green. Node 24
failures in conversation, ledger, queue, seat and webui match the base.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:13:46 -05:00

257 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
`{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. `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. |
| `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. |
| `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`, `tasks`, `agents` | No args; plain JSON projections scoped to capability's business. |
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
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.
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 for one business;
they do not confer mutation or resolution authority.
## 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');
```
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. 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.
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.