Files
stack/packages/bus
jason.woltjeandClaude Opus 5.5 7e73c2cd13 feat(tasks): the Vikunja v2 adapter, broker task verbs and sync (row 38, S3, darkwing)
packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).

Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).

Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:40:48 -05:00
..

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:

{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

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.