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:
{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 asaction.refused.{op:'refuse',cap,code,subject?}: records a refusal the host decided itself (instance list, capacity, a failed spawn, a git socket check) asaction.refusedagainst the caller, the code only. A task ref assubjectfiles 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 writessession.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 withrun-ended, an unbound run withunknown-run.reasonis 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'sgit.push.workingauthority before the host takes a push's bundle. It records nothing unless it refuses.{op:'recordPush',cap,push}:pushis{task_ref,project,branch,ref, commit,previous}once the work ref moved. It checks the authority again and writes theaction.allowedforgit.push.working, with the task as subject and{project,branch,ref,commit,previous}in its body. That event is the push record: the onlyaction.allowedforgit.push.workingthat 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}:argsis{task_ref,request}. The caller must holdreview.verdictand be the role thereview.requestedevent was addressed to (request-not-foundotherwise). 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}:argsis{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 classcross-role(not-authorizedotherwise), resolved with its approval option and not yet used (decision-consumedotherwise). ThenreadLanding's checks, and the verdict's commit must be the task's latest push (ref-moved). A landing with no subject refusesno-subject, since an agent has no--subjectto pass. Returns a ticket with what the host needs: the project, the work ref, the commit, the verdict and the resolution, with itsvia(cliorbroker). It records nothing unless it refuses.{op:'recordLand',ticket,land}:landis{commit,previous,reviewed, base,patch_id}oncemainmoved. The checks run again inside the transaction. It writes theaction.allowedforgit.merge.protectedon the task's trail, which spends the decision, and sends the role a RESULT in the decision's thread. When it refuses, the host movesmainback.{op:'landRefused',ticket,args}:argsis{code,detail,notify}. It writesaction.refusedwith the code on the task's trail and ends the landing. Withnotifythe role gets a RESULT too, since the git socket had already answeredstarted.{op:'landHeld',record}:recordis{business,role,thread}. True when the role, in that thread (null for a role run), holds across-rolemerge decision a landing could use. The launcher binds the git socket's land op only then;checkLandstill 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.
| 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 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. |
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.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 | 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. |
| 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; human sessions only. The business's projects, oldest first. |
| 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?}; human sessions only. The caller's own threads, oldest first. |
| 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). Writes nothing. |
| session.list | {thread}; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, [{id,at,name,predecessor,default}]. 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. Amessage.sendto it refusesthread-required-use-thread-send. - Any other role's message to a thread instance needs
in_reply_to. The broker walks thein_reply_tochain 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 refusesthread-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 refusesthread-instance-mismatch. - A thread run's
message.receivereturns 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.
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).
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.
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.
workingis{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.inputNeededlists 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.stateisworkingwhenworkingis set, elsewaitingwheninputNeededis nonempty, elseidle. Working wins over waiting, as on the control board.strandedlists messages to the role that an earlier holder was delivered and never read, as{id, from, run, at}: the sender (humanfor 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:giteaorvikunja.account: the role'stracker.botfor Vikunja, the sync identity'sbotfor its row,nullfor Gitea.role: the instance, ortracker syncforbusiness/@sync.dateKindanddate:rotateByfor Gitea,expiresfor Vikunja.state:credentials.status()against the host's clock at the request:valid,expiring(under 7 days),expired, orrotation-duefor 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
business 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 business package's launch-settings.mjs
writes them with the human as actor (packages/business/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
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 v3l, SHA-256
cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a.
It has 22 tables, 87 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version 3l, 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:
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.