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]>
257 lines
16 KiB
Markdown
257 lines
16 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.
|
||
|
||
`{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.
|