# MCP
Source: https://docs.rkat.ai/api/mcp
Use Meerkat as an MCP client to call external tools, or expose Meerkat as MCP tools for other clients.
Model Context Protocol (MCP) is the tool protocol Meerkat uses in two ways: as a **client** calling external MCP servers for tools, and as a **server** exposing itself as MCP tools for other clients.
## Getting started as a client
```bash theme={null}
rkat mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
```
```bash theme={null}
rkat mcp list
```
The agent will automatically discover and use tools from registered MCP servers.
## Tool overview (server mode)
The `meerkat-mcp-server` crate exposes Meerkat as MCP tools that other clients can call.
`meerkat_run` and `meerkat_resume` keep the same public MCP tool contract, but
their execution path is runtime-backed: `meerkat_run` allocates a session
through the shared session service and waits for that admitted turn to finish,
and `meerkat_resume` re-enters the same `session_id` instead of starting a
second surface-local execution loop.
The base MCP tool surface below is declared and dispatched in the
`meerkat-mcp-server` crate (`crates/meerkat-mcp-server/src/lib.rs`), which is the
source of truth for tool names and availability.
| Tool | Description |
| - | - |
| `meerkat_run` | Start a new agent session |
| `meerkat_resume` | Continue a session or provide tool results |
| `meerkat_help` | Ask Meerkat usage help with the embedded platform skill |
| `meerkat_read` | Read a session's state |
| `meerkat_history` | Read a session's committed history |
| `meerkat_sessions` | List sessions in the active realm |
| `meerkat_blob_get` | Fetch a blob payload by id |
| `meerkat_interrupt` | Interrupt an in-flight turn |
| `meerkat_archive` | Archive (remove) a session |
| `meerkat_config` | Get or update server config |
| `meerkat_capabilities` | Query runtime capabilities |
| `meerkat_models_catalog` | List available models with provider profiles and capabilities |
| `meerkat_skills` | List or inspect available skills |
| `meerkat_schedule_*` tools | Agent-facing schedule create/get/list/update/pause/resume/delete/occurrences tools |
| `workgraph_*` tools | Agent-facing WorkGraph create/read/claim/update/link/evidence/close tools |
| `meerkat_mcp_add` | Stage a live MCP server addition on an active session (refuses `oauth_account` and `oauth_account_selection`) |
| `meerkat_mcp_remove` | Stage a live MCP server removal on an active session |
| `meerkat_mcp_reload` | Stage a live MCP server reload on an active session |
| `meerkat_event_stream_open` | Open a session-level agent event stream |
| `meerkat_event_stream_read` | Read the next item from an open event stream |
| `meerkat_event_stream_close` | Close a previously opened event stream |
| `meerkat_mob_create` | Create a mob from a typed public definition (mob feature) |
| `meerkat_mob_list` | List active mobs (mob feature) |
| `meerkat_mob_status` | Get lifecycle status for one mob (mob feature) |
| `meerkat_mob_lifecycle` | Apply a lifecycle action to a mob (mob feature) |
| `meerkat_mob_spawn` | Spawn a member into a mob (mob feature) |
| `meerkat_mob_spawn_many` | Spawn multiple members into a mob (mob feature) |
| `meerkat_mob_retire` | Retire a mob member (mob feature) |
| `meerkat_mob_respawn` | Respawn a mob member with topology restore (mob feature) |
| `meerkat_mob_wire` | Wire two mob members or an external peer (mob feature) |
| `meerkat_mob_wire_members_batch` | Wire multiple mob member pairs in one call (mob feature) |
| `meerkat_mob_unwire` | Remove mob comms wiring (mob feature) |
| `meerkat_mob_member_send` | Deliver host-owned work to a specific mob member (mob feature) |
| `meerkat_mob_append_system_context` | Append one ordinary durable ordered System message to a member session (mob feature) |
| `meerkat_mob_events` | Read mob event history (mob feature) |
| `meerkat_mob_flows` | List flows defined for a mob (mob feature) |
| `meerkat_mob_run` | Invoke a mob as a typed callable run (mob feature) |
| `meerkat_mob_flow_run` | Start a mob flow run (mob feature) |
| `meerkat_workgraph_flow_launch` | Durably bind a WorkGraph item to an exact Mob Flow attempt before launch (mob feature) |
| `meerkat_workgraph_flow_reconcile` | Explicitly reconcile a durable WorkGraph Flow binding (mob feature) |
| `meerkat_workgraph_flow_abandon_uncertain` | CAS-fence an ambiguous Begin-without-run launch, project failure evidence, and make a superseding retry eligible (mob feature) |
| `meerkat_workgraph_flow_binding_get` | Read one redacted WorkGraph Flow execution binding (mob feature) |
| `meerkat_workgraph_flow_binding_list` | List bounded redacted WorkGraph Flow execution bindings (mob feature) |
| `meerkat_mob_flow_status` | Get flow run status (mob feature) |
| `meerkat_mob_run_result` | Get the typed output envelope for a mob run (mob feature) |
| `meerkat_mob_flow_cancel` | Cancel a flow run (mob feature) |
| `meerkat_mob_force_cancel` | Force-cancel a member's active work (mob feature) |
| `meerkat_mob_wait_kickoff` | Wait for autonomous kickoff completion (mob feature) |
| `meerkat_mob_wait_ready` | Wait for mob startup readiness (mob feature) |
| `meerkat_mob_profile_*` tools | Realm-scoped mob profile create/get/list/update/delete (mob feature) |
| `meerkat_mob_member_history` | Read a mob member transcript page by identity, local or placed (mob feature) |
| `meerkat_mob_hosts` | List bound member hosts with capabilities (mob feature) |
| `meerkat_mob_route_installs` | Read outstanding cross-host route-install obligations (mob feature) |
| `meerkat_mob_event_stream_open` | Open a mob-level event stream (mob feature) |
| `meerkat_mob_event_stream_read` | Read the next event from an open mob stream (mob feature) |
| `meerkat_mob_event_stream_close` | Close a previously opened mob event stream (mob feature) |
| `meerkat_comms_send` | Send a canonical comms command to a session (comms feature) |
| `meerkat_comms_peers` | List peers visible to a session (comms feature) |
Default `rkat-mcp` builds are driven by the crate feature set. The base/default
tool surface includes core session/config/history/blob/skills tools and
schedule and WorkGraph tools. Mob tools appear when the `mob` feature is
compiled in.
The WorkGraph Flow tools are advertised only by hosts that also configure a
WorkGraph service. Their shared Mob runtime bridge owns reconciliation; MCP is
only the transport adapter.
Flow status carries a redacted reverse execution-binding reference when the run
was launched by the bridge. Public binding views omit activation parameters,
idempotency and correlation values, and execution principals.
When the `mob` feature is enabled, the public MCP host surface uses typed
`meerkat_mob_*` control-plane tools. Inside running sessions, mob capability is
still agent-side and comes from composing a mob tools factory into
`SessionBuildOptions.mob_tools`, which provides `mob_*` tools to the
model. Public host MCP surfaces should not re-export that raw dispatcher.
The multi-host console family adds the three **observation tools only**.
Existing `meerkat_mob_spawn` and `meerkat_mob_spawn_many` mutations may carry
`placement` for an already-bound host. Host admin (`bind_host`/`revoke_host`),
grant admin (`grant_scopes`/`revoke_scopes`/`grants`), hard cancel, and member
live-channel verbs remain deliberately absent — MCP servers are routinely
wired into agent tool contexts, and an LLM-reachable admin mutation would be a
privilege-escalation vector. Host/grant/live admin lives on JSON-RPC/SDK plus
CLI; hard cancel is JSON-RPC/SDK-only. Unknown tool names fail closed.
`resume_from_role` is not accepted by `meerkat_mob_spawn`, spawn-many, helper,
or profile tools. Durable member role migration is a one-request trusted-host
operation available only to in-process `SpawnMemberSpec` and the private
member-host materialization protocol. It is not an MCP permission or a standing
profile setting.
### Surface boundaries
The MCP server deliberately does not mirror every operator API:
| Domain | MCP exposure |
| - | - |
| Approvals | None; use JSON-RPC/SDK `approval/*` |
| Durable jobs and monitors | None; use JSON-RPC/SDK `jobs/*` and `monitors/start` |
| Blobs | `meerkat_blob_get` fetches a known blob ID |
| Stable artifact records/downloads | None; use JSON-RPC/SDK `artifact/*` |
| Live audio/WebRTC channels | None; use JSON-RPC/SDK `live/*` |
| Multi-host mobs | Placement plus observation only; admin remains RPC/SDK/CLI |
Session event streams are bounded live subscriptions. If a consumer falls
behind, the stream yields the typed `stream_truncated` event with a
`StreamLagged` dropped count and then continues with retained events. That is a
live-delivery gap, not an EventStore projection result. Built-in realm-backed
persistent sessions can feed a separate durable audit projector whose unbounded
queue is independent of the UI ring. That projection is asynchronous derived
state, so reconcile available replay/snapshot evidence with current session
state when completeness matters.
## Use MCP servers as tools (client)
### CLI configuration (recommended)
Use the `rkat mcp` commands to manage MCP servers:
```bash theme={null}
# Add a stdio server
rkat mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
# Add a streamable HTTP server
rkat mcp add api --url https://mcp.example.com/api
# Add a streamable HTTP server with Claude-compatible positional URL syntax
rkat mcp add --transport http remote https://mcp.example.com/api
# Log in when the HTTP server requires OAuth
rkat mcp login remote
# List servers
rkat mcp list
# Remove a server
rkat mcp remove filesystem
```
### Config file format
MCP servers are stored in TOML in two scopes:
* **Project**: `.rkat/mcp.toml`
* **User**: `~/.rkat/mcp.toml`
Project config wins on name collisions.
```toml theme={null}
# Stdio server (local process)
[[servers]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"]
# Stdio server with env
[[servers]]
name = "database"
command = "python"
args = ["-m", "db_mcp_server"]
env = { DATABASE_URL = "${DATABASE_URL}" }
# HTTP server (streamable HTTP is default for URLs)
[[servers]]
name = "remote-api"
url = "https://mcp.example.com/api"
headers = { Authorization = "Bearer ${MCP_API_TOKEN}" }
# OAuth-protected HTTP server. No OAuth schema is persisted here;
# discovery and token storage happen when connecting or running `mcp login`.
[[servers]]
name = "remote-oauth"
url = "https://mcp.example.com/api"
# Legacy SSE server
[[servers]]
name = "legacy-sse"
url = "https://old.example.com/sse"
transport = "sse"
```
Supported transports: **stdio**, **streamable HTTP** (default for URLs), and **SSE**.
Environment variables in config use `${VAR_NAME}` syntax.
A streamable HTTP request is never re-sent after the server drops its
session (`404`). A tool call whose own request is answered `404` for its
session fails with the typed `outcome_uncertain` tool error (it may have
taken effect). So does a call that fails after a redirect shown by its own
returned response (a changed final URL, or a `3xx` the policy did not
follow). A transport failure with no response after a followed redirect
cannot show that redirect and remains an ordinary failure; that neither
proves the call had no effect nor makes a retry safe. Once the session
is known expired, later calls on that connection are refused unsent until
the host reconnects the server. A `404` on the session's background SSE GET
also marks the session expired; it does not change the outcome of a call
already in flight, which can still succeed. A server that answers an
unsupported GET with `404` instead of the specified `405` will see its
sessions treated as expired and must be fixed to answer `405`.
For OAuth-protected streamable HTTP servers, keep config simple. Add the URL
with `rkat mcp add`, set `oauth_account` to the account the login must prove,
then run `rkat mcp login ` for an explicit browser login, or
`rkat run "..." --mcp-auth interactive` to allow first-use browser auth during
an interactive TTY run. Normal runs default to `--mcp-auth stored`.
### Host-driven OAuth
MCP OAuth is a host ceremony. Agents never open a browser or see any of its
data: when a server has no usable credential, its connection fails with the
typed `AuthorizationRequired` status (`McpError::AuthorizationRequired`,
`McpRouter::servers_awaiting_authorization`), and the host decides when to
ask its user.
* **Account selection.** Interactive login requires an account selection on
the server entry, set only in host configuration:
* `oauth_account = ""`: the login must prove that account.
* `oauth_account_selection = "discover"`: the first login binds the
account the strategy verifies, and publishes only into an empty
credential slot. A later login reconnects only that same account with
the same issuer, client, resource and strategy; anything else needs
`auth/logout` first.
* `oauth_account_selection = "unverified"`: an explicit opt-in for servers
that cannot prove an account. The credential is bound to the resource,
issuer and client, but no account is verified or reported: `account_id`
is always absent and status reports `account_verification: "unverified"`
(host UI should say "account not verified"). No account strategy runs,
and a re-login over an existing credential needs `auth/logout` first.
Whoever administers the host's configuration sets these keys: the
project or user `mcp.toml`, or the host's RPC and REST `mcp/add`. The
`meerkat_mcp_add` management tool refuses both keys, because a host may
expose that tool to an agent. The two keys conflict. Each selection has
its own credential slot: changing it never reuses or migrates another
selection's credential, and a failed
verification never falls back to `unverified` or to an unselected
credential. A selected server must use streamable HTTP without a static
`Authorization` header and a host OAuth resolver. Mob portable profiles
cannot carry `oauth_account_selection` and refuse it. Credentials are
stored per installation (the host's token store), not per realm, and a
new login over a Known credential stored before account bindings were
recorded needs a disconnect first (using it does not). The default account
strategy is OpenID Connect UserInfo: it
requests `openid`, calls the issuer's `userinfo_endpoint` with the new
access token and reports `sub`, which must equal `oauth_account` when one
is named. A refresh of a verified credential observes the subject again
and refuses a different one; if the subject cannot be observed (for
example no account strategy is installed) the refresh fails transiently
and nothing is published. Hosts whose
servers prove the account another way supply their own
`McpOAuthAccountStrategy`. Start checks first that the account can be
proven: for the default strategy, the issuer's OpenID Connect discovery
must name the exact issuer and an https (or loopback) `userinfo_endpoint`.
An issuer that cannot is refused before client registration and before any
browser opens.
* **Discovery, registration and scopes.** The authorization server's metadata
is read from RFC 8414 discovery, falling back to OpenID Connect discovery.
Dynamic client registration declares `application_type: "native"`. The
attempt requires the scopes the server's `401` challenge names (its
`scope`), or else the resource metadata's `scopes_supported`, in addition
to the account strategy's own; the completion is refused unless all of them
are granted. `offline_access` is requested, never required, when the
authorization server advertises it.
* **Credentials per request.** An established connection reads its bearer
from the credential owner on every request, so a refreshed credential is
used without reconnecting. A request without a usable credential is refused
before it is sent, and a request the server refuses with `401` fails with
the typed `AuthorizationRequired` status. Neither is replayed. A custom
`McpAuthResolver` must commit the credential its `interactive_login`
returns, so that `stored_bearer_token` returns it from then on.
* **Embedding hosts** use `HostAuthService::mcp_login_start` and
`mcp_login_complete` (and `mcp_login_cancel` on timeout). Start admits the
attempt through the runtime OAuth flow owner (PKCE and one-time state) and
returns the authorize URL; complete takes the loopback callback, verifies
it against the admitted attempt, exchanges the code and persists the
credential. `mcp_status` reports the attempt pending for a server by a
non-secret reference, which `mcp_login_cancel_by_attempt_ref` cancels, and
`mcp_logout` retires any pending attempt and removes the server's stored
credential, so a callback admitted before the logout cannot store one.
`AgentFactory::mcp_auth_resolver` (or
`AgentBuildConfig::mcp_auth_resolver`) connects agents to those
credentials. Runtime-backed RPC, REST and MCP-server hosts install this
resolver by default, including for live `mcp/add` servers.
* **One attempt per server.** A start while an attempt is pending for the
same server returns that attempt (`disposition: joined`) instead of a
second one. `HostAuthService::mcp_begin_loopback_login` binds the loopback
callback for you; its pending login has a typed `cancel` that retires the
callback binding and the attempt. Opening the browser is advisory: a
failed launch never retries or cancels the attempt, which ends only by
completion, cancel or expiry.
* **RPC and REST hosts** use `auth/login/start`, `auth/login/complete`,
`auth/login/cancel` and (RPC) `auth/status/get` with an MCP target instead
of a provider binding:
```json theme={null}
{"mcp": {"server_name": "remote-oauth", "server_url": "https://mcp.example.com/api", "oauth_account": "subject-id"},
"redirect_uri": "http://127.0.0.1:53682/mcp/oauth/callback"}
```
`redirect_uri` must be an http loopback URL. Completion sends only the
callback's `code` and `state` (and the same `redirect_uri`): issuer, client
and resource come from the admitted attempt, and nothing network-facing
happens until `state` is proven. Cancel takes `{mcp, state}`. A joined start
returns the pending attempt's URL and state, because wire callers are
host-privileged by contract. A join reuses the pending attempt only if this
host process admitted it after its account check; otherwise the attempt is
retired and a fresh one started. While an attempt is pending, `auth/status/get`
reports it as `attempt: {ref, phase: "pending", expires_at}`. The `ref` is
not secret and grants nothing: a host that no longer holds the state (for
example after its own restart) cancels with `{mcp, attempt_ref}`. RPC
`auth/logout` with `{mcp}` retires any pending attempt and removes the
server's stored credential (nothing is revoked at the provider). Status and
completion report `account_verification` (`verified`, `unverified` or
`legacy`). The server must be configured (project or user `mcp.toml` of the
host): `server_url` must equal its configured URL, and `oauth_account` or
`oauth_account_selection`, if given, the configured value; they never
select or change the mode. Anything else is refused before any network
request.
The authorize URL and `state`, and the callback's `code` and `state`, let
whoever holds them complete the attempt. A host must bind the loopback
callback itself and never put these values in a tool result, transcript,
agent event, elicitation result or log. It opens the URL in the user's own
browser: an ordinary host is supported, and no agent-unreachable host is
required. A host whose agents have a browser or computer-use tool should not
open the URL in a browser such a tool drives.
## Meerkat as an MCP server
### Connecting other clients
You can connect any MCP-capable client to a Meerkat MCP server. Run a server process that exposes the `meerkat_*` tools (stdio or HTTP), then point the client at it.
Claude Code reads MCP servers from a `.mcp.json` file in your project root.
Example stdio config:
```json theme={null}
{
"mcpServers": {
"meerkat": {
"command": "rkat-mcp",
"args": [],
"env": {
"ANTHROPIC_API_KEY": "your-key"
}
}
}
}
```
Codex CLI supports MCP servers via a command and also via config. Example:
```bash theme={null}
codex mcp add meerkat --env ANTHROPIC_API_KEY=your-key -- rkat-mcp
```
You can also configure MCP servers in `~/.codex/config.toml` under
`[mcp_servers.]`.
Gemini CLI can add MCP servers via CLI or config. Example:
```bash theme={null}
# HTTP/streamable HTTP
gemini mcp add --scope=project --transport=http meerkat https://your-mcp-server.example.com
```
Or configure in `.gemini/settings.json` (project-scoped) with an `mcpServers`
map that points to your server URL.
### Hosting the MCP server
`meerkat-mcp-server` is a library crate that provides tool schemas and handlers.
For a full public MCP host, serve `MeerkatMcpState::advertised_tools_list()`
from the same state used by the `handle_tools_call*` entrypoints, or run the
bundled `rkat-mcp` binary. This instance-filtered catalog omits unavailable
runtime tools, such as skills when no skill runtime is installed.
`meerkat_mcp_server::tools_list()` is the static composable definition catalog,
not the ready-to-serve instance catalog. For a mob-only MCP host,
`meerkat-mob-mcp` exposes `public_tools_list()` and
`handle_public_tools_call()` for the typed `meerkat_mob_*` control plane.
`meerkat-mob-mcp::tools_list()` and `handle_tools_call()` remain the internal
agent-side `mob_*` dispatcher helpers; they are not the public host contract.
The bundled `rkat-mcp` binary supports the complete runtime scope flags:
```bash theme={null}
rkat-mcp [--realm ] [--isolated] [--instance ] \
[--realm-backend ] [--state-root ] \
[--context-root ] [--user-config-root ] [--expose-paths]
```
If `--realm` is omitted, the server creates a new isolated realm by default.
`--realm-backend` is a creation hint only; after the first open, the manifest-pinned backend is authoritative.
The bundled binary is stdio-only. If an embedding host publishes these tools
over HTTP, that host owns transport authentication, authorization, and
encryption; provider auth bindings configure outbound model credentials and do
not authenticate inbound MCP callers.
#### Logging
`rkat-mcp` writes trace events to stderr only; stdout carries nothing but the
MCP JSON channel. The default filter is `info`, which is what makes the
`verbose` parameter of `meerkat_run` and `meerkat_resume` produce output.
`RUST_LOG` replaces the default (`RUST_LOG=warn` keeps only warnings and
errors; a blank value is treated as unset), and a value that does not parse is
reported on stderr before the default applies, so an operator's typo is never
swallowed. Clients that capture stderr see these lines; nothing changes on
stdout.
## Request cancellation
The bundled server accepts MCP's `notifications/cancelled` notification.
While a `tools/call` request with JSON-RPC ID `2` is in flight, send:
```json Client -> server notification theme={null}
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2}}
```
`requestId` must be the original request's exact ID, preserving its
number/string type, not a session ID. This notification has no `id` and
receives no response. It can cancel an uncommitted `meerkat_run` request before
creation has returned a session identity. For example, cancellation before
response publication can resolve that original request as:
```json Server -> client response to the original request theme={null}
{"jsonrpc":"2.0","id":2,"error":{"code":-32005,"message":"request cancelled before response publish"}}
```
Cancellation does not roll back committed work: committed success is not
rewritten to cancellation. This request lifecycle operation is distinct from
`meerkat_interrupt`, which targets an existing session's turn. Raw Meerkat
JSON-RPC instead uses `cancel` with `request_id`; do not interchange those
transport spellings.
## Event streaming
### Push notifications
Set `stream: true` on `meerkat_run` or `meerkat_resume`:
```json tools/call request theme={null}
{
"jsonrpc": "2.0",
"id": 20,
"method": "tools/call",
"params": {
"name": "meerkat_run",
"arguments": {"prompt": "Explain Rust ownership", "stream": true}
}
}
```
The bundled server emits MCP `notifications/message` messages while the
request runs:
```json Push notification theme={null}
{
"jsonrpc": "2.0",
"method": "notifications/message",
"params": {
"level": "info",
"data": {
"source": "meerkat",
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"event": {
"event_id": "01936f8a-7b2c-7000-8000-000000000010",
"source": {
"type": "session",
"session_id": "01936f8a-7b2c-7000-8000-000000000001"
},
"seq": 12,
"timestamp_ms": 1736937000000,
"payload": {"type": "text_delta", "delta": "Ownership"}
}
}
}
}
```
The agent event is a full `EventEnvelope`; inspect
`params.data.event.payload`, not `params.data.event.type`. Notifications have
no request ID and do not replace the final `tools/call` response. A failure to
serialize an event instead emits `level: "error"` with
`data.error: "event_serialization_failed"` and `data.message`.
### Pull streams: open, read, close
Pull streams are independent tool handles, not push notifications. Invoke
these names via `tools/call`:
| Tool | Arguments | Decoded result |
| - | - | - |
| `meerkat_event_stream_open` | Required `session_id` | `stream_id`, `session_id` |
| `meerkat_event_stream_read` | Required `stream_id`; optional `timeout_ms`, `no_timeout` | `stream_id`, `status`, and `event` only for status `event` |
| `meerkat_event_stream_close` | Required `stream_id` | `stream_id`, `closed` (boolean) |
| `meerkat_mob_event_stream_open` | Required `mob_id`; optional `member_id` | `stream_id`, `mob_id`, `member_id` (null for mob-wide) |
| `meerkat_mob_event_stream_read` | Required `stream_id`; optional `timeout_ms`, `no_timeout` | Same read-status wrapper |
| `meerkat_mob_event_stream_close` | Required `stream_id` | `stream_id`, `closed` (boolean) |
The three mob tools require the `mob` feature. Each pull-tool result wraps
its payload as JSON text in `result.content[0].text`; parse that string once.
For example:
```json Open session stream theme={null}
{
"jsonrpc": "2.0",
"id": 30,
"method": "tools/call",
"params": {
"name": "meerkat_event_stream_open",
"arguments": {"session_id": "01936f8a-7b2c-7000-8000-000000000001"}
}
}
```
```json Open response theme={null}
{
"jsonrpc": "2.0",
"id": 30,
"result": {
"content": [{
"type": "text",
"text": "{\"stream_id\":\"550e8400-e29b-41d4-a716-446655440000\",\"session_id\":\"01936f8a-7b2c-7000-8000-000000000001\"}"
}]
}
}
```
```json Open member stream (mob feature) theme={null}
{
"jsonrpc": "2.0",
"id": 40,
"method": "tools/call",
"params": {
"name": "meerkat_mob_event_stream_open",
"arguments": {"mob_id": "review-team", "member_id": "worker-1"}
}
}
```
```json Decoded member-open payload (content[0].text) theme={null}
{
"stream_id": "550e8400-e29b-41d4-a716-446655440001",
"mob_id": "review-team",
"member_id": "worker-1"
}
```
Read with flat timeout fields (there is no nested `timeout` object):
```json Read session stream theme={null}
{
"jsonrpc": "2.0",
"id": 31,
"method": "tools/call",
"params": {
"name": "meerkat_event_stream_read",
"arguments": {
"stream_id": "550e8400-e29b-41d4-a716-446655440000",
"timeout_ms": 1000,
"no_timeout": false
}
}
}
```
`timeout_ms` is an optional non-negative millisecond count. Omission/`null`
uses the production default of **5000 ms**; `no_timeout` defaults to `false`.
`no_timeout: true` waits indefinitely and takes precedence even if
`timeout_ms` is also supplied. Mob reads use the same flat fields with
`meerkat_mob_event_stream_read` and the mob stream's returned ID.
The decoded read payload has one of three statuses:
```json Event (decoded session/member payload) theme={null}
{
"stream_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "event",
"event": {
"event_id": "01936f8a-7b2c-7000-8000-000000000010",
"source": {
"type": "session",
"session_id": "01936f8a-7b2c-7000-8000-000000000001"
},
"seq": 12,
"timestamp_ms": 1736937000000,
"payload": {"type": "text_delta", "delta": "Ownership"}
}
}
```
```json Timeout (decoded payload) theme={null}
{"stream_id":"550e8400-e29b-41d4-a716-446655440000","status":"timeout"}
```
```json Exhausted (decoded payload) theme={null}
{"stream_id":"550e8400-e29b-41d4-a716-446655440000","status":"closed"}
```
A timeout leaves the handle open for the next read. Exhaustion returns
`closed` and removes the handle; another read then fails as not found.
Session and per-member reads carry an `EventEnvelope` directly
under `event`. Omit `member_id` to open a mob-wide stream: its events are
`AttributedEvent` values with member attribution and a nested `envelope`, so
the agent payload is at `event.envelope.payload` instead of `event.payload`.
Close a handle when finished:
```json Close session stream theme={null}
{
"jsonrpc": "2.0",
"id": 32,
"method": "tools/call",
"params": {
"name": "meerkat_event_stream_close",
"arguments": {"stream_id": "550e8400-e29b-41d4-a716-446655440000"}
}
}
```
```json Decoded close payload (content[0].text) theme={null}
{"stream_id":"550e8400-e29b-41d4-a716-446655440000","closed":true}
```
For a mob handle, use `meerkat_mob_event_stream_close` with its `stream_id`.
`closed: true` means this call removed the handle; `false` means it was already
absent. Close results use a boolean, unlike a read's `status: "closed"`.
## Tool reference
### meerkat\_run
Start a new Meerkat agent session with the given prompt.
```json Request (minimal) theme={null}
{
"prompt": "Write a haiku about Rust"
}
```
```json Request (full) theme={null}
{
"prompt": "Analyze this code for bugs",
"transient_turn_context": "Current host facts regenerated for this turn",
"system_prompt": "You are a senior code reviewer.",
"model": "claude-opus-4-8",
"max_tokens": 4096,
"provider": "anthropic",
"output_schema": {
"type": "object",
"properties": {
"bugs": {"type": "array", "items": {"type": "string"}},
"severity": {"type": "string"}
},
"required": ["bugs", "severity"]
},
"structured_output_retries": 2,
"stream": false,
"verbose": false,
"tools": [
{
"name": "read_file",
"description": "Read a file from disk",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
},
"handler": "callback"
}
],
"enable_builtins": false,
"builtin_config": {
"enable_shell": false,
"shell_timeout_secs": 30
},
"keep_alive": null,
"comms_name": null,
"hooks_override": null
}
```
`transient_turn_context` is non-empty text; whitespace is significant. It is
retained with the pending runtime input for exact crash retry and projected
only into foreground provider requests for this logical turn. It is not a
System message, is not appended to Session, and is excluded from compaction and
extraction calls.
```json Response (completed run, no extraction) theme={null}
{
"content": [{
"type": "text",
"text": "{\"content\":[{\"type\":\"text\",\"text\":\"Agent response here\"}],\"session_id\":\"01936f8a-7b2c-7000-8000-000000000001\",\"turns\":1,\"tool_calls\":0,\"structured_output\":null,\"extraction_error\":null,\"schema_warnings\":null,\"skill_diagnostics\":null}"
}]
}
```
#### Parameter reference
User prompt for the agent.
Override system prompt.
Model name (e.g. `"claude-opus-4-8"`, `"gpt-5.5"`).
Max tokens per turn.
Explicit MCP override vocabulary: `"anthropic"`, `"openai"`, `"gemini"`,
or `"other"`. `"self_hosted"` is not an accepted explicit token here.
Omission/`null` still permits configured model/binding resolution, including
configured self-hosted routes; accepting `"other"` does not guarantee an
operational custom provider.
Optional structural credential selector, for example
`{"realm":"prod","binding":"anthropic","profile":"claude_oauth"}`.
`realm` and `binding` are required when supplied; `profile` is optional.
Configure the referenced [realm, binding, and auth profile](/guides/auth)
first. Omission/`null` lets the create resolver select credentials.
This is not a colon-delimited CLI string and does not accept server-owned
provenance. It is a `meerkat_run` selector, not a new `meerkat_resume` field.
JSON schema for structured output (wrapper or raw schema).
Max retries for structured output validation. Omit / `null` to use the product default on run or inherit the persisted session value on resume.
Stream agent events via `notifications/message`; see
[event streaming](#event-streaming) for the envelope and pull-stream tools.
Enable verbose event logging (server-side).
Tool definitions for the agent (see [McpToolDef schema](#mcptooldef-schema) below).
Builtins override. Omit / `null` to use the default on run or inherit the persisted session value on resume.
Config for builtins (only used when `enable_builtins` is true).
Shell-tools override. Omit / `null` to use the default on run or inherit the persisted session value on resume.
Default shell command timeout.
Keep session alive after turn for comms. On `meerkat_run`, `null`/omitted uses the run default (`false`), `true` enables, and `false` explicitly disables. Requires `comms_name` when enabled.
Agent name for comms.
Run-scoped hook overrides.
#### McpToolDef schema
Tool name.
Tool description.
JSON Schema for tool input.
Handler type (`"callback"` = result provided via `meerkat_resume`).
When tools with `handler: "callback"` are provided and the agent requests a tool call, the response includes `pending_tool_calls`. The MCP client must execute the tool and provide results via `meerkat_resume`.
### meerkat\_resume
Resume an existing runtime-backed session or provide tool results for pending
tool calls. The call waits for the resumed turn to complete and returns the same
`session_id` that `meerkat_run` originally materialized.
```json Request (minimal) theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"prompt": "Continue with this",
"transient_turn_context": "Fresh host facts for this turn"
}
```
```json Request (with tool results) theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"prompt": "",
"stream": false,
"verbose": false,
"tools": [],
"tool_results": [
{
"tool_use_id": "tc_abc123",
"content": "File contents here...",
"is_error": false
}
],
"enable_builtins": false,
"builtin_config": null,
"keep_alive": null,
"comms_name": null,
"model": null,
"max_tokens": null,
"provider": null,
"hooks_override": null
}
```
```json Response (pending tool call) theme={null}
{
"content": [{
"type": "text",
"text": "{\"content\":[{\"type\":\"text\",\"text\":\"Agent is waiting for tool results\"}],\"session_id\":\"01936f8a-7b2c-7000-8000-000000000001\",\"status\":\"pending_tool_call\",\"resumable\":true,\"pending_tool_calls\":[{\"tool_use_id\":\"tc_abc123\",\"tool_name\":\"read_file\",\"args\":{\"path\":\"/tmp/data.txt\"}}]}"
}]
}
```
Decode the pending payload from `content[0].text`. It is a control result
with `status: "pending_tool_call"`, `session_id`, `resumable: true`,
`pending_tool_calls`, and human-readable `content`, not a completed-run
payload. A `session_ref` may be present when assigned by the surface;
`session_created: true`, when present, says that the suspended request created
the session (false is omitted). Do not infer successful-run counters from a
pending result.
Echo each provider-issued `tool_use_id` exactly in `tool_results`, as
`tc_abc123` is echoed above. Never correlate by tool name or array position,
even when several pending calls invoke the same tool.
#### Parameter reference
Session ID to resume.
Follow-up prompt (can be empty when providing tool results).
Stream agent events via `notifications/message`; see
[event streaming](#event-streaming).
Enable verbose event logging.
Tool definitions (should match the original run).
Tool results for pending tool calls.
Exact provider-issued `tool_use_id` from the matching pending call.
Result content (or error message).
Whether this is an error result.
Builtins override. Omit / `null` to inherit the persisted session value.
Builtin tool config.
Shell-tools override. Omit / `null` to inherit the persisted session value.
Keep-alive override for this resume. `null` = inherit persisted session intent, `true` = enable, `false` = disable.
`meerkat_run` and `meerkat_resume` follow the same commit/cancel rule as the other interactive surfaces: committed success is not rewritten to cancellation, and post-commit create failure returns session identity so the session can be resumed.
Agent name for comms.
Model override. On materialized sessions this hot-swaps the LLM client for the remainder of the session.
Max tokens override.
Provider override for mid-session switching with `model`: `"anthropic"`,
`"openai"`, `"gemini"`, or `"other"`, as on `meerkat_run`.
Omission/`null` permits normal model/binding resolution; `"self_hosted"`
is not an accepted explicit token.
Run-scoped hook overrides.
### Response format
Both `meerkat_run` and `meerkat_resume` return MCP-standard tool results.
Parse the JSON-encoded `content[0].text` payload. Completed runs contain the
fields below; callback-pending control results use the distinct shape above.
MCP content blocks with the agent's text.
Session ID (save for `meerkat_resume`).
Number of LLM calls made.
Number of tool calls executed.
Parsed structured output when extraction succeeds, or explicit `null`.
Post-turn extraction failure details, or explicit `null`. The object has
`last_output` (the committed main-turn assistant text extraction tried to
transform), `attempts` (number of extraction attempts), and `reason`
(human-readable failure description).
Schema compatibility warnings.
The main turn can successfully return committed text in `content` even if
post-turn extraction fails. Inspect both `structured_output` and
`extraction_error`; missing structured output does not prove extraction was
never requested. Unlike REST/RPC `WireRunResult`, the MCP completed-run inner
payload includes explicit `null` values for these absent optional fields; the
outer MCP text wrapper remains unchanged.
### meerkat\_config
Get or update realm config for this MCP server instance.
```json Get theme={null}
{ "action": "get" }
```
```json Set theme={null}
{
"action": "set",
"config": { "agent": { "model": "gpt-5.5" } },
"expected_generation": 3
}
```
```json Patch theme={null}
{
"action": "patch",
"patch": { "agent": { "max_tokens_per_turn": 8192 } },
"expected_generation": 4
}
```
One of `"get"`, `"set"`, `"patch"`.
Full config to replace (for `set` action).
RFC 7396 merge-patch delta (for `patch` action).
Optional optimistic concurrency check for `set` and `patch`.
Response includes config envelope fields:
* `config`
* `generation`
* `realm_id`
* `instance_id`
* `backend`
* `resolved_paths` (only when the server runs with `--expose-paths`)
The MCP surface returns only the `ConfigEnvelope` shown above. It does **not**
carry the `live_propagation` field: that is part of the richer JSON-RPC
`ConfigWriteResult` contract, which reports the per-channel hot-swap / refresh /
close outcome (`swapped`, `skipped`, `swap_failed`, `refreshed`, `closed`,
`refresh_failed`, `close_failed`) when a `set`/`patch` write fans out to live
channels. Use the JSON-RPC `config/set` / `config/patch` methods when you need
the typed live-propagation report.
### meerkat\_capabilities
Returns the runtime capability set with status resolved against config.
```json Request theme={null}
{}
```
```json Response theme={null}
{
"contract_version": {"major": 0, "minor": 8, "patch": 52},
"capabilities": [
{
"id": "sessions",
"description": "Session lifecycle management",
"status": "Available"
},
{
"id": "structured_output",
"description": "Structured output extraction with JSON schema",
"status": "Available"
},
{
"id": "hooks",
"description": "Lifecycle hooks for tool and turn events",
"status": "Available"
},
{
"id": "builtins",
"description": "Built-in tools (task management)",
"status": {"DisabledByPolicy": {"description": "Disabled by config"}}
},
{
"id": "shell",
"description": "Shell command execution",
"status": {"DisabledByPolicy": {"description": "Disabled by config"}}
},
{
"id": "comms",
"description": "Inter-agent communication",
"status": {"NotCompiled": {"feature": "comms"}}
},
{
"id": "memory_store",
"description": "Semantic memory indexing",
"status": "Available"
},
{
"id": "skills",
"description": "Skill loading and injection",
"status": "Available"
}
]
}
```
Possible `status` values:
| Status | Shape | Meaning |
| - | - | - |
| `Available` | `"Available"` | Compiled in, config-enabled |
| `DisabledByPolicy` | `{"DisabledByPolicy": {"description": "..."}}` | Compiled in but disabled |
| `NotCompiled` | `{"NotCompiled": {"feature": "..."}}` | Feature flag absent |
| `NotSupportedByProtocol` | `{"NotSupportedByProtocol": {"reason": "..."}}` | Protocol does not support it |
### meerkat\_models\_catalog
Return the curated model catalog with provider profiles, capability metadata, and parameter schemas.
```json Request theme={null}
{}
```
```json Response theme={null}
{
"contract_version": {"major": 0, "minor": 8, "patch": 52},
"providers": [
{
"provider": "anthropic",
"default_model_id": "claude-opus-5-5",
"models": [
{
"id": "claude-opus-5",
"display_name": "Claude Opus 5",
"tier": "recommended",
"context_window": 1000000,
"max_output_tokens": 128000,
"profile": {
"model_family": "claude-opus-5",
"supports_temperature": false,
"supports_thinking": true,
"supports_reasoning": false,
"params_schema": {}
}
}
]
}
]
}
```
No parameters required. The catalog is resolved from built-in model metadata plus config-backed provider/server entries.
### meerkat\_skills
List available skills with provenance information, or inspect one skill's full
body by typed `SkillKey`.
```json List all skills theme={null}
{ "action": "list" }
```
```json Inspect a skill theme={null}
{
"action": "inspect",
"skill_key": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"skill_name": "task-workflow"
}
}
```
```json Inspect a specific source theme={null}
{
"action": "inspect",
"skill_key": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"skill_name": "task-workflow"
},
"source": "00000000-0000-4b11-8111-000000000001"
}
```
```json List response theme={null}
{
"skills": [
{
"key": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"skill_name": "task-workflow"
},
"name": "Task Workflow",
"description": "How to use task tools",
"scope": "builtin",
"source": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"display_name": "embedded",
"transport_kind": "embedded",
"fingerprint": "embedded:inventory",
"status": "active"
},
"is_active": true
}
]
}
```
```json Inspect response theme={null}
{
"key": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"skill_name": "task-workflow"
},
"name": "Task Workflow",
"description": "How to use task tools",
"scope": "builtin",
"source": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"display_name": "embedded",
"transport_kind": "embedded",
"fingerprint": "embedded:inventory",
"status": "active"
},
"body": "# Task Workflow\n\n..."
}
```
`"list"` to list all skills, or `"inspect"` to load one skill's full body.
Required for `inspect`. The typed skill identity with `source_uuid` and
`skill_name` fields.
Optional source UUID selector for `inspect`; omit it to load the canonical
source for `skill_key`.
The list response includes both active and shadowed skills. Shadowed skills have
`is_active: false` and a `shadowed_by` field indicating which source has
precedence. Inspect canonicalizes `skill_key` through the identity registry
before loading the body.
# REST API
Source: https://docs.rkat.ai/api/rest
HTTP server for running and managing agent sessions over REST endpoints.
Meerkat ships a REST server for running and managing agent sessions over HTTP. This is the best fit if you want a simple, language-agnostic API for the Meerkat core.
## Getting started
For the simplest environment-backed setup, export the key for your chosen
provider before launching the server. [Realm auth profiles and
bindings](/guides/auth) are the persistent alternative:
```bash theme={null}
export ANTHROPIC_API_KEY="sk-..."
```
In that same configured terminal, start the installed binary:
```bash theme={null}
rkat-rest --realm team-alpha
```
Leave the server running and use another terminal for `curl`. Select an
Anthropic model to match the key above:
```bash theme={null}
curl -X POST http://127.0.0.1:8080/sessions \
-H "Content-Type: application/json" \
-d '{"prompt": "Hello, Meerkat!", "model": "claude-opus-5", "provider": "anthropic"}'
```
## Runtime scope
`rkat-rest` accepts:
* `--realm `
* `--isolated`
* `--instance `
* `--realm-backend `
* `--state-root `
* `--context-root `
* `--user-config-root `
* `--expose-paths`
If `--realm` is omitted, the server creates a new isolated opaque realm (`realm-...`).
`--realm-backend` is a creation hint only; after first open, `realm_manifest.json`
is authoritative.
The REST server has no general inbound HTTP authentication layer. The `auth/*`
routes manage outbound provider credentials; they do not authenticate callers
to this API. Keep the listener on loopback or put it behind an authenticated,
encrypted reverse proxy. The optional webhook-secret check protects only
`POST /sessions/{id}/external-events` and
`POST /sessions/{id}/peer-response-terminal`, not the other routes.
## Endpoint overview
This overview follows the generated OpenAPI artifact at
`artifacts/schemas/rest-openapi.json`.
| Method | Path | Description |
| - | - | - |
| `POST` | `/help` | Ask Meerkat usage help with the embedded platform skill |
| `POST` | `/sessions` | Create and run a new session |
| `GET` | `/sessions` | List sessions |
| `GET` | `/sessions/{id}` | Fetch session metadata and usage |
| `GET` | `/sessions/{id}/status` | Fetch current runtime state for a session |
| `GET` | `/sessions/{id}/history` | Fetch committed session transcript messages |
| `POST` | `/sessions/{id}/system_context` | Append one ordinary durable ordered System message |
| `POST` | `/sessions/{id}/system_prompt` | Replace one durable versioned system-prompt key |
| `POST` | `/sessions/{id}/messages` | Continue an existing session |
| `POST` | `/sessions/{id}/external-events` | Queue a runtime-backed external event |
| `POST` | `/sessions/{id}/peer-response-terminal` | Queue a typed peer terminal response event |
| `POST` | `/sessions/{id}/interrupt` | Interrupt an in-flight turn |
| `POST` | `/sessions/{id}/runs/{run_id}/stop` | Stop one exact run and terminalize every contributor bound to it |
| `DELETE` | `/sessions/{id}` | Archive (remove) a session |
| `GET` | `/sessions/{id}/events` | SSE stream for real-time updates |
| `GET` | `/schedule/tools` | List schedule tool definitions |
| `POST` | `/schedule/call` | Call a schedule tool directly |
| `POST` | `/schedules` | Create a schedule |
| `GET` | `/schedules` | List schedules |
| `GET` | `/schedules/{id}` | Fetch one schedule |
| `PATCH` | `/schedules/{id}` | Update a schedule |
| `DELETE` | `/schedules/{id}` | Delete a schedule |
| `POST` | `/schedules/{id}/pause` | Pause a schedule |
| `POST` | `/schedules/{id}/resume` | Resume a schedule |
| `GET` | `/schedules/{id}/occurrences` | List schedule occurrences |
| `GET` | `/workgraph/items` | List WorkGraph items |
| `GET` | `/workgraph/items/{id}` | Fetch one WorkGraph item |
| `GET` | `/workgraph/ready` | List ready WorkGraph items |
| `GET` | `/workgraph/snapshot` | Read a WorkGraph observability snapshot |
| `GET` | `/workgraph/events` | Read WorkGraph event history |
| `POST` | `/workgraph/goal/status` | Read goal and attention status |
| `POST` | `/workgraph/attention/list` | List attention bindings |
| `POST` | `/sessions/{id}/mcp/add` | Stage live MCP server addition (mcp feature) |
| `POST` | `/sessions/{id}/mcp/remove` | Stage live MCP server removal (mcp feature) |
| `POST` | `/sessions/{id}/mcp/reload` | Reload MCP server(s) (mcp feature) |
| `POST` | `/comms/send` | Push comms message into a session (comms feature) |
| `GET` | `/comms/peers` | List discoverable peers (comms feature) |
| `GET` | `/auth/profiles` | List realm auth profiles, backend profiles, and bindings |
| `POST` | `/auth/profiles` | Store binding-scoped credentials |
| `GET` | `/auth/bindings/{binding_id}` | Read a binding-scoped auth profile |
| `DELETE` | `/auth/bindings/{binding_id}` | Delete binding-scoped credentials |
| `POST` | `/auth/bindings/{binding_id}/test` | Test a binding resolve path |
| `POST` | `/auth/login/start` | Start interactive auth login |
| `POST` | `/auth/login/complete` | Complete interactive auth login |
| `POST` | `/auth/login/cancel` | Retire a pending MCP or connector OAuth attempt by its state, or an MCP attempt by its attempt\_ref |
| `POST` | `/auth/login/device/start` | Start device-code login |
| `POST` | `/auth/login/device/complete` | Complete device-code login |
| `GET` | `/auth/bindings/{binding_id}/status` | Read binding auth status |
| `POST` | `/auth/bindings/{binding_id}/logout` | Log out a binding |
| `GET` | `/realms` | List configured realms |
| `GET` | `/realms/{id}` | Read one realm |
| `GET` | `/mob/{id}/events` | Mob event SSE stream (mob feature) |
| `POST` | `/mob/{id}/spawn-helper` | Spawn a helper into a mob (mob feature) |
| `POST` | `/mob/{id}/fork-helper` | Fork a helper from an existing member (mob feature) |
| `POST` | `/mob/{id}/wait-kickoff` | Wait for kickoff completion (mob feature) |
| `POST` | `/mob/{id}/wire-members-batch` | Wire multiple mob members in one request (mob feature) |
| `GET` | `/mob/{id}/members/{agent_identity}/status` | Read member status (mob feature) |
| `GET` | `/mob/{id}/members/{agent_identity}/tool-declaration` | Read durable tool declaration and convergence status as `MobMemberToolDeclarationResult` (mob feature) |
| `PUT` | `/mob/{id}/members/{agent_identity}/tool-declaration` | Apply a declaration with exact-revision CAS: `RestApplyMemberToolDeclarationRequest` → `MobApplyMemberToolDeclarationResult` (mob feature) |
| `POST` | `/mob/{id}/members/{agent_identity}/identity-adoption` | Adopt an existing member under an expected-absent precondition: `RestAdoptMemberIdentityDeclarationRequest` → `MobAdoptMemberIdentityDeclarationResult` (mob feature) |
| `POST` | `/mob/{id}/members/{agent_identity}/tool-convergence/resolve` | Resolve blocked identity convergence against desired/active revisions: `RestResolveIdentityConvergenceBlockRequest` → `MobResolveIdentityConvergenceBlockResult` (mob feature) |
| `POST` | `/mob/{id}/members/{agent_identity}/cancel` | Force-cancel a member (mob feature) |
| `POST` | `/mob/{id}/members/{agent_identity}/runs/{run_id}/stop` | Stop one exact run of a member (mob feature) |
| `POST` | `/mob/{id}/members/{agent_identity}/respawn` | Respawn a member (mob feature) |
| `GET` | `/mob/{id}/members/{agent_identity}/history` | Read a mob member transcript page by identity (mob feature) |
| `GET` | `/mob/{id}/hosts` | List tracked member hosts with bind phase and capabilities (mob feature) |
| `GET` | `/mob/{id}/route-installs` | Outstanding cross-host route-install obligations (mob feature) |
| `GET` | `/skills` | List skills with provenance |
| `GET` | `/health` | Liveness check |
| `GET` | `/runtime/host_info` | Read runtime host identity, endpoints, and realm projection |
| `GET` | `/runtime/capabilities` | Read runtime host capability flags |
| `GET` | `/runtime/health` | Read runtime host health |
| `GET` | `/models/catalog` | Curated model catalog with provider profiles |
| `GET` | `/capabilities` | Runtime capabilities |
| `GET` | `/config` | Read config |
| `PUT` | `/config` | Replace config |
| `PATCH` | `/config` | Merge-patch config (RFC 7396) |
| `POST` | `/requests/{request_id}/cancel` | Cancel an uncommitted in-flight request |
Helper requests (`/mob/{id}/spawn-helper`, `/mob/{id}/fork-helper`) require
`result_label` and `max_text_bytes`. Spawn and fork responses are
exact-operation results with required `output`, `tokens_used`,
`agent_identity`, `member_ref`, `bounded_result`, `session_id`, `usage`,
`turns`, and `tool_calls`; `retirement_error` is present only when cleanup
debt remains after the certified result was captured.
Schedule endpoints return the same flattened public `Schedule` contract as
JSON-RPC. Planning, labels, and created/updated timestamps are top-level;
there is no nested `config` and persisted `machine_state` is not public.
WorkGraph goal and attention mutation routes require trusted in-process
host/session authority and are not part of the public REST catalog.
Approvals, durable jobs and monitors, stable artifact record/download methods,
and low-latency live channels are not REST routes. Those operator surfaces are
available through JSON-RPC and the generated SDKs. REST history can carry blob
references, but REST does not expose blob or artifact download paths.
Multi-host mob administration (host bind/revoke, hard-cancel, member live
channels, grant management) is deliberately NOT served by REST — REST carries
observation GETs only. The complete admin family lives on JSON-RPC and the
Python/TypeScript SDKs; host binding/revoke, grants, and live control also have
CLI verbs, while hard cancel is RPC/SDK-only.
Durable member role migration is also outside the public REST contract. The
shared OpenAPI component catalog includes private member-host bridge schemas,
including `MaterializeLaunchMode`, because components are generated from shared
wire types. No REST path accepts `resume_from_role`; only a trusted Rust host or
the private member-host materialization protocol can issue that one-shot
declaration. The `paths` object, not component reachability, defines REST
exposure.
Generated images are not returned as inline bytes in history. `GET /sessions/{id}/history` returns assistant image blocks with `image_id`, `blob_ref`, dimensions, and metadata; fetch image bytes through the blob/artifact surface exposed by the runtime-backed RPC/SDK path.
REST keeps observation and helper endpoints for mobs, but typed lifecycle/control
for app hosts lives on the canonical RPC/SDK `mob/*` surface. Inside running
sessions, mob capability is still exposed by composing `meerkat-mob-mcp`
(`MobMcpState` + `AgentMobToolSurfaceFactory`) into
`SessionBuildOptions.mob_tools` in the host runtime. `external_tools` remains
reserved for callback and MCP-backed dispatchers.
WorkGraph REST endpoints are observability/operator lookup, including
goal-status and attention-list reads. Agents create, claim, update, link,
evidence, close, reassign attention, and escalate policy through WorkGraph tools
inside sessions; goal and attention mutators stay on trusted in-process host
authority.
## Request cancellation
REST request cancellation is opt-in and request-ID based.
* send `X-Meerkat-Request-Id: ` on `POST /sessions` or `POST /sessions/{id}/messages`
* call `POST /requests/{request_id}/cancel` to cancel uncommitted in-flight work
* duplicate in-flight request IDs are rejected
Cancellation only affects uncommitted work:
* pre-start / pre-commit work may return cancelled
* committed success is not rewritten to cancellation
* post-commit create failures still return session identity and remain resumable
## Configuration
REST configuration is realm-scoped:
* macOS: `~/Library/Application Support/meerkat/realms//config.toml`
* Linux: `~/.local/share/meerkat/realms//config.toml`
* Windows: `%APPDATA%\\meerkat\\realms\\\\config.toml`
Each realm also has `realm_manifest.json` (backend pinning) and `config_state.json` (generation CAS state).
Key sections:
```toml theme={null}
[rest]
host = "127.0.0.1"
port = 8080
[agent]
model = "claude-opus-4-8"
max_tokens_per_turn = 8192
[tools]
builtins_enabled = false
shell_enabled = false
schedule_enabled = true
workgraph_enabled = false
```
API keys are provided via environment variables:
* `ANTHROPIC_API_KEY`
* `OPENAI_API_KEY`
* `GOOGLE_API_KEY`
## Endpoints
### POST /help
Ask Meerkat usage help with the embedded platform skill.
```json Request theme={null}
{
"question": "How do I add an MCP server?"
}
```
Returns a `HelpResponse` with the answer text and any plan metadata requested by the input.
### POST /sessions
Create and run a new session.
```json Request (minimal) theme={null}
{
"prompt": "Your prompt here"
}
```
```json Request (full) theme={null}
{
"prompt": "Your prompt here",
"system_prompt": "Optional system prompt",
"model": "claude-opus-4-8",
"provider": "anthropic",
"max_tokens": 4096,
"output_schema": {
"schema": {"type": "object", "properties": {"answer": {"type": "string"}}},
"name": "answer",
"strict": false,
"compat": "lossy",
"format": "meerkat_v1"
},
"structured_output_retries": 2,
"verbose": false,
"keep_alive": null,
"comms_name": null,
"peer_meta": null,
"hooks_override": null,
"enable_builtins": null,
"enable_shell": null,
"enable_memory": null,
"enable_schedule": null,
"enable_workgraph": null
}
```
```json Response (minimal request) theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"text": "Response text",
"turns": 1,
"tool_calls": 0,
"usage": {
"input_tokens": 50,
"output_tokens": 200,
"total_tokens": 250
}
}
```
#### Request fields
The user prompt: a string or a typed content-block array, for example
`[{"type":"text","text":"Hello"}]`. Media blocks depend on the selected
model/provider; see [model capabilities](/reference/capability-matrix).
Host-attached injected context for the first turn. Each entry materializes
as a separate typed injected-context user-channel message immediately before
the first turn's user message, in order; injected context is excluded from
semantic-memory indexing. Older REST servers silently ignore this field
(existing REST version-skew posture).
Non-empty, exact host-regenerated facts for the first logical turn.
Whitespace is significant. Meerkat persists the text only with the pending
runtime input for crash retry and projects it as a request-local user-channel
context message immediately before that turn's conversational user message.
It never enters Session history, compaction, or extraction requests.
Typed per-request system-prompt policy: omit/`null` to inherit, a string to
set an explicit prompt, or `{"action": "disable"}` to suppress every prompt
source.
Model name (e.g. `"claude-opus-4-8"`, `"gpt-5.5"`).
Provider: `"anthropic"`, `"openai"`, `"gemini"`, `"self_hosted"`, `"other"`.
Optional structural credential selector, for example
`{"realm":"prod","binding":"anthropic","profile":"claude_oauth"}`.
`realm` and `binding` are required when supplied; `profile` is optional.
Configure the referenced [realm, binding, and auth profile](/guides/auth)
first. Omission/`null` lets the create resolver select credentials. This is
an object, not the CLI's colon-delimited string; provenance is server-owned.
Max tokens per turn.
JSON schema for structured output extraction (wrapper or raw schema).
Max retries for structured output validation. `null`/omitted uses the server default on create and inherits the persisted session value on continue.
Enable verbose event logging (server-side).
Keep session alive after turn for comms. On create, `null`/omitted uses the create default (`false`), `true` enables, and `false` explicitly disables. Requires `comms_name` when enabled.
Agent name for inter-agent communication.
Friendly metadata for peer discovery (name, description, labels).
Run-scoped hook overrides (see [Hooks](/guides/hooks)).
Enable built-in tools (task management, etc.). Omit to use factory defaults.
Enable shell tool (requires `enable_builtins`). Omit to use factory defaults.
Enable semantic memory. Omit to use factory defaults.
Override schedule tools for this session. Omit to use factory defaults.
Override WorkGraph tools for this session. Omit to use factory defaults.
#### Response fields
UUID of the created session.
The agent's response text.
Number of LLM calls made.
Number of tool calls executed.
Token usage breakdown.
Parsed structured output when extraction succeeds. Omitted when absent.
Post-turn extraction failure details: `last_output` is the committed
main-turn assistant text that extraction attempted to transform, `attempts`
is the number of extraction attempts made, and `reason` describes the
failure. Omitted when absent.
Schema compatibility warnings per provider. Omitted when absent.
A successful response can contain committed `text` even if post-turn
structured-output extraction fails. Inspect both `structured_output` and
`extraction_error`; missing structured output does not prove extraction was
never requested. These optional REST fields are omitted when absent, not
filled with `null`.
### GET /sessions//history
Read committed transcript history for a session without changing the lightweight metadata shape of `GET /sessions/{id}`.
Query parameters:
* `offset` — skip this many messages from the start of the transcript
* `limit` — cap the number of returned messages
```json theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"message_count": 8,
"offset": 0,
"limit": 50,
"has_more": false,
"messages": [
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Hello" },
{
"role": "block_assistant",
"blocks": [{ "block_type": "text", "data": { "text": "Hi there." } }],
"stop_reason": "end_turn",
"assistant_message_id": "01936f8a-7b2c-7000-8000-00000000a001"
}
]
}
```
History is returned oldest-to-newest and reflects the last committed session snapshot only.
`block_assistant` rows committed by 0.8.45 or later carry `assistant_message_id`,
the same id the message's live SSE events carried; join live output to history
by that id. Rows the realtime transcript materializer commits carry
`realtime_origin` instead, whose `provider_item_ids` match the provider item ids
on the live transport's realtime observations. See
[assistant message identity](/reference/session-contracts#assistant-message-identity).
### POST /sessions/\{id}/system\_context
Append one ordinary durable ordered System message at the admitted transcript
boundary. `content` uses the `CoreRenderable` tagged shape, and the optional
`idempotency_key` makes an exact caller retry safe.
```json Request theme={null}
{
"content": {"type": "text", "text": "Treat the incident as severity one."},
"source": "incident-controller",
"idempotency_key": "incident-42-policy-v1"
}
```
```json Response theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"status": "applied"
}
```
### POST /sessions/\{id}/system\_prompt
Replace one keyed system-prompt slot with version CAS. The first explicit
adoption supplies `target_message_index` for an existing unversioned System row
and omits `expected_version`. Later updates omit the target and provide the
latest `expected_version`. `expected_parent_revision` optionally adds a
transcript-head CAS.
```json Request theme={null}
{
"key": "operator-policy",
"expected_version": 1,
"content": "Escalate critical actions for human approval.",
"actor": "operator:alice"
}
```
The result contains `session_id`, `key`, the new `version`, `message_index`,
`status` (`applied` or `duplicate`), `transcript_revision`, and an optional
rewrite `commit`. Mob-owned system prompts must be updated through their mob
owner and are rejected on this session route.
### GET /sessions
List sessions. Supports optional label filters via query parameters.
```json Response theme={null}
{
"sessions": [
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"created_at": 1736937000,
"updated_at": 1736937060,
"message_count": 4,
"total_tokens": 500,
"is_active": false
}
]
}
```
Each row is a `WireSessionSummary`. `created_at` and `updated_at` are Unix
seconds. `is_active` is the reported session activity flag, not a full
runtime-state enum; use the status endpoint for runtime state. Optional
`session_ref` and non-empty `labels` may also be present. The separate
`GET /sessions/{id}` detail response retains its RFC3339 timestamp strings.
### POST /sessions/\{id}/messages
Continue an existing session.
```json Request theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"prompt": "Follow-up message"
}
```
```json Response theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"text": "Response to follow-up",
"turns": 1,
"tool_calls": 0,
"usage": {
"input_tokens": 100,
"output_tokens": 150,
"total_tokens": 250
}
}
```
#### Request fields
Session ID (must match the path `{id}`).
Follow-up prompt as text or typed content blocks, for example
`[{"type":"text","text":"Continue"}]`. Media support is model/provider
dependent; see [model capabilities](/reference/capability-matrix).
Host-attached injected context for this turn. Each entry materializes as a
separate typed injected-context user-channel message immediately before the
turn's user message, in order; injected context is excluded from
semantic-memory indexing. Older REST servers silently ignore this field
(existing REST version-skew posture).
Non-empty, exact host-regenerated facts for this logical turn. Whitespace is
significant. The value is available to every foreground provider call and
retry in the turn, persists only while the runtime input is pending, and
never becomes a Session message.
Override the system prompt.
Model override for this turn. On materialized sessions this hot-swaps the LLM client for the remainder of the session.
Provider override for this turn. Typically inferred from `model`. Used with `model` for mid-session provider switching.
Max tokens override for this turn.
Structured output schema for this turn.
Max retries for structured output validation. Omit / `null` to inherit the persisted session value on continue.
Enable verbose event logging.
Keep-alive override for this turn. `null` = inherit persisted session intent, `true` = enable, `false` = disable.
Agent name for comms.
Run-scoped hook overrides.
#### Response fields
Same shape as `POST /sessions`.
### POST /sessions/\{id}/interrupt
Interrupt an in-flight turn. No-op if the session is idle.
```json Response theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"interrupted": true,
"result": "interrupted"
}
```
`interrupted` is derived from the typed `result`. The public classifier also
reports `"interrupted"`/`true` for an existing idle session's no-op; it is not
proof that a live turn was cancelled. The shared result type also has
`"staged_noop"`/`false` for staged sessions on RPC's deferred-create path;
REST does not expose deferred creation. Typed failures remain HTTP errors,
not successful interrupt results.
### POST /sessions/\{id}/runs/\{run\_id}/stop
Stop one exact run and terminalize every input already bound to it, including
durable steers that joined it. Learn the run id from the `run_started` event
(`identity.run_id`). The response is the same `StopRunResult` as the RPC
`turn/stop_run` method.
```json Request theme={null}
{"reason": "user pressed stop"}
```
```json Response theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"receipt": {
"outcome": "stopped",
"run_id": "01936f8b-0000-7000-8000-000000000042",
"contributors": [
{"input_id": "01936f8b-0000-7000-8000-0000000000a1", "completion": "cancelled", "terminal": "cancelled"}
]
}
}
```
`receipt.outcome` is `stopped`, `not_current` (the run is no longer current;
nothing was touched), or `not_stoppable` (a runtime stop or teardown owns the
still-bound run). A malformed `run_id` is `400`.
`POST /mob/{id}/members/{agent_identity}/runs/{run_id}/stop` stops one exact
run of a mob member and returns `MobStopMemberRunResult`
(`{mob_id, agent_identity, receipt}`) with the same receipt.
### POST /requests/\{request\_id}/cancel
Cancel an uncommitted in-flight request that previously supplied `X-Meerkat-Request-Id`.
```json Response theme={null}
{"cancelled": true}
```
Returns `200` with `{"cancelled": false, "reason": "already_terminal"}` when
the tracked request already published or completed. Returns `404` only when the
request ID is unknown.
### DELETE /sessions/\{id}
Archive (remove) a session.
```json Response theme={null}
{"archived": true}
```
Returns `404` if the session is not found.
### POST /sessions/\{id}/external-events
Queue an external event through the runtime-backed admission path.
```bash Without auth (localhost only) theme={null}
curl -X POST http://localhost:8080/sessions/01936f8a-7b2c-7000-8000-000000000001/external-events \
-H "Content-Type: application/json" \
-d '{"kind": "generic_json", "event_type": "monitor.alert", "payload": {"alert": "CPU spike", "host": "web-03"}}'
```
```bash With webhook secret theme={null}
curl -X POST http://localhost:8080/sessions/01936f8a-7b2c-7000-8000-000000000001/external-events \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: my-secret" \
-d '{"kind": "generic_json", "event_type": "deployment.alert", "payload": {"alert": "deployment failed"}}'
```
```json Response (202 Accepted) theme={null}
{"queued": true}
```
When `RKAT_WEBHOOK_SECRET` is configured on the server, this route requires
the `X-Webhook-Secret` header. Admission queues the event without waking an
idle session. Correlated terminal peer responses use
`POST /sessions/{id}/peer-response-terminal`, not this generic event route.
Must be `"generic_json"`. The `"peer_response_terminal"` tag is reserved
and rejected here.
Event classification, for example `"monitor.alert"`.
Application payload inside the tagged envelope, not a flat request body.
Optional typed content blocks accompanying the event.
Webhook secret for authentication. Required when `RKAT_WEBHOOK_SECRET` env var is set on the server. Compared using constant-time equality (`subtle::ConstantTimeEq`).
| HTTP Status | When |
| - | - |
| 202 | Event queued successfully |
| 404 | Session not found |
| 401 | Missing or invalid webhook secret |
| 503 | Event inbox is full |
### POST /sessions/\{id}/peer-response-terminal
Admit a terminal peer response through the typed runtime ingress. Like
`external-events`, this route requires `X-Webhook-Secret` when the server has
`RKAT_WEBHOOK_SECRET` configured; a missing or invalid secret returns HTTP
`401`. The following example assumes that secret is enabled:
```bash With webhook secret theme={null}
curl -X POST http://localhost:8080/sessions/01936f8a-7b2c-7000-8000-000000000001/peer-response-terminal \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: my-secret" \
-d '{
"peer_id": "00000000-0000-4000-8000-000000000161",
"display_name": "analyst",
"request_id": "00000000-0000-4000-8000-000000000162",
"status": "completed",
"result": {"token": "silver harbor"}
}'
```
Required when `RKAT_WEBHOOK_SECRET` is set on the server; omit only when
webhook-secret authentication is disabled.
Canonical peer routing ID. Display names are not accepted as routing identity.
Optional presentation label.
Peer correlation ID for the request this terminal response completes.
Terminal response status: `"completed"`, `"failed"`, or `"cancelled"`.
Peer-returned terminal payload.
### GET /sessions/\{id}
Fetch session metadata and usage.
```json Response theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:31:00Z",
"message_count": 4,
"total_tokens": 500
}
```
### GET /sessions/\{id}/events
Server-Sent Events (SSE) stream for real-time updates.
Event types:
| Event | Description |
| - | - |
| `session_loaded` | Emitted on connect with session metadata |
| `run_started` | Agent execution began |
| `run_completed` | Agent run finished |
| `run_failed` | Agent run failed |
| `turn_started` | New LLM call within the turn; `assistant_message_id` names the assistant message it will commit |
| `text_delta` | Streaming text chunk from LLM |
| `text_complete` | Full text for this turn |
| `tool_call_requested` | LLM wants to call a tool |
| `tool_result_received` | Tool result processed |
| `turn_completed` | LLM call finished; `assistant_message_id` names the message the turn closes. It is a live signal, not the commit: only a history row carrying the id is the commit. It can precede boundary work, run-completed hooks and persistence that still fail the run, and it is absent when a run fails after the row was pushed. It is also absent on realtime materializer turns, whose rows pair by `realtime_origin.provider_item_ids` |
| `tool_execution_started` | Tool dispatch began |
| `tool_execution_completed` | Tool returned a result |
| `tool_execution_timed_out` | Tool exceeded timeout |
| `compaction_started` | Context compaction began |
| `compaction_completed` | Compaction finished |
| `compaction_failed` | Compaction failed |
| `budget_warning` | Approaching resource limits |
| `retrying` | Retrying after transient error; the retry reuses `assistant_message_id` |
| `hook_started` | Hook execution began |
| `hook_completed` | Hook finished |
| `hook_failed` | Hook execution failed |
| `hook_denied` | Hook blocked operation |
| `skills_resolved` | Skills loaded for turn |
| `skill_resolution_failed` | Skill resolution failed |
| `stream_truncated` | An upstream bounded subscriber reported lag and a dropped count; not guaranteed for REST transport loss |
| `done` | Emitted when the broadcast channel closes |
The live SSE subscription is bounded and best-effort. Upstream
`stream_truncated` envelopes can be forwarded, but the REST transport's own
broadcast receiver can also lose events without a marker or dropped count.
Do not infer completeness from the absence of a lag marker. Built-in
realm-backed persistent sessions can feed an optional durable audit projector
through a separate unbounded queue, so SSE lag does not drop its input. That
audit log is asynchronous derived state, not a guaranteed complete replay.
Reconnect and reconcile from session history/status, or use the JSON-RPC
`events/*` methods when that optional replay surface is installed and healthy.
### GET /skills
List all skills with provenance information. Returns active and shadowed entries.
```json Response theme={null}
{
"skills": [
{
"key": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"skill_name": "task-workflow"
},
"name": "Task Workflow",
"description": "How to use task tools",
"scope": "builtin",
"source": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"display_name": "embedded",
"transport_kind": "embedded",
"fingerprint": "embedded:inventory",
"status": "active"
},
"is_active": true
},
{
"key": {
"source_uuid": "dc256086-0d2f-4f61-a307-320d4148107f",
"skill_name": "task-workflow"
},
"name": "Custom Task Workflow",
"description": "Override tasks",
"scope": "project",
"source": {
"source_uuid": "dc256086-0d2f-4f61-a307-320d4148107f",
"display_name": "company",
"transport_kind": "git",
"fingerprint": "repo-7cc66f36fd9db1a1",
"status": "active"
},
"is_active": true
}
]
}
```
Returns `404` if skills are not enabled.
### GET /health
Returns `"ok"` (HTTP 200). Use for liveness checks.
### GET /runtime/host\_info
Returns the read-only `RuntimeHostInfo` projection: process identity, host ID
scope, realm and endpoint metadata, feature flags, and the same health payload
as `GET /runtime/health`. It does not enroll a host or grant placement
authority.
### GET /runtime/capabilities
Returns `RuntimeHostCapabilities`, a contract version plus boolean
`RuntimeHostFeatureFlags`. These host feature flags are distinct from the
generic status entries returned by `GET /capabilities`.
### GET /runtime/health
Returns `RuntimeHostHealth` with `status` and a `checks` map. The declared
dimensions are `jobs`, `session_liveness`, `session_durability`,
`session_runtime_loop`, and `session_run_start`. REST measures the four session
dimensions. It has no detached-job service probe, so it reports
`unmeasured:jobs` as a coverage marker and leaves that marker out of the status
rollup.
A plain dimension key is a measured result. `unreadable:` means the
probe ran but could not obtain a reading and rolls the overall status up to at
least `degraded`. `unmeasured:` means this surface has no probe and
does not by itself make a healthy host permanently degraded.
### GET /models/catalog
Return the curated model catalog with provider profiles, capability metadata, and parameter schemas.
```json Response (abbreviated) theme={null}
{
"contract_version": {"major": 0, "minor": 8, "patch": 52},
"providers": [
{
"provider": "anthropic",
"default_model_id": "claude-opus-5-5",
"models": [
{
"id": "claude-opus-5",
"display_name": "Claude Opus 5",
"tier": "recommended",
"context_window": 1000000,
"max_output_tokens": 128000,
"profile": {
"model_family": "claude-opus-5",
"supports_temperature": false,
"supports_thinking": true,
"supports_reasoning": false,
"params_schema": {}
}
}
]
}
]
}
```
The catalog is resolved from built-in model metadata plus config-backed provider/server entries. Each provider entry includes the default model and a list of models with their capabilities and parameter schemas.
### GET /capabilities
Returns runtime capabilities with status resolved against config.
```json Response (abbreviated) theme={null}
{
"contract_version": {"major": 0, "minor": 8, "patch": 52},
"capabilities": [
{
"id": "sessions",
"description": "Session lifecycle management",
"status": "Available"
},
{
"id": "shell",
"description": "Shell command execution",
"status": {"DisabledByPolicy": {"description": "Disabled by config"}}
}
]
}
```
The full response includes all registered capabilities from the running build,
including `sessions`, `streaming`, `structured_output`, `hooks`, `builtins`,
`shell`, `comms`, `memory_store`, `schedule`, `work_graph`, `session_store`,
`session_compaction`, `skills`, and `mcp_live` when their owning crates are
linked. See the [JSON-RPC API](/api/rpc) for the corresponding RPC surface.
### GET /config
Returns a realm config envelope:
* `config`
* `generation`
* `realm_id`
* `instance_id`
* `backend`
* `resolved_paths`
### PUT /config
Replaces config.
Accepted request forms:
* Direct config object (compat)
* Wrapped form with CAS:
* `{ "config": , "expected_generation": }`
### PATCH /config
Applies RFC 7396 merge patch.
Accepted request forms:
* Direct patch object (compat)
* Wrapped form with CAS:
* `{ "patch": , "expected_generation": }`
If `expected_generation` is stale, the server returns `400` with a generation-conflict message.
### GET /auth/profiles
List the auth profiles, backend profiles, and provider bindings for a realm.
Realm to read.
Optional profile selector accepted by the shared auth query shape.
```json Response theme={null}
{
"realm_id": "prod",
"auth_profiles": [],
"backend_profiles": [],
"bindings": []
}
```
### POST /auth/profiles
Store credentials for an existing binding-scoped auth profile. The binding must
resolve to an auth profile whose source is `managed_store`; inline, env,
external resolver, platform-default, command, and file-descriptor sources are
configured outside this endpoint.
```json Request theme={null}
{
"realm_id": "prod",
"binding_id": "openai",
"profile_id": "prod_openai",
"provider": "openai",
"auth_method": "api_key",
"secret": "sk-..."
}
```
```json Response (201 Created) theme={null}
{
"realm_id": "prod",
"binding_id": "openai",
"auth_binding": {
"realm": "prod",
"binding": "openai",
"profile": "prod_openai"
},
"profile_id": "prod_openai",
"provider": "openai",
"auth_method": "api_key",
"stored": true
}
```
Realm containing the binding.
Binding whose configured auth profile will receive the stored credential.
Optional explicit profile override for the binding.
Provider expected from the resolved auth profile.
Stored-secret method: `"api_key"`, `"azure_api_key"`, or `"static_bearer"`.
Secret material to persist in the token store.
### GET /auth/bindings/\{binding\_id}
Read the auth profile resolved by a binding.
Binding to resolve.
Realm containing the binding.
Optional explicit profile override for the binding.
### DELETE /auth/bindings/\{binding\_id}
Clear stored credentials for the resolved binding-scoped auth profile.
Binding whose stored credentials should be cleared.
Realm containing the binding.
Optional explicit profile override for the binding.
### POST /auth/bindings/\{binding\_id}/test
Resolve a binding through the provider registry and report whether credential
material or a dynamic authorizer is available.
```json Request theme={null}
{
"realm_id": "prod",
"profile_id": "prod_openai"
}
```
Binding to test.
Realm containing the binding.
Optional explicit profile override for the binding.
### POST /auth/login/start
Begin a loopback OAuth login. The server owns the OAuth state and PKCE verifier.
```json Request theme={null}
{
"provider": "anthropic",
"redirect_uri": "http://127.0.0.1:53682/callback",
"realm_id": "prod",
"binding_id": "anthropic",
"profile_id": "claude_oauth"
}
```
Configured credential-owner realm; the server's active realm is not an
automatic substitute.
Configured binding to receive the OAuth credentials.
Optional explicit auth profile override.
Start and complete must address the same configured credential
owner/binding/profile. See [authentication setup](/guides/auth).
### POST /auth/login/complete
Complete a loopback OAuth login and store the resulting tokens under an explicit
binding-scoped `AuthBindingRef`.
```json Request theme={null}
{
"provider": "anthropic",
"code": "provider-code",
"state": "state-from-start",
"redirect_uri": "http://127.0.0.1:53682/callback",
"realm_id": "prod",
"binding_id": "anthropic",
"profile_id": "claude_oauth"
}
```
Realm containing the binding.
Binding that owns the stored OAuth tokens.
Optional explicit profile override for the binding.
The same endpoints accept an MCP server target instead of a provider binding:
`{"mcp": {"server_name", "server_url", "oauth_account", "oauth_account_selection"}, ...}`
(the last two only name the configured selection), or a connector
target, `{"connector": {"slot", "issuer", "client", "resource", "scopes",
"strategy_id", "account_selection"}, ...}`. See
[Host-driven OAuth](/api/mcp#host-driven-oauth) and
[Connector OAuth](/guides/auth#connector-oauth).
Auth endpoint errors return `{"error": "", "reason": ""}`. Branch on `reason` (for example `slot_occupied`, `account_mismatch`, `attempt_missing`, `reauth_required`, `infrastructure`), not on the text; status codes are unchanged.
### POST /auth/login/cancel
Retire a pending MCP or connector OAuth attempt by its state. An MCP server
must be configured; a connector attempt is named by its slot,
`{"connector": {"realm_id", "slot_id"}, "state"}`. An MCP attempt may instead
be named by the non-secret reference the RPC `auth/status/get` reports,
`{"mcp": {...}, "attempt_ref"}`, never by both.
```json Request theme={null}
{
"mcp": {"server_name": "remote-oauth", "server_url": "https://mcp.example.com/api"},
"state": "state-from-start"
}
```
### POST /auth/login/device/start
Begin a device-code OAuth login.
```json Request theme={null}
{
"provider": "google",
"realm_id": "prod",
"binding_id": "google",
"profile_id": "gemini_oauth"
}
```
Configured credential-owner realm, explicitly supplied even when it matches
the server's active realm.
Configured binding to receive the device-flow credentials.
Optional explicit auth profile override.
Use that same credential owner/binding/profile for device completion; the
example explicitly selects `gemini_oauth` in both requests.
### POST /auth/login/device/complete
Poll one device-code OAuth completion attempt and, when ready, store the tokens
under an explicit binding-scoped `AuthBindingRef`.
```json Request theme={null}
{
"provider": "google",
"device_code": "device-code-from-start",
"realm_id": "prod",
"binding_id": "google",
"profile_id": "gemini_oauth"
}
```
Returns `202` with `{ "state": "pending" }` when the provider has not finished,
`429` with `{ "state": "slow_down" }` when the caller should back off, and `200`
with a `ready` payload after tokens are persisted.
### GET /auth/bindings/\{binding\_id}/status
Read binding-scoped auth status. The response includes the flattened binding
identity plus `profile_id`, `provider`, `auth_method`, public state,
expiration, refresh timestamp, account ID, and refresh-token presence.
Binding whose status should be read.
Realm containing the binding.
Optional explicit profile override for the binding.
### POST /auth/bindings/\{binding\_id}/logout
Clear stored credentials for the binding and publish the auth lifecycle release.
Binding to log out.
Realm containing the binding.
Optional explicit profile override for the binding.
### GET /realms
List configured realm summaries.
### GET /realms/\{id}
Read one realm connection set.
### POST /comms/send
Push a canonical comms command into a running session. Requires the `comms` feature.
```json Request theme={null}
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"kind": "peer_message",
"to": "01936f8a-7b2c-7000-8000-000000000002",
"body": "Please review the latest diff",
"handling_mode": "queue"
}
```
```json Response (200 OK, acknowledged transport) theme={null}
{
"kind": "peer_message_sent",
"envelope_id": "01936f8a-7b2c-7000-8000-000000000003",
"delivery": "acked"
}
```
#### Closed request variants
`CommsSendParams` is a flat, `kind`-tagged union, not a shared bag of optional
fields. Every variant requires a string `session_id` identifying the sending
or receiving local session and rejects unknown fields. `to` is a canonical
peer ID, never a display name.
| `kind` | Required fields in addition to `session_id` and `kind` | Optional fields |
| - | - | - |
| `input` | `body` (string) | `blocks`, `source`, `stream`, `handling_mode`, `allow_self_session` |
| `peer_message` | `to`, `body` (string) | `blocks`, `content_taint`, `handling_mode` |
| `peer_lifecycle` | `to`, `lifecycle_kind`, `params` | None |
| `peer_request` | `to`, `intent`, `params` | `blocks`, `content_taint`, `handling_mode`, `stream` |
| `peer_response` | `to`, `in_reply_to`, `status` | `result`, `blocks`, `content_taint`, `handling_mode` |
* `blocks` is a typed `ContentBlock` array, not arbitrary JSON.
* `source` belongs only to `input`: `"tcp"`, `"uds"`, `"stdin"`, `"webhook"`,
or `"rpc"` (default `"rpc"`). It is not a free-text source label.
* `stream` accepts `"none"` (default) or `"reserve_interaction"` on `input`
and `peer_request`.
* `allow_self_session` is an input-only boolean, default `false`.
* `content_taint` is the typed `SendTaintOverride` on content-bearing peer
variants; omission inherits the runtime's outbound declaration.
* `handling_mode` accepts `"queue"` or `"steer"`. Omission/`null` defaults to
`"queue"` for `input`, `peer_message`, and `peer_request`. It is forbidden
on machine-classified progress responses. A terminal `peer_response` may
specify it or inherit the recorded inbound request's mode and requires
that recorded request to still be repliable.
`peer_message` is the ordinary collaboration primitive. `peer_lifecycle`
is a one-way notification: its `lifecycle_kind` is a topology kind
(`"mob.peer_added"`, `"mob.peer_retired"`, `"mob.peer_unwired"`,
`"mob.dismiss"`) or a member-kickoff status kind (`"mob.kickoff_pending"`,
`"mob.kickoff_starting"`, `"mob.kickoff_started"`,
`"mob.kickoff_callback_pending"`, `"mob.kickoff_failed"`,
`"mob.kickoff_cancelled"`). Kickoff kinds reach the receiving agent as a
visible `lifecycle` comms notice; none of them opens a request. `mob.dismiss` is supervisor-directed; accepting that enum
value does not grant the caller dismissal authority. Its required
`CommsPeerLifecycleParams` object contains `peer` (string) and optional
`role`, `description`, and typed `peer_spec` (`BridgePeerSpec`).
`peer_request` is a structured ask with correlated replies. Its closed intent
vocabulary is `"supervisor.bridge"` or `"checksum_token"`, not arbitrary
application labels such as `"review"` or `"delegate"`. Required `params`
must match the intent: a typed `BridgeCommand` for `supervisor.bridge`, or
`{"subject":"..."}` for `checksum_token`.
`peer_response.in_reply_to` is the request correlation ID. `status` accepts
`"accepted"`, `"completed"`, or `"failed"`. Optional `result` is a typed
`BridgeReply` or a checksum result
`{"request_intent":"checksum_token","request_subject":"...","token":"..."}`,
not a free-form object. The canonical bridge shapes are
`meerkat_contracts::wire::supervisor_bridge::{BridgeCommand, BridgeReply,
BridgePeerSpec}` and their generated schema definitions.
#### Send receipts
Success returns HTTP `200` with a `CommsSendResult` selected by `kind`:
| Result `kind` | Fields |
| - | - |
| `input_accepted` | `interaction_id`, `stream_reserved` |
| `peer_message_sent` | `envelope_id`, `delivery` |
| `peer_lifecycle_sent` | `envelope_id`, `delivery` |
| `peer_request_sent` | `envelope_id`, `interaction_id`, `request_id` (same value as `envelope_id`), `stream_reserved`, `delivery` |
| `peer_response_sent` | `envelope_id`, `in_reply_to`, `delivery` |
`delivery` records the selected transport's strongest proven delivery fact:
`acked`, `volatile_handed_off`, `queued`, or a `durably_resolved` object with
its typed ingress `outcome`. An acknowledgement does not prove completion of
the recipient's agent turn; a queued send does not prove receiver admission.
This is distinct from the webhook routes' `202`/`{"queued":true}` admission
response.
Unlike the two webhook ingress routes (which optionally check
`X-Webhook-Secret` against `RKAT_WEBHOOK_SECRET`), `/comms/send` has **no
authentication**. It is intended for trusted internal callers that already
know the session ID.
### GET /comms/peers
List discoverable peers for a session. Requires the `comms` feature.
Session ID to query peers for.
```json Response theme={null}
{
"peers": [
{
"peer_id": "550e8400-e29b-41d4-a716-446655440001",
"name": "reviewer",
"address": {
"transport": "tcp",
"endpoint": "127.0.0.1:4201"
},
"source": "trusted",
"sendable_kinds": ["peer_message", "peer_request", "peer_response"],
"capabilities": {
"version": 1,
"extensions": {"review": true}
},
"meta": {
"description": "Reviews pull requests",
"labels": {"role": "reviewer"}
}
},
{
"peer_id": "550e8400-e29b-41d4-a716-446655440002",
"name": "coordinator",
"address": {
"transport": "inproc",
"endpoint": "coordinator"
},
"source": "inproc",
"sendable_kinds": ["peer_message", "peer_request", "peer_response"],
"capabilities": {"version": 1, "extensions": {}},
"meta": {}
}
]
}
```
`peer_id` is the canonical routing value accepted by `POST /comms/send`;
`name` is display-only and need not be unique. `address` is a typed
`{transport, endpoint}` object, while `source`, `sendable_kinds`,
`capabilities`, and `meta` describe discovery provenance and supported use.
## Error responses
The ordinary REST `ApiError` adapter returns an HTTP status and a JSON
envelope:
```json theme={null}
{
"error": "Human-readable error message",
"code": "ERROR_CODE"
}
```
| HTTP Status | Code | When |
| - | - | - |
| 400 | `BAD_REQUEST` | Invalid parameters, session ID mismatch, keep\_alive without comms |
| 401 | `UNAUTHORIZED` | Missing or invalid webhook secret |
| 404 | `NOT_FOUND` | Resource not found |
| 409 | `CONFLICT` | Operation conflicts with current state |
| 409 | `DUPLICATE_INPUT` | Duplicate runtime input; response also carries top-level `existing_id` |
| 409 | `DUPLICATE_REQUEST_ID` | Request ID is already in flight |
| 410 | `GONE` | Resource is no longer available |
| 499 | `REQUEST_CANCELLED` | Request cancellation won before committed success; may include `details` |
| 500 | `CONFIGURATION_ERROR` | Server/configuration resolution failure |
| 500 | `AGENT_ERROR` | LLM provider error, tool dispatch failure, agent loop error |
| 500 | `INTERNAL_ERROR` | Store initialization failure, unexpected server error |
| 503 | `SERVICE_UNAVAILABLE` | Required service or capacity is unavailable |
The adapter's data-bearing bad-request/internal variants use HTTP `400`/`500`
with a route-supplied typed `code` and `details`. Other route-specific typed
projections retain their own codes and statuses. This table is not the
canonical cross-surface [ErrorCode index](/reference/api-reference#error-code-reference)
applied wholesale: for example, create-time `SessionError::Busy` currently
maps to `BAD_REQUEST`/`400`, not a universal `SESSION_BUSY`/`409`.
Do not require this envelope on every failure. Auth routes may return only
`{"error":"..."}`; webhook admission has route-specific error projections,
and framework JSON/path extraction errors can use different bodies.
For a session that was durably created but whose first turn failed, the server
returns `SESSION_CREATED_WITH_TURN_FAILURE` with resumable session identity in
`details`. A genuinely unknown provider failure now runs the bounded retry
policy; when that policy is exhausted, `details.error.kind` is
`retry_exhausted` rather than `llm_failure`. Clients that branch on this nested
kind should accept the new value. Known non-retryable LLM failures can still use
`llm_failure`.
```json Session not found theme={null}
{
"error": "Session not found: 01936f8a-7b2c-7000-8000-000000000099",
"code": "NOT_FOUND"
}
```
```json Bad request theme={null}
{
"error": "Session ID mismatch: path=abc body=def",
"code": "BAD_REQUEST"
}
```
## Notes
Keep-alive mode requires `keep_alive: true` and a `comms_name`. If `keep_alive` is
requested but the binary was not compiled with comms support, the server
returns a `BAD_REQUEST` error.
`POST /sessions/{id}/external-events` queues a runtime-backed external event. It is a queue-only admission path, not a second direct execution loop.
`hooks_override` allows per-request hook overrides including adding extra hook
entries and disabling specific hooks by ID. See [Hooks](/guides/hooks) for the
`HookRunOverrides` schema.
# JSON-RPC stdio server
Source: https://docs.rkat.ai/api/rpc
Stateful JSON-RPC 2.0 interface for stdio, TCP, and optional WebSocket or WebRTC live channels.
Meerkat exposes a JSON-RPC 2.0 surface for IDE integration, desktop apps, and automation tools. Stdio is the default, and the binary also supports TCP plus optional WebSocket and feature-gated WebRTC live transports. Like the REST and MCP servers, RPC composes the shared runtime-backed service so agents can stay alive between turns. RPC adds a broad typed method and notification surface suited to interactive clients.
The RPC surface is fully runtime-backed:
* `keep_alive` is runtime/session behavior
* `session/external_event` queues runtime-backed external work
* committed success is not rewritten to cancellation
## Getting started
```bash theme={null}
rkat-rpc
```
The server reads newline-delimited JSON (JSONL) from stdin and writes JSONL to stdout. Each line is a complete JSON-RPC 2.0 message.
Optional listener modes:
```bash theme={null}
rkat-rpc --tcp 127.0.0.1:9001
rkat-rpc --tcp 127.0.0.1:9001 --live-ws 127.0.0.1:9002
rkat-rpc --live-webrtc # requires the live-webrtc build feature
```
TCP and live WebSocket listeners are local-only by default. Binding
a non-loopback address such as `0.0.0.0:9001` requires `--allow-remote`;
that flag only opts in to network exposure and does not add
authentication or encryption. Use it behind a production-safe transport
wrapper such as SSH tunneling, mTLS, or another authenticated encrypted
channel. Plain TCP host capabilities continue to report
`secure_remote_rpc: false`.
`rkat-rpc --tcp` is a JSON-RPC host transport. It is not the signed
Meerkat peer/comms channel used by remote agents or external mob members;
use `rkat run --comms-listen-tcp ...` for that.
```json theme={null}
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
```
```json theme={null}
{"jsonrpc":"2.0","id":2,"method":"session/create","params":{"prompt":"Hello!"}}
```
`rkat-rpc` defaults to a new isolated realm each time. To share sessions with
another process, use the same `--realm ` and physical `--state-root`, or a
matching `--context-root` that resolves the same project-local root.
## Runtime scope
`rkat-rpc` accepts global scope flags:
```bash theme={null}
rkat-rpc [--realm ] [--isolated] [--instance ] [--realm-backend ] [--state-root ] [--context-root ] [--user-config-root ]
```
* `--realm`: explicit sharing/isolation key
* `--instance`: optional instance metadata
* `--realm-backend`: creation hint only; actual backend is pinned per realm manifest
Transport and live-channel flags are:
```bash theme={null}
rkat-rpc [--tcp ] [--allow-remote] [--live-ws ] \
[--live-webrtc] [--live-tool-timeout-ms ] [--live-ws-scheme ]
```
`--live-webrtc` is present only in builds with the `live-webrtc` feature.
`--live-ws-scheme wss` changes the advertised bootstrap URL for a
TLS-terminating proxy; the listener itself still accepts plain WebSocket bytes.
## Method overview
This table mirrors the generated catalog in `artifacts/schemas/rpc-methods.json`
(`meerkat_contracts::rpc_method_catalog`). `initialize` returns the methods
enabled by both the compiled build and runtime composition. Reduced builds,
an absent skill runtime, or unconfigured live transports can expose a subset
of this full catalog.
| Method | Category | Params | Result | Description |
| - | - | - | - | - |
| `initialize` | Handshake | — | `ServerCapabilities` | Returns server capabilities |
| `tools/register` | Tools | `ToolsRegisterParams` | `ToolsRegisterResult` | Register callback tools for server-to-client execution |
| `help/ask` | Help | `HelpRequest` | `HelpResponse` | Ask Meerkat usage help with the embedded platform skill |
| `runtime/host_info` | Runtime | — | `RuntimeHostInfo` | Read host identity, endpoints, capabilities, health, and realm projection |
| `runtime/capabilities` | Runtime | — | `RuntimeHostCapabilities` | Read runtime host capability flags |
| `runtime/health` | Runtime | — | `RuntimeHostHealth` | Read runtime host health |
| `jobs/get` | Jobs | `JobsGetParams` | `JobsGetResult` | Read a realm-scoped detached-job projection without worker write authority |
| `jobs/list` | Jobs | `JobsListParams` | `JobsListResult` | List detached jobs for one origin session in the active realm |
| `jobs/cancel` | Jobs | `JobsCancelParams` | `JobsCancelResult` | Commit a cancellation request and notify the owning runner |
| `jobs/progress` | Jobs | `JobsProgressParams` | `JobsProgressResult` | Read committed detached-job progress |
| `jobs/result` | Jobs | `JobsResultParams` | `JobsResultResult` | Read a safe terminal-result summary |
| `jobs/artifacts` | Jobs | `JobsArtifactsParams` | `JobsArtifactsResult` | List safe artifact references produced by a job |
| `jobs/retry` | Jobs | `JobsRetryParams` | `JobsRetryResult` | Schedule a generated-machine-authorized retry |
| `jobs/health` | Jobs | — | `JobsHealthResult` | Read realm-scoped detached-job health, owed outbox deliveries, and undrained runtime deliveries |
| `jobs/subscribe` | Jobs | `JobsSubscribeParams` | `JobsSubscribeResult` | Add a durable record, notification, or event delivery subscription |
| `jobs/unsubscribe` | Jobs | `JobsUnsubscribeParams` | `JobsUnsubscribeResult` | Remove a durable delivery subscription |
| `monitors/start` | Jobs | `MonitorsStartParams` | `MonitorsStartResult` | Start a high-trust durable script monitor with explicit restart and output contracts |
| `mobkit/jobs/heartbeat` | Host jobs | `MobkitJobHeartbeatParams` | `MobkitJobMutationResult` | Renew the exact committed attempt lease |
| `mobkit/jobs/progress` | Host jobs | `MobkitJobProgressParams` | `MobkitJobMutationResult` | Commit progress under exact attempt/fence authority |
| `mobkit/jobs/checkpoint` | Host jobs | `MobkitJobCheckpointParams` | `MobkitJobMutationResult` | Commit a restart checkpoint under exact attempt/fence authority |
| `mobkit/jobs/complete` | Host jobs | `MobkitJobCompleteParams` | `MobkitJobMutationResult` | Commit successful terminality under exact attempt/fence authority |
| `mobkit/jobs/fail` | Host jobs | `MobkitJobFailParams` | `MobkitJobMutationResult` | Commit failed terminality under exact attempt/fence authority |
| `mobkit/jobs/cancel_ack` | Host jobs | `MobkitJobCancelAckParams` | `MobkitJobMutationResult` | Acknowledge cancellation under exact attempt/fence authority |
| `session/create` | Session | `CreateSessionParams` | `WireRunResult \| DeferredCreateResult` | Create session and run first turn |
| `session/list` | Session | `ListSessionsParams` | `ListSessionsResult` | List active sessions |
| `session/read` | Session | `ReadSessionParams` | `WireSessionInfo` | Get session state |
| `session/history` | Session | `ReadSessionHistoryParams` | `WireSessionHistory` | Get committed session transcript messages |
| `session/export_atif` | Session | `ExportAtifParams` | `Value` | Export currently projected session replay evidence as an ATIF trajectory |
| `session/update_system_prompt` | Session | `UpdateSystemPromptParams` | `SystemPromptUpdateResult` | Explicitly replace one durable versioned system-prompt key |
| `session/activate_instruction` | Session | `ActivateInstructionParams` | `InstructionActivationReceipt` | Append one immutable instruction revision at an admitted idle transcript boundary |
| `session/instruction_activations` | Session | `ReadInstructionActivationsParams` | `InstructionActivationReadPage` | Read authoritative activation records from the durable ordered transcript |
| `session/rewrite_transcript` | Session | `RewriteSessionTranscriptParams` | `SessionTranscriptRewriteResult` | Commit a same-session transcript rewrite |
| `session/transcript_revision` | Session | `ReadSessionTranscriptRevisionParams` | `WireSessionTranscriptRevision` | Read a transcript revision by id |
| `session/transcript_revisions` | Session | `ListSessionTranscriptRevisionsParams` | `WireSessionTranscriptRevisionList` | List retained transcript revision commits with the current head |
| `session/restore_transcript_revision` | Session | `RestoreSessionTranscriptRevisionParams` | `SessionTranscriptRewriteResult` | Restore an earlier transcript revision in place |
| `session/fork_at` | Session | `ForkSessionAtParams` | `SessionForkResult` | Fork an idle session at a transcript message index |
| `session/fork_replace` | Session | `ForkSessionReplaceParams` | `SessionForkResult` | Fork an idle session and apply a typed transcript replacement |
| `blob/get` | Session | `BlobGetParams` | `BlobPayload` | Fetch a blob payload by id |
| `artifact/list` | Artifact | `ArtifactListParams` | `ArtifactListResult` | List stable artifact records |
| `artifact/get` | Artifact | `ArtifactIdParams` | `ArtifactRecord` | Get one stable artifact record |
| `artifact/download` | Artifact | `ArtifactDownloadParams` | `ArtifactDownloadResult` | Download blob-backed artifact payload bytes |
| `approval/request` | Approval | `ApprovalRequestParams` | `ApprovalRecord` | Create an approval audit record; persistence depends on the host store |
| `approval/list` | Approval | `ApprovalListParams` | `ApprovalListResult` | List approval audit records |
| `approval/get` | Approval | `ApprovalGetParams` | `ApprovalRecord` | Get one approval audit record |
| `approval/decide` | Approval | `ApprovalDecideParams` | `ApprovalRecord` | Record an approval audit decision |
| `events/latest_cursor` | Events | `EventsLatestCursorParams` | `EventsLatestCursorResult` | Read the latest replay cursor for a typed event scope |
| `events/list_since` | Events | `EventsListSinceParams` | `EventsListSinceResult` | List replayable events after a typed cursor |
| `events/snapshot` | Events | `EventsSnapshotParams` | `EventsSnapshotResult` | Read a point-in-time snapshot with its replay cursor |
| `session/archive` | Session | `ArchiveSessionParams` | `Value` | Remove session from runtime |
| `session/external_event` | Session | `SessionExternalEventParams` | `RuntimeAcceptResult` | Queue a runtime-backed external event |
| `session/peer_response_terminal` | Session | `SessionPeerResponseTerminalParams` | `RuntimeAcceptResult` | Admit a correlated terminal peer response through the typed runtime ingress |
| `session/inject_context` | Session | `InjectSystemContextParams` | `InjectSystemContextResult` | Append one ordinary durable ordered System message at the admitted transcript boundary |
| `session/input_status` | Session | `SessionInputStateParams` | `SessionInputStateResult` | Read an input's stored runtime state (terminal outcome, run association) by input id or idempotency key |
| `turn/start` | Turn | `StartTurnParams` | `WireRunResult` | Start a new turn on existing session |
| `turn/interrupt` | Turn | `InterruptParams` | `InterruptResult` | Cancel in-flight turn |
| `turn/stop_run` | Turn | `StopRunParams` | `StopRunResult` | Stop one exact run and terminalize every contributor bound to it |
| `live/open` | Live | `LiveOpenParams` | `LiveOpenResult` | Open a live channel; optionally bound its serialized seed messages |
| `live/webrtc/answer` | Live | `LiveWebrtcAnswerParams` | `LiveWebrtcAnswerResult` | Answer a browser WebRTC offer for an already-open live channel |
| `live/media_health` | Live | `LiveMediaHealthParams` | `LiveMediaHealthResult` | Report decoded-audio counters for the output a live/media\_health\_requested notification named |
| `live/status` | Live | `LiveChannelParams` | `LiveStatusResult` | Get the status of a live channel |
| `live/close` | Live | `LiveChannelParams` | `LiveCloseResult` | Close a live channel |
| `live/send_input` | Live | `LiveSendInputParams` | `LiveSendInputResult` | Send an audio, text, or model-supported image chunk to a live channel |
| `live/commit_input` | Live | `LiveCommitInputParams` | `LiveCommitInputResult` | Commit pending input on a live channel (turn boundary) |
| `live/interrupt` | Live | `LiveChannelParams` | `LiveInterruptResult` | Interrupt the assistant turn on a live channel (barge-in) |
| `live/truncate` | Live | `LiveTruncateParams` | `LiveTruncateResult` | Truncate assistant output at a client-tracked playback cursor |
| `live/playback_complete` | Live | `LivePlaybackCompleteParams` | `LivePlaybackCompleteResult` | Report completed client playback for one generated assistant output |
| `live/refresh` | Live | `LiveChannelParams` | `LiveRefreshResult` | Apply mutable session config (instructions/tools/audio) to an open live channel |
| `session/stream_open` | Session | `SessionStreamOpenParams` | `SessionStreamOpenResult` | Open a standalone session event stream |
| `session/stream_close` | Session | `SessionStreamCloseParams` | `SessionStreamCloseResult` | Close a standalone session event stream |
| `mob/create` | Mob | `MobCreateParams` | `MobCreateResult` | Create a mob from a definition (mob feature) |
| `mob/list` | Mob | — | `MobListResult` | List mobs (mob feature) |
| `mob/status` | Mob | `MobIdParams` | `MobStatusResult` | Get mob status (mob feature) |
| `mob/members` | Mob | `MobIdParams` | `MobMembersResult` | List mob members (mob feature) |
| `mob/member_tool_declaration` | Mob | `MobMemberToolDeclarationParams` | `MobMemberToolDeclarationResult` | Read one durable member-tool declaration and fresh identity convergence status (mob feature) |
| `mob/apply_member_tool_declaration` | Mob | `MobApplyMemberToolDeclarationParams` | `MobApplyMemberToolDeclarationResult` | Apply one revision-fenced durable member-tool declaration update (mob feature) |
| `mob/adopt_member_identity_declaration` | Mob | `MobAdoptMemberIdentityDeclarationParams` | `MobAdoptMemberIdentityDeclarationResult` | Adopt an already-realized member into durable identity intent under an explicit expected-absent precondition (mob feature) |
| `mob/resolve_identity_convergence_block` | Mob | `MobResolveIdentityConvergenceBlockParams` | `MobResolveIdentityConvergenceBlockResult` | Resume or cancel blocked identity replacement under exact desired and active revisions (mob feature) |
| `mob/spawn` | Mob | `MobSpawnParams` | `MobSpawnResult` | Spawn a member into a mob (mob feature) |
| `mob/spawn_many` | Mob | `MobSpawnManyParams` | `MobSpawnManyResult` | Spawn multiple members into a mob (mob feature) |
| `mob/retire` | Mob | `MobMemberParams` | `MobRetireResult` | Retire a mob member (mob feature) |
| `mob/respawn` | Mob | `MobRespawnParams` | `MobRespawnResult` | Respawn a mob member (mob feature) |
| `mob/wire` | Mob | `MobWireParams` | `MobWireResult` | Wire two mob members for comms (mob feature) |
| `mob/wire_members_batch` | Mob | `MobWireMembersBatchParams` | `MobWireMembersBatchResult` | Wire multiple local mob member edges in one batch (mob feature) |
| `mob/unwire` | Mob | `MobUnwireParams` | `MobUnwireResult` | Remove mob comms wiring (mob feature) |
| `mob/lifecycle` | Mob | `MobLifecycleParams` | `MobLifecycleResult` | Stop, resume, complete, destroy, or reset a mob (mob feature) |
| `mob/events` | Mob | `MobEventsParams` | `MobEventsResult` | Read mob events by cursor (mob feature) |
| `mob/ingress_interaction` | Mob | `MobIngressInteractionParams` | `MobIngressInteractionResult` | Ensure an ingress member and deliver user input with replay anchors (mob feature) |
| `mob/stream_open` | Mob | `MobStreamOpenParams` | `MobStreamOpenResult` | Open mob or member event stream (mob feature) |
| `mob/stream_close` | Mob | `MobStreamCloseParams` | `MobStreamCloseResult` | Close mob or member event stream (mob feature) |
| `mob/member_send` | Mob | `MobMemberSendParams` | `MobMemberSendResult` | Deliver ordinary content to a specific mob member via the host control plane (mob feature) |
| `mob/append_system_context` | Mob | `MobAppendSystemContextParams` | `MobAppendSystemContextResult` | Append one ordinary durable ordered System message to a member session (mob feature) |
| `mob/flows` | Mob | `MobIdParams` | `MobFlowsResult` | List mob flows (mob feature) |
| `mob/run` | Mob | `MobRunParams` | `MobFlowRunResult` | Invoke a mob as a typed callable run (mob feature) |
| `mob/flow_run` | Mob | `MobFlowRunParams` | `MobFlowRunResult` | Start a mob flow run (mob feature) |
| `mob/flow_status` | Mob | `MobFlowStatusParams` | `MobFlowStatusResult` | Get flow run status (mob feature) |
| `mob/run_result` | Mob | `MobRunResultParams` | `MobRunResult` | Get a typed output envelope for a mob run (mob feature) |
| `mob/flow_cancel` | Mob | `MobFlowCancelParams` | `MobFlowCancelResult` | Cancel a flow run (mob feature) |
| `mob/ensure_member` | Mob | `MobEnsureMemberParams` | `MobEnsureMemberResult` | Idempotent spawn — returns existing entry if member already exists (mob feature) |
| `mob/reconcile` | Mob | `MobReconcileParams` | `MobReconcileResult` | Declarative roster reconcile against a desired spec list (mob feature) |
| `mob/list_members_matching` | Mob | `MobListMembersMatchingParams` | `MobListMembersMatchingResult` | Label-filtered member listing with server-side filter (mob feature) |
| `mob/spawn_helper` | Mob | `MobSpawnHelperParams` | `MobHelperResult` | Spawn a helper member and wait for its result (mob feature) |
| `mob/fork_helper` | Mob | `MobForkHelperParams` | `MobHelperResult` | Fork a helper from an existing member and wait for its result (mob feature) |
| `mob/force_cancel` | Mob | `MobMemberParams` | `MobForceCancelResult` | Force-cancel a member's active work (mob feature) |
| `mob/turn_start` | Mob | `MobTurnStartParams` | `WireRunResult` | Start a turn on a mob member by identity (mob feature) |
| `mob/member_status` | Mob | `MobMemberParams` | `MobMemberStatusResult` | Read canonical status for a specific member (mob feature) |
| `mob/snapshot` | Mob | `MobIdParams` | `MobSnapshotResult` | Point-in-time aggregate of mob status plus member list (mob feature) |
| `mob/destroy` | Mob | `MobIdParams` | `MobDestroyResult` | Destroy a mob and surface the structured MobDestroyReport (mob feature) |
| `mob/rotate_supervisor` | Mob | `MobIdParams` | `MobRotateSupervisorResult` | Rotate the supervisor bridge for all members of a mob (mob feature) |
| `mob/submit_work` | Mob | `MobSubmitWorkParams` | `MobSubmitWorkResult` | Submit a unit of work to a mob member through the work lane (mob feature) |
| `mob/conclude_objective` | Mob | `MobConcludeObjectiveParams` | `MobConcludeObjectiveResult` | Explicitly conclude a durable kickoff objective (mob feature) |
| `mob/cancel_work` | Mob | `MobCancelWorkParams` | `MobCancelWorkResult` | Cancel a previously submitted unit of work (mob feature) |
| `mob/cancel_all_work` | Mob | `MobCancelAllWorkParams` | `MobCancelAllWorkResult` | Cancel all in-flight work for a mob member (mob feature) |
| `mob/wait_kickoff` | Mob | `MobWaitParams` | `MobWaitMembersResult` | Wait for kickoff completion for a member (mob feature) |
| `mob/wait_ready` | Mob | `MobWaitParams` | `MobWaitMembersResult` | Wait for mob startup readiness (members bound but kickoff not required, mob feature) |
| `mob/profile/create` | Mob | `MobProfileCreateParams` | `MobProfileLookupResult` | Create a realm-scoped mob profile (mob feature) |
| `mob/profile/get` | Mob | `MobProfileNameParams` | `MobProfileLookupResult` | Read a realm-scoped mob profile (mob feature) |
| `mob/profile/list` | Mob | — | `MobProfileListResult` | List realm-scoped mob profiles (mob feature) |
| `mob/profile/update` | Mob | `MobProfileUpdateParams` | `MobProfileLookupResult` | Update a realm-scoped mob profile (mob feature) |
| `mob/profile/delete` | Mob | `MobProfileDeleteParams` | `MobProfileDeleteResult` | Delete a realm-scoped mob profile (mob feature) |
| `mob/grant_scopes` | Mob | `MobGrantScopesParams` | `MobGrantScopesResult` | Record (full-replace) a principal's control-scope grant (mob feature) |
| `mob/revoke_scopes` | Mob | `MobRevokeScopesParams` | `MobRevokeScopesResult` | Revoke control scopes from a principal's grant; omit scopes to revoke the entire grant (mob feature) |
| `mob/grants` | Mob | `MobIdParams` | `MobGrantsResult` | List raw control-scope grant records; expired rows appear verbatim (mob feature) |
| `mob/member_history` | Mob | `MobMemberHistoryParams` | `MobMemberHistoryResult` | Read a mob member transcript page by identity; one shape local and remote (mob feature) |
| `mob/hosts` | Mob | `MobIdParams` | `MobHostsResult` | List tracked member hosts with bind phase and declared capabilities (mob feature) |
| `mob/route_installs` | Mob | `MobIdParams` | `MobRouteInstallsResult` | Outstanding cross-host route-install obligations (mob feature) |
| `mob/bind_host` | Mob | `MobBindHostParams` | `MobBindHostResult` | Bind a member-host daemon to a mob from its binding descriptor (mob feature) |
| `mob/revoke_host` | Mob | `MobRevokeHostParams` | `MobRevokeHostResult` | Revoke a bound (or bind-requested) member host (mob feature) |
| `mob/hard_cancel_member` | Mob | `MobHardCancelParams` | `MobHardCancelResult` | Hard-cancel a mob member; immediate user-interrupt authority (mob feature) |
| `mob/stop_member_run` | Mob | `MobStopMemberRunParams` | `MobStopMemberRunResult` | Stop one exact run of a mob member and terminalize its contributors (mob feature) |
| `mob/member_live_open` | Mob | `MobMemberLiveOpenParams` | `LiveOpenResult` | Open a live realtime channel on a mob member by identity (mob feature) |
| `mob/member_live_close` | Mob | `MobMemberLiveChannelParams` | `LiveCloseResult` | Close one named live channel on a mob member (mob feature) |
| `mob/member_live_status` | Mob | `MobMemberLiveStatusParams` | `LiveStatusResult` | Read live channel status for a mob member; omit channel\_id to discover (mob feature) |
| `mob/member_live_control` | Mob | `MobMemberLiveControlParams` | `BridgeLiveControlOutcome` | Drive one turn-level live control verb on a member channel (mob feature) |
| `schedule/create` | Schedule | `CreateScheduleRequest` | `Schedule` | Create a new schedule (schedule feature) |
| `schedule/get` | Schedule | `ScheduleIdParams` | `Schedule` | Get a schedule by ID (schedule feature) |
| `schedule/list` | Schedule | `ListSchedulesParams` | `ScheduleListResult` | List schedules (schedule feature) |
| `schedule/update` | Schedule | `UpdateScheduleParams` | `Schedule` | Update a schedule (schedule feature) |
| `schedule/pause` | Schedule | `ScheduleIdParams` | `Schedule` | Pause a schedule (schedule feature) |
| `schedule/resume` | Schedule | `ScheduleIdParams` | `Schedule` | Resume a paused schedule (schedule feature) |
| `schedule/delete` | Schedule | `ScheduleIdParams` | `Schedule` | Delete a schedule (schedule feature) |
| `schedule/occurrences` | Schedule | `ScheduleOccurrencesParams` | `ScheduleOccurrencesResult` | List occurrences for a schedule (schedule feature) |
| `schedule/tools` | Schedule | — | `ScheduleToolsResult` | List schedule tools (schedule feature) |
| `schedule/call` | Schedule | `ScheduleToolCallParams` | `Value` | Call a schedule tool directly (schedule feature) |
| `workgraph/get` | WorkGraph | `WorkGraphIdParams` | `WorkItem` | Read a WorkGraph item (workgraph feature) |
| `workgraph/list` | WorkGraph | `WorkItemFilter` | `WorkItemsResult` | List WorkGraph items (workgraph feature) |
| `workgraph/ready` | WorkGraph | `ReadyWorkFilter` | `WorkItemsResult` | List ready WorkGraph items (workgraph feature) |
| `workgraph/snapshot` | WorkGraph | `WorkGraphSnapshotFilter` | `WorkGraphSnapshot` | Read a WorkGraph observability snapshot (workgraph feature) |
| `workgraph/events` | WorkGraph | `WorkGraphEventFilter` | `WorkEventsResult` | Read WorkGraph event history (workgraph feature) |
| `workgraph/goal/status` | WorkGraph | `GoalStatusRequest` | `GoalStatusResult` | Read goal and attention status (workgraph feature) |
| `workgraph/attention/list` | WorkGraph | `AttentionListRequest` | `AttentionListResult` | List attention bindings, optionally filtered by status (workgraph feature) |
| `comms/send` | Comms | `CommsSendParams` | `CommsSendResult` | Dispatch a comms command to a session |
| `comms/peers` | Comms | `CommsPeersParams` | `CommsPeersResult` | List discoverable peers (comms feature) |
| `skills/list` | Skills | — | `SkillListResponse` | List skills with provenance |
| `mcp/add` | MCP | `McpAddParams` | `McpLiveOpResponse` | Stage live MCP server addition (mcp feature) |
| `mcp/remove` | MCP | `McpRemoveParams` | `McpLiveOpResponse` | Stage live MCP server removal (mcp feature) |
| `mcp/reload` | MCP | `McpReloadParams` | `McpLiveOpResponse` | Reload one or all MCP servers (mcp feature) |
| `models/catalog` | Models | — | `ModelsCatalogResponse` | List available models with capabilities and provider profiles |
| `capabilities/get` | Capabilities | — | `CapabilitiesResponse` | Runtime capability report |
| `config/get` | Config | — | `ConfigEnvelope` | Read current config |
| `config/set` | Config | `ConfigSetParams` | `ConfigWriteResult` | Replace config |
| `config/patch` | Config | `ConfigPatchParams` | `ConfigWriteResult` | Merge-patch config (RFC 7396) |
| `auth/profile/list` | Auth | `RealmIdParams` | `WireAuthProfilesList` | List auth profiles in a realm |
| `auth/profile/get` | Auth | `BindingIdParams` | `WireAuthProfileDetail` | Resolve one binding's auth profile with an optional profile override |
| `auth/profile/create` | Auth | `CreateProfileParams` | `WireAuthProfileCreated` | Store direct credentials for a configured auth binding |
| `auth/profile/delete` | Auth | `BindingIdParams` | `WireAuthProfileCleared` | Clear credentials for a configured auth binding |
| `auth/login/start` | Auth | `LoginStartParams` | `WireLoginStart` | Begin an OAuth login for a provider binding, an MCP server (`{"mcp": {...}}`) or a connector slot (`{"connector": {...}}`); returns authorize\_url, state, redirect\_uri and the target echo (PKCE verifier stays server-side). Host-channel data: never pass it to an agent |
| `auth/login/complete` | Auth | `LoginCompleteParams` | `WireLoginReady` | Exchange authorization code for tokens; persists to TokenStore. For an MCP target, issuer, client and resource come from the admitted attempt named by `state`; a connector target must equal the admitted attempt's descriptor |
| `auth/login/cancel` | Auth | `LoginCancelParams` | `WireLoginCancelled` | Retire a pending MCP or connector OAuth attempt by its state, or an MCP attempt by the `attempt_ref` that `auth/status/get` reports (`{"mcp": {...}, "attempt_ref": ...}`) |
| `auth/login/device_start` | Auth | `DeviceStartParams` | `WireDeviceStart` | Begin device-code OAuth; returns user\_code + verification URL |
| `auth/login/device_complete` | Auth | `DeviceCompleteParams` | `WireDeviceCompleteResult` | Single-poll completion for a device-code OAuth login |
| `auth/login/provision_api_key` | Auth | `ProvisionApiKeyParams` | `WireProvisionApiKeyResult` | Console-OAuth → API key provisioning (Anthropic oauth\_to\_api\_key) |
| `auth/status/get` | Auth | `AuthStatusParams` | `WireAuthStatusResult` | Get binding-resolved auth status with an optional profile override, or the authorization status of an MCP server target (`{"mcp": {...}}`; a pending login attempt is reported as `attempt: {ref, phase, expires_at}`, never its URL or state) or a connector slot (`{"connector": {...}}`) |
| `auth/logout` | Auth | `AuthLogoutParams` | `WireAuthLogoutResult` | Revoke + remove persisted credentials of a provider binding, or remove the stored credential of an MCP server target (`{"mcp": {...}}` returns `{mcp, cleared}`) |
| `realm/list` | Realm | — | `WireRealmList` | List realm summaries |
| `realm/get` | Realm | `RealmIdParams` | `WireRealmConnectionSet` | Get full realm connection set |
Auth method errors (`auth/*`) carry a typed reason in `error.data.reason`, one of the closed `WireAuthErrorReason` values (for example `slot_occupied`, `account_mismatch`, `attempt_missing`, `reauth_required`, `infrastructure`). Branch on the reason, not the message text. Infrastructure failures have a fixed message.
Both helper parameter types (`MobSpawnHelperParams`, `MobForkHelperParams`)
require `result_label` and `max_text_bytes`. `MobHelperResult` is the exact
operation carrier: `output`, `tokens_used`, `agent_identity`, `member_ref`,
`bounded_result`, `session_id`, `usage`, `turns`, and `tool_calls` are
required, while `retirement_error` reports post-result cleanup debt when
present.
Every `Schedule` result is one flattened public object. Configuration fields
including `planning_horizon_days`, `planning_horizon_occurrences`, `labels`,
`created_at_utc`, and `updated_at_utc` are top-level; there is no nested
`config` and persisted machine authority is not exposed as `machine_state`.
WorkGraph goal and attention mutation methods require trusted host/session
authority. JSON-RPC exposes observability reads; goal creation, reassignment,
policy escalation, confirmation, and closure stay on trusted in-process host and
agent-tool surfaces.
Generated assistant images use the same surface-neutral history and blob APIs as other blob-backed artifacts. Read `session/history`, find assistant blocks with `block_type: "image"`, then call `blob/get` with `data.blob_ref.blob_id` to retrieve the base64 payload.
Live channels provide low-latency audio/text streaming with model-gated image input. Create a session
with a realtime-capable model (e.g. `gpt-realtime-2`), then call
`live/open` to start the channel. The `--live-ws ` flag on
`rkat-rpc` enables the WebSocket listener required for audio transport.
Use `live/status` to observe channel state and check
`live/open`'s `capabilities.image_in` before sending image context.
RPC is the canonical typed substrate for the SDKs: use explicit `mob/*` lifecycle, host-ingress, and observation methods from apps. Inside running sessions, mob capability is exposed by composing `meerkat-mob-mcp` (`MobMcpState` + `AgentMobToolSurfaceFactory`) into `SessionBuildOptions.mob_tools`, which provides authorized `mob_*` tools to the agent. `external_tools` remains for callback and MCP-backed tool dispatchers.
WorkGraph RPC methods are observability reads. Programmatic hosts inspect
WorkGraph through `workgraph/get`, `list`, `ready`, `snapshot`, `events`,
`goal/status`, and `attention/list`; agents mutate ordinary WorkGraph state
through the `workgraph_*` tools. Authority-bearing attention reassignment and
policy escalation are runtime-injected WorkGraph tool operations, not public
JSON-RPC methods.
## Runtime host projections and health
`runtime/host_info` is a read-only projection of process identity, host ID
scope, realm and endpoint metadata, feature flags, and health. It does not
enroll a member host or grant mob placement authority.
`runtime/capabilities` returns a contract version plus boolean
`RuntimeHostFeatureFlags`: `runtime_backed_sessions`, `mobs`, `mcp_live`,
`comms`, `blobs`, `session_events`, `session_streams`, `schedules`, `skills`,
`event_replay`, `artifacts`, `approvals`, `external_members`,
`secure_remote_rpc`, `multi_host_mobs`, and `durable_jobs`. These host facts are
distinct from the generic status-bearing `CapabilityId` entries returned by
`capabilities/get`. Older payloads that omit `multi_host_mobs` or
`durable_jobs` decode those fields as `false`; clients should use the flag and
not infer multi-host support from a version number.
`runtime/health` returns the worse-wins rollup across five declared dimensions:
`jobs`, `session_liveness`, `session_durability`, `session_runtime_loop`, and
`session_run_start`. This RPC host probes all five. A plain check key is a
measured result. `unreadable:` means a probe ran but could not
obtain a reading and rolls the overall status to at least `degraded`.
`unmeasured:` is a coverage marker for a surface with no probe and
does not enter the rollup.
The session liveness check covers queued work parked on an active executor with
no run in flight, including repeated staging churn. `session_run_start` covers
a current staged run that is overdue to begin. Neither claims to measure a run
that began and then stopped producing progress.
## Protocol
Standard JSON-RPC 2.0 with `"jsonrpc": "2.0"` on every message. Three message types:
* **Request** (either direction): has `id`, `method`, `params`. The server
sends `tool/execute` requests to clients for registered callback tools.
* **Response** (back to the requester): echoes `id` with `result` or `error`.
* **Notification** (either direction): has `method`, `params`, no `id`, and
receives no response.
```text theme={null}
Client Server
| |
|-- initialize ----------------->|
|<-- result { capabilities } ----|
|-- initialized (notification) ->|
| |
|-- session/create { prompt } -->|
|<-- session/event (notif) ------| // AgentEvent stream
|<-- session/event (notif) ------|
|<-- result { session_id, ... } -|
| |
|-- turn/start { session_id } -->|
|<-- session/event (notif) ------|
|<-- result { text, usage } -----|
| |
|-- turn/interrupt { sid } ----->|
|<-- result {} ------------------|
| |
|-- session/archive { sid } ---->|
|<-- result {} ------------------|
```
## Request cancellation
Cancel an in-flight request by its exact JSON-RPC request ID, preserving its
number/string type. For example, while `session/create` request `2` is still
in flight, send this notification:
```json Client -> server notification theme={null}
{"jsonrpc":"2.0","method":"cancel","params":{"request_id":2}}
```
The notification has no `id` and receives no reply. When cancellation wins
before the original request commits success, the original request receives
`REQUEST_CANCELLED` (`-32005`):
```json Server -> client response to the original request theme={null}
{"jsonrpc":"2.0","id":2,"error":{"code":-32005,"message":"request cancelled before response publish"}}
```
The target is a request ID, not a session ID, so cancellation can be sent
before session creation returns an identity. It is not a rollback guarantee:
committed success is not rewritten to cancellation. This is separate from
`turn/interrupt`, which targets an existing session's turn. MCP uses the
different `notifications/cancelled` notification and `requestId` spelling
described in the [MCP reference](/api/mcp#request-cancellation).
## Callback tools
Register client-owned foreground tools with `tools/register`:
```json Client -> server theme={null}
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/register",
"params": {
"tools": [{
"name": "read_file",
"description": "Read a permitted project file",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}
}]
}
}
```
```json Server -> client theme={null}
{"jsonrpc":"2.0","id":12,"result":{"registered":1}}
```
Registration advertises the tool; execution requires the reverse exchange.
During a turn, the server sends its own JSON-RPC request:
```json Server -> client request theme={null}
{
"jsonrpc": "2.0",
"id": "srv-1",
"method": "tool/execute",
"params": {
"tool_use_id": "call_123",
"name": "read_file",
"arguments": {"path": "data.txt"}
}
}
```
```json Client -> server response theme={null}
{
"jsonrpc": "2.0",
"id": "srv-1",
"result": {"content": "File contents", "is_error": false}
}
```
Echo the server request's `id`, not the turn's ID or `tool_use_id`.
`tool_use_id` is the provider's tool-call correlation identity; `name` selects
the registered tool and `arguments` contains its JSON arguments. In the result,
`content` defaults to an empty string and `is_error` defaults to `false` if
omitted. A client can instead return a JSON-RPC `error` for the same request
ID; the server projects it as a tool-execution error.
Keep reading and answering reverse requests while waiting for the turn's
result, or the turn cannot finish its callback. This foreground RPC exchange
is neither MCP's `pending_tool_calls`/`meerkat_resume` continuation nor the
separate detached-job callback extensions. `tool/execute` is not a
client-callable catalog method.
## Session methods
### initialize
Handshake. Returns server capabilities.
```json Request theme={null}
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"server_info": {
"name": "meerkat-rpc",
"version": "0.8.52"
},
"contract_version": "0.8.52",
"methods": [
"initialize", "initialized",
"session/create", "session/list", "session/read",
"turn/start", "turn/interrupt",
"runtime/host_info", "config/get",
"live/open", "mob/create"
]
}
}
```
The example array is abbreviated. The actual array reflects the compiled
build **and** the runtime's enabled composition: `skills/list` requires a
skill runtime; `live/*` requires a configured live transport, and
`live/webrtc/answer` additionally requires enabled WebRTC support. For the
full catalog rather than this runtime subset, including parameter/result type
names, use `artifacts/schemas/rpc-methods.json`.
Server name.
Server version.
Protocol contract version.
List of supported method names.
### session/create
Create a new session and run the first turn. Set `initial_turn` to `"deferred"` to return a pending `session_id` without running the first turn; that returned `session_id` is valid for the first `turn/start`, including sessions created with inline `external_tools`.
```json Request (minimal) theme={null}
{
"jsonrpc": "2.0",
"id": 2,
"method": "session/create",
"params": {
"prompt": "What is Rust?"
}
}
```
```json Request (full) theme={null}
{
"jsonrpc": "2.0",
"id": 2,
"method": "session/create",
"params": {
"prompt": "What is Rust?",
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"max_tokens": 4096,
"system_prompt": "You are a helpful assistant.",
"output_schema": {
"schema": {"type": "object", "properties": {"answer": {"type": "string"}}},
"name": "answer"
},
"structured_output_retries": 2,
"hooks_override": null,
"enable_builtins": false,
"enable_shell": false,
"enable_memory": false,
"enable_schedule": null,
"enable_workgraph": null,
"keep_alive": false,
"comms_name": null,
"provider_params": null,
"preload_skills": [
{"source_uuid": "dc256086-0d2f-4f61-a307-320d4148107f", "skill_name": "extraction-email"}
],
"skill_refs": [
{
"kind": "structured",
"source_uuid": "dc256086-0d2f-4f61-a307-320d4148107f",
"skill_name": "formatting-markdown"
}
]
}
}
```
```json Response (minimal immediate request) theme={null}
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"text": "Rust is a systems programming language...",
"turns": 1,
"tool_calls": 0,
"usage": {
"input_tokens": 50,
"output_tokens": 200,
"total_tokens": 250
}
}
}
```
Only `prompt` is required. Other fields follow the per-field defaults and
override rules below; not every omitted value inherits configuration.
During execution, `session/event` notifications are emitted (see
[Notifications](#notifications)).
#### Parameter reference
The user prompt as a string or typed content-block array, for example
`[{"type":"text","text":"Hello"}]`. Media support depends on the selected
model/provider; see [model capabilities](/reference/capability-matrix).
Host-attached injected context for the first turn. Each entry materializes
as a separate typed injected-context user-channel message immediately before
the first turn's user message, in order; injected context is excluded from
semantic-memory indexing.
Non-empty, exact request-only host facts for the immediate first turn;
whitespace is significant. The pending runtime input retains the bytes for
crash retry; the Session transcript, compaction summarizer, and extraction
phase never receive them. Deferred create rejects this field; provide it on
the eventual `turn/start`.
Model name (e.g. `"claude-opus-4-8"`, `"gpt-5.5"`).
Provider name: `"anthropic"`, `"openai"`, `"gemini"`, or `"self_hosted"`.
Structural credential selector, for example
`{"realm":"prod","binding":"anthropic","profile":"claude_oauth"}`.
`realm` and `binding` are required when supplied; `profile` is optional.
Configure the referenced [realm, binding, and auth profile](/guides/auth)
first. Omission/`null` lets the create resolver choose credentials.
A colon-delimited CLI string is not accepted; provenance is server-owned.
Max tokens per turn.
Omission/`null` inherits the base prompt; a string sets an explicit base
prompt; `{"action":"disable"}` suppresses inherited/config/default/AGENTS
base prompt sources. Separately appended dispatcher, tool, and additional
instruction sections may remain, so disabling is not a promise of no System
content. This create-time policy differs from `turn/start`'s ordinary
ordered System-message string.
JSON schema for structured output extraction (wrapper or raw schema).
Max retries for structured output validation. Omission/`null` inherits
configuration (stock default `2`); explicit `0` is preserved.
Run-scoped hook overrides (entries to add, hook IDs to disable).
Override built-in tools (task management, etc.). Omission/`null` inherits
host/factory configuration; explicit `false` disables them.
Override the shell tool (requires built-ins). Omission/`null` inherits
host/factory configuration; explicit `false` disables it.
Override semantic memory (`memory_search` tool + compaction indexing).
Omission/`null` inherits host/factory configuration; explicit `false`
disables it.
Override schedule tools for this session. `null` uses the surface default.
Override WorkGraph tools for this session. `null` uses the surface default.
Keep the new session alive after its turn for comms. Omission is `false`;
`null` is rejected on create. `true` requires `comms_name`. This differs
from `turn/start`'s nullable override of existing session intent.
Agent name for inter-agent communication.
Provider-specific parameters (e.g., thinking config, reasoning effort).
#### Response fields
UUID of the created session.
The agent's response text.
Number of LLM calls made.
Number of tool calls executed.
Token usage breakdown.
Parsed structured output when extraction succeeds. Omitted when absent.
Post-turn extraction failure details: `last_output` is the committed
main-turn assistant text that extraction attempted to transform, `attempts`
is the number of extraction attempts made, and `reason` describes the
failure. Omitted when absent.
Schema compatibility warnings. Omitted when absent.
A successful run result can retain committed `text` even when post-turn
structured-output extraction fails. Inspect `structured_output` together with
`extraction_error`; absence of structured output does not prove extraction was
never requested. These optional fields in `WireRunResult` are omitted when
absent, not filled with `null`.
### session/history
Read committed transcript history for an existing session.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 7,
"method": "session/history",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"offset": 0,
"limit": 50
}
}
```
Returns oldest-to-newest committed messages plus pagination metadata. This method follows the same owner-resolution rules as `session/read`, including mob-owned session IDs when mob support is enabled.
When `limit` is omitted, the server returns at most 100 messages. The maximum
accepted `limit` is 1,000 and the maximum `offset` is 1,000,000; larger values
fail with `INVALID_PARAMS` before the session store is queried.
### session/export\_atif
Export the currently available event audit/replay projection as an ATIF (Agent
Trajectory Interchange Format) v1.7 trajectory document.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 7,
"method": "session/export_atif",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"agent_name": "meerkat",
"agent_version": "0.8.52",
"model_name": "claude-opus-5"
}
}
```
`agent_name`, `agent_version`, and `model_name` are optional overrides for the
trajectory's `agent` block; when `model_name` is omitted the session's model is
used.
The export replays the currently projected event log page by page, so it
requires a host with event projection installed. Projection is asynchronous
best-effort derived state and the handler does not drain it first; an export can
therefore lag the latest committed session state. Outcomes:
* A host without durable event replay fails with `INVALID_REQUEST` and
`event replay is not enabled for this runtime host`.
* An existing session whose durable log is empty (for example a session created
with a deferred first turn) returns a trajectory that names the session with
no steps and `final_metrics.total_steps` of `0`. It is not a missing session.
* A session that does not exist fails with `SESSION_NOT_FOUND`.
* A session whose durable log exceeds 500,000 events fails with
`INVALID_PARAMS` naming the bound; page the log with `events/list_since`, or
export it to a file with `rkat session export-atif`, which has no such bound.
* A session whose trajectory outgrows the outbound message limit (32 MiB) fails
with `BUDGET_EXHAUSTED` naming that limit and the bytes it had accumulated.
The fold measures itself page by page, so the refusal arrives while the
document is still partial rather than after the whole log is in memory.
The two bounds answer with different codes on purpose: the event bound is a
property of the request (`INVALID_PARAMS`), the size bound is a property of the
response, so it answers the way outbound admission answers any oversized result
(`BUDGET_EXHAUSTED`). They cover different content mixes: an all-delta log can
hold hundreds of thousands of events and still fold into a small document, which
is what the event bound is for; a tool-heavy log outgrows the response long
before that count, which is what the size bound is for.
### session/rewrite\_transcript
Commit a transcript rewrite without changing the session identity. The request names a
message-range selection, supplies replacement messages, and records a machine-readable
reason. The session appends a rewrite commit, advances its transcript head, and retains
the parent revision for audit and restore.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"method": "session/rewrite_transcript",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"selection": {
"type": "message_range",
"start": 2,
"end": 3
},
"replacement": [
{
"role": "block_assistant",
"blocks": [{ "block_type": "text", "data": { "text": "Compacted assistant trace" } }],
"stop_reason": "end_turn"
}
],
"reason": {
"kind": "compaction",
"note": "Replace verbose assistant output with durable compacted form"
},
"running_behavior": "reject"
}
}
```
Returns the stable `session_id`, the parent revision, the new revision, and the updated
message count. `running_behavior` currently supports `"reject"`, which returns
`SESSION_BUSY` if the target session has active work.
### session/transcript\_revision
Read one retained transcript revision body by revision id.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"method": "session/transcript_revision",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"revision": "current",
"offset": 0,
"limit": 50
}
}
```
Use `"current"` to read the active transcript head, or pass a concrete revision returned
by `session/rewrite_transcript` or `session/restore_transcript_revision`.
### session/transcript\_revisions
List retained transcript revision commits (oldest first) together with the current
transcript head revision. Each entry records the revision the commit produced, the
parent it was applied against, the recorded actor, the rendered rewrite reason, and
the commit timestamp.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"method": "session/transcript_revisions",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"offset": 0,
"limit": 50
}
}
```
Sessions without any rewrite commit return an empty list; `head_revision` still names
the active transcript head. Pass a returned `revision` to `session/transcript_revision`
to read the retained body, or to `session/restore_transcript_revision` to restore it.
### session/restore\_transcript\_revision
Restore a retained transcript revision as the active transcript head without changing
the session identity. Restore is represented as another rewrite commit so the graph
remains append-only even though the source transcript projection is updated in place.
`revision` accepts the same selector as `session/transcript_revision`: `"current"`
resolves to the head revision, so restoring it surfaces the typed no-op rewrite error.
The restored rows are the session's own retained rows, so every restored
`block_assistant` row keeps its `assistant_message_id` (a `session/rewrite_transcript`
replacement never carries one).
```json theme={null}
{
"jsonrpc": "2.0",
"id": 10,
"method": "session/restore_transcript_revision",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"revision": "sha256:...",
"reason": {
"kind": "restore"
},
"running_behavior": "reject"
}
}
```
### session/fork\_at
Create a new idle session whose transcript is the source session prefix ending before `message_index`. The source session is not mutated.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"method": "session/fork_at",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"message_index": 2,
"running_behavior": "reject"
}
}
```
```json theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"source_session_id": "01936f8a-7b2c-7000-8000-000000000001",
"session_id": "01936f8a-7b2c-7000-8000-000000000009",
"message_count": 2
}
}
```
`running_behavior` currently supports `"reject"`, which returns `SESSION_BUSY` if the source session has active work.
### session/fork\_replace
Create a new idle session from the source prefix through `message_index`, replacing the addressed message or block with a typed replacement. Later source messages are intentionally omitted so callers continue from the edited branch instead of replaying stale descendants.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"method": "session/fork_replace",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"message_index": 2,
"replacement": {
"type": "message",
"message": {
"role": "user",
"content": "Edited follow-up"
}
},
"running_behavior": "reject"
}
}
```
```json theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"source_session_id": "01936f8a-7b2c-7000-8000-000000000001",
"session_id": "01936f8a-7b2c-7000-8000-000000000010",
"message_count": 3
}
}
```
Supported replacement variants are `message`, `user_content_block`, `assistant_block`, and `tool_result_content_block`. Edits always create a new session identity.
### session/list
List sessions.
```json Request theme={null}
{"jsonrpc":"2.0","id":3,"method":"session/list"}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"sessions": [
{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"created_at": 1736937000,
"updated_at": 1736937060,
"message_count": 4,
"total_tokens": 500,
"is_active": false
}
]
}
}
```
Each row is a `WireSessionSummary` with `created_at` and `updated_at` in
Unix seconds. `is_active` is the reported session activity flag, not a
runtime-state enum. Optional `session_ref` and non-empty `labels` may also be
present.
`session/list` is owner-paginated rather than load-all/truncate: omitted
`limit` defaults to 100, the maximum is 1,000, and `offset` is capped at
1,000,000. The same 100/1,000 collection limits apply to
`events/list_since`, `workgraph/list`, `workgraph/ready`,
`workgraph/snapshot`, and `workgraph/events`.
WorkGraph ready-set and snapshot projections are atomic only within explicit
process bounds. A projection that would need to scan more than 1,000 items,
1,000 edges, or 1,000 attention bindings fails closed with
`INVALID_PARAMS`; narrow the realm/namespace/filter and retry. The store does
not materialize an oversized graph and truncate it after the fact.
### session/read
Get session metadata.
```json Request theme={null}
{"jsonrpc":"2.0","id":4,"method":"session/read","params":{"session_id":"01936f8a-7b2c-7000-8000-000000000001"}}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"created_at": 1736937000,
"updated_at": 1736937060,
"message_count": 4,
"is_active": false,
"model": "claude-sonnet-4-6",
"provider": "anthropic"
}
}
```
Session ID to read.
The result is `WireSessionInfo`, not the list summary: it includes
`model`/`provider` but no `total_tokens` or `state`. Timestamps are Unix
seconds, and `is_active` is the reported activity flag. Optional fields
include `session_ref`, `last_assistant_text`, `resolved_capabilities`, and
non-empty `labels`.
### session/archive
Remove a session from the runtime.
```json Request theme={null}
{"jsonrpc":"2.0","id":5,"method":"session/archive","params":{"session_id":"01936f8a-..."}}
```
```json Response theme={null}
{"jsonrpc":"2.0","id":5,"result":{"archived":true}}
```
Session ID to archive.
## Turn methods
### turn/start
Start a new turn on an existing session.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 6,
"method": "turn/start",
"params": {
"session_id": "01936f8a-...",
"prompt": "Can you explain ownership?",
"skill_refs": [
{
"kind": "structured",
"source_uuid": "dc256086-0d2f-4f61-a307-320d4148107f",
"skill_name": "rust-patterns-ownership"
}
]
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"session_id": "01936f8a-...",
"text": "Ownership is Rust's approach to memory management...",
"turns": 1,
"tool_calls": 0,
"usage": {
"input_tokens": 300,
"output_tokens": 400,
"total_tokens": 700
}
}
}
```
Returns the same run-result shape as `session/create`. Fails with error code `-32002` (SESSION\_BUSY) if a turn is already in progress.
Session ID to continue.
The follow-up prompt as text or typed content blocks, for example
`[{"type":"text","text":"Continue"}]`. Media support is model/provider
dependent; see [model capabilities](/reference/capability-matrix).
Host-attached injected context for this turn. Each entry materializes as a
separate typed injected-context user-channel message immediately before the
turn's user message, in order; injected context is excluded from
semantic-memory indexing.
Non-empty, exact request-only host facts for this newly admitted turn;
whitespace is significant. Projected before the admitted conversational
user message for foreground model calls only; never appended to Session.
Model override for this turn. On pending (deferred) sessions this sets the model before materialization. On materialized sessions this hot-swaps the LLM client for the remainder of the session.
Provider override (e.g. `"anthropic"`, `"openai"`, `"gemini"`). Typically inferred from `model`.
Omission/`null` preserves current session parameters.
`{"action":"set","value":{"thinking_budget_tokens":10000}}` replaces the
stored override; `{"action":"clear"}` clears it. The compatibility form
`{"thinking_budget_tokens":10000}` is also accepted as a Set payload.
The inner object is the typed `ProviderParamsOverride` with
`temperature`, `top_p`, `max_output_tokens`, `reasoning`,
`thinking_budget_tokens`, and typed `provider_tag` extensions, not an
arbitrary bag of provider-native keys. Applies alongside model/provider
overrides.
Omission/`null` preserves the session's binding.
`{"action":"set","value":{"realm":"prod","binding":"anthropic","profile":"claude_oauth"}}`
selects an existing [configured binding/profile](/guides/auth);
`profile` is optional. A bare binding object such as
`{"realm":"prod","binding":"anthropic"}` is also accepted as a Set payload.
`{"action":"clear"}` removes the persisted override and resumes normal
credential resolution; it does not disable credentials. Do not supply
server-owned provenance.
Max tokens override.
Tagged structured skill references to resolve and inject for this turn. Each entry uses `{"kind":"structured","source_uuid":"...","skill_name":"..."}`.
### turn/interrupt
Cancel an in-flight turn. No-op if the session is idle.
```json Request theme={null}
{"jsonrpc":"2.0","id":7,"method":"turn/interrupt","params":{"session_id":"01936f8a-7b2c-7000-8000-000000000001"}}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"interrupted": true,
"result": "interrupted"
}
}
```
```json Response (deferred/staged session) theme={null}
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"interrupted": false,
"result": "staged_noop"
}
}
```
Session ID to interrupt.
### turn/stop\_run
Stop one exact run and terminalize every input already bound to it, including
durable steers that joined the run at a model boundary. Unlike
`turn/interrupt`, a joined steer never takes a follow-up turn, and a failed
attempt is never replayed. Input admitted but not yet joined to the run stays
queued and runs afterwards.
Learn the run id from the `run_started` event (`identity.run_id`) or from an
input's `last_run_id` (`session/input_status`).
```json Request theme={null}
{"jsonrpc":"2.0","id":8,"method":"turn/stop_run","params":{"session_id":"01936f8a-7b2c-7000-8000-000000000001","run_id":"01936f8b-0000-7000-8000-000000000042","reason":"user pressed stop"}}
```
```json Response (stopped) theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"receipt": {
"outcome": "stopped",
"run_id": "01936f8b-0000-7000-8000-000000000042",
"contributors": [
{"input_id": "01936f8b-0000-7000-8000-0000000000a1", "completion": "cancelled", "terminal": "cancelled"},
{"input_id": "01936f8b-0000-7000-8000-0000000000b2", "completion": "runtime_terminated", "terminal": "cancelled"}
]
}
}
}
```
```json Response (late stop) theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"receipt": {
"outcome": "not_current",
"run_id": "01936f8b-0000-7000-8000-000000000042",
"current_run_id": "01936f8b-0000-7000-8000-000000000043"
}
}
}
```
The call returns once every contributor has reached its terminal. The receipt
`outcome` is one of:
* `stopped`: the run was current. `contributors` lists every input bound to it,
with the delivered `completion` class and the committed `terminal` class. A
cancelled joined steer reports `completion: "runtime_terminated"` with
`terminal: "cancelled"`; read `terminal` for the lifecycle fact.
* `not_current`: the run is no longer (or never was) current. Nothing was
touched, and newer runs and queued input are not interrupted.
* `not_stoppable`: the run is still bound, but a runtime stop or teardown owns
it (`state`). Nothing was staged.
A malformed `run_id` is an invalid-params error.
Session ID.
Exact run to stop.
Reason recorded with the interrupt.
`interrupted` is derived from the typed `result`. The public classifier
reports `"interrupted"`/`true` for an existing idle session's no-op as well
as a live interruption; it is not proof that a live turn was cancelled.
An admitted deferred/staged session has no materialized run to interrupt and
returns `"staged_noop"`/`false`. Failures use JSON-RPC errors rather than
these successful outcomes.
## Event methods
### session/external\_event
Queue a runtime-backed external event for an existing session.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"method": "session/external_event",
"params": {
"session_id": "01936f8a-...",
"kind": "generic_json",
"event_type": "ci-pipeline",
"payload": {"alert": "build failed", "repo": "main"}
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"outcome_type": "accepted"
}
}
```
### session/peer\_response\_terminal
Admit a correlated terminal peer response through the typed runtime ingress.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"method": "session/peer_response_terminal",
"params": {
"session_id": "01936f8a-...",
"peer_id": "00000000-0000-4000-8000-000000000161",
"display_name": "analyst",
"request_id": "00000000-0000-4000-8000-000000000162",
"status": "completed",
"result": {"token": "silver harbor"}
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"outcome_type": "accepted",
"input_id": "01936f8a-..."
}
}
```
Session ID to admit the peer response to.
Canonical peer routing ID.
Optional presentation label. It is not used as routing identity.
Peer correlation ID for the request this terminal response completes.
Terminal response status: `"completed"`, `"failed"`, or `"cancelled"`.
Peer-returned terminal payload.
Error `-32603` if runtime admission fails, `-32602` if the session locator is invalid.
This is a queue-only runtime admission path; it does not create a second direct execution loop.
### comms/peers
List discoverable peers from configured `TrustedPeers` and active in-process registrations. Requires the `comms` feature.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"method": "comms/peers",
"params": {
"session_id": "01936f8a-..."
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"peers": [
{
"peer_id": "550e8400-e29b-41d4-a716-446655440001",
"name": "reviewer",
"address": {
"transport": "tcp",
"endpoint": "127.0.0.1:4201"
},
"source": "trusted",
"sendable_kinds": ["peer_message", "peer_request", "peer_response"],
"capabilities": {
"version": 1,
"extensions": {"review": true}
},
"meta": {
"description": "Reviews pull requests",
"labels": {"role": "reviewer"}
}
},
{
"peer_id": "550e8400-e29b-41d4-a716-446655440002",
"name": "coordinator",
"address": {
"transport": "inproc",
"endpoint": "coordinator"
},
"source": "inproc",
"sendable_kinds": ["peer_message", "peer_request", "peer_response"],
"capabilities": {"version": 1, "extensions": {}},
"meta": {}
}
]
}
}
```
Session ID to query peers for.
Use `peer_id` as the `to` value for `comms/send`; `name` is display-only and
may collide. Each entry uses the canonical typed `PeerDirectoryEntry` shape:
`address` is `{transport, endpoint}`, `source` records discovery provenance,
`sendable_kinds` states supported comms operations, `capabilities` carries the
versioned capability envelope, and `meta` contains supplementary description
and labels.
### skills/list
List all skills with provenance information, including active and shadowed entries.
```json Request theme={null}
{"jsonrpc":"2.0","id":12,"method":"skills/list"}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 12,
"result": {
"skills": [
{
"key": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"skill_name": "task-workflow"
},
"name": "Task Workflow",
"description": "How to use task tools",
"scope": "builtin",
"source": {
"source_uuid": "00000000-0000-4b11-8111-000000000001",
"display_name": "embedded",
"transport_kind": "embedded",
"fingerprint": "embedded:inventory",
"status": "active"
},
"is_active": true
},
{
"key": {
"source_uuid": "00000000-0000-4b11-8111-000000000002",
"skill_name": "my-deployment"
},
"name": "Deployment Guide",
"description": "Project-specific deployment",
"scope": "project",
"source": {
"source_uuid": "00000000-0000-4b11-8111-000000000002",
"display_name": "project",
"transport_kind": "filesystem",
"fingerprint": "filesystem:.rkat/skills",
"status": "active"
},
"is_active": true
},
{
"key": {
"source_uuid": "dc256086-0d2f-4f61-a307-320d4148107f",
"skill_name": "task-workflow"
},
"name": "Custom Task Workflow",
"description": "Override tasks",
"scope": "project",
"source": {
"source_uuid": "dc256086-0d2f-4f61-a307-320d4148107f",
"display_name": "company",
"transport_kind": "git",
"fingerprint": "repo-7cc66f36fd9db1a1",
"status": "active"
},
"is_active": true
}
]
}
}
```
List of `SkillEntry` objects with `key`, `name`, `description`, `scope`, canonical `source` provenance, `is_active`, and optional canonical `shadowed_by` provenance.
Returns error `-32603` if skills are not enabled.
`skills/inspect` is not part of the advertised RPC catalog. Use `skills/list` for provenance, or the CLI/MCP skill inspection surfaces when full skill bodies are needed.
## Event replay and durable projection
The `events/*` family is the complete cursor-based replay surface for hosts that
report `runtime/capabilities.features.event_replay = true`:
* `events/latest_cursor` returns the latest cursor for a typed scope
* `events/list_since` pages envelopes after an optional cursor and reports
`latest_cursor` plus `has_more`
* `events/snapshot` returns a point-in-time session snapshot paired with its
cursor
The current public `EventReplayScope` is
`{"type":"session","session_id":"..."}`. A cursor is scope-bound and
monotonic; scope mismatch and a cursor ahead of the latest sequence fail typed
rather than being silently clamped.
Live session subscribers use a bounded 256-entry broadcast. If a consumer
falls behind, it receives `AgentEvent::StreamTruncated` with
`StreamLagged { dropped }`, then retained events. Persistent hosts can install
one internal audit projector through a separate unbounded queue and warn when
the queue reaches 1,024 pending events. UI lag cannot drop projector input.
EventStore append remains asynchronous best-effort derived state: an append
fault latches projection halt and replay fails closed, but does not undo the
committed turn. The `RuntimeStore`/backend carrier remains session authority;
`SessionStore` rows and EventStore are component/content and projection seams.
## Durable jobs and monitors
The app-facing `jobs/*` methods expose realm-scoped job projections without
worker attempt IDs or fence tokens. Read methods cover summaries, progress,
terminal results, artifact references, and health. `jobs/cancel` commits a
cancellation request, `jobs/retry` asks the generated job machine to schedule a
retry at `retry_due_at_ms`, and subscribe/unsubscribe manage durable
record/notification/event delivery.
`monitors/start` is the explicit high-trust script-monitor entry point. It
requires `session_id`, a caller-stable `submission_key`, `command`, positive
`timeout_secs`, `restart_class`, and `delivery`. The default output protocol is
`framed_jsonl`; `lines` is also supported. Agent-authored monitors cannot claim
the `adoptable` restart class. Detached shell execution fails closed unless the
realm supplies persistent job and blob stores plus its runtime delivery
projector; there is no volatile fallback.
The `mobkit/jobs/*` methods are host-worker mutation methods. Every heartbeat,
progress, checkpoint, completion, failure, or cancellation acknowledgement is
admitted under the exact attempt and fence authority carried by its typed
params. Application clients should not synthesize worker mutations from the
safe `JobSummary` projection.
### jobs/health
`jobs/health` returns `{ "detached_jobs": JobHealthSummary }`. The summary
separates three states:
* `ok`: the complete census observed no unhealthy job fact
* `degraded`: the census observed a stale lease, attention state, or backlog
* `unreadable`: a read failed or the bounded census was truncated, so it did
not establish health
`coverage` is either `{"kind":"complete"}` or
`{"kind":"truncated","scanned":N,"limit":N}`. A truncated census always
has `status: "unreadable"`; its counts are lower bounds. Keep
`pending_outbox_jobs` separate from `runtime_inbox_backlog`: the former counts
realm jobs whose delivery was never handed to a runtime, while the latter is a
host-store-scoped count of committed runtime deliveries that were accepted but
not drained. It can therefore include sessions from other logical realm IDs on
the same host.
## Blobs and artifacts
`blob/get` accepts a realm-local blob ID and returns `BlobPayload` with
`blob_id`, `media_type`, and base64 `data`. Assistant image blocks and job
results can carry blob references instead of inline bytes.
Artifacts are stable metadata records over generated output. `artifact/list`
filters by optional `session_id` and exact labels, `artifact/get` reads one
record, and `artifact/download` resolves a blob-backed payload with an optional
`expected_media_type` integrity check. Artifact IDs are opaque realm-local
identities, not filesystem paths.
## Approvals
The approval family consists of `approval/request`, `approval/list`,
`approval/get`, and `approval/decide`. Requests carry a typed owner, resource,
proposed action, risk, non-empty `allowed_decisions`, optional expiry,
metadata, and provenance.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 30,
"method": "approval/request",
"params": {
"requester": "human:alice",
"owner": {"owner_type": "session", "session_id": "01936f8a-7b2c-7000-8000-000000000001"},
"resource": {"kind": "shell_command", "id": "shell:deploy"},
"proposed_action": {
"kind": "shell_command",
"summary": "deploy the current release",
"body": {"command": "./deploy.sh"}
},
"risk": "high",
"allowed_decisions": ["approve", "deny"]
}
}
```
Owners are `runtime`, `session`, `mob`, `run`, `tool_call`, or
`external_member`. Resource kinds are `file`, `shell_command`, `tool_call`,
`device`, `runtime`, `network`, or `other`; risk is `low`, `medium`, `high`, or
`critical`. Proposed action kinds are `shell_command`, `file_write`,
`file_delete`, `network_call`, `device_control`, `tool_call`, or `other`.
Decisions are `approve` or `deny`, and record status is `pending`,
`approved`, `denied`, `expired`, or `cancelled`. The service rejects an empty
decision set, an unsupported decision, or a second terminal decision. Expiry
is evaluated lazily on `approval/get`, `approval/list`, or `approval/decide`:
a past-due request may first be stored as pending, but its next observation
persists `expired` and it can no longer be decided. `cancelled` is a durable
status that can be restored, but the current public RPC service has no
`approval/cancel` transition.
`external_member` is an audit owner shape, not a cross-host forwarding
contract. Multi-host v1 keeps approval records and decisions host-local.
RPC opens `/approvals.json` whenever its `PersistenceBundle`
exposes a store path, including a named built-in memory realm. Only an ad hoc
bundle with no store path falls back to process-local approval records. The
`approvals` runtime host feature is true only when this approval service is
persistent.
The file store is a one-host sidecar. It serializes updates with a
process-local mutex and atomic temp-file rename, but has no cross-process lock,
revision, or CAS. Run only one approval-owning RPC host against a given
`approvals.json`; shared-state multi-host approval writing is not supported.
Approval records are audit data, not a secret store. The proposed action body,
request body, metadata, and provenance are persisted as supplied. Do not put
credentials or tokens in those fields, and protect the resolved realm store
directory with host filesystem permissions. `FileApprovalStore` does not
currently force a private file mode independently of the process umask.
These methods maintain approval audit lifecycle records only. Creating or
deciding a record does not automatically gate, authorize, or execute a shell
command, tool call, device action, or any other side effect.
`requester` and the `actor` supplied to `approval/decide` are audit identities,
not transport authentication proofs. `rkat-rpc` does not authenticate them.
Authorize approval decisions in the trusted host or authenticated proxy before
forwarding the RPC call. REST, MCP, and CLI do not expose approval methods.
## Schedules, WorkGraph, and multi-host mobs
The schedule family provides CRUD, pause/resume, occurrence listing, tool
schema discovery, and direct typed tool calls. Schedule and WorkGraph state are
realm-scoped durable domains when the owning feature and store are available.
Public WorkGraph RPC is intentionally observational: item get/list, ready work,
snapshot, event history, goal status, and attention-list reads. Agents mutate
WorkGraph through the `workgraph_*` tool family; trusted in-process hosts own
goal and attention mutation authority.
Multi-host mob RPC includes host bind/revoke, host and route observation,
scope grants, hard cancel, placed member history, and member live-channel
control. Check `runtime/capabilities.features.multi_host_mobs` rather than
inferring support from generic `mobs` or the server version.
`mob/member_live_open` supports WebSocket transport only for both local and
placed members. A request for `transport: "webrtc"` is rejected; WebRTC uses
the session-scoped `live/open` and `live/webrtc/answer` flow below only when
the caller can address the owning session directly. In multi-host v1,
controller `session/*` methods do not proxy a placed member's host-local
session, so there is no controller-side WebRTC path to a placed member.
Durable member role migration is not part of `mob/spawn`, profiles, or any
public JSON-RPC/SDK params. The one-shot `resume_from_role` declaration exists
only on trusted in-process `SpawnMemberSpec` and the private remote-host
`MaterializeLaunchMode::Resume` wire. It authorizes one exact cold migration
from the stored predecessor role while keeping mob ID, member identity,
session, and transcript fixed; live sessions and mismatched declarations fail
closed.
### workgraph/get
Read one WorkGraph item by id. Requires the `workgraph` feature and a
realm-scoped runtime; a runtime without a realm identity returns a typed fault
instead of inventing a `default` scope.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 30,
"method": "workgraph/get",
"params": {
"id": "work_release_notes",
"namespace": "default"
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 30,
"result": {
"id": "work_release_notes",
"realm_id": "home",
"namespace": "default",
"title": "Prepare release notes",
"status": "in_progress",
"priority": "high",
"labels": ["release", "docs"],
"revision": 4,
"evidence_refs": [
{
"kind": "pr",
"id": "1082",
"label": "Catalog PR",
"summary": "Adds the 0.8.33 catalog models"
}
]
}
}
```
The response is abridged; the full `WorkItem` shape is in the
[WorkGraph reference](/reference/workgraph-reference#workitem).
The `WorkItemId`.
Defaults to the runtime's realm. Any other value is rejected: the service holds exactly one `WorkGraphNamespaceGrant`.
Defaults to the granted namespace (`default` on standard surfaces). Any other value is rejected.
Every evidence entry, here and everywhere a `WorkEvidenceRef` appears (the
`workgraph_add_evidence` tool argument, host mutation surfaces such as MobKit's
`mobkit/workgraph/evidence/add`), has the shape
`{"kind": string, "id": string, "label"?: string, "summary"?: string}`. `kind`
is an opaque provenance label (`artifact`, `pr`, `log`, `summary`,
`notification`, ...); the reserved confirmation kinds (`host_confirmation`,
`principal_confirmation`, `supervisor_confirmation`, `reviewer_confirmation`)
are refused from generic callers and stamped only by trusted goal-confirm
authority.
### workgraph/list
List items in the granted realm and namespace. Params are a `WorkItemFilter`;
all fields are optional and an empty object lists live work.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 31,
"method": "workgraph/list",
"params": {
"namespace": "default",
"statuses": ["completed", "failed"],
"labels": ["release", "docs"],
"include_terminal": true,
"limit": 50
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 31,
"result": {
"items": [
{"id": "work_release_notes", "status": "completed", "labels": ["release", "docs"], "revision": 6}
]
}
}
```
Defaults to the runtime's realm; must equal the granted realm.
Defaults to the granted namespace; must equal it.
Present in the wire shape, rejected by the standard single-grant services. A namespace grant never implies a multi-namespace capability.
Keep only items whose status is listed: `open`, `in_progress`, `blocked`, `completed`, `cancelled`, `failed`. Checked before the terminality rule below.
Every listed label must be present on the item (a conjunction). `["release", "docs"]` returns only items carrying both; filter on one label to widen the result.
When false, `completed`, `cancelled`, and `failed` items are dropped even if `statuses` names them: `{"statuses": ["completed"]}` alone returns an empty list. Set it to true to read closed work.
Maximum items returned. Omitted means 100; values above 1,000 fail with `INVALID_PARAMS` before the store is queried.
Items and edges are never deleted. There is no unlink or delete method on any
surface and no tool for it; the graph is an append-only ledger. To take an item
out of live views, close it as `cancelled` (it then hides behind
`include_terminal`); to replace it, create the successor and link a
`supersedes` edge from the new item (`from_id`) to the old one (`to_id`).
### workgraph/ready
List items whose machine-derived readiness holds right now: live status, no
unresolved `blocks` edge, parent join satisfied, and time gates passed. Params
are a `ReadyWorkFilter`.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 32,
"method": "workgraph/ready",
"params": {
"namespace": "default",
"labels": ["release"],
"limit": 20
}
}
```
Defaults to the runtime's realm; must equal the granted realm.
Defaults to the granted namespace; must equal it.
Every listed label must be present (same conjunction as `workgraph/list`).
Maximum items returned after sorting by priority (high first), then creation time, then id. Omitted means 100; above 1,000 is `INVALID_PARAMS`.
There is no `include_terminal` here because a terminal item is never ready.
Readiness is evaluated atomically over the namespace; a namespace with more
than 1,000 items fails with `INVALID_PARAMS` and must be narrowed rather than
truncated. The result is `{"items": [...]}`.
### workgraph/snapshot
Read one atomic observation of the namespace. Params are a
`WorkGraphSnapshotFilter` with the same fields and semantics as
`WorkItemFilter` (`realm_id`, `namespace`, `all_namespaces`, `statuses`,
`labels`, `include_terminal`, `limit`).
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 33,
"method": "workgraph/snapshot",
"params": {
"namespace": "default",
"include_terminal": false,
"limit": 200
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 33,
"result": {
"realm_id": "home",
"namespace": "default",
"all_namespaces": false,
"captured_at": "2026-09-03T10:15:00Z",
"event_high_water_mark": 412,
"items": [],
"edges": [],
"attention": [],
"ready_item_ids": []
}
}
```
`statuses`, `labels`, and `include_terminal` select the items exactly as in
`workgraph/list`; `limit` then bounds them (default 100, maximum 1,000).
`edges` contains only edges whose both endpoints are among the returned items,
`attention` only bindings on those items, and `ready_item_ids` the subset of
returned items that is ready at `captured_at`. A snapshot therefore omits an
edge to a terminal item unless `include_terminal` is true. The atomic scan is
bounded before filtering: a namespace with more than 1,000 items, 1,000 edges,
or 1,000 attention bindings fails with `INVALID_PARAMS` and must be narrowed.
### workgraph/events
Read the public event history of the namespace. Params are a
`WorkGraphEventFilter`.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 34,
"method": "workgraph/events",
"params": {
"namespace": "default",
"after_seq": 400,
"limit": 100
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 34,
"result": {
"events": [
{
"seq": 401,
"realm_id": "home",
"namespace": "default",
"item_id": "work_release_notes",
"kind": "closed",
"at": "2026-09-03T10:14:58Z",
"payload": {"status": "completed"},
"facts": [
{"kind": "namespace_terminal", "namespace": "default", "observed_at": "2026-09-03T10:14:58Z"}
]
}
]
}
}
```
Defaults to the runtime's realm; must equal the granted realm.
Defaults to the granted namespace; must equal it.
Rejected by the standard single-grant services.
Return only events with `seq` strictly greater than this value. Use the last `seq` you saw, or a snapshot's `event_high_water_mark`, to page forward.
Maximum events returned; above 1,000 is `INVALID_PARAMS`.
Each event carries `seq`, `realm_id`, `namespace`, optional `item_id`, `kind`
(`created`, `updated`, `readiness_observed`, `claimed`, `released`, `blocked`,
`closed`, `linked`, `evidence_added`, `attention_created`,
`attention_updated`), `at`, a kind-specific `payload`, and any typed `facts`
committed with the mutation (`{"kind": "item_ready" | "lease_expired" |
"namespace_terminal", ...}`). Flow execution-binding events are kept in the
durable journal but omitted from this projection.
### workgraph/goal/status
Read one goal: the work item plus its attention binding. Params are a
`GoalStatusRequest`.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 35,
"method": "workgraph/goal/status",
"params": {
"binding_id": "attn_release_notes",
"namespace": "default"
}
}
```
The `WorkAttentionBindingId`.
Defaults to the runtime's realm; must equal the granted realm.
Defaults to the granted namespace; must equal it.
The result is `{"item": WorkItem, "attention": WorkAttentionBinding}`. An
unknown binding is `INVALID_PARAMS` with the typed not-found message.
### workgraph/attention/list
List attention bindings in the granted namespace, optionally narrowed by
target or status. Params are an `AttentionListRequest`; an empty object lists
every binding.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 36,
"method": "workgraph/attention/list",
"params": {
"namespace": "default",
"target": {"kind": "session", "session_id": "01936f8a-..."},
"status": {"state": "active"}
}
}
```
Defaults to the runtime's realm; must equal the granted realm.
Defaults to the granted namespace; must equal it.
`{"kind": "session", "session_id": ...}` or `{"kind": "lowered_owner", "owner_key": {...}}`.
Tagged by `state`: `{"state": "active"}`, `{"state": "paused", "until"?: timestamp}`, `{"state": "superseded"}`, or `{"state": "stopped"}`.
The result is `{"attention": [WorkAttentionBinding, ...]}`. Attention
reassignment and policy escalation are not RPC methods; they are
authority-bearing tools injected into turns that carry an active attention
projection.
## Live channel methods
Live channels provide low-latency audio and text streaming plus model-gated still-image input on sessions that use a realtime-capable model. Create a session with a model like `gpt-realtime-2`, then call `live/open` to start the channel explicitly. Start `rkat-rpc` with `--live-ws ` for WebSocket transport, or with the feature-gated `--live-webrtc` flag for WebRTC signaling. At least one live transport must be enabled for the `live/*` family to be advertised. See the [Live Channels guide](/guides/realtime) for the full flow.
### Opening a live channel
Create a session on a realtime-capable model and call `live/open`:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 19,
"method": "session/create",
"params": {
"prompt": "Let's talk.",
"model": "gpt-realtime-2",
"provider": "openai"
}
}
```
```json theme={null}
{
"jsonrpc": "2.0",
"id": 20,
"method": "live/open",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001"
}
}
```
`live/open` returns a `LiveOpenResult` with transport bootstrap (e.g. WebSocket URL), `WireLiveChannelCapabilities`, and `WireLiveContinuityMode`.
Session whose canonical history seeds the live channel.
Optional input-turning mode. Omitted preserves provider-managed behavior.
Optional live transport. Omitted uses the server default.
Optional positive serialized-character budget for the core-owned seed
message window. Omitted preserves the complete canonical history. When
present, core selects a recent complete-turn replay suffix. Contiguous
System, SystemNotice, and injected-context messages immediately preceding
a selected user turn stay with that turn; older System rows may be omitted.
An existing compaction-summary head may also be retained. Any truncation
returns degraded continuity. Zero is rejected.
System messages follow the same bounded replay policy as other ordered
transcript data; their combined historical size need not fit the window.
The complete canonical System sequence is retained separately as a refresh
drift witness, **not** as hidden provider instructions that restore omitted
rows. Canonical multimodal sidecars also remain outside the message window:
image identity, tombstones, and aggregate accounting stay complete even when
older replay messages are omitted.
### live/webrtc/answer
For WebRTC, request `"transport": "webrtc"` on `live/open`. The returned
transport bootstrap contains a single-use `token` and names
`live/webrtc/answer` as the canonical signaling method. Send the browser offer
SDP with that token:
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 21,
"method": "live/webrtc/answer",
"params": {
"channel_id": "ch_01936f8a-...",
"token": "single-use-bootstrap-token",
"offer_sdp": "v=0..."
}
}
```
The result is `{ "answer_sdp": "v=0..." }`. Tokens are channel-bound,
short-lived, and single-use; replay, expiry, or a channel mismatch fails typed.
### live/status
Read the current state of a live channel.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 22,
"method": "live/status",
"params": {
"channel_id": "ch_01936f8a-..."
}
}
```
### live/send\_input
Send one typed input chunk. Image input is available only when
`LiveOpenResult.capabilities.image_in` is `true`; an image is staged as context
for the next text, audio, or explicitly committed response. Every image must
carry a caller-stable, session-scoped `idempotency_key`.
The JSON-RPC JSONL transport accepts frames up to 64 MiB, excluding the
trailing newline. Direct live WebSocket frames are a different input path:
they support JSON text chunks and negotiated raw PCM audio only, with a 2 MiB
aggregate/per-frame ceiling. Images always use this JSON-RPC method, even when
the channel's observation/audio transport is WebSocket or WebRTC.
Server-originated JSON-RPC responses are capped at 32 MiB and queued
notifications at 8 MiB. Their byte/count reservations are process-wide and
remain owned through the bounded transport write; oversized collection reads
must use pagination instead of relying on socket buffering.
```json Image input theme={null}
{
"jsonrpc": "2.0",
"id": 23,
"method": "live/send_input",
"params": {
"channel_id": "ch_01936f8a-...",
"chunk": {
"kind": "image",
"idempotency_key": "turn-42-diagram",
"mime": "image/png",
"data": "iVBORw0KGgo..."
}
}
}
```
The key must be non-empty, no more than 128 UTF-8 bytes, contain no control
characters, and have no leading or trailing whitespace. Replay the same key
with the same canonical MIME and bytes after a lost receipt: Meerkat does not
resend it to the provider and emits the existing durable identity again.
Reusing a key for different content is rejected as
`image_input_idempotency_conflict`. If text or audio is already staged, commit
it before submitting an image; otherwise the image is rejected as
`image_input_requires_commit`.
`{ "status": "sent" }` proves only that the adapter queue accepted the
command. Wire validation and queue admission can instead fail immediately with
typed `LiveSendInputErrorData`. A check that fails after acceptance is emitted
as a typed `command_rejected` observation. Only a
`user_content_committed` observation carrying the same `idempotency_key`
proves the provider-acknowledged image reached durable canonical session
history. The receipt is redacted: it contains identity, ordering, media type,
and the key, never image bytes.
Each image is limited to 20 MiB decoded, and canonical live image history is
limited to 40 MiB decoded in aggregate, counting repeated references as
separate occurrences. `live/send_input` rejects a new image that would cross
the aggregate ceiling as `image_input_history_budget_exceeded` before provider
send or persistence. That keeps every successfully committed live image
reopenable. Legacy or out-of-band history already above the ceiling, missing
blobs, and content-address mismatches fail `live/open` rather than trimming,
substituting, or changing image context. See the
[Live Channels guide](/guides/realtime#sending-image-input) for retry and
transport details.
### Live channel lifecycle methods (`live/*`)
| Method | Purpose |
| - | - |
| `live/open` | Open a live channel; optional positive `seed_max_chars` bounds serialized whole-turn seed messages |
| `live/webrtc/answer` | Answer a browser WebRTC offer for an already-open live channel |
| `live/media_health` | Report decoded-audio counters for the output a `live/media_health_requested` notification named; a `media_fault` verdict means the runtime closed the channel |
| `live/status` | Get the status of a live channel |
| `live/close` | Close a live channel |
| `live/send_input` | Send an audio, text, or model-supported image chunk to a live channel |
| `live/commit_input` | Commit pending input on a live channel (turn boundary) |
| `live/interrupt` | Interrupt the assistant turn on a live channel (barge-in) |
| `live/truncate` | Truncate assistant output at a client-tracked playback cursor |
| `live/playback_complete` | Report completed client playback for one generated assistant output |
| `live/refresh` | Apply mutable session config (instructions/tools/audio) to an open channel; config-only, does not replay history; identity swaps and canonical transcript/user-content rewrites return typed reopen-required errors |
Public Live WebRTC channels also send the notification
`live/assistant_playback_hint { channel_id, hint: "duck" | "restore" }`: duck
local assistant playback while assistant audio overlaps the user's speech,
restore it afterwards. It is purely additive (clients that ignore it
behave as before); see
[Barge-in playback hint](/guides/realtime#barge-in-playback-hint) for the
recommended client policy.
WebSocket calls require `--live-ws `. WebRTC open/answer requires a build
with the `live-webrtc` feature and startup with `--live-webrtc`. If both are
enabled, select the transport explicitly or accept the server default.
## MCP methods
### mcp/add
Stage a live MCP server addition for a running session. The server is connected at the next turn boundary.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 14,
"method": "mcp/add",
"params": {
"session_id": "01936f8a-...",
"server_config": {
"name": "my-tools",
"command": "npx",
"args": ["-y", "@my/mcp-server"]
},
"persisted": false
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 14,
"result": {
"session_id": "01936f8a-...",
"operation": "add",
"server_name": "my-tools",
"status": "staged",
"persisted": false,
"applied_at_turn": null
}
}
```
Session ID to add the server to.
Typed MCP server configuration. Include `name` with either stdio fields (`command`, optional `args`/`env`) or HTTP fields (`url`, optional `headers`/`transport`).
Whether to also write the server to disk config.
### mcp/remove
Stage removal of an MCP server from a running session. Active tool calls drain before the server disconnects at the next turn boundary.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 15,
"method": "mcp/remove",
"params": {
"session_id": "01936f8a-...",
"server_name": "my-tools",
"persisted": false
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 15,
"result": {
"session_id": "01936f8a-...",
"operation": "remove",
"server_name": "my-tools",
"status": "staged",
"persisted": false,
"applied_at_turn": null
}
}
```
Session ID to remove the server from.
Name of the MCP server to remove.
Whether to also remove from disk config.
### mcp/reload
Reload one or all MCP servers for a running session. Useful after config changes.
```json Request theme={null}
{
"jsonrpc": "2.0",
"id": 16,
"method": "mcp/reload",
"params": {
"session_id": "01936f8a-...",
"server_name": "my-tools"
}
}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 16,
"result": {
"session_id": "01936f8a-...",
"operation": "reload",
"server_name": "my-tools",
"status": "staged",
"persisted": false,
"applied_at_turn": null
}
}
```
Session ID to reload servers for.
Specific server to reload. If null, reloads all servers.
## Models
### models/catalog
Return the curated model catalog with provider profiles, capability metadata, and parameter schemas.
```json Request theme={null}
{"jsonrpc":"2.0","id":17,"method":"models/catalog"}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 17,
"result": {
"contract_version": {"major": 0, "minor": 8, "patch": 52},
"providers": [
{
"provider": "anthropic",
"default_model_id": "claude-opus-5-5",
"models": [
{
"id": "claude-opus-5",
"display_name": "Claude Opus 5",
"tier": "recommended",
"context_window": 1000000,
"max_output_tokens": 128000,
"profile": {
"model_family": "claude-opus-5",
"supports_temperature": false,
"supports_thinking": true,
"supports_reasoning": false,
"params_schema": {}
}
}
]
}
]
}
}
```
No parameters are required. The catalog is resolved from built-in model metadata plus config-backed provider/server entries; invalid provider config can yield `INVALID_PARAMS`.
Structured catalog contract version with integer `major`, `minor`, and
`patch` components. Unlike `initialize`, this result does not stringify it.
List of provider entries, each containing `provider`, `default_model_id`, and `models`.
Model profile with capability flags (`supports_temperature`, `supports_thinking`, `supports_reasoning`) and `params_schema`.
## Capabilities
### capabilities/get
Return capabilities registered by the running build's linked owners, with
status resolved against current config. This is not an enumeration of every
capability vocabulary member: an unavailable feature can have no row at all.
A registered declaration can still report
`{"NotCompiled":{"feature":"..."}}`; missing declarations are not automatically
synthesized with that status.
```json Request theme={null}
{"jsonrpc":"2.0","id":8,"method":"capabilities/get"}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"contract_version": {"major": 0, "minor": 8, "patch": 52},
"capabilities": [
{
"id": "sessions",
"description": "Session lifecycle management",
"status": "Available"
},
{
"id": "structured_output",
"description": "Structured output extraction with JSON schema",
"status": "Available"
},
{
"id": "hooks",
"description": "Lifecycle hooks for tool and turn events",
"status": "Available"
},
{
"id": "builtins",
"description": "Built-in tools (task management)",
"status": {"DisabledByPolicy": {"description": "Disabled by config"}}
},
{
"id": "shell",
"description": "Shell command execution",
"status": {"DisabledByPolicy": {"description": "Disabled by config"}}
},
{
"id": "comms",
"description": "Inter-agent communication",
"status": {"NotCompiled": {"feature": "comms"}}
},
{
"id": "memory_store",
"description": "Semantic memory indexing",
"status": "Available"
},
{
"id": "skills",
"description": "Skill loading and injection",
"status": "Available"
}
]
}
}
```
Possible `status` values:
| Status | Shape | Meaning |
| - | - | - |
| `Available` | `"Available"` | Compiled in, config-enabled, protocol supports it |
| `DisabledByPolicy` | `{"DisabledByPolicy": {"description": "..."}}` | Compiled in but disabled by policy |
| `NotCompiled` | `{"NotCompiled": {"feature": "..."}}` | Feature flag absent at compile time |
| `NotSupportedByProtocol` | `{"NotSupportedByProtocol": {"reason": "..."}}` | This protocol surface does not support it |
## Config methods
### config/get
Read current realm config envelope.
```json Request theme={null}
{"jsonrpc":"2.0","id":9,"method":"config/get"}
```
```json Response theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"config": {
"agent": {
"model": "claude-sonnet-4-6",
"max_tokens_per_turn": 8192
}
},
"generation": 3,
"realm_id": "team-alpha",
"instance_id": "rpc-worker-1",
"backend": "sqlite",
"resolved_paths": {
"root": "/.../meerkat/realms/team-alpha",
"manifest_path": "/.../realm_manifest.json",
"config_path": "/.../config.toml",
"sessions_sqlite_path": "/.../sessions.sqlite3",
"sessions_jsonl_dir": "/.../sessions_jsonl"
}
}
}
```
### Config write results
RPC `config/set` and `config/patch` return `ConfigWriteResult`: the committed
`ConfigEnvelope` fields (`config`, `generation`, `realm_id`, `instance_id`,
`backend`, `resolved_paths`) are flattened at the top level, with optional
`live_propagation`.
The report is omitted when the write does not fan out; the current fan-out
trigger is a change to `agent.model`. When present, it contains:
| Field | Shape / meaning |
| - | - |
| `clean` | Boolean: `swap_failed`, `refresh_failed`, and `close_failed` are all empty |
| `swapped` | Session IDs whose live model identity was swapped |
| `skipped` | `{session_id, reason}` entries; reason is `{"kind":"no_op_or_override"}` or `{"kind":"identity_lookup_failed","error":"..."}` |
| `swap_failed` | `{session_id, error}` entries |
| `refreshed` | Session IDs whose refresh command was accepted into the adapter queue and acknowledged by runtime authority |
| `closed` | Session IDs whose channels were closed |
| `refresh_failed` | `{session_id, failure}` entries with a typed refresh-failure reason |
| `close_failed` | `{session_id, failure}` entries with a typed close-failure reason |
Inspect `skipped` even when `clean` is `true`: clean does not mean every
session changed, nor that identity lookup succeeded for every session.
It describes this report's synchronous failure lists, not eventual provider
application. Observe subsequent live-channel status and errors to determine
whether an accepted refresh was applied.
Propagation occurs **after** the config generation commits. A non-clean
report is a successful config write with live-propagation failures, not a
rollback of the committed generation. Reconcile the listed sessions/channels
separately. This report is specific to RPC writes, not `config/get` or the
REST/MCP config envelope.
### config/set
Replace config, optionally using generation CAS. Returns the
[config write result](#config-write-results).
`config/set` replaces the realm's whole config document. The request is read
as a typed `Config`, so every key it leaves out is written with its default.
In a child realm each written key is the child's own override: the realm
stops inheriting those keys from its parent, and capabilities the parent
disabled whose defaults are on (`tools.schedule_enabled`, provider web search,
Gemini Google search, `skills.enabled`) are enabled again. Sending back the
`config` of a `config/get` response does the same, because that `config` has
every default filled in. For a partial edit, use `config/patch` (below).
```json Request theme={null}
{
"jsonrpc":"2.0",
"id":10,
"method":"config/set",
"params":{
"config": {"agent":{"model":"gpt-5.5"}},
"expected_generation": 3
}
}
```
```json Response (abbreviated envelope with propagation report) theme={null}
{
"jsonrpc":"2.0",
"id":10,
"result": {
"config": {"agent":{"model":"gpt-5.5"}},
"generation": 4,
"realm_id": "team-alpha",
"instance_id": null,
"backend": "sqlite",
"resolved_paths": { "...": "..." },
"live_propagation": {
"clean": false,
"swapped": [],
"skipped": [{
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"reason": {"kind": "no_op_or_override"}
}],
"swap_failed": [{
"session_id": "01936f8a-7b2c-7000-8000-000000000002",
"error": "Provider client could not be rebuilt"
}],
"refreshed": [],
"closed": [],
"refresh_failed": [],
"close_failed": []
}
}
}
```
### config/patch
Merge-patch the config (RFC 7396). Returns the same
[config write result](#config-write-results). This example changes only
`max_tokens_per_turn`, leaving `agent.model` unchanged, so its result omits
`live_propagation`.
```json Request theme={null}
{
"jsonrpc":"2.0",
"id":11,
"method":"config/patch",
"params":{
"patch":{"agent":{"max_tokens_per_turn":8192}},
"expected_generation": 4
}
}
```
```json Response theme={null}
{
"jsonrpc":"2.0",
"id":11,
"result": {
"config": {"agent":{"max_tokens_per_turn":8192}},
"generation": 5,
"realm_id": "team-alpha",
"instance_id": null,
"backend": "sqlite",
"resolved_paths": { "...": "..." }
}
}
```
`config/set` also accepts a direct config object as params for compatibility. In that mode, no CAS check is applied.
## Notifications
During turn execution, the server emits `session/event` notifications with an
`EventEnvelope` under `params.event`:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/event",
"params": {
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"event": {
"event_id": "01936f8a-7b2c-7000-8000-000000000010",
"source": {
"type": "session",
"session_id": "01936f8a-7b2c-7000-8000-000000000001"
},
"seq": 12,
"timestamp_ms": 1736937000000,
"payload": {"type": "text_delta", "delta": "Rust is"}
}
}
}
```
Inspect `params.event.payload.type` and (for text deltas)
`params.event.payload.delta`, not `params.event.type`. `event_id`, typed
`source`, `seq`, and `timestamp_ms` belong to the envelope; optional `mob_id`
is envelope metadata.
Event types match the `AgentEvent` enum in `crates/meerkat-core/src/event.rs`
(`agent_event_type` assigns each variant its stable wire name) and its schema
projection in `artifacts/schemas/events.json`. New variants must appear in
both that enum and this table. The decodable vocabulary includes the
explicitly marked legacy row; not every variant is emitted by current writers.
Message-scoped events (`turn_started`, `reasoning_delta`,
`reasoning_complete`, `text_delta`, `text_complete`, `server_tool_content`,
`assistant_image_appended`, `turn_completed`, `retrying`) carry an optional
`assistant_message_id`. It equals `assistant_message_id` on the committed
`block_assistant` row returned by `session/history`, so clients join live rows
to history by id rather than by text or position. `run_completed` carries the
id of the message whose text `result` repeats. Only a history row carrying the
id is the commit: `turn_completed` is a live signal that can precede work that
still fails the run, and can be absent for a committed row. Rows the realtime
transcript materializer commits carry no `assistant_message_id`; they carry
`realtime_origin.provider_item_ids`, which match the provider item ids on the
live transport's realtime observations. See
[assistant message identity](/reference/session-contracts#assistant-message-identity).
| Event | Description |
| - | - |
| `run_started` | Agent execution began |
| `run_completed` | Agent run finished |
| `extraction_succeeded` | Structured-output extraction succeeded after a completed run; `request_usage` has one usage row per extraction request |
| `extraction_failed` | Structured-output extraction failed after a completed run; `request_usage` has one usage row per answered extraction request |
| `run_failed` | Agent run failed |
| `turn_started` | New LLM call within the turn; `assistant_message_id` names the assistant message it will commit |
| `reasoning_delta` | Streaming reasoning/thinking chunk from the model |
| `reasoning_complete` | Reasoning/thinking complete for this block |
| `text_delta` | Streaming text chunk from LLM |
| `text_complete` | Full text for this turn |
| `server_tool_content` | Provider-executed tool content surfaced during a model turn |
| `assistant_image_appended` | Canonical assistant image block appended to transcript history; `assistant_message_id` names the message that carries it |
| `tool_call_requested` | LLM wants to call a tool |
| `tool_result_received` | Tool result processed |
| `turn_completed` | LLM call finished (every agent-loop call, including tool-loop calls with `stop_reason: tool_use`); absent `usage` means no accounting row, not zero tokens; `assistant_message_id` names the message the turn closes. It is a live signal, not the commit: only a history row carrying the id is the commit. It can precede boundary work, run-completed hooks and persistence that still fail the run, and it is absent when a run fails after the row was pushed. It is also absent on realtime materializer turns, whose rows pair by `realtime_origin.provider_item_ids` |
| `turn_usage_accounting_unmeasured` | Provider accounting was absent; no zero row may be fabricated, and this observation alone does not assert turn completion |
| `turn_usage_accounting_identity_disputed` | Reported counters exist but provider/model attribution differs; preserve the reported identity and counters |
| `tool_execution_started` | Tool dispatch began |
| `tool_execution_completed` | Tool returned a result |
| `tool_execution_timed_out` | Tool exceeded timeout |
| `compaction_started` | Context compaction began |
| `compaction_completed` | Compaction finished |
| `compaction_failed` | Compaction failed |
| `budget_warning` | Approaching resource limits |
| `retrying` | Retrying after transient error; the retry reuses `assistant_message_id`, so discard that id's live buffer |
| `model_fallback_skipped` | A fallback candidate was skipped with its typed target/reason |
| `model_fallback_staged` | A retry staged a fallback target identity before commit |
| `model_fallback_committed` | A retry committed the fallback identity change |
| `model_fallback_target_failed` | A selected fallback target failed; carries previous/target identity and the error |
| `hook_started` | Hook execution began |
| `hook_completed` | Hook finished |
| `hook_failed` | Hook execution failed |
| `hook_denied` | Hook blocked operation |
| `skills_resolved` | Skills loaded for turn |
| `skill_resolution_failed` | Skill resolution failed |
| `tool_config_changed` | Tool visibility or MCP config changed |
| `background_job_completed` | Background shell job reached a typed terminal status; `terminal_status` is semantic and `status` is only an optional display mirror |
| `transcript_rewrite_committed` | Legacy 0.8.10 compatibility/read-only full-body rewrite row; current writers must not emit it |
| `transcript_rewrite_audit_receipt_committed` | Current receipt-only evidence for a committed ordered transcript rewrite suffix |
| `peer_content_ingested` | Typed peer content committed into session context, with correlation/peer facts and optional sender taint; absent taint is not clean |
| `provider_cache_breakpoints_discarded` | Cache evidence no longer binds to the committed transcript; cache reuse is reduced without itself failing the turn |
| `boundary_append_applied` | A durable steer's typed appends were written into the running turn at a model boundary; carries `run_id`, `input_id`, model-projected `content`, `append_count`, optional canonical `notices`, and optional application-time `transcript_start` offset |
| `boundary_appends_discarded` | Exact discarded application facts (`session_id`, `run_id`, `input_ids`), published after any required requeue persistence succeeds |
| `live_channel_closed` | One of the session's live channels closed; published after every committed close with `session_id`, `channel_id`, a typed `reason` (`LiveChannelCloseReason`) and `reopen_recommended` |
| `request_reasoning_lowered` | One provider attempt of a turn that carries a reasoning-effort preference: the batch's `requested` disposition, the attempt's `baseline` effort, and the `outcome` it sent (`applied` with the level, or `not_applied` with a typed reason; absent when the batch applies no level). `turn_number` or `fallback_attempt` names the attempt |
| `interaction_complete` | Interaction-scoped stream completed |
| `interaction_callback_pending` | Interaction reached a callback boundary and is waiting for tool results |
| `interaction_failed` | Interaction-scoped stream failed |
| `stream_truncated` | This bounded live subscriber lagged; `reason.kind` is `stream_lagged` and `reason.dropped` reports the dropped count |
Reconcile `boundary_append_applied` against `boundary_appends_discarded` for
the same session, run, and input, and against canonical transcript history.
A run terminal alone does not prove that an append was discarded. A discard
does not terminalize the input or invalidate its later application in another
run; canonical retained history remains authoritative.
`live_channel_closed` tells every session observer why a live channel closed
without polling `live/status`. `reason` is one of:
| Reason | Meaning |
| - | - |
| `client_requested` | A caller asked to close the channel (`live/close`, a member host close, or the client's own close request on the channel transport) |
| `client_disconnected` | The client's transport went away (socket or peer connection lost) |
| `provider_closed` | The provider ended the session |
| `error` | A terminal adapter, provider or transport protocol error, including a configuration the provider rejected |
| `media_fault` | The channel's first assistant output had a non-empty transcript but the client decoded no audible audio for it |
| `replaced` | The runtime retired the channel for a replacement (context or result recovery reopens the session on a fresh channel) |
| `open_abandoned` | The channel's open was abandoned before it activated |
`reopen_recommended` is true only for a `media_fault` while the session's one
reopen is still available; the reopened channel keeps its retained context.
`stream_truncated` is subscriber-local. It reports UI-ring lag, not an
EventStore projection result. Use `events/list_since` or `events/snapshot`
when that optional replay surface is healthy, and reconcile against current
session state after receiving it.
### session/stream\_event notifications
When a session event stream is open (via `session/stream_open`), the server
emits `session/stream_event` notifications. `params.event` is the same nested
`EventEnvelope` as in `session/event`, scoped by an outer stream
handle.
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/stream_event",
"params": {
"stream_id": "550e8400-e29b-41d4-a716-446655440000",
"sequence": 1,
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"event": {
"event_id": "01936f8a-7b2c-7000-8000-000000000010",
"source": {
"type": "session",
"session_id": "01936f8a-7b2c-7000-8000-000000000001"
},
"seq": 12,
"timestamp_ms": 1736937000000,
"payload": {"type": "text_delta", "delta": "Rust is"}
}
}
}
```
Outer `sequence` numbers the explicit stream's deliveries and is distinct
from the source envelope's `event.seq`. Outer `stream_id` and `session_id`
are routing fields; scoped streams can additionally carry `scope_id` and
`scope_path`. None of these flattens or replaces `event.payload`.
### session/stream\_end notifications
An explicit session stream terminates with this notification:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/stream_end",
"params": {
"stream_id": "550e8400-e29b-41d4-a716-446655440000",
"session_id": "01936f8a-7b2c-7000-8000-000000000001",
"ended": true,
"outcome": "explicit_close"
}
}
```
`outcome` is `"remote_end"`, `"terminal_error"`, or `"explicit_close"`.
Calling `session/stream_close` terminates the handle with `explicit_close`;
normal upstream exhaustion uses `remote_end`. When a terminal error is
reported, the notification additionally contains
`error: {"code":"stream_queue_overflow","message":"..."}` or
`error: {"code":"stream_receiver_gone","message":"..."}`.
`stream_truncated` is an event-level lag observation, **not** this terminal
protocol. Stop consuming a terminated handle and reopen/reconcile as needed.
A disconnected transport or lost notification receiver cannot be relied on to
deliver its final end notification.
### mob/stream\_event notifications
When a mob event stream is open (via `mob/stream_open`), the server emits `mob/stream_event` notifications. Requires the `mob` feature.
```json theme={null}
{
"jsonrpc": "2.0",
"method": "mob/stream_event",
"params": {
"stream_id": "550e8400-e29b-41d4-a716-446655440000",
"sequence": 1,
"event": { "...": "..." }
}
}
```
For mob-wide streams the event is an `AttributedEvent` (`source` member identity + profile + envelope). For per-member streams the event is the raw `EventEnvelope`. Runtime incarnation ids and fence tokens are bridge-internal and are not part of public stream payloads.
### mob/stream\_end notifications
Requires the `mob` feature. The mob terminal payload omits `session_id`:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "mob/stream_end",
"params": {
"stream_id": "550e8400-e29b-41d4-a716-446655440000",
"ended": true,
"outcome": "terminal_error",
"error": {
"code": "stream_queue_overflow",
"message": "event stream notification queue is full"
}
}
}
```
As with session streams, `outcome` is `"remote_end"`, `"terminal_error"`, or
`"explicit_close"`; `mob/stream_close` selects explicit-close termination.
Conditional `error` contains `code` (`"stream_queue_overflow"` or
`"stream_receiver_gone"`) and `message`; it is absent without an error.
Stop using the terminated handle and reopen/reconcile as appropriate.
Transport disconnection can prevent delivery of this final notification.
Neither stream-end notification is a callable RPC method.
## Error codes
Standard JSON-RPC codes plus Meerkat-specific application codes:
| Code | Name | Description |
| - | - | - |
| -32700 | Parse error | Invalid JSON |
| -32600 | Invalid request | Not a valid JSON-RPC request |
| -32601 | Method not found | Unknown method |
| -32602 | Invalid params | Missing or invalid parameters |
| -32603 | Internal error | Server error |
| -32001 | Session not found | Session ID does not exist |
| -32002 | Session busy | Turn already in progress |
| -32003 | Session not running | Session not in running state |
| -32010 | Provider error | LLM provider issue (missing key, auth) |
| -32011 | Budget exhausted | Resource limits reached |
| -32012 | Hook denied | Hook blocked the operation |
| -32013 | Agent error | Internal agent error |
| -32020 | Capability unavailable | Required capability not available |
| -32021 | Skill not found | Requested skill does not exist |
| -32022 | Skill resolution failed | Skill resolution error |
## Architecture
The RPC server is **stateful**: agents stay alive between turns through the shared runtime-backed service also used by REST and MCP. RPC's distinguishing surface is its broad JSON-RPC method catalog, scoped subscriptions, and server notifications.
```text theme={null}
Client (IDE, app) <--stdio JSONL--> RpcServer
|
MethodRouter
|
SessionRuntime
/ | \
Session Session Session
Task Task Task
| | |
Agent Agent Agent
(exclusive ownership)
```
Each session gets a dedicated tokio task that exclusively owns the `Agent`. This solves the `cancel(&mut self)` requirement without mutex. Commands (`StartTurn`, `Interrupt`, `Shutdown`) are sent via channels.
The JSONL writer and notification queues are bounded, and ordinary session
subscriptions are best-effort. A slow client can therefore receive a typed
`stream_truncated` marker instead of every live event. Persistent event audit
projection can use a separate unbounded internal queue, so UI lag does not drop
projector input. The EventStore remains asynchronous derived state rather than
session completeness authority.
## Comparison with other surfaces
| | CLI | REST | MCP | RPC |
| - | - | - | - | - |
| Host lifecycle | One command | Long-lived | Long-lived | Long-lived |
| Realm-backed sessions | Yes | Yes | Yes | Yes |
| Multiple session IDs | Sequential commands | Yes | Yes | Yes |
| Streaming | Terminal rendering | SSE | Optional protocol notifications | JSONL notifications |
| Cancellation | Ctrl+C or `session interrupt` | `POST /sessions/{id}/interrupt` | `meerkat_interrupt` | `turn/interrupt` |
| Bidirectional host channel | No | HTTP plus one-way SSE | Tool request/response | Yes |
## See also
* [CLI reference](/cli/commands)
* [REST API](/api/rest)
* [MCP](/api/mcp)
* [Architecture](/reference/architecture)
# Delivery, Interaction, and Durable Operations
Source: https://docs.rkat.ai/architecture/delivery-interaction-operations
Architecture decision separating one-way comms delivery, conversational interaction, and machine-owned durable operations.
# Delivery, Interaction, and Durable Operations
Status: Accepted and implemented. Supervisor rotation remains a
feature-specific durable operation. The platform now also ships durable jobs
and runtime delivery, but not one universal operation API.
## Context
Meerkat needs three different temporal contracts. Treating all three as raw
request/response couples correctness to a live reply route and makes timeout or
caller cancellation ambiguous.
* **Delivery** is a signed, routed, fire-and-forget envelope. This remains the
primitive owned by `meerkat-comms`.
* **Interaction** is optional, short-lived request/response correlation for
conversational behavior. Existing `PeerResponse` and `in_reply_to` handling
remain a compatibility interaction projection.
* **Operation** is a durable machine-owned command with a stable ID, observable
progress, and a terminal receipt. Its truth cannot depend on a live
interaction.
## Decision
Supervisor rotation is a durable operation scoped to one member session.
```text theme={null}
SubmitSupervisorRotation(operation_id, target) // one-way delivery
↓
member machine persists and advances
↓
ObserveSupervisorRotation(operation_id) // read-only interaction
→ pending | completed | rejected
```
The member records the operation ID, full previous and target authority, and
the first pending phase before changing live trust. That same transition fences
the old epoch. A session-owned worker advances revoke, publish, and completion;
process recovery restarts a pending worker from durable state.
Submission has no semantic ACK. An exact retry with the same operation ID and
target observes or resumes the same operation. Reusing the ID for another
target is rejected. Observation may use the existing interaction transport,
but a timeout means only that no receipt was observed before the deadline: it
does not cancel, roll back, or obscure the durable operation.
Terminal receipts remain keyed by operation ID across later rotations. The
current bound supervisor may observe that history, so rotating authority again
does not make an earlier completion or rejection unreachable.
The mob supervisor persists the target and operation ID before the first
one-way submission. It observes completion as the new authority and commits
the local authority change only after every affected member reports completion.
No trust edge may be created solely to deliver an ACK to a revoked supervisor.
Optional completion notification is one-way and non-authoritative.
## Current Platform Substrate
The later durable-job work introduced a reusable execution and delivery
substrate without changing this decision's temporal split:
* `DetachedJobMachine` and `meerkat-jobs` own durable submit, deduplication,
fenced attempts, leases, progress, cancellation, terminal results, and a
terminal outbox.
* `RuntimeDeliveryMachine` and the runtime delivery inbox own stable delivery
identity, monotonic sequence, exact replay, and the ordered application
cursor.
* The canonical `job_runtime_delivery` composition transfers terminal and
notification outbox entries into the runtime inbox, then acknowledges the
transfer back to the job store.
This substrate serves reusable background execution. It does not replace the
supervisor-rotation protocol, turn `meerkat-comms` into RPC, or require every
feature-owned durable operation to become a detached job. A general host-facing
`OperationId -> OperationState -> TerminalReceipt` API remains intentionally
deferred.
## Consequences
* `meerkat-comms` remains fundamentally one-way and does not become a general
RPC substrate.
* Supervisor rotation survives sender timeout, handler cancellation, and cold
restart without rollback-by-timeout.
* Existing synchronous-looking host APIs become submit-and-observe adapters;
their result shape can remain compatible.
* Ordinary current-supervisor verification may continue using the existing
interaction compatibility path; it is not rotation authority.
* A universal host-facing operation surface is not implied by the job API;
durable features may retain their own machine-owned command and receipt
protocols.
# Durable Instruction Activation
Source: https://docs.rkat.ai/architecture/durable-instruction-activation
Architecture decision for reproducible instruction evolution in durable sessions.
# Durable Instruction Activation
Status: Accepted for implementation, pending integrated review and CI
Date: 2026-08-25
Scope: persistent Meerkat sessions and model-visible textual instructions
## Need
Long-lived agents need to adopt reviewed instruction revisions without
silently changing the meaning of an earlier transcript. The durable change
must be reproducible, auditable, exactly-once under retry, safe while a turn is
active, and distinguishable from a controller's desired assignment.
The application remains responsible for authoring instruction content,
evaluating it, approving it, and choosing a rollout. This primitive does not
hot-swap executable skill resources, tool implementations, hooks, provider
configuration, or build composition. Those remain with their existing typed
factory and runtime owners and may require respawn or a new lineage.
## Existing authority is the baseline
Meerkat does not need another chronological delivery mechanism:
* `Session` owns the ordered transcript.
* `Message::System` is repeatable and position-sensitive.
* provider lowering consumes the transcript order when the resolved model can
represent mid-conversation System rows.
* the persistent session service already serializes control work with turn
finalization and recovery gates.
* an append admitted while a content turn is active cannot alter that in-flight
request.
The missing generic capability is typed artifact and activation identity, CAS,
durable receipts, safe persistence, compatibility admission, introspection,
and rewrite protection on that existing ordered transcript fact.
`session/update_system_prompt` owns a different semantic. It versions a
replaceable prompt slot through transcript rewrite and projects only the latest
version. Reusing it would silently reinterpret older conversation under a new
prompt, so it is not the instruction-activation authority.
## Rejected alternatives
### A parallel activation ledger or pending table
Rejected. It would create a second answer to what entered model context and a
cross-store commit problem. Desired rollout is application/MobKit state;
effective durable context is Meerkat transcript state.
### Generic append metadata or string conventions
Rejected. `source`, idempotency strings, or rendered prefixes cannot prove
artifact identity, content digest, supersession, rollback, or stale-writer
rejection. Typed truth must live on the transcript row.
### Keyed prompt replacement
Rejected. Latest-only projection erases the chronological transition from
model context and changes the interpretation of prior conversation.
### Cumulative raw instruction bodies
Rejected. Two equal-priority bodies that happen to occur in order do not
define supersession. Contradictory revisions would both remain instructions.
### Fork on every revision
Rejected as the default. A new lineage is valid for destructive reset or a
provider that cannot represent the transition, but ordinary compatible agents
should retain continuity.
## Domain fact and model projection
An activation is an ordinary ordered `Message::System` row carrying a sealed
typed instruction-activation identity. It is not a new queue, message role, or
session metadata ledger. The canonical row stores one exact, versioned render
envelope. The v1 envelope contains JSON-quoted single-line identity headers, a
blank-line delimiter, then the exact approved UTF-8 body without normalization.
The typed identity stores `render_version = 1` and the explicit predecessor in
`supersedes`. Deserialization re-renders from the typed fields and body and
requires byte equality with `SystemMessage.content`.
In the origin session, model materialization preserves the row's role, position,
and already-stored envelope bytes. The origin envelope states:
* the named revision becomes effective from this transcript boundary
* it supersedes the earlier activation for the same namespace and key
* the exact approved instruction body follows
The same render version also permanently defines an inherited historical-only
envelope for a child `SessionId`; it quotes the original envelope as historical
context and states that it is not an activation in the child. This explicit
versioned rendering, rather than ordering folklore, gives the
later body its same-key supersession meaning and makes replay independent of a
future renderer upgrade. Earlier rows remain durable evidence for conversation
that occurred under them. New render versions are additive and permanently
ratcheted; an existing row is never re-rendered under a newer version.
The exact v1 origin envelope uses LF separators, headers in the order below,
canonical JSON string escaping for string values, lowercase digest hex, decimal
body byte length, one empty line, then the body bytes with no added suffix:
```text theme={null}
[meerkat-instruction-activation-v1]
namespace=
key=
revision=
activation=
supersedes=
origin_session_id=
content_sha256=sha256:<64-lowercase-hex>
body_bytes=
```
The inherited v1 projection changes only the first line to
`[meerkat-inherited-instruction-v1]`, inserts
`child_session_id=` after `origin_session_id`, and inserts
`status=historical_only_not_active_in_child` before `body_bytes`. These exact
algorithms remain available for every persisted `render_version = 1` row.
The sealed identity contains:
* `InstructionNamespace`, an application or issuer namespace
* `InstructionKey`
* `InstructionRevisionId`
* `InstructionContentDigest`
* `InstructionActivationId`
* `supersedes: Option`
* `origin_session_id`, the Meerkat `SessionId` that minted the row
* `render_version`
The digest is canonical lowercase `sha256:<64 hex digits>` over the exact UTF-8
bytes of the approved body. Requests enforce configured body and identifier
bounds. Meerkat verifies the digest before mutation. Within one retained
session lineage, one `(namespace, key, revision_id)` can bind only one digest
and body. Global cross-session artifact immutability remains application-owned;
Meerkat is not an artifact registry.
The System row's semantic carriers must be mutually exclusive. An ordinary
append identity, replaceable prompt-version identity, and instruction
activation identity cannot coexist. All constructors, deserialization paths,
head materialization, rewrites, compaction, and activation validate this
closed invariant.
`origin_session_id` is the lineage fact. Meerkat does not infer a MobKit
generation or invent a second lineage identifier. In the origin session, the
latest activation for a key is effective. In a fork whose `SessionId` differs,
copied rows are inherited historical context until that child appends an
origin-local activation.
## Compatibility, machine admission, and effective truth
An activation receipt means the row is committed to the authoritative
persistent session. `Applied` additionally means the already-materialized
session projection was converged to that committed transcript before the
command completed. No disposition claims that a provider request has already
occurred.
Repository evidence rules out a separate actor-retirement mechanism for the
compatible text path. Every turn rebuilds its model request from
`Session::messages_for_model_boundary`; a compatible provider does not retain a
second instruction context inside the actor. The existing runtime-turn
finalization boundary already serializes a transcript mutation after any active
turn and before a new one. The activation facade therefore holds that boundary,
asks the generated runtime owner for the current transcript-edit admission
verdict, checks the machine-owned resolved capability surface for the current
materialization, rejects an open realtime channel, and only then calls the
persistent typed mutation seam. Model reconfiguration threads its already
resolved target capability through live apply and rollback; it never re-reads a
mutable registry between generated admission and installation.
This is intentionally not a new MeerkatMachine transition. The machine already
owns the active-work admission fact used here. Adding another generated state
plane would duplicate transcript authority without adding a runtime fact. The
persistent service remains unable to expose activation directly through the
generic control extension; surfaces must use the facade that holds runtime
admission and compatibility.
Admission outcomes are:
* a model without mid-conversation System support returns
`UnsupportedCurrentLowering` and appends nothing
* a detached, dormant, staged, unknown, or otherwise unmaterialized target
returns `TargetNotMaterialized` and appends nothing in v1
* an open live channel returns `LiveChannelOpen` and appends nothing; v1 does
not claim a close/reopen handoff it has not completed
* a staged session returns `TargetNotMaterialized`
* an ephemeral service returns `DurabilityUnavailable`
Store-backed reads remain available for detached and dormant sessions. Mutation
does not pretend that persisted LLM metadata alone proves a current lowering.
Provider adapters remain a final fail-closed guard after the catalog-owned
admission check. A later model hot-swap that cannot represent the already
recorded ordered System rows must also fail closed before the new identity is
installed.
## CAS, idempotency, and rollback
`InstructionActivationExpectation` is a closed enum:
* `Absent`
* `Effective(InstructionActivationId)`
There is no optional or wildcard predecessor. Activation IDs are scoped by
session lineage, namespace, and key and bind an immutable request fingerprint.
* An exact retry while that activation is still effective returns `Duplicate`.
When the caller supplied an external write fence, the retry still enters the
target-locked RuntimeStore boundary and revalidates that fence before the
receipt is returned; it appends no second activation row.
* Reusing an activation ID with another ref or body returns
`ActivationIdentityConflict`.
* Replaying an exact old activation ID after a later activation returns
`RecordedButNotEffective`; it never proves adoption and never reactivates it.
* A stale predecessor returns `EffectiveActivationConflict` and appends
nothing.
* A repeated revision ID with another body or digest returns
`ImmutableRevisionConflict`.
Rollback is a new activation ID naming an earlier immutable revision. It is an
append at a later transcript boundary, never erasure or restore. This prevents
ABA ambiguity.
## Persistent and cancellation-safe mutation
Only `PersistentSessionService` implements durable activation. The operation
uses the existing turn-finalization and recovery gates and the existing actor
command path. The session actor derives and appends the exact activation. The
persistent owner then commits that actor state through the configured
WholeBlob or HeadCanonical boundary. There is no detached candidate document,
post-commit actor convergence step, or policy-specific recovery state machine.
The command runs in a detached owner task that retains the turn boundary until
the actor-owned durable commit completes. Transport or caller cancellation can
drop only its waiter, not the commit owner. A persistence failure returns no
receipt and discards the mutated actor through the existing cleanup path, so
that actor cannot admit a later turn or overwrite canonical state. A lost
successful response is recovered by querying canonical activation records
before retry; an exact retry returns `Duplicate`.
When live support is enabled, activation first acquires the existing
machine-owned live/open lifecycle lease, then the turn-finalization boundary,
and rechecks live-channel absence and generated admission while both are held.
This prevents a new live/open from crossing the actor/store commit and makes an
already-started live/open finish before activation decides admission.
An external composition may supply a generic `RuntimeStoreWriteFence` through
the explicit fenced activation verb. Meerkat first owns its existing live-open
lease and turn-finalization boundary. The selected RuntimeStore then invokes
the fence synchronously from inside its own target write lock or SQLite
transaction, with the physical target publication itself as the fenced
operation. `Applied` means that operation ran exactly once. `Conflict` and
`Backoff` mean it did not run, so neither durable transcript nor live session
projection advances.
The fence must acquire external lifecycle authority without waiting, reread
its canonical facts, and retain its serialization guard while invoking the
target publication. This avoids lock inversion with an external retirement
path that already owns lifecycle authority and is waiting for Meerkat's safe
boundary. MobKit uses the seam for stable identity, generation, session, and
fencing-token continuity. Meerkat does not persist or interpret a transient
runtime alias, and the fence remains a precondition on the existing
RuntimeStore commit rather than another activation authority. RuntimeStore
decorators must opt in to the fenced verb explicitly; the default is a typed
unsupported refusal so a decorator cannot silently bypass its projection or
recovery behavior.
Physical store revisions and fencing tokens remain server-internal. A public
command receipt binds session ID, lineage, full activation identity,
predecessor, a stable activation ordinal, the current transcript projection
witness, and the event's typed disposition. Persistence introspection derives
that record from the RuntimeStore-backed transcript, never from a receipt
cache. It does not reconstruct the historical live-projection disposition.
Current rollout observation may join that durable record with runtime
availability, but it may never replace the record with a cache.
## Rewrite, compaction, restore, and fork
Activation rows are immutable transcript-boundary anchors.
* Ordinary append and prompt-update APIs cannot mint or adopt them.
* Generic same-session rewrite and prompt update require exact equality of
every activation row at its transcript index; they cannot mint, alter, move,
erase, or restore an activation across conversational content.
* Same-session restore requires every activation row to remain byte-for-byte
equal at its exact transcript index. It cannot move an activation across
conversational content or restore an erased activation boundary.
* Deserialization validates current rows and every retained revision body.
* Fork-at may copy only exact activation rows from the selected source prefix.
Generic fork replacement rejects every activation-bearing replacement, so it
cannot forge inherited or effective authority.
* In a child session, copied rows render with a permanently versioned
historical-only envelope. A child with inherited rows cannot start a model
turn until it has appended an explicit origin-local activation for every
inherited effective key. The child activation may name the inherited row in
`supersedes` while its CAS expectation remains `Absent` for the child origin.
Meerkat's current compactor retains System rows. V1 deliberately follows that
existing rule: every activation anchor and exact body stays in the live ordered
transcript, while ordinary conversation may be summarized around it.
Compaction never invents, reorders, edits, or deletes an activation and the
model-boundary projection preserves every retained anchor in place. This keeps
idempotency, revision immutability, rollback evidence, and replay independent
of a second retained-history lookup.
This is safe but not free: activation bodies accumulate for the life of the
lineage. The 256 KiB per-body limit and 1 MiB total retained activation-body
budget bound v1 growth. An exact retry of the current activation remains legal
at the budget because it appends nothing. Growth beyond the budget is a stable
typed refusal. Retiring an old body is a future schema change that must first
define a typed summary/witness mapping and make duplicate and
immutable-revision checks consult retained audit history.
## Typed public contract
All identifiers are non-empty trimmed UTF-8, at most 128 bytes, and reject
control characters. Instruction bodies are at most 256 KiB. Namespace naming
does not introduce an authorization system: the application remains
responsible for exposing the activation command only through its governed
approval workflow, while Meerkat authenticates and authorizes the containing
surface according to that surface's existing rules.
`InstructionActivationRequest` contains:
* namespace, key, revision ID, declared digest, and exact body
* activation ID
* closed CAS expectation `Absent | Effective(id)`
* explicit `supersedes`, which must equal the observed previous same-key
activation when present but is not the CAS carrier
`InstructionActivationReceipt` contains session ID, origin session ID, full
activation identity, predecessor activation ID, the stable zero-based ordinal
among activation rows, the current projection witness, and `Applied |
Duplicate`. Identity and ordinal are stable across compaction. The projection
witness contains the current zero-based message index and current prefix
revision, so it can change when compaction summarizes earlier conversation.
`Applied` proves the actor-owned materialized session state was committed by
the lifecycle/persistence owner.
`InstructionActivationRecord` is persistence-only and contains the durable
identity, origin, predecessor, stable activation ordinal, and current
projection witness.
`InstructionActivationReadQuery` accepts optional namespace/key filters and a
bounded offset with `limit` from 1 through 200.
`InstructionActivationReadPage` returns durable records in transcript order
and an optional next offset. When the query names one exact namespace and key,
it also returns core-derived `InstructionActivationKeyState`: the effective
origin-local record, chronological head, child explicit-activation
requirement, and exact expectation/supersedes values for the next request.
MobKit consumes this state instead of reimplementing transcript or fork
semantics.
Stable `InstructionActivationErrorCode` variants are:
* `invalid_request`
* `digest_mismatch`
* `effective_activation_conflict`
* `activation_identity_conflict`
* `immutable_revision_conflict`
* `recorded_but_not_effective`
* `inherited_activation_requires_explicit_activation`
* `malformed_activation_history`
Stable `InstructionActivationAdmissionErrorCode` variants are:
* `target_not_materialized`
* `unsupported_current_lowering`
* `live_channel_open`
* `durability_unavailable`
* `session_busy`
* `external_write_fence_conflict`
* `external_write_fence_backoff`
JSON-RPC maps admission refusals to stable typed data and preserves domain
codes from the persistent seam. No activation refusal is inferred by parsing
its message.
Core types are:
* `InstructionNamespace`, `InstructionKey`, `InstructionRevisionId`
* `InstructionContentDigest`, `InstructionRevisionRef`
* `InstructionActivationId`, `InstructionActivationIdentity`
* `InstructionActivationExpectation`, `InstructionActivationRequest`
* `InstructionActivationDisposition`, `InstructionActivationReceipt`
* `InstructionActivationRecord`
* `InstructionActivationError` with stable error codes for every refusal above
Service/facade methods are:
* `MeerkatSessionRuntime::activate_instruction`
* `MeerkatSessionRuntime::activate_instruction_with_write_fence`
* `MeerkatSessionRuntime::read_instruction_activations`
* `SessionServiceHistoryExt::read_instruction_activation_records` as the
persistence-only internal/read-model seam
Surfaces:
* JSON-RPC `session/activate_instruction` and
`session/instruction_activations`
* generated Rust wire and JSON Schema contracts
* model catalog capability `supports_mid_conversation_system_messages`
Transport handlers only parse, authorize, invoke the service, and lower typed
errors. They do not classify state or rebuild receipts.
## Ownership and failure table
| Fact | Canonical owner | Failure behavior |
| - | - | - |
| Content, evaluation, approval | Application | Meerkat validates declared identity and digest only |
| Desired rollout | Application composition / MobKit | Never treated as adoption proof |
| Ordered effective activation | Persistent Meerkat Session via RuntimeStore | No commit means no effective change |
| Active-turn boundary | MeerkatMachine admission plus runtime facade | Wait behind the finalization boundary, then commit |
| Model/live compatibility | Resolved Meerkat runtime capabilities | Typed refusal before append |
| Same-lineage activation audit | Transcript plus retained revision graph | Load fails closed on malformed state |
| Rollback | Later typed activation | No deletion or restore |
## Release binding and delivery
The reviewed durable activation authority is integrated into the release
authority's exact qualified realtime Meerkat head for the 0.8.30 candidate.
Its manifest and lockfile both resolve the public `oai-rt-rs` 0.4.1 release,
tagged from exact commit `c051ee75479aaf71a8f1fd5cef2ea2bbe90f15e1` and
independently verified from crates.io with SHA-256
`fe8d87027059933028b385e8ea9059d452cad805c632531ed3c20314492b6aa8`.
Until the candidate is merged, tagged, and published, this exact integration
commit remains development Git truth rather than a released package.
MobKit binds its Meerkat dependency to the exact origin-reachable integration
commit, not to a package release or movable branch, and preserves the same
locked Realtime release. Cross-repository CI consequently tests the development
graph it claims to review.
After the integrated gate, the release authority owns BuildBuddy Turbo S
e2e-smoke diagnosis, repair, rerun, and green evidence on that exact Meerkat
commit. The exact Meerkat/MobKit pair must then pass isolated no-deploy OB3 and
HomeCore qualification before the Meerkat lead may publish 0.8.30. This feature
lane does not independently merge, tag, or publish either repository.
## Dogma check
* The ordered transcript remains the sole model-context authority.
* No new activation machine, queue, or ledger is introduced.
* RuntimeStore alone proves physical currentness.
* Provider capability and live state fail closed before mutation.
* Typed core code owns semantics; surfaces stay thin.
* Global policy governance stays outside Meerkat.
* Durable receipts are unavailable where durability cannot be proved.
# Meerkat Dogma
Source: https://docs.rkat.ai/architecture/meerkat-dogma
Primary runtime architecture doctrine for semantic authority, seams, projections, and surfaces.
# Meerkat Dogma
Status: Accepted canonical architecture doctrine
Scope: All Meerkat runtime, surface, provider, SDK, protocol, storage, auth, mob, scheduling, WASM, and governance work
Companion: `docs/architecture/meerkat-dogma-commentary.md` explains examples, tensions, and repair patterns
## Purpose
This document states the rules. It does not negotiate with convenience.
Meerkat exists to expose one coherent agent runtime through many surfaces. Its
architecture fails when semantic truth escapes into helpers, transports,
provider adapters, caches, stores, SDKs, test harnesses, or folklore.
If a design violates this document, the default answer is no. Rewrite the design
until the owner, seam, contract, and failure mode are explicit.
## Interpretation
Every rule below is a veto.
When two rules appear to pull in different directions, do not choose the easier
violation. Stop, name the semantic fact, name the owner, and add the missing
typed seam. Until the owner is clear, fail closed.
Compatibility is not authority. Compatibility mirrors are temporary debt. They
may exist only as read-only projections of canonical truth, with an explicit
expiry condition such as a date, contract version, or named breaking-release
gate, and only if they cannot change semantic behavior. Introducing one
requires explicit approval from the Highest Dogma Authority. Treat approval as
a rare exception, not a reviewer's local judgment call.
Permanent read models, presentation shapes, and interop adapters are not
mirrors. They are projections. They obey the projection rules.
Deliberate is not valid. "By design" is a claim about intent, and intent does
not justify retention. A legacy shape may be retained only under a functional
retention category:
* durable-format stability — a persisted format that must stay readable
* wire-opaqueness — opaque pass-through bytes the system never interprets
* external protocol — a wire shape owned by a protocol we do not control
* divergence-free projection — a projection structurally incapable of
diverging from its owner
* fail-closed rejection machinery — code whose job is to reject legacy
persisted or wire shapes with a typed error, together with its negative
tests; this is the policy-mandated disposal of legacy data, not retention
debt
Pinning tests prove deliberateness, not validity. Pre-1.0 self-compatibility
never justifies retention. A "by design" verdict without one of these
categories is a violation wearing a label.
## The Dogma
### 1. Authority Is Singular
Every semantically meaningful fact must have exactly one canonical owner.
This includes lifecycle phase, transition legality, terminal outcome, admission,
barrier membership, trust, identity, capability, policy, defaults, durability,
schema version, async operation truth, peer wiring truth, and surfaced result
class.
If the answer to "who owns this?" is "the machine, but also this helper",
"the profile, but also this adapter", "the runtime, but also this surface", or
"the store, but also this cache", the design is wrong.
If a fact changes what the system is, what transition is legal, what result is
true, or what policy applies, it must live in a named authority:
* a machine
* a composition
* a handoff protocol
* a typed catalog/profile/registry
* a declared store contract
* a public wire contract generated from the owning model
Unnamed ownership is not ownership.
### 2. Generated Machines Own Canonical Change
Canonical machine state must change only through generated machine authority.
Handwritten code must not:
* mutate canonical machine state directly
* implement a parallel reducer
* keep a shadow transition table
* decide transition legality
* choose semantic executor steps from local predicates, projections, caches, or
duplicated guards
* emit semantic effects outside the owning transition
* invent terminal classification outside generated or machine-derived paths
Handwritten code may observe facts, classify raw inputs, hold handles, perform
IO, schedule tasks, execute effects, persist snapshots, and route evidence.
That permission ends at meaning and enabledness. Any meaningful classification
or semantic next-step decision must lower into a typed machine input, typed
composition route, typed authority contract, or typed handoff protocol. If no
such input, route, contract, or protocol exists, the model is incomplete. The
runtime conforms to the machine; the machine does not conform to an awkward
runtime path.
Owning state is not enough. If a command changes semantic behavior, the
enabledness of that command, the selected executor step, the emitted effect, and
the closure feedback must be owned by a generated machine, composition, handoff
protocol, or typed authority contract.
A semantic effect is not complete when it is emitted. It is complete when its
realization, failure, cancellation, or abandonment returns through the owning
authority path, or when the owning model explicitly declares the effect
fire-and-forget mechanics.
### 3. Shells, Stores, and Projections Are Mechanical
Shell code owns mechanics, not truth.
Mechanical code may realize an authorized executor step. It must not derive the
step from stale projection state, local guard replicas, cache shape, transport
readiness, or test-only shortcuts. If a shell-local predicate can change what
semantic work happens next, it is authority and must move behind a named owner.
Stores persist authority. They do not create it. Recovery replays or restores
machine-owned truth through declared transition, snapshot, journal, or migrator
contracts. A store must not seed lifecycle, terminality, policy, or ownership
from projection folklore.
Projections are allowed only when all of these are explicit:
* source truth
* rebuild trigger
* staleness policy
* boundary where the projection may be used
If stale projection state can change behavior, it is not a projection. It is a
second source of truth.
Local fallback state must never cross a boundary disguised as canonical truth.
Local registries, receipts, counters, caches, waiters, indexes, and side maps
may exist only as private mechanics unless a named authority owns their meaning.
### 4. Truth Is Typed, Identity Is Canonical
If a distinction matters semantically, it must be typed.
Forbidden as semantic authority:
* string prefixes
* enum names parsed from text
* provider-native field names in shared logic
* path naming conventions
* JSON blobs whose `kind` field carries the real meaning
* error-message parsing
* optional fields that hide missing ownership
* "convention says this means completed, detached, trusted, or unreachable"
Public control nouns must be domain handles. Raw infrastructure identity is not
an app-facing API unless that identity is canonical, owner-resolved, and the
right abstraction for the caller.
`Option` is not a substitute for ownership. If owner context is required,
require it in the type. If a path cannot supply it, that path cannot expose the
contract.
### 5. Composability Is Feature-Owned
Meerkat is composable. Optional capabilities must live at their owning feature
boundary.
If a scheduler feature has skills, hooks, tools, manifests, routes, schemas,
migrations, fixtures, policy, docs, generated artifacts, or public affordances,
those assets belong to the scheduler crate or package. They do not belong in a
global `meerkat-core` package, global skills bundle, surface crate, or
convenience registry because that is easier to discover.
Global registries, catalogs, manifests, bundles, SDK indexes, and product
surfaces may aggregate optional features only as mechanical projections of
feature-owned declarations. They must not become the semantic home for the
feature.
Core crates may define stable extension seams, shared traits, and common
vocabulary. They must not absorb optional feature semantics, assets, policy,
docs, tests, or generated files. A feature that cannot be enabled, disabled,
tested, versioned, or removed with its owning package is not actually
composable.
### 6. Surfaces Are Thin Over The Shared Runtime
CLI, REST, RPC, MCP, SDKs, WASM, browser hosts, examples, and product binaries
are skins over shared runtime or explicit standalone substrates.
Surfaces may:
* format UX
* normalize transport details
* adapt wire shapes
* choose presentation
* expose friendly names
Surfaces must not:
* own semantic truth
* reconstruct runtime lifecycle
* invent handles, phases, defaults, or terminal classes
* maintain separate bootstrap semantics
* bypass shared surface/runtime helpers when a shared seam exists
* silently emulate runtime-backed behavior in standalone mode
Runtime-backed surfaces must lower into the shared runtime path. Standalone,
ephemeral, embedded, and browser paths must be explicit modes with explicit
limits, not secret substitutes for the product runtime.
User-facing syntax may be kinder than internal machinery. Its lowering must
still route through the canonical owner.
### 7. Providers and Policy Stay Behind Their Owning Seams
Meerkat is provider-agnostic at the session, runtime, tool, memory, mob, and
surface model level.
Provider-native transport, request, response, retry, streaming, realtime, and
auth mechanics belong in provider-specific crates or typed provider-extension
seams. Shared runtime code must not branch on provider folklore, provider-native
string fields, native response shapes, or prefix inference unless a typed
provider/catalog/profile contract owns that distinction.
Provider identity, model identity, capability truth, auth freshness, defaults,
and provider-specific policy must flow through their owning seams:
* model catalog and `ModelProfile`
* provider identity types
* realm auth binding and `AuthMachine`
* facade/factory composition
* typed provider-extension payloads where true portability is impossible
Policy is composed at the facade, factory, profile, auth, or composition seam.
Not in provider adapters, random helpers, RPC handlers, CLI flags, SDK defaults,
or leaf surfaces.
Inherit, disable, and set are different facts. If a seam needs all three, use a
tri-state type. Plain optionality is forbidden when it collapses policy meaning.
Dynamic policy must follow dynamic identity. If model, provider, auth binding,
session, realm, or owner identity changes, derived policy must be recomputed at
the owning seam.
### 8. Terminality and Faults Are Explicit
The same semantic condition must have one canonical terminal path.
Do not terminate differently because a condition was detected in a different
loop, task, provider callback, SDK parser, transport, or recovery path.
Success means truthful completion. Do not report success after a failed,
abandoned, partial, unauthoritative, or unverified execution because the old path
expects success.
Soft control and hard failure are different mechanisms. A steer is not a
timeout. A warning is not a terminal outcome. A retry delay is not completion. A
dropped receiver is not successful delivery. A logged error is not handled
failure.
Faults that affect semantics must be modeled or fail closed. This includes:
* cancellation while a transition is in progress
* resource shutdown and leaked task ownership
* deadline and timing semantics
* queue capacity, backpressure, and dropped observations
* external state drift
* schema/version skew
* adversarial or oversized input
* panic recovery
* cascading failure across seams
* test-mode behavior that differs from production semantics
Fail closed on semantics. Degrade on accounting.
"Modeled or fail closed" is a requirement about faults that affect SEMANTICS.
It is not a licence to refuse on facts that do not. Refusing work that is
already correct, over a fact that does not change what is true, is its own
Rule 8 violation: it reports failure for an execution that succeeded, which is
"success means truthful completion" read backwards.
A fault must fail closed when the fact it concerns is semantic:
* what the model said, and what the committed transcript contains
* which tools ran, and with what arguments
* what was durably committed, and whether that evidence is provable
* who holds authority, and whether a transition is legal
* whether declared authority was exercised, such as a hook verdict
* what a set of bytes means, when we cannot authenticate the claim
A fault must degrade when the fact it concerns is accounting, observability, or
advisory: token counts and billing attribution, cache breakpoints and other
optimization artifacts, telemetry, provider metadata, derived counters, and
rebuildable projections. Billing metadata is not a domain model. An artifact
about the transcript is not the transcript.
THE OPERATIONAL TEST, when classification is contested: ASK WHETHER THE CALLER
HAS AN ACTION THAT CLEARS THE REFUSAL. If none exists, the refusal is not a
verdict the caller can act on; it is a dead end wearing a verdict's clothes.
A member that is briefly "attached" cannot be made less attached by the caller
asking it to stop, so refusing the stop strands them. This test is easier to
apply at review time than classifying the fact, and it usually agrees.
Three clauses govern, and all three must hold.
CLASSIFY THE FACT, NOT THE SITE. The question is never "did something fail
here" but "what does this fault make untrue". If the answer is "a number
nobody's correctness depends on", it may not terminalize work that is already
correct. Two faults in the same function may have opposite correct verdicts.
BLAST RADIUS MAY NOT EXCEED THE FACT'S SCOPE. A per-record fault refuses that
record, not the collection. A per-artifact fault refuses that artifact, not the
turn. A per-realm fault refuses that realm, not the inventory. The carve-out is
exact and must be quoted whenever this clause is applied: degrade is legitimate
for a collection whose members are independent, and illegitimate for a
collection whose MEMBERSHIP is the fact being witnessed, because dropping a
member there mints false absence. An exact-set witness, such as a recovery
input set deciding whether queued user work is redelivered, is refused whole or
not at all.
ABSENCE MUST BE REPRESENTABLE, NEVER SUBSTITUTED. A degrade that fabricates the
missing value is worse than the refusal it replaces, because it converts "no
answer" into "a wrong answer that looks right". A zero token count, a saturated
message count, an assumed identity, and a reported identity silently rewritten
to the expected one are all forbidden. Where two sources disagree, the
disagreement is the fact: record it as disputed, and do not elect a winner. If
the type cannot express "unmeasured", the degrade is not implementable until it
can, and widening the type is the first task, not an afterthought. A degraded
value must not advance an axis that enforcement reads: an unmeasured turn does
not move the budget.
Every degrade carries a typed marker on the outcome it degrades, visible on the
wire and to the operator. The vocabulary is fixed: UNMEASURED means nobody
produced the observation, UNREADABLE means an attempt was made and returned
nothing, DISPUTED means two sources disagree and neither was elected, and
DISCARDED means a named artifact was dropped and the work continued without it.
None of these is a claim of health. The marker rides a typed carrier on the
outcome. A tracing log is not a marker, a string prefix is not a typed carrier
outside a presentation projection, and a new bespoke event per degrade is
carrier proliferation that Rule 9 forbids: extend the advisory carrier the
outcome already owns, and let the domain reason enums stay domain-specific.
A degrade is a behavioural contract, so it is tested as one. The negative test
required of fail-closed rejection machinery has a mirror here: feed the
triggering fact, then assert both that the work completes AND that it carries
the marker. A test asserting only completion certifies a silent degrade, which
is the failure this section exists to prevent.
Where a degrade is correct, the guard that refuses is not always the fix site.
If a correct fail-closed guard strands a user, the defect may be downstream of
it: an error that lost its type at a reporting seam, a failure observed and
reported to no one, or an entry never evicted. Relaxing a semantic guard to
unstick a reporting defect reintroduces the confusion the guard exists to
prevent.
Batching must preserve causality without inventing consensus. Transcript
interaction, run, and objective identities are merged independently; agreement
on one field survives disagreement on another. A conflicting field remains
conflicted for the full batch and cannot be repopulated by a later input merely
because its public projection is empty.
A transcript compaction and its discarded-history memory projection are one
semantic commit even when they cross stores. Durable memory must remain
invisible until the authoritative transcript rewrite is committed; the runtime
must record the exact typed rewrite intent in the same atomic boundary, and
only that durable outbox may authorize idempotent finalization or recovery.
The compaction semantic is an opaque witness minted by the validated core
rebuild path, never inferred from a caller-controlled reason string. Reasons
remain audit/presentation data. Marker-free legacy commits may acquire the
typed semantic only when their retained revision bodies prove the exact
full-range shrinking compaction shape.
Session metadata and released-format compatibility markers may carry the intent
but cannot become commit authority. Cancellation, process loss, retry, and scope deletion
must leave either the old transcript with no visible projection or the committed
rewrite with its exact projection—never a half-published pair. Scope deletion
must persist a deletion-wins tombstone that fences staged, finalized, cached,
and later-replayed projections across concurrency and process restart.
If the system cannot explain what happened, who owns the consequence, and what
the caller may trust, the design is not complete.
### 9. Contracts, Crates, and Generation Are Ratchets
Public contracts must be derived from the authority path. Legacy compatibility
fields and shapes may exist only as temporary compatibility mirrors with an
expiry condition.
Generated schemas, SDK types, machine specs, formal models, OpenAPI, RPC
catalogs, event contracts, and governance ledgers must cover the production path
they claim to describe. A generated artifact that does not constrain production
behavior is theater.
Hand-authored semantic mirrors are forbidden.
A hand-authored compatibility mirror may exist only as a temporary, read-only
projection of a named authority, with a declared owner, freshness rule, enforced
drift test, explicit expiry condition, and explicit Highest Dogma Authority
approval. It must not feed decisions, defaults, recovery, terminality,
acceptance, or public authority.
If it has no expiry, make it a proper projection, generate it, or delete it.
If it can change behavior, it is a second source of truth.
Crate boundaries are authority boundaries:
* `meerkat-core` owns shared agent/runtime contracts and must stay free of
network and filesystem authority.
* `meerkat-contracts` owns public wire shapes, error codes, schema emission, and
contract versioning.
* `meerkat-runtime` owns the runtime control plane and `MeerkatMachine` path.
* `meerkat-session` owns session service implementations, not runtime authority.
* provider crates own provider-native mechanics, not shared semantics.
* surface crates own transport and UX, not meaning.
* facade crates compose; they do not hide new authorities.
* optional feature crates own their feature-local skills, tools, hooks,
manifests, docs, routes, schemas, migrations, fixtures, generated artifacts,
and policy extensions.
Optional capability bundles use a feature-owned declaration seam:
* shared substrate crates may define typed vocabulary, collection traits,
registry mechanics, and generated projection emitters;
* the owning feature crate declares its capability id usage, policy resolver,
companion skills, tools, route/schema fragments, fixtures, and docs;
* global contracts and facades consume those declarations mechanically and may
publish projections, but they must not hand-author optional feature policy or
route truth.
Every feature must shrink ambiguity. Adding a type, manifest field, machine,
helper, SDK parser, compatibility flag, or generated file while leaving the old
semantic ambiguity alive is not progress.
## Shortcut Rejection List
Reject these during review:
* stringly typed semantic classifiers
* provider-specific logic leaking into shared runtime or surfaces
* helper-local lifecycle truth
* surface-owned request, session, auth, model, or terminal semantics
* SDK defaults that fabricate required facts
* public APIs that expose raw infra identity as a user control handle
* local fallback registries masquerading as canonical owners
* projections that make behavior decisions
* shell-local guards or planners that choose semantic executor steps
* emitted effects treated as complete without modeled realization, failure,
cancellation, abandonment, or declared fire-and-forget semantics
* stores that recover from stale or compatibility metadata as authority
* success responses after hidden failure
* unbounded queues or dropped observations with semantic consequences
* generated contracts that do not govern production behavior
* optional feature assets placed in global core, surface, or skills packages
instead of the owning feature crate/package
* global registries that hand-author optional feature truth instead of
discovering feature-owned declarations
* compatibility mirrors introduced without Highest Dogma Authority approval
* compatibility mirrors without date or version expiry
* compatibility paths that can still affect meaning
* terminalizing a completed execution on an accounting, telemetry, or
optimization-artifact fault
* a collection read that propagates a per-record refusal to the whole
collection, outside an exact-set witness
* a degrade that substitutes a fabricated value for an absent one, or that
advances an enforcement axis from a degraded reading
* a degrade whose only trace is a log line, rather than a typed marker on the
outcome it degraded
* a refusal the caller has no action to clear
## Final Test
For every important behavior, answer:
* What fact is being asserted?
* Who owns it?
* How is it typed?
* Which generated authority, contract, profile, registry, store contract, or
handoff protocol carries it?
* Which authority decides whether the next semantic executor step is enabled?
* How does each emitted effect close: realized, failed, cancelled, abandoned, or
explicitly fire-and-forget?
* What happens on failure?
* Which surfaces merely project it?
* Which crate or package owns the optional feature bundle, and which globals
merely discover it?
If any answer is "sort of", the design is wrong.
## Legacy Rule Numbering
Older audits, ledgers, and reviews cite the archived runtime doctrine
(`docs-internal/archive/public-docs-removed-2026-05-11/architecture/meerkat-runtime-dogma.md`)
by legacy rule number (#1–#20). This table is the authoritative mapping; do
not re-derive it from memory. Rule 5 (Composability Is Feature-Owned) has no
legacy counterpart.
| Legacy | Legacy title | Canonical rule |
| - | - | - |
| #1 | One semantic fact, one owner | 1 |
| #2 | Machines own semantics | 2 |
| #3 | Shell owns mechanics, not meaning | 3 |
| #4 | One semantic condition, one canonical terminal path | 8 |
| #5 | Typed truth, never folklore | 4 |
| #6 | App-facing APIs expose domain handles | 4 |
| #7 | Raw infrastructure identity must be canonical | 4 |
| #8 | Optionality must not hide ownership uncertainty | 4 |
| #9 | Override-first at the composition seam | 7 |
| #10 | Inherit, disable, and set are different facts | 7 |
| #11 | Profiles are real owners, or they are not | 1, 7 |
| #12 | Dynamic policy must follow dynamic identity | 7 |
| #13 | Derived projections are rebuildable, never authoritative | 3 |
| #14 | Local fallback state must never leak | 3 |
| #15 | Success means truthful completion | 8 |
| #16 | Soft control and hard failure are different mechanisms | 8 |
| #17 | Surfaces are skins, not authorities | 6 |
| #18 | Runtime conforms to machines | 2 |
| #19 | A feature must shrink ambiguity, not move it | 9 |
| #20 | If we cannot say where a fact lives, the design is wrong | Final Test |
# Meerkat Dogma Commentary
Source: https://docs.rkat.ai/architecture/meerkat-dogma-commentary
Companion commentary with examples, repair shapes, and review questions for Meerkat dogma.
# Meerkat Dogma Commentary
Status: Accepted companion commentary
Primary doctrine: `docs/architecture/meerkat-dogma.md`
## Purpose
The dogma states the law. This commentary explains how to apply it without
turning the law into theater.
The commentary is not softer doctrine. It is practical doctrine: examples,
repair shapes, review questions, and guidance for resolving tension between
rules. If the primary dogma says no, this document does not make it yes. It
explains how to redesign the work until the answer can become yes.
## How To Read This Book
Read every chapter through the same question:
> Where does the semantic fact live?
Most Meerkat architecture failures are variations on one sin: a fact has an
official owner and an unofficial owner. The unofficial owner is usually a
helper, surface, cache, provider adapter, SDK parser, recovery path, or
compatibility field introduced for convenience. The unofficial owner is almost
always easier to write. That is why it is dangerous.
This book uses these terms consistently:
* **Authority**: the named owner of semantic truth.
* **Shell**: code that performs mechanics around an authority.
* **Projection**: a rebuildable, read-only view of authority-owned truth.
* **Mirror**: a duplicate compatibility shape. Mirrors are temporary debt and
require Highest Dogma Authority approval plus expiry.
* **Surface**: a transport, UX, SDK, host, or product skin over an authority.
* **Seam**: a typed boundary where ownership, policy, effects, or evidence pass
between authorities or between authority and shell.
## Resolving Tension Between Rules
Dogma rules do not form a priority list where lower rules can be sacrificed.
When two rules appear to pull in different directions, the design is missing a
seam, owner, or type.
Use this order:
1. Name the semantic fact.
2. Name the current owner claimed by the design.
3. Name every other path that can create, alter, recover, infer, or surface that
fact.
4. Decide which path is the authority. Delete, lower, or project the rest.
5. If multiple rules still pull, add a typed seam that preserves both truths
instead of collapsing them.
6. If compatibility is demanded, require Highest Dogma Authority approval,
expiry, drift tests, and proof that compatibility cannot affect behavior.
7. If the system cannot explain failure, staleness, or recovery, fail closed.
Common tensions have standard resolutions:
| Tension | Wrong Resolution | Correct Resolution |
| - | - | - |
| UX wants a friendly shortcut; authority wants a typed handle | Let the surface synthesize the handle | Let the surface accept friendly syntax, then resolve through the canonical owner |
| Provider has a native concept; runtime wants provider agnosticism | Branch on provider strings in shared runtime | Add a typed provider-extension seam or keep the concept inside the provider crate |
| Read performance wants denormalization; authority wants one source of truth | Let the index become fallback truth | Define the index as a projection with source, rebuild trigger, staleness rule, and non-authority boundary |
| Old clients need a field; dogma forbids mirrors | Keep the legacy field forever | Add a temporary compatibility mirror with approval, expiry, drift test, and no behavior influence |
| Optional feature wants shared discovery | Put feature skills, hooks, routes, and schemas in a global package | Keep assets in the owning feature crate/package; let globals mechanically discover declarations |
| A task failed after emitting partial evidence; caller expects success | Return success and log the failure | Model or surface the partial failure truth through the canonical terminal path |
| A shell can observe something the machine cannot | Let the shell update semantic state | Convert the observation into a typed input or declare the machine incomplete |
| A legacy shape looks deliberate; review wants to bless it "by design" | Accept intent (comments, pinning tests, age) as justification | Require a functional retention category: durable-format stability, wire-opaqueness, external protocol, divergence-free projection, or fail-closed rejection machinery. Deliberate is not valid; pre-1.0 self-compat never justifies retention |
## Chapter 1: Authority Is Singular
### Thesis
A Meerkat fact has one throne.
Every semantically meaningful fact must have exactly one canonical owner. Not
one preferred owner and several helpful replicas. Not one runtime owner and a
surface override. Not one machine truth and one recovery truth. One owner.
This rule is first because every other rule depends on it. Generated machines
cannot own canonical change if helpers also own transition legality. Surfaces
cannot be thin if they reconstruct lifecycle. Provider seams cannot hold if
shared runtime branches on provider folklore. Stores cannot be mechanical if
recovery metadata becomes terminality truth.
The question is not where the fact is convenient to compute. The question is:
who has the right to say what is true?
If that answer is unclear, the design must fail closed.
### What The Rule Forbids
Rule 1 forbids shared authority over meaning.
It forbids helper-local lifecycle truth: a runtime task, waiter, cache, or loop
deciding that a session is running, cancelled, failed, detached, completed, or
recoverable when the owning machine has not said so.
It forbids surface authority: CLI, REST, RPC, MCP, SDKs, WASM bindings, browser
hosts, or examples inventing session status, defaults, result classes,
admission rules, auth behavior, provider selection, or terminal outcomes.
It forbids store authority drift: event stores, projectors, snapshots, indexes,
receipts, and recovery paths inferring canonical state from stale projection
rows, convenience summaries, or legacy metadata.
It forbids provider authority leaks: provider mechanics deciding shared runtime
semantics through native field names, stop reasons, error strings, stream chunk
shapes, or request conventions.
It forbids policy split-brain: profiles, catalogs, provider adapters, CLI flags,
SDK defaults, auth helpers, and runtime builders each deciding pieces of model
capability, auth freshness, trust, tool permission, retry behavior, or defaults.
It forbids mob ambiguity: supervisors, rosters, bridges, flows, peer transports,
and message routers each maintaining their own truth about membership,
delegation, handoff, peer identity, task ownership, or barrier completion.
It forbids tool ambiguity: tool dispatch, builtin tools, skills, hooks, memory,
and policy checks each deciding admission or capability without lowering into
one typed owner.
Most of all, it forbids "the same fact, but optimized over here."
Optimization may create projections. It may not create a second sovereign.
### Violation Examples
#### Provider Stream As Terminal Authority
A runtime loop marks a run completed because the provider stream ended cleanly,
while the machine still has pending tool operations.
The stream observed evidence. It did not own terminality.
Correct shape: the provider path lowers stream completion into typed evidence.
The machine or terminal handoff protocol decides whether the run completed,
failed, remained pending, or needs cleanup.
#### SessionService As Parallel Lifecycle
`SessionService` keeps an internal status enum that can disagree with
`MeerkatMachine`.
The service may host, persist, and route sessions. It must not become a
parallel lifecycle authority.
Correct shape: the service projects machine-owned lifecycle or submits typed
inputs to the machine. It does not decide lifecycle phase itself.
#### Projector As Recovery Authority
A projector writes `last_status = "failed"` and recovery later trusts that
field to decide whether a session is terminal.
The projector created a read model. Recovery turned it into authority.
Correct shape: recovery uses declared machine snapshots, transition journals,
events, or migrators. The projector remains rebuildable and non-authoritative.
#### SDK As Default Owner
An SDK sees a missing model field and silently chooses a default provider.
A user-facing convenience fabricated model policy.
Correct shape: defaults come from the owning profile, catalog, facade, realm
config, or explicit public contract. SDKs expose absence, mismatch, or server
truth. They do not invent it.
#### Mob Transport As Roster Owner
A mob bridge considers a worker detached because its transport disconnected,
while the roster still considers the worker assigned.
Transport liveness is evidence. Assignment truth belongs to the roster,
composition, or handoff protocol.
Correct shape: the transport submits typed disconnect evidence. The mob
authority decides whether the member is detached, reconnecting, retired,
failed, or still assigned.
### Correct Solution Shapes
Start by naming the fact. Do not name the module first.
Examples:
* session lifecycle phase
* tool admission
* provider capability
* mob worker membership
* realm trust
* terminal result class
* async operation truth
* public session identity
Then name the owner.
If the fact is transition legality, lifecycle, terminality, or canonical state,
the owner is usually a generated machine or machine composition.
If the fact is model capability, provider identity, default behavior, or
provider policy, the owner is a typed catalog, profile, registry, or
provider-extension seam.
If the fact is auth freshness, realm trust, or credential binding, the owner is
the auth machine, realm binding, or token-store contract. The token store
persists credentials. It does not decide trust.
If the fact is persistence durability or replay semantics, the owner is a
declared store contract. A store may own append order, snapshot version, replay
boundary, and migration version. It must not own lifecycle meaning unless that
meaning is explicitly part of the canonical contract.
If the fact is public wire shape, the owner is the public contract generated
from or constrained by the authority path.
Once the owner is named, force all other code into one of three roles:
* input adapter
* effect executor
* projection
Input adapters classify raw material and lower it into typed inputs. Provider
chunks, CLI flags, RPC payloads, SDK calls, hook returns, and tool results
belong here.
Effect executors perform IO after authority permits it. They call providers,
run tools, persist events, send messages, schedule tasks, and emit observations.
Projections reshape owned truth for query, display, indexing, compatibility, or
interop. They declare source truth, rebuild trigger, staleness policy, and
boundary. They do not decide behavior.
### Review Questions
* What exact fact is being asserted?
* Who is allowed to change it?
* Can two modules answer this question differently?
* Is this a machine fact, profile fact, catalog fact, store-contract fact, auth
fact, mob-composition fact, or public-contract fact?
* Is any helper, cache, waiter, task, callback, surface, SDK, or test fixture
maintaining a local copy that can affect behavior?
* Does recovery rebuild authority from canonical events, snapshots, or
migrators, or from projection folklore?
* Does a provider-native field leak into shared runtime meaning?
* Does a surface lower into the shared runtime path, or reconstruct semantics
because that was easier?
* Can stale data change admission, defaults, terminality, identity, trust,
routing, or policy?
* If the owner disappeared, would another component quietly pretend to know the
truth?
### Tension Notes
Rule 1 and Rule 2: naming the owner is not enough. If the owner is a machine,
canonical change must go through generated machine authority. Handwritten
reducers are not implementation detail. They are rival authority.
Rule 1 and Rule 3: stores and projections can look authoritative because they
are durable or queryable. Durability is not sovereignty. A store owns
persistence guarantees; the machine owns meaning.
Rule 1 and Rule 4: ownership must be typed. A convention, string prefix,
optional field, path shape, or provider-native name is not an owner.
Rule 1 and Rule 5: optional features own their own assets. A global bundle that
hosts feature truth is a second owner.
Rule 1 and Rule 6: surfaces may be friendly, but they must not become clever.
User syntax can differ from internal machinery only if lowering returns to the
canonical owner.
Rule 1 and Rule 7: provider-specific crates may own provider-native
translation. They do not own Meerkat semantics. Shared runtime receives typed
facts, not provider folklore.
Rule 1 and Rule 8: terminality is where split authority becomes most dangerous.
A timeout, dropped stream, cancelled task, provider error, or failed tool must
lower into one terminal authority.
Rule 1 and Rule 9: generated artifacts must constrain the real authority path.
A generated schema that describes one truth while production follows another is
documentation theater.
### Maxims
* One fact, one owner.
* Evidence is not authority.
* A projection that decides behavior is a usurper.
* Convenience defaults are policy in disguise.
* If two paths can disagree, the architecture is already lying.
## Chapter 2: Generated Machines Own Canonical Change
### Thesis
Canonical machine state changes only through generated machine authority.
This rule is the operational form of "machines own semantics." It is not enough
for a machine model to exist, or for production code to vaguely resemble it. If
handwritten code can decide the same transition, mutate the same state, or
classify the same terminal outcome, the machine is decoration.
Generated authority owns the semantic core:
* what states exist
* which inputs are valid
* which transitions are legal
* what canonical state mutation occurs
* which semantic effects are emitted
* which terminal class follows from machine truth
Handwritten code is still necessary. It receives raw inputs, waits on tasks,
performs IO, schedules work, persists snapshots, routes messages, and executes
effects. But it must stop at the border of meaning. Once a fact changes what
the system is, what transition is legal, what obligation exists, or what result
the caller may trust, it must cross into the machine as a typed input, typed
composition route, or typed handoff protocol.
The runtime conforms to the generated machine. The generated machine must never
be bent to match an awkward handwritten runtime path.
### What The Rule Forbids
Rule 2 forbids any handwritten path that becomes a second reducer.
A reducer is any code that looks at current semantic state plus an event and
decides the next semantic state. It does not have to be named `reduce`. It may
be a helper, actor method, RPC handler, recovery routine, test harness,
callback, retry loop, status mapper, or provider adapter. If it decides
canonical state, transition legality, terminality, or semantic effects, it is a
reducer.
Forbidden patterns include:
* directly setting canonical machine fields from handwritten code
* matching on lifecycle state and manually advancing to another lifecycle state
* maintaining a shadow transition table in an actor, handler, or helper
* duplicating machine states in local enums, booleans, maps, or status structs
* deciding that a transition is legal because a shell-local condition says so
* emitting semantic effects that the owning transition did not produce
* classifying terminal results through ad hoc branches instead of generated or
machine-derived classifiers
* recovering state from projection fields, compatibility metadata, or local
cache truth
* making a test pass by forcing machine state instead of driving the machine
through legal inputs
The decisive test:
> If removing the handwritten shell would remove semantic truth, generated
> authority is incomplete.
### Shell Classification And Typed Inputs
The shell is allowed to decide when to call the machine. It may receive raw
evidence and classify it into a typed machine input.
Legitimate shell classification:
* a JSON-RPC request arrives; the handler maps it to a typed runtime command
* a task exits; the owner records raw completion evidence and submits typed
feedback
* a provider stream yields a chunk; the adapter maps it to typed observation
data
* a timer fires; the scheduler routes a typed timeout input
* a store loads a snapshot; recovery submits declared replay, restore, attach,
or migrator inputs
The permission ends there. The classified output must become a typed machine
input, typed composition route, or typed handoff protocol event.
If the shell has to say "this is kind of like completed, except from recovery"
or "this provider error should probably close the run", the model is missing a
type. Add the input. Add the route. Add the protocol. Do not encode missing
semantics in shell folklore.
A proper typed input carries the facts the machine needs to judge legality:
domain identity, owner identity, correlation IDs, generation or fence tokens
when relevant, payload evidence, and the semantic category being asserted.
### Handoff Protocols
Many semantic transitions do not finish inside one synchronous function call. A
machine may authorize work, emit an effect, and depend on an owner to realize
that effect asynchronously. That seam is dangerous because the shell is tempted
to decide what happened later.
A handoff protocol closes the seam.
Correct shape:
1. The machine transition emits an effect.
2. The effect declares a handoff protocol when later owner feedback is
semantically meaningful.
3. The composition names the realizing owner, allowed feedback inputs,
correlation fields, and closure policy.
4. Handwritten owner code performs the IO or async work.
5. Owner feedback returns through generated protocol helpers.
6. The generated machine or composition path classifies the result and advances
canonical state.
The shell may report evidence: task exited, channel closed, provider returned
error, tool completed, peer acknowledged, timeout elapsed. The shell may not
invent the semantic class: completed, failed, cancelled, abandoned, consumed,
detached, terminal, retryable, or authoritative.
### Violation Examples
#### Direct Lifecycle Mutation
A runtime task exits and handwritten code sets `phase = Completed`, emits a
success event, and clears waiters.
The shell observed a task fact, then invented lifecycle and terminal truth.
Correct shape: the shell submits typed completion evidence through generated
input or handoff feedback. The generated transition decides whether the session
completed, failed, cancelled, or remains obligated.
#### Shadow Transition Helper
An actor has `advance_flow_run()` with a handwritten `match` over `Pending`,
`Running`, `Waiting`, `Succeeded`, and `Failed`, while the catalog also defines
flow lifecycle.
The helper is a parallel reducer, even if its branches look reasonable.
Correct shape: convert each meaningful stimulus into generated machine input:
dependency satisfied, branch failed, quorum reached, retry exhausted, or owner
feedback received. Local maps become projections or private mechanics updated
from generated transitions.
#### String Terminal Classifier
An RPC handler maps internal status strings to public result classes: `"done"`
becomes success, `"stopped"` becomes cancelled, `"error"` becomes failure.
Terminal classification escaped the machine and became surface folklore.
Correct shape: use generated terminal classifiers or machine-derived typed
result classes. The surface may format the result; it must not decide it.
#### Recovery From Projection Truth
Recovery reads a denormalized `last_status` field from a read model and seeds
canonical state as running, detached, or completed.
A projection became authority.
Correct shape: recover from declared snapshot, journal, replay, or migrator
contracts. For transient runtime facts, observe the live fact and submit typed
attach, detach, or rebind input through generated authority.
### Correct Solution Shapes
Add a machine input when the shell is classifying a real semantic event that
the machine cannot currently express.
Add a handoff protocol when a machine-owned effect crosses into async owner
work and later feedback matters.
Promote cross-machine truth into a composition when no single machine owns the
full semantic fact. Acceptance, execution, consumption, barrier satisfaction,
and terminal result alignment often live at composition seams.
Convert local state into a projection when it exists for speed, lookup,
formatting, or transport. A projection must name source truth, rebuild trigger,
staleness policy, and use boundary.
Move provider-specific mechanics behind provider seams. Native request and
response forms are boundary facts, not shared runtime semantics.
Generate the classifier when handwritten code maps machine outcomes to
terminal classes. Exhaustiveness matters. Default arms and "unknown means
success" are architectural sins.
Strengthen tests around the authority path: illegal inputs fail closed,
handoff feedback cannot bypass the protocol, and recovery reaches the same
truth as live execution.
### Review Questions
* What canonical fact changes here?
* Which generated machine, composition, or handoff protocol owns that change?
* Is handwritten code mutating canonical state directly?
* Is this function deciding transition legality, or merely routing typed
evidence?
* Does any local enum, boolean, map, status field, or cache duplicate machine
state?
* Does terminal classification come from generated or machine-derived code?
* If this is async work, where is the handoff protocol and generated feedback
helper?
* If the shell classifies raw input, what typed input does it lower into?
* If no typed input exists, why is the model considered complete?
* Could recovery replay this path without hidden shell-local truth?
* Would deleting this handwritten helper delete meaning, or only mechanics?
### Tension Notes
Rule 2 and Rule 1: for machine facts, singular authority means generated
authority. If there is tension between a convenient helper and the machine, the
helper loses.
Rule 2 and Rule 3: shell mechanics and projections are allowed only while they
remain mechanical. The shell observes and routes; the machine decides.
Rule 2 and Rule 4: a generated machine input is only authoritative if it
carries typed truth. String classifiers and optional owner context smuggle
handwritten semantics back into the path.
Rule 2 and Rule 8: terminal classifiers must be generated or machine-derived.
Fault evidence may come from shell code, but fault meaning belongs to the
machine or protocol.
Rule 2 and Rule 9: if the generated machine does not govern the production
transition, fix production or fix generation. Do not preserve a hand-authored
mirror and call it compatibility.
### Maxims
* The shell may bring evidence; only the machine may pronounce meaning.
* If no typed input exists, the model is incomplete.
* A handwritten reducer is a second source of truth wearing a helper's cloak.
* Async work may leave the machine; semantic closure must return through
protocol.
* Generated authority is not documentation. It is the law.
## Chapter 3: Shells, Stores, and Projections Are Mechanical
### Thesis
Shells, stores, and projections are allowed to move truth, preserve truth, and
display truth. They are not allowed to create truth.
Rule 3 exists because many architectural failures enter through "just a cache",
"just recovery", "just a read model", "just local state", or "just
compatibility metadata." These components feel harmless because they are not
the central machine. That is exactly why they are dangerous. They sit near
authority, often run during exceptional paths, and can quietly become authority
when the real owner is absent, slow, stale, or inconvenient.
A mechanical component does not decide what happened. It carries evidence to
the owner that can decide.
### What The Rule Forbids
This rule forbids any shell, store, projection, cache, receipt table, local
registry, waiter map, recovery helper, or compatibility field from becoming an
undeclared owner of semantic truth.
It forbids:
* deriving lifecycle phase, terminality, policy, trust, ownership, or recovery
outcome from stored or projected convenience fields
* treating stale projection state as permission to continue, terminate, retry,
recover, or accept input
* using local fallback state as a substitute for canonical session, runtime,
provider, peer, or machine state
* letting read models make behavioral decisions instead of serving queries and
presentation
* recovering canonical state from denormalized views, indexes, SDK shadows,
compatibility metadata, or provider-shaped artifacts
* allowing shell code to patch up missing authority by inventing defaults,
inferred ownership, or synthetic outcomes
* allowing compatibility metadata to outlive its expiry or influence behavior
The forbidden move is always the same: a mechanical component starts answering
semantic questions.
### Violation Examples
#### Projection Becomes Authority
A session list projection stores `status = "completed"` for fast UI display.
Later, REST checks that projected status to decide whether cancellation is
legal.
The projection may display completion. It may not decide transition legality.
Correct shape: REST sends a typed cancellation input and lets the authority
accept or reject it. The projection remains a read model.
#### Store Creates Recovery Truth
A recovery path loads the most recent snapshot, notices a terminal-looking
event, and marks the run complete without replaying through the declared
transition, snapshot, journal, or migrator contract.
The store preserved bytes, not meaning.
Correct shape: recovery restores authority through the contract that owns
recovery semantics, or fails closed.
#### Stale Read Model Changes Behavior
A worker checks an eventually consistent `active_sessions` index before
dispatching a tool call. If the session is absent, the worker drops the call as
"session gone."
Eventual consistency is acceptable for query latency, not semantic disposal.
Correct shape: the worker uses the canonical session/runtime owner to validate
dispatch. The index may help find candidates, but absence from the index cannot
prove semantic absence unless that exact boundary is owned and declared.
#### Local Fallback Registry Masquerades As Canonical
A surface keeps a local map from request IDs to session handles. When the
shared runtime cannot resolve a handle, the surface falls back to its local map
and continues.
The local map is correlation state. It cannot override canonical handle
authority.
Correct shape: the local map correlates in-flight requests. If canonical
resolution fails, the surface reports failure or re-enters the declared
authority path.
#### Compatibility Metadata Drives New Semantics
A legacy event field `legacy_terminal = true` exists for old clients. New
recovery code reads it to decide whether a run reached terminal state.
Compatibility metadata has crossed into authority.
Correct shape: legacy fields are temporary, read-only compatibility mirrors
with expiry and approval. New behavior reads canonical terminal fact from the
owning machine or event contract.
### Correct Solution Shapes
Every projection must declare:
* source truth
* rebuild trigger
* staleness policy
* allowed use boundary
Source truth must be a machine state, event contract, snapshot contract, typed
registry, profile, catalog, or other named authority. A projection without
named source truth is usually an undocumented second model.
Rebuild triggers may be event append, snapshot update, schema migration,
provider callback normalization, explicit invalidation, or periodic refresh.
The trigger must be tied to the authority path, not incidental shell activity.
Staleness policy may be strongly consistent, eventually consistent, best
effort, cached with TTL, invalidated by version, or stale for presentation
only. It must state what stale data may not do.
Use boundary may be UI display, list query, search, diagnostics, audit export,
provider interop, human summary, metrics, or compatibility response. Outside
that boundary, the projection is suspect.
Read models are legitimate permanent projections. They reshape canonical truth
for query, performance, display, search, diagnostics, or interop.
Compatibility mirrors are different. They preserve an old semantic shape
temporarily for legacy consumers. They require explicit Highest Dogma Authority
approval, expiry, drift tests, and prohibition against feeding behavior.
### Review Questions
* What semantic fact is this shell, store, or projection carrying?
* Who owns that fact before this component sees it?
* Can this component change behavior when local state is stale, missing, or
wrong?
* Is recovery restoring machine-owned truth, or reconstructing meaning from
residue?
* Is this read model used only for query/presentation, or does it decide
transitions, defaults, admission, terminality, or policy?
* What is the projection source truth, rebuild trigger, staleness policy, and
allowed use boundary?
* Is local fallback state private mechanics, or can it escape as canonical
truth?
* Is compatibility metadata being read by new code to decide behavior?
* If the projection vanished, would the system lose performance and display
quality, or semantic correctness?
### Tension Notes
Rule 3 and Rule 1: do not ban projections; prevent projections from owning
meaning.
Rule 3 and Rule 2: stores persist and restore machine-owned truth only through
declared machine, snapshot, journal, or migrator contracts. Storage durability
is not transition authority.
Rule 3 and Rule 5: global feature registries and indexes are projections of
feature-owned declarations. They do not own optional capability truth.
Rule 3 and Rule 6: surface-local request maps, SDK shadows, browser caches, and
CLI state must remain private mechanics.
Rule 3 and Rule 8: stale indexes, dropped waiters, missing receipts, and
recovery guesses must not become terminal outcomes.
Rule 3 and Rule 9: generated or not, an artifact constrains production truth
only if it is the authority path. Otherwise it is a projection, and projections
must stay in their lane.
### Maxims
* A projection may answer "what should I show?" It may not answer "what is
true?"
* A store preserves authority; it does not become authority.
* Stale data may be visible. It must not be decisive.
* Local fallback state is private plumbing until a named authority owns it.
* Compatibility metadata is not memory. It is debt with an expiry date.
## Chapter 4: Truth Is Typed, Identity Is Canonical
### Thesis
A semantic fact is not real in Meerkat until it has a type, an owner, and a
canonical identity path.
Strings, paths, provider fields, JSON shapes, optional values, and naming
conventions may carry evidence. They must not carry law. If code needs to
distinguish completed from detached, trusted from merely known, reachable from
absent, OpenAI from Anthropic, skill A from another skill with the same name, or
member identity from runtime incarnation, that distinction belongs in a named
type.
Folklore scales badly. Once meaning lives in string prefixes, missing fields,
provider-native names, or "everyone knows this path means X", every surface and
helper starts carrying a private copy of the architecture.
Typed truth is the antidote. Canonical identity is the boundary.
### What The Rule Forbids
Rule 4 forbids using unowned representation as semantic authority.
Forbidden patterns include:
* parsing model, provider, realm, peer, skill, session, or operation meaning
from string prefixes
* treating enum variant names, display labels, log messages, or error text as
machine-readable truth
* letting provider-native request or response fields drive shared runtime
behavior
* using path layout, file names, command names, or URL shape as lifecycle or
ownership truth
* accepting generic JSON where `kind`, `type`, or an ad hoc field secretly
determines behavior
* inferring trust, completion, detachment, reachability, or ownership from
convention
* using `Option` when the real question is which owner supplies the fact
* exposing raw infrastructure identifiers as public control handles when the
caller needs a domain handle
* treating provenance as decorative audit text instead of typed evidence bound
to an owner
The target is not strings themselves. Strings are fine for text. The violation
begins when a string becomes the place where architecture hides.
### Violation Examples
#### Provider Folklore In Shared Logic
Bad:
```rust theme={null}
if model_name.starts_with("openai:") {
enable_provider_web_search();
}
```
The shared runtime is guessing provider semantics from names.
Correct shape:
```rust theme={null}
match model_profile.capabilities.web_search {
WebSearchCapability::ProviderNative { policy } => enable(policy),
WebSearchCapability::Unavailable => disable(),
}
```
The provider crate owns native interpretation. Shared code receives typed
facts.
#### Public Handles That Are Really Infra IDs
A public API exposes a raw session UUID as if it were enough to express resume
or archive behavior. If session meaning is realm-scoped, backend-scoped,
tenant-scoped, or authority-scoped, the handle is incomplete.
Correct shape:
```rust theme={null}
struct SessionHandle {
realm_id: RealmId,
session_id: SessionId,
}
```
The handle names the domain operation and carries the owner context required to
resolve it.
#### Skill Identity By Name Alone
`load_skill("email-extractor")` is not canonical if multiple sources can
provide that name.
Correct shape:
```rust theme={null}
struct SkillKey {
source_uuid: SourceUuid,
skill_name: SkillName,
}
```
The source identity and skill name together form the canonical key.
#### Optionality Hiding Ownership Uncertainty
Bad:
```rust theme={null}
struct RunRequest {
model: Option,
auth_binding: Option,
}
```
This collapses intentional inherit, accidental omission, realm default,
provider default, anonymous mode, missing auth, and explicit auth into absence.
Correct shape:
```rust theme={null}
enum ModelSelection {
InheritRealmDefault,
Explicit(ModelId),
ProviderDefault(ProviderId),
}
enum AuthSelection {
InheritRealmBinding,
Explicit(AuthBindingRef),
AnonymousAllowed,
}
```
When absence has multiple meanings, absence is not a type.
#### JSON `kind` As The Real Contract
Bad:
```rust theme={null}
match value["kind"].as_str() {
Some("detached") => mark_detached(),
Some("completed") => mark_completed(),
_ => mark_unknown(),
}
```
The JSON blob is the real enum, but the code refuses to admit it.
Correct shape:
```rust theme={null}
#[serde(tag = "kind", content = "data")]
enum SessionObservation {
Detached { session: SessionId, reason: DetachReason },
Completed { session: SessionId, outcome: CompletionOutcome },
Failed { session: SessionId, error: RuntimeFailure },
}
```
Tagged serialization is fine. Untyped interpretation is not.
### Correct Solution Shapes
Use newtypes for semantic primitives. If two values must never be confused,
they need different types even when both wrap `Uuid`, `String`, or `usize`.
```rust theme={null}
struct SessionId(Uuid);
struct OperationId(Uuid);
struct RealmId(String);
struct SkillName(String);
struct AgentIdentity(String);
struct Generation(u64);
```
A primitive tells the compiler how data is stored. A newtype tells the codebase
what the data means.
Use domain handles at boundaries. A public control surface should accept
handles that match the user operation, not the nearest internal row key. A
handle is wrong if receiving code must guess the missing realm, source, owner,
generation, profile, or authority.
Separate raw identity from canonical identity. A provider request id can be
evidence for a provider attempt. It is not a Meerkat operation id. A runtime
epoch can order hidden runtime execution. It is not mob member identity.
Model absence as policy when absence has meaning. Use `Option` only when
absence has exactly one harmless meaning. Do not use it for inherit vs disable
vs explicit, known vs unknown, unresolved vs forbidden, or unauthenticated vs
anonymous allowed.
Treat provider-native data as evidence, not shared semantics. Provider-specific
fields may be preserved, audited, and lowered inside provider-owned code. They
must not become shared runtime conditionals.
### Review Questions
* What semantic distinction is this code making?
* Is that distinction represented as a type, or inferred from representation?
* Who owns the meaning of this identifier?
* Is the handle complete enough to resolve the owner without ambient context?
* Could two authorities produce the same visible name?
* Could the same raw id mean different things in different realms, generations,
providers, or sources?
* Is an `Option` hiding inherit, disable, unresolved, unknown, forbidden, or
defaulted?
* Is provider-native data crossing into shared runtime behavior?
* Is provenance typed enough to support audit, replay, recovery, and drift
detection?
* Would a new surface or SDK be forced to copy a convention to behave
correctly?
### Tension Notes
Rule 4 and Rule 1: types do not create authority by themselves. A typed
duplicate of an unowned fact is still a duplicate. Name both the type and the
authority.
Rule 4 and Rule 2: adapters may classify raw input into typed observations.
They may not make canonical state changes because they have a nice enum.
Rule 4 and Rule 3: a projection should be typed, but typing does not let it
decide behavior.
Rule 4 and Rule 5: optional feature names, capability ids, skill ids, hook ids,
and route ids must be typed. Global strings are where composability hides
collisions.
Rule 4 and Rule 6: friendly names are allowed only as projections over
canonical handles. UX sugar must resolve through the owner.
Rule 4 and Rule 7: provider-native fields must become typed provider-owned
facts before shared code sees them.
Rule 4 and Rule 8: error strings are especially tempting folklore. Do not parse
terminality out of text.
Rule 4 and Rule 9: generated contracts must preserve domain types. Flattening
`SkillKey` into `"source/name"` or policy enums into nullable strings
reintroduces folklore at the boundary.
### Maxims
* If it matters, name it in the type system.
* A string may carry evidence; it must not carry law.
* A public handle must resolve its owner without guessing.
* `None` is not a policy.
* Provenance that cannot be checked is decoration.
## Chapter 5: Composability Is Feature-Owned
### Thesis
Meerkat is built from composable capabilities, not from a monolith with a bag
of optional decorations.
An optional feature owns more than its Rust functions. It owns the feature's
skills, tools, hooks, manifests, routes, schemas, docs fragments, migrations,
fixtures, policy extensions, generated artifacts, and public affordances. Those
assets are part of the feature's semantic boundary.
If a capability can be enabled by Cargo feature, runtime module, product
edition, mobpack, plugin, provider, or host environment, then the capability's
assets must travel with the owning crate or package. Global packages may
discover the feature. They may not become the place where the feature secretly
lives.
This rule exists because Meerkat is meant to be recomposed. A feature that
leaves skills in core, schemas in a surface, docs in a global package, and
policy in a side registry is not composable. It is scattered.
### What The Rule Forbids
Rule 5 forbids global-package gravity.
It forbids:
* optional feature skills placed in a global `meerkat-core` or global skills
package
* feature-specific hooks, tools, fixtures, routes, or schemas owned by a
surface crate because that surface exposed them first
* global manifests hand-authoring feature truth instead of discovering
feature-owned declarations
* SDK indexes, MCP catalogs, CLI help, REST route tables, or product manifests
becoming the source of truth for optional capability availability
* optional feature docs that describe behavior not shipped, tested, or versioned
with the feature
* core crates absorbing feature policy because the feature needs a shared
vocabulary
* generated files for an optional feature emitted into a global location with
no owner, freshness rule, or feature-gated drift test
* tests and fixtures for optional behavior living outside the owning feature so
the feature can be removed while its expectations remain
Core may define extension seams. Core may define common trait vocabulary,
domain handles, base event shapes, and discovery protocols. Core must not host
the feature's semantics merely because many surfaces need to find them.
### Violation Examples
#### Scheduler Skill Hidden In Core
An optional scheduler feature ships its Codex/Claude skill, hook manifest,
wire route declarations, fixtures, and policy knobs by adding them to a global
`meerkat-core` skills package because global discovery already scans there.
The scheduler has lost composition ownership. Installing core now smuggles in
scheduler semantics, disabling scheduler leaves stale assets behind, and future
features will copy the same global dumping ground.
Correct shape: the scheduler crate/package owns the scheduler skill, hook and
tool declarations, schemas, docs fragments, fixtures, migrations, and policy
extension points. The global skills package or platform registry discovers the
scheduler-owned declaration mechanically when the scheduler feature is present.
Without the scheduler feature, those assets do not exist.
#### CLI Route Table Owns Mobpack Feature Truth
A CLI command table hand-lists routes and help text for optional mobpack
features. The mobpack crate has a different manifest, and the REST surface has
a third copy.
The optional feature now has three owners: CLI, REST, and mobpack.
Correct shape: mobpack owns a typed feature declaration. CLI, REST, MCP, SDKs,
and docs consume generated or checked projections from that declaration.
#### SDK Package Ships Disabled Feature Types
The TypeScript SDK exposes scheduler-specific methods and generated types even
when the scheduler capability is not present in the runtime build. Calls fail
late or silently degrade.
The SDK has made an optional capability look universal.
Correct shape: SDK capability surfaces are generated from feature-owned
declarations and runtime capability negotiation. Absent feature means absent or
explicitly unavailable API, not optimistic universal syntax.
#### Provider Feature Assets In Shared Runtime
An optional provider extension contributes prompt templates, native tool
schemas, auth policy, fixtures, and docs by editing shared runtime files.
The provider extension cannot be removed cleanly, and shared runtime now knows
provider-native semantics.
Correct shape: provider extension assets live in the provider or feature crate.
Shared runtime sees only typed extension seams and mechanically discovered
capability declarations.
### Correct Solution Shapes
Use feature-owned declarations:
```text theme={null}
feature crate/package -> feature manifest/declaration -> generated or checked projections -> surfaces/runtime discovery
```
The feature declaration should name:
* capability id and version
* owning crate or package
* exposed skills, tools, hooks, routes, schemas, docs fragments, fixtures, and
generated artifacts
* required extension seams and feature flags
* availability conditions
* policy extension points
* drift tests and generation commands
Global registries should be projections:
```text theme={null}
feature-owned declarations -> registry generator -> checked global index
```
The global index may make discovery fast. It must be rebuildable, checked for
freshness, and unable to invent feature semantics.
Product bundles may assemble features:
```text theme={null}
product profile -> selected feature packages -> generated capability manifest -> surfaces
```
The product profile chooses what to include. It does not move ownership out of
the feature package.
When a feature needs shared abstractions, split vocabulary from ownership.
Core may define `SchedulerCapability`, a discovery trait, or a typed route
shape. The scheduler crate owns scheduler behavior, skills, schemas, docs,
fixtures, and policy. Shared vocabulary is the socket, not the appliance.
Tests must follow the same boundary. Feature tests, smoke fixtures, and e2e
expectations that assert feature semantics live with the feature or are
generated from feature-owned declarations. Cross-feature integration tests may
compose feature packages, but they do not become the owner of either feature.
### Review Questions
* Is this capability optional by feature flag, package, runtime module,
provider, product profile, plugin, or host environment?
* Which crate or package owns the capability?
* Are every skill, hook, tool, route, schema, fixture, migration, doc fragment,
generated file, and policy extension owned there?
* Is any global package hosting feature assets instead of projecting them?
* Is any surface declaring feature availability from local tables?
* Can the feature be enabled, disabled, versioned, tested, and removed with its
owning package?
* If a global registry exists, what generator or drift test proves it is a
projection?
* Does the SDK expose feature-specific APIs only when the feature is present or
explicitly negotiated?
* Is shared core defining vocabulary, or has it absorbed feature semantics?
* Would deleting the feature crate leave stale skills, docs, schemas, tests, or
policy behind?
### Tension Notes
Rule 5 and Rule 1: composability is ownership applied to packages. If a
feature's assets live elsewhere, ownership has split.
Rule 5 and Rule 2: generated machine inputs and feature-owned declarations must
agree. Do not hand-author a global feature manifest beside generated machine
truth.
Rule 5 and Rule 3: global registries are projections. They may speed discovery;
they may not decide feature availability from stale or local state.
Rule 5 and Rule 4: capability ids, feature ids, skill ids, hook ids, and route
ids must be typed. Stringly global names create collisions and hidden coupling.
Rule 5 and Rule 6: surfaces may expose a composed product shape. They must not
move feature ownership into CLI, REST, MCP, SDK, or WASM packages.
Rule 5 and Rule 7: provider-specific optional features still belong behind
provider or feature seams. Shared runtime receives typed declarations, not
native provider assets.
Rule 5 and Rule 8: feature-specific faults and terminal outcomes must be
owned by the feature's machine, handoff protocol, or declared contract, not a
global dispatcher.
Rule 5 and Rule 9: global indexes, generated catalogs, and product manifests
must be derived from feature-owned declarations and protected by drift tests.
### Maxims
* Optional feature assets live with the optional feature.
* Global discovery is allowed; global semantic ownership is not.
* Core owns seams, not every capability that plugs into them.
* If disabling a feature leaves its skill behind, the feature was never
properly optional.
* A product bundle composes features; it does not confiscate them.
## Chapter 6: Surfaces Are Thin Over The Shared Runtime
### Thesis
A Meerkat surface is a translation boundary. It is not a second runtime.
CLI, JSON-RPC, REST, MCP, SDKs, WASM, browser hosts, examples, and product
binaries may make Meerkat easier to call, read, embed, and ship. They may not
decide what Meerkat means. The same session, turn, model, tool, auth, mob,
comms, fault, and terminal semantics must survive every surface projection.
The surface may speak HTTP, JSON-RPC, MCP tools, CLI flags, Python objects,
TypeScript promises, Rust traits, or browser callbacks. Those are dialects. The
law beneath them is shared runtime authority or an explicitly declared
standalone substrate.
If a surface needs behavior that no shared runtime seam can express, add the
missing typed seam. Do not implement the behavior locally.
### What The Rule Forbids
Rule 6 forbids surface-owned semantic authority.
It forbids:
* surface-local session lifecycle state that competes with `SessionService` or
`MeerkatMachine`
* separate create, resume, interrupt, cancellation, external-event, or commit
semantics per surface
* CLI flags, REST handlers, RPC methods, MCP tools, SDK methods, or browser
callbacks that infer terminality locally
* SDK defaults that fabricate required facts such as realm, model, auth,
runtime mode, persistence, or capability truth
* MCP host tools that bypass typed public control planes and expose raw
agent-side dispatchers
* REST or RPC adapters that call directly into agent execution when product
path requires runtime admission
* examples that teach direct construction as ordinary public path when correct
path is runtime-backed
* duplicated bootstrap, executor, materialization, config, auth, or provider
setup when a shared helper exists
* standalone fallback behavior that silently emulates runtime-backed behavior
without the runtime owner
* browser/WASM surfaces that market unavailable filesystem, TCP, stdio MCP,
shell, or persistent realm behavior as present
A surface may classify its own syntax. It may parse `--resume`, route
`POST /sessions/{id}/turns`, receive `sessions/create`, expose `meerkat_resume`,
or offer `session.turn()` in an SDK. That classification must lower into typed
shared runtime operations. The surface parser may choose the door. It may not
become the house.
### Violation Examples
#### CLI As Hidden Runtime
`rkat resume` opens persistence, reconstructs a session, applies its own
`resumed` phase, and calls agent execution without the runtime-backed binding
path.
Correct shape: CLI syntax lowers into the canonical execution family. Friendly
commands are UX conveniences over shared session/runtime operations.
#### REST As Direct Execution Loop
`POST /sessions/{id}/external-events` directly wakes an agent task or drains an
inbox because that is simpler than entering runtime admission.
Correct shape: REST accepts HTTP, validates the request, and queues a typed
runtime-backed external event. It does not invent a second event loop.
#### SDK As Optimistic Mirror
A Python or TypeScript SDK creates a local `Session` object, defaults missing
status to `completed`, hides unknown events, or guesses capabilities from
package version.
Correct shape: SDK objects wrap canonical session identity and generated or
versioned contracts. Unknown events remain unknown, version mismatch is
explicit, and convenience methods project runtime facts.
#### WASM As Secret Product Emulation
The browser runtime uses in-memory state but presents itself as sharing durable
realms, filesystem config, stdio MCP servers, shell tools, or native
persistence.
Correct shape: WASM/browser mode is explicit: browser-safe substrate,
browser-safe providers and tools, in-memory or declared storage, and no hidden
native affordances.
### Correct Solution Shapes
Runtime-backed product surfaces should follow:
```text theme={null}
surface syntax -> typed request -> shared surface helper -> MeerkatMachine bindings -> SessionService -> AgentFactory
```
The adapter owns protocol mechanics: HTTP status codes, JSON-RPC ids, MCP
content blocks, CLI output, SDK method names, browser event callbacks. The
shared runtime owns lifecycle, admission, runtime bindings, commit boundaries,
comms drain, routing, and terminal truth.
Standalone substrates should be explicit:
```text theme={null}
declared standalone mode -> explicit RuntimeBuildMode::StandaloneEphemeral -> SessionService substrate -> AgentFactory
```
The mode must be visible in type, config, docs, and tests. It must reject or
omit runtime-owned capabilities it cannot honestly support.
When CLI, RPC, REST, MCP, SDK backend, or product binaries repeat bootstrap or
executor logic, move the behavior into a shared helper unless it is purely
protocol formatting.
User-facing syntax may be kind. It may collapse old commands, rename internal
nouns, provide shorthand, hide operator controls, or present friendly handles.
The lowering must be exact.
Capability-scoped surfaces may expose different subsets when built with
different features or deployed into constrained environments. The subset must
be explicit. Help text, `initialize`, capability responses, SDK feature flags,
and generated protocol artifacts must agree.
### Review Questions
* Does this surface enter the same runtime-backed path as its peers?
* If it is standalone, is that mode explicit and limited?
* Which shared helper does this bootstrap, executor, request-lowering, or
response-projection logic use?
* Is any surface handler deciding lifecycle phase, terminality, retryability,
auth truth, model capability, or persistence truth locally?
* Are CLI flags, HTTP routes, RPC methods, MCP tools, SDK methods, and WASM
exports only syntax over typed operations?
* Are defaults owned by the correct profile, realm config, request contract, or
runtime seam?
* Does cancellation mean the same thing across surfaces where the capability
exists?
* Are unknown events, statuses, and version mismatches surfaced honestly?
* Do examples teach the public path, or canonize an escape hatch?
* Would deleting this surface leave runtime semantics intact?
### Tension Notes
Rule 6 and Rule 1: a surface may own presentation state, transport state, and
user interaction flow. It may not own runtime facts.
Rule 6 and Rule 2: a surface may parse raw input and classify protocol
operations. Meaningful classification must lower into typed machine input,
composition route, or service contract.
Rule 6 and Rule 3: surface caches, SDK objects, UI state, stream buffers, and
list views are projections. They may improve responsiveness. They may not
decide behavior when stale.
Rule 6 and Rule 4: friendly names are projections over canonical handles. If a
CLI alias, SDK object id, MCP parameter, browser key, or REST path segment
becomes authority, type it and resolve it through the owner.
Rule 6 and Rule 5: surfaces may expose composed optional capabilities. They
must not host feature-owned skills, hooks, routes, schemas, docs, or policy.
Rule 6 and Rule 7: surfaces may expose provider selection and provider
parameters, but provider meaning belongs behind catalog, profile, provider, and
auth seams.
Rule 6 and Rule 8: HTTP disconnect, WebSocket close, JSON-RPC cancellation, MCP
tool failure, SDK abort, and browser unload must lower into explicit runtime or
standalone fault semantics.
Rule 6 and Rule 9: generated schemas, SDK types, OpenAPI, RPC catalogs, MCP
tools, and docs must describe the production path, not a parallel surface-local
interpretation.
### Maxims
* A surface may translate. It may not decide.
* Friendly syntax is holy only after typed lowering.
* Standalone is a mode, not a fallback.
* Shared behavior belongs in shared helpers.
* If CLI, REST, RPC, MCP, SDK, and WASM disagree, at least one is lying.
## Chapter 7: Providers and Policy Stay Behind Their Owning Seams
### Thesis
Meerkat is one runtime that can speak through many providers. The provider is a
backend coordinate, not a second architecture.
Provider-specific mechanics belong behind provider-owned seams. Shared runtime,
sessions, tools, memory, mobs, surfaces, SDKs, and public contracts may carry
typed provider identity and typed provider-extension payloads, but they must
not learn provider folklore. They must not parse native response shapes, infer
capabilities from model-name trivia, or decide policy from raw provider
strings.
Policy is not ambient. Capability truth, defaults, auth freshness, tool
availability, provider params, web-search behavior, and model-specific
operational defaults must flow from declared owners: `ModelProfile`, the model
catalog, realm auth bindings, `AuthMachine`, facade/factory composition, and
typed provider-extension seams.
Switching from Anthropic to OpenAI, Gemini, or a self-hosted alias must not
change Meerkat architecture. It should only change typed facts resolved by the
owning seams.
### What The Rule Forbids
Rule 7 forbids provider-specific branches in shared code unless the branch is
owned by a typed catalog, profile, or provider seam.
Forbidden patterns include:
* runtime code that checks `provider == "openai"` and changes semantic
behavior directly
* surface code that parses `gpt-*`, `claude-*`, or `gemini-*` to infer
capabilities
* provider-native request or response structs escaping into session history,
tool logic, mob orchestration, memory, hooks, SDKs, or public contracts
* provider adapters deciding shared policy such as tool visibility, retry
class, auth lifecycle, default timeout, or terminal outcome
* raw `provider_params` becoming authority in shared runtime
* public APIs that expose provider-native fields as if they were Meerkat
concepts
* `Option` policy overrides where `None` ambiguously means inherit, disable,
unset, unknown, or use provider default
* auth freshness checks in CLI, RPC handlers, SDK defaults, or provider
adapters
* resume or model hot-swap paths that keep stale policy from previous
model/provider/auth identity
* exact self-hosted aliases being overridden by prefix inference
* hooks rewriting effective provider params after the owning factory/profile
seam has composed them
Provider-specific crates may know provider-specific facts. Typed
provider-extension seams may carry irreducible provider details. The violation
begins when those details become untyped shared meaning.
### Violation Examples
#### Prefix Folklore As Capability Truth
Bad:
```rust theme={null}
if model.starts_with("gpt-") {
hide_tool("view_image");
}
```
This makes a model-name convention the owner of capability truth. It fails for
self-hosted aliases, renamed models, compatibility endpoints, and future
catalog entries.
Correct shape: resolve typed provider and model identity through the model
catalog. Read capabilities from `ModelProfile`. Unknown provider/model pairs
fail closed or require explicit catalog registration.
#### Provider Branch In A Surface
Bad:
```rust theme={null}
if cli_provider == "gemini" {
params["google_search"] = false;
} else {
params["web_search"] = false;
}
```
The CLI has learned native provider parameter names. The surface is now a
policy composer and provider adapter.
Correct shape: the CLI records user intent, such as disabling provider-native
web search for this run. The facade/factory resolves effective
model/provider/profile and lowers the intent through the provider-owned
extension seam.
#### Provider Adapter Owns Policy
A provider crate checks session config, model profile, and runtime settings to
decide whether to inject web search.
The provider crate has become a policy composer.
Correct shape: the facade/factory composes effective provider-tool policy from
config, `ModelProfile`, session metadata, and explicit overrides. The provider
crate receives an already-composed request plan and performs native request
shaping.
#### Auth Failure Becomes Adapter Authority
A provider adapter sees HTTP 401 and marks a session as requiring reauth.
Provider transport error mutated session/auth truth. The adapter usurped
`AuthMachine`.
Correct shape: the adapter returns typed auth evidence, such as
`ProviderAuthError::Unauthorized`. The auth lease path routes evidence to the
binding-scoped `AuthMachine`.
#### Raw Provider Params In Shared Runtime
Shared runtime reads `provider_params["reasoning_effort"] == "high"` to
increase budget.
A provider-native JSON key is driving shared runtime behavior.
Correct shape: provider params are ingress or provider-extension payloads.
Shared runtime may carry them opaquely after validation, but must not interpret
native keys. If a value affects shared behavior, promote it into a typed
Meerkat concept with an owner.
#### Dynamic Identity With Static Policy
A session starts on Claude, derives tool visibility and web-search defaults,
then resumes with an OpenAI model while keeping old derived provider policy.
Correct shape: persist user intent and explicit overrides. Recompute derived
policy whenever model, provider, auth binding, session, realm, owner, or
catalog identity changes.
### Correct Solution Shapes
Provider crates own native mechanics:
* request serialization
* response parsing
* native streaming protocol
* provider retry affordances as evidence
* provider auth challenge interpretation as typed evidence
* provider-specific image/backend request shaping
* provider-native parameter validation at the provider extension seam
They do not own Meerkat session semantics, tool semantics, mob semantics,
memory semantics, terminality, or public contract meaning.
Once Meerkat uses a provider/model fact to make shared decisions, that fact
belongs in catalog/profile authority: vision, image tool results, inline video,
provider-native web search, timeout defaults, parameter schemas, and similar
facts.
Provider extensions must be typed seams. Good extension seams identify the
provider, validate against a provider-owned schema, reject unknown or
unsupported fields when the profile is closed, remain opaque to shared runtime
unless promoted into a shared type, and lower to native request fields only
inside the provider boundary.
`AuthMachine` owns auth lifecycle. Provider runtimes may consume resolved
leases. Provider adapters may return typed auth evidence. Refresh, expiry,
transient failure, permanent failure, reauth requirement, and release belong to
the binding-scoped auth machine.
Policy composition belongs at seams with full identity context: selected model,
resolved provider, `ModelProfile`, realm and auth binding, session identity,
explicit user overrides, config defaults, runtime mode, and provider-extension
payloads.
Overrides must preserve the difference between inherit, disable, set,
unsupported, and unavailable. Plain optionality is not policy.
### Review Questions
* What provider-specific fact is being used, and who owns it?
* Is this code inside a provider crate, typed provider-extension seam, or shared
runtime?
* Is shared code branching on provider strings, model prefixes, native field
names, or native response shapes?
* Does `ModelProfile` or the catalog already own this capability/default?
* If this is new capability/default, should catalog/profile ownership expand?
* Are provider params validated before use?
* Are provider params opaque to shared runtime after validation?
* Does this override distinguish inherit, disable, and set?
* What happens if model/provider/auth binding changes during resume, hot-swap,
or mob member construction?
* Is derived policy recomputed from new identity?
* Does `AuthMachine` own auth lifecycle, or is a surface/provider/helper
caching auth truth?
* Does exact self-hosted alias resolution happen before prefix inference?
* Is provider-native data stored as canonical Meerkat state?
* If a provider-specific concept must cross a boundary, is there a typed seam
and drift test?
### Tension Notes
Rule 7 and Rule 1: provider mechanics may belong to provider crates, but shared
capability and policy facts belong to catalog/profile/auth/factory seams. Do
not let both own the same fact.
Rule 7 and Rule 2: auth lifecycle is not a provider callback. Provider
callbacks produce evidence. `AuthMachine` owns auth state change.
Rule 7 and Rule 3: provider-native forms are projections at the boundary. They
may be useful for IO, diagnostics, or interop, but must not seed canonical
session truth during recovery.
Rule 7 and Rule 4: provider strings, model prefixes, and JSON keys are not
enough. Use typed provider identity, model identity, `AuthBindingRef`,
`ModelProfile`, validated extension payloads, and typed policy overrides.
Rule 7 and Rule 5: optional provider features own their feature-local assets
behind provider or feature seams.
Rule 7 and Rule 6: surfaces may expose friendly provider syntax. They must
lower it into shared runtime seams.
Rule 7 and Rule 8: provider failures become typed Meerkat faults. Transport
timeout, rate limit, auth failure, malformed response, dropped stream, and
provider cancellation are not interchangeable.
Rule 7 and Rule 9: provider crates are authority boundaries. Dependencies from
shared runtime or surfaces into provider-specific internals are dogma alarms
unless explicitly part of a typed seam.
### Maxims
* Provider names are coordinates, not law.
* Policy follows identity or it lies.
* Raw provider params may enter through the gate; they may not govern the
house.
* Auth failure is `AuthMachine` evidence, not adapter authority.
* If a provider branch lives outside its seam, prove the owner or delete the
branch.
## Chapter 8: Terminality and Faults Are Explicit
### Thesis
Terminality is semantic truth. Faults are semantic evidence. They are not
incidental control-flow details.
A Meerkat run may be driven by loops, futures, provider streams, transports,
stores, projections, callbacks, and recovery code, but those paths must not
invent their own endings. The same condition must lower to the same terminal
authority no matter where it is detected.
The forbidden lie is not only "everything succeeded." It is also "nothing
happened", "the caller does not need to know", "the test path is close enough",
and "we logged it, therefore we handled it."
### What The Rule Forbids
Rule 8 forbids parallel terminal paths. A timeout caught in a provider adapter,
agent loop, and SDK transport must not produce three different result classes.
It forbids false success. A run that was cancelled, partially streamed,
abandoned by a dropped receiver, recovered from stale state, or completed
without verified authority must not be reported as successful completion.
It forbids turning hard failure into soft control. A warning may steer
behavior; it may not stand in for terminal outcome. A retry delay may schedule
the next attempt; it may not mean progress. A dropped channel may end delivery;
it may not mean the event was observed.
It forbids fault handling by side effect. Logging, metrics, best-effort cleanup,
swallowed panics, defaulted wire fields, and silent reconnection are evidence
at most. They are not semantic outcomes unless the owning machine, protocol, or
contract says they are.
It forbids hidden divergence between production and tests. Tests may use
smaller budgets and fake providers, but they must preserve the same terminal
semantics, ownership, schema behavior, cancellation obligations, and
backpressure policy as production.
### Violation Examples
#### Cancellation Cuts Through A Transition
A session loop races the main run future against interrupt handling. The
interrupt arm wins and the run future is dropped while a transition is between
two awaited writes. The caller receives `interrupted`, but machine state now
contains half of the abandoned transition.
Correct shape: state-mutating async paths are cancel-safe, guarded by rollback,
or wrapped in explicit transition state that can resume, compensate, or
terminally fail.
#### Partial Provider Output As Success
A provider stream yields content chunks, then the connection dies. The adapter
returns partial text as a normal assistant message because the old path expects
a response.
Correct shape: partial output is evidence. It is truthful success only if the
authority explicitly models partial terminal success.
#### Lagged Event Channel As Transport Annoyance
A REST or RPC event channel lags, drops events, logs the lag, and continues. If
observers rely on those events to reconstruct lifecycle or terminal outcome,
the drop is semantic.
Current Meerkat makes this split explicit. A configured persistent event audit
projector receives envelopes through a dedicated unbounded queue independent
of the bounded UI broadcast. A lagging UI subscriber receives a typed
`StreamTruncated` marker with the dropped count and must resynchronize; that UI
lag cannot drop projector input. The audit append is still asynchronous derived
state. A failure latches projection halt and refuses later replay entries past
the gap without undoing the committed session turn.
Correct shape: decide whether the channel is an authority path or an
observability projection. An authority path must be lossless or fail closed.
Dropped projection observations have a declared consequence: reject, replay,
resync, degrade, or mark the observer stale.
#### Schema Default As Recovery
A schema reader accepts malformed tool arguments by defaulting them to `null`.
The system has replaced one semantic fact with another.
Correct shape: missing args, malformed args, and empty args are different typed
outcomes when they matter.
#### Panic Recovery Without State Repair
A panic is caught and converted into internal error after transition code has
already mutated state.
Correct shape: panic recovery restores pre-state, marks a partial-transition
state, or crashes loudly before corrupt state is observed.
### Correct Solution Shapes
Unify terminal paths. Define terminal outcome at the machine, composition,
handoff protocol, or public contract that owns the run.
Make success carry proof. A success result means the canonical authority
reached a successful terminal state and required effects, observations, or
persistence obligations were satisfied.
Separate soft and hard control. Use typed steering for warnings, hints, retry
recommendations, degraded modes, and budget pressure. Use typed terminal
outcomes for cancellation, timeout, rejection, exhaustion, corruption,
authorization failure, and unrecoverable drift.
Make cancellation a first-class obligation. Any state-mutating async path that
can be dropped must be cancel-safe, guarded by rollback, or wrapped in explicit
transition state.
Define backpressure where the queue is introduced. Bounded queues need named
capacity, overflow policy, and semantic consequence: block, reject, drop
newest, drop oldest, compact, or degrade. Unbounded queues need extraordinary
justification.
Put timing into profiles and contracts. Deadlines, retry budgets, stale leases,
idle timeouts, keepalive intervals, and provider request limits must be owned
timing policy, not magic durations in leaves.
Model external drift. Providers, auth systems, stores, leases, response caches,
sockets, and remote sessions can change outside Meerkat. The next interaction
must not pretend internal state remained fresh.
Treat schema skew as terminal or migration event. Version mismatch, unknown
fields, malformed payloads, missing required facts, and stale persisted data
must either migrate through declared migrators or fail closed.
Specify cascade routes. Every seam error in a composition needs a failure
route: retry, back off, compensate, fail dependents, isolate member, degrade
surface, or terminate parent.
Keep test modes semantically isomorphic. Tests may accelerate clocks and fake
IO, but they must preserve outcome classes, queue behavior, schema strictness,
cancellation model, and failure routes.
### Review Questions
* What is the one canonical terminal outcome for this condition?
* Can the same condition be detected in another task, callback, transport,
SDK, provider, recovery path, or test harness?
* Does success prove completion, or merely absence of local error?
* What happens if cancellation occurs between every pair of awaits?
* What happens when the receiver is slow, gone, or lagged?
* Where are deadlines and retry budgets owned?
* What external state can drift while Meerkat is idle?
* What happens to old persisted data and new wire payloads when schemas
diverge?
* Can adversarial input force excessive memory, ambiguous identity, malformed
defaults, or panic paths?
* If this fails inside a composition, who else fails, retries, compensates, or
continues?
* Do tests exercise the same terminal and fault semantics as production?
### Tension Notes
Rule 8 and Rule 1: terminality belongs to the same authority that owns
lifecycle truth. If a helper knows the run is over but the machine does not,
the helper is attempting to become authority.
Rule 8 and Rule 2: fault handling may require new machine inputs or states. Do
not patch around a missing timeout, cancellation, or partial-output transition
in handwritten code.
Rule 8 and Rule 3: stores and projections may report replay failure, drift, or
staleness. They must not invent recovered truth.
Rule 8 and Rule 4: faults need typed identity. Closed, gone, lagged, cancelled,
expired, rejected, and failed are not interchangeable strings.
Rule 8 and Rule 5: optional features must own their own feature-specific fault
semantics and fixtures.
Rule 8 and Rule 6: surfaces may choose how to display a fault. They may not
reclassify it.
Rule 8 and Rule 7: provider-native failures must be translated at the provider
seam into typed Meerkat outcomes.
Rule 8 and Rule 9: public contracts must expose real production fault
semantics. A schema that omits cancellation, partial completion, backpressure
failure, or version rejection is lying.
### Maxims
* One condition, one ending.
* Success must prove itself.
* A logged fault is not a handled fault.
* Cancellation is a transition, not an interruption.
* Tests may be faster than production; they may not be a different world.
## Chapter 9: Contracts, Crates, and Generation Are Ratchets
### Thesis
Meerkat declared architecture must tighten reality, not decorate it.
A public contract, crate boundary, generated file, schema, SDK type, formal
spec, or governance ledger is not a comment. It is an architectural ratchet:
once introduced, it must make drift harder, ambiguity smaller, and production
behavior more constrained than before.
A generated artifact that does not govern the production path is theater. A
crate boundary that does not preserve ownership is camouflage. A compatibility
field with no expiry is rot with a friendly name.
The question is never merely whether a contract exists. The question is: does
this contract bind the production authority path, and does CI prove it cannot
drift?
### What The Rule Forbids
Rule 9 forbids hand-authored semantic mirrors.
A semantic mirror is any duplicated representation of a meaningful fact that
can drift from its owner: a second status enum, a legacy wire field that still
feeds behavior, a handwritten SDK type that decides defaults differently from
the server, a documentation table used as a catalog, or a generated schema that
describes a path production does not actually use.
Rule 9 forbids generated artifacts that merely describe intent. Generated
artifacts must constrain production. If OpenAPI, RPC catalogs, SDK types,
machine specs, generated kernels, formal models, or governance ledgers can go
stale without failing a test, they are stage props.
Compatibility mirrors are forbidden unless all of these are true:
* the mirror is temporary
* the mirror is read-only
* the mirror is derived from a named authority
* the mirror has a declared owner
* the mirror has a freshness rule
* the mirror has an enforced drift test
* the mirror has an explicit expiry condition
* the mirror has explicit Highest Dogma Authority approval
* the mirror cannot feed decisions, defaults, recovery, terminality,
acceptance, or public authority
Compatibility mirrors are not routine engineering tools. They are rare,
approved debt with a fuse.
Rule 9 also forbids crate-boundary laundering. Moving code into another crate
does not make it legitimate. If shared runtime branches on provider folklore,
the provider seam has leaked. If a surface owns request lifecycle truth, the
surface has become authority. If `meerkat-core` gains filesystem or network
authority, the core contract has been corrupted. If a facade quietly creates a
new semantic owner, composition has become concealment.
Finally, it forbids false progress. Adding a type, schema, manifest, generated
file, SDK helper, compatibility flag, or governance row while leaving old
ambiguity alive is not improvement. It widens the blast radius.
### Violation Examples
#### Generated Contract That Does Not Govern Production
The repo commits RPC schemas, SDK types, or OpenAPI output, but the server
accepts methods through a separate handwritten router that can diverge.
The generated artifact is not a ratchet.
Correct shape: production router, public schema, SDK types, and docs derive
from the same authoritative catalog or mechanically checked projections. CI
regenerates artifacts and fails on diff.
#### Catalog Machine Verified, Production Machine Forked
The catalog DSL defines a canonical machine and feeds schema generation and
model checking, while production carries a separate `machine!` body for the
same conceptual machine.
Formal verification covers one body; production executes another.
Correct shape: one authoritative machine body per canonical machine. Production
modules are generated from the catalog or expand catalog-owned source. Drift
checks compare schema and generated output, not just names.
#### Legacy Field Without Expiry
A public response keeps `old_status` for backwards compatibility. New code
still reads it, and there is no breaking-release gate, contract version, date,
owner, or removal test.
The field became permanent authority by inertia.
Correct shape: if the old field must remain temporarily, it is derived
read-only from canonical status, approved by the Highest Dogma Authority,
documented with expiry, covered by drift test, and forbidden from feeding
behavior.
#### Contract Crate Becomes Behavior Crate
`meerkat-contracts` starts computing runtime policy, selecting provider
defaults, validating machine transitions, or embedding provider behavior
because wire types were nearby.
The crate boundary inverted.
Correct shape: `meerkat-contracts` defines stable wire vocabulary, schema
surfaces, error codes, and versioning. Runtime policy lives in runtime, facade,
profile, auth, or composition seams.
#### Ambiguity Moved Instead Of Removed
A bug says terminal states are ambiguous. The fix adds `TerminalStatusV2` while
leaving `status`, `phase`, and `completed: bool` active. Different surfaces pick
different fields.
The change increased ambiguity.
Correct shape: introduce the new canonical type with a migration plan that
retires or mechanically derives old forms. If old wire fields must exist
temporarily, they are expiry-bound mirrors and cannot affect behavior.
### Correct Solution Shapes
Use authority-derived generation:
* catalog DSL generates machine schemas, formal specs, and production machine
modules
* typed RPC catalog generates method schemas, SDK wrappers, and server
registration checks
* OpenAPI is emitted from the same REST route contract production registers
* SDK types are generated from `meerkat-contracts` wire types or checked schema
* governance ledgers are generated from typed registries and fail when
ownership rows are missing or stale
The lineage is:
```text theme={null}
authority source -> generated artifact -> production check -> drift failure
```
Protect generated output with deterministic rerun-and-diff gates: run the
generator in CI, check worktree diff, fail on hand-edited generated files, fail
on missing generated headers, and fail on schema or catalog drift.
Make compatibility debt ugly on purpose:
```text theme={null}
legacy_status:
source: MachineTerminalClass
owner: meerkat-contracts public compatibility
reason: clients before contract version N
expiry: remove at contract version N+1
approval: Highest Dogma Authority, YYYY-MM-DD
drift test: legacy_status == derive_legacy_status(machine_terminal_class)
behavior: forbidden as input to server decisions
```
If that feels heavy, good. The weight is the point.
Treat projections differently from mirrors. A session list index, provider
native request body, or CLI display shape may be permanent. These are not
compatibility mirrors if they are mechanical projections and cannot override
authority.
Use crate ownership as enforcement:
* `meerkat-core` owns shared contracts and trait vocabulary; it must not gain
network or filesystem authority.
* `meerkat-contracts` owns public wire shapes, schemas, error codes, and
contract versioning; it must not become runtime policy.
* `meerkat-runtime` owns the runtime control plane and `MeerkatMachine` path.
* `meerkat-session` owns session service implementations; it must not become
runtime authority.
* provider crates own provider-native mechanics; they must not export provider
folklore as shared truth.
* surface crates own transport and UX; they must not own meaning.
* facade crates compose; they must not hide new authorities.
Public contracts are promises. Every public method, field, schema, error code,
handle, event, or SDK type must answer: who owns this fact, how is it generated
or checked, what versioning rule governs change, what clients may rely on it,
what drift test prevents divergence, and what happens when the authority
changes.
### Review Questions
* What production authority path does this contract describe?
* Is the artifact generated from that path, or hand-authored beside it?
* Can production behavior drift without failing CI?
* What rerun-and-diff or schema parity gate protects this artifact?
* If this is hand-authored, why is it not a semantic mirror?
* Is this a projection, compatibility mirror, or new authority?
* What is the source truth for this projection?
* Can stale projection data affect behavior?
* Does every compatibility mirror have Highest Dogma Authority approval?
* Does every compatibility mirror have date, contract version, or
breaking-release expiry?
* Does any legacy field feed decisions, defaults, recovery, terminality,
acceptance, or public authority?
* Does this crate own the concern being added to it?
* Are public SDKs and schemas derived from the same contract production uses?
* Does this change remove ambiguity, or add another representation beside the
old one?
* If a future maintainer edits the wrong place, what test fails?
### Tension Notes
Rule 9 and Rule 1: contracts, crate boundaries, generated artifacts, and
ledgers must preserve singular ownership.
Rule 9 and Rule 2: it is not enough to generate specs from a machine catalog if
production executes a separate handwritten body.
Rule 9 and Rule 3: permanent read models are projections. Compatibility mirrors
are external-client debt with approval and expiry.
Rule 9 and Rule 4: a generated bad contract is still bad. Generation ratchets
the chosen model; it does not sanctify the wrong abstraction.
Rule 9 and Rule 5: generated indexes and product manifests for optional
features must derive from feature-owned declarations.
Rule 9 and Rule 6: generated surface schemas must describe shared
runtime-backed behavior, not parallel surface-local interpretation.
Rule 9 and Rule 7: when provider portability is impossible, use a typed
provider-extension seam. Do not smuggle native fields into shared logic.
Rule 9 and Rule 8: public error codes, terminal statuses, cancellation results,
retry classes, and recovery outcomes must be generated from or checked against
the authority that owns fault semantics.
### Maxims
* If it does not constrain production, it is not a contract.
* A generated artifact without a drift gate is theater.
* A permanent duplicate is either a projection or a violation.
* Compatibility mirrors require a fuse and the Highest Dogma Authority's
blessing.
* Every new artifact must reduce ambiguity, or it has failed.
# Multi-Host Mobs Plan
Source: https://docs.rkat.ai/architecture/multi-host-mobs-plan
Implemented multi-host mob architecture and its preserved v4.1 adjudication and implementation record.
# Multi-Host Mobs — Architecture & Implementation Plan (v4.1)
> Implementation status: **implemented**. The complete phase 1-8 plus 6b implementation merged in PR #867 at `fd1e275030c76af059eb7a2ab0a2f6a555617563` on 2026-07-16. Restart and rejected-run recovery follow-up #888 landed at `32510368852738e703e31b51b2a9192a96d7d205` on 2026-07-17. This file remains the historical adjudication and implementation record; current code plus the Mobs guide and Mob Architecture reference are authoritative for the shipping public surface.
>
> Post-plan amendment: PR #976 at `cbaca57cc38cc71656f1163bff1e1b7492cf3373` added optional `resume_from_role` to private `MaterializeLaunchMode::Resume`. It authorizes one exact cold durable role migration, fails closed when absent or wrong, and is not a public console, agent-tool, profile, or SDK spawn field. The v4.1 text below intentionally preserves the launch contract as originally designed; this amendment is the current delta.
>
> Historical design status: **adjudicated v4.1** (2026-07-07, at HEAD 0.7.22 / da467fca8; v4.1 ecosystem expansion same day). Every substrate claim was re-verified against that HEAD (file:line anchors are from it). Structure: §§1-13 are the core plan; §§14-19 are the ecosystem chapters (realms/config, resource resolution, live channels, surfaces, orchestration-adjacent systems, session lifecycle), each independently substrate-mapped and adversarially red-teamed; §20 is the threat model; §21 is operations/packaging/governance. §4.1 is the cross-chapter adjudication ledger - every conflict between chapters was decided there, and the chapter texts already reflect those decisions.
> Historical v4.1 statement: this document was the merged, authoritative plan. It superseded the v1 sketch (2026-06-03), the v2.1 adversarial adjudication (2026-06-15), and the pasted "RMAT-aware extension plan" draft; the v3 "leaderless peer fabric" concept remains a **separate track** (see Adjudication history).
> Companion doctrine: `docs/architecture/meerkat-dogma.md`. Machine registry: `canonical_machine_schemas()` in `meerkat-machine-schema/src/catalog/mod.rs`.
## 1. Goal
Distributed mobs whose members run on multiple hosts with **the same UX as local**:
* Agents communicate by identity (`AgentIdentity`), never knowing whether a peer is local or remote.
* Users observe and control every member — including remote and remotely-spawned members — through the same console/API surfaces: list, status, history, live events, send, cancel, retire, wire.
* Mob membership/topology stays `MobMachine` authority. Comms delivery stays `meerkat-comms` authority. Session execution/history stays runtime/session authority. MobKit stays a projection layer.
Example target topology:
```text theme={null}
Host A: A (controlling host: runs the MobActor / MobMachine)
Host B: B1, B2, later B21, B22 (B2 spawns B21/B22 onto Host B)
Host C: C1, C2, C3
Edges: A<->B1 A<->C1 A<->C2 B1<->B2 C1<->C2 C2<->C3
```
`send_message(B1)` works identically from A (cross-host) and from B2 (same host). The console, attached to Host A, sees and controls all eight identities.
## 2. Adjudication history (do not re-litigate silently)
| Round | Date | Outcome |
| - | - | - |
| v1 review | 2026-06-03 | Direction right; 5 proposed primitives duplicated machine-owned facts (`topology_epoch`, fences, roster); \~60-70% substrate exists; blocker = cross-host event/history. |
| v2.1 | 2026-06-15 | Adversarial re-map at 0.7.4. Deferred the one-host-listener consolidation (then designed as `HostTransportFrame` two-layer envelope) to v2 because it generated a cluster of majors; chose the shipped per-member external-TCP plane for v1. |
| v3 | 2026-06-16 | Concept fork: leaderless peer fabric (HomeBotNet/ESP32). Dissolves the control/data duality; **separate product track**, not this plan. |
| v4 | 2026-07-07 | Draft plan corrected and rebuilt. **Deliberately overturns the v2.1 listener deferral** (D1) on new evidence: the identity coupling in the receive path is two seams, not a framing protocol — the deferral priced a design we are not building. Keeps v2.1's other calls (HostId, placement-in-admission, control scopes, event/history as the critical path). |
| **v4.1 (this doc)** | 2026-07-07 | Ecosystem expansion: six dimensions mapped at HEAD, designed, and red-teamed (substrate + dogma skeptics per chapter, 33 blocking findings resolved); 12 cross-chapter conflicts adjudicated in §4.1; threat model and operations chapters added. |
## 3. Constraints (v1, corrected wording)
* Realms are **not** distributed. A member's sessions/events/credentials persist in the member host's local realm. Cross-host reads are bridge-served projections, never remote store handles.
* One process hosts many members; **one TCP ingress port per host process** (not per agent, not per member).
* **No new authority owners.** No parallel roster, no second topology counter, no host-local fence rotation, no comms-owned route authority. Every new semantic fact folds into an existing machine or a catalog-generated scoped authority.
* **Protocols are separated; transport is shared.** Peer data (`MessageKind`) and supervisor control (`BridgeCommand` over the `supervisor.bridge` intent) already share the comms envelope, listener, and inbox — that stays. Separation is at the protocol/authority level: typed payloads, distinct admission authorities, one ingress. (The draft's "do not merge the planes" wording is retired; read literally it contradicts shipped reality — `meerkat-runtime/src/comms_drain.rs:1687` dispatches bridge commands out of the ordinary drain.)
* The **peer envelope does not change** in v1 (see D5-adjacent rationale in §7.1).
* MobKit consumes Meerkat surfaces; it grows no roster, directory, or authority.
## 4. Decisions
**D1 — Data plane: one host acceptor, demux on `envelope.to`.**
A host process runs one `TcpTransportListener`; inbound envelopes are routed to the addressed member by the existing `to: PubKey` field. Evidence that this is small: there is no connection-level identity handshake — the only identity coupling in the receive path is the per-envelope gate `envelope.to != keypair.public_key()` (`meerkat-comms/src/io_task.rs:70`) and the single `inbox_sender` parameter of `handle_connection` (io\_task.rs:34-56). The acceptor replaces that pair with a registry lookup `PubKey → (member keypair for acks, member inbox)`. No new frame, no codec change, no envelope version. The v2.1 deferral priced the `HostTransportFrame` design (two-layer envelope, second routing owner); the demux design has neither. The registry is a **host-local projection installed and removed only by machine effects** (materialize/retire), never self-populated. `require_peer_auth` is unconditionally on for the acceptor — cross-host ingress without signature+trust verification is unrepresentable. The existing per-listener first-byte `{` sniff that branches into the JSON pairing handshake (`comms_runtime.rs:3195-3238`) is kept, but pairing binds the **host identity** (§7.2), not a member. Standalone external members (`rkat run --comms-listen-tcp`) remain supported as the degenerate one-identity acceptor. Outbound is unchanged (router dials per send; connection reuse is a v2 optimization, noted in §13).
**D2 — Observation: proxy-and-merge through the controlling host.**
The console has one endpoint (the controlling host's RPC/REST). The controlling host reads remote members' history/events over the supervisor bridge and merges them into the existing mob stream. Controlling→member-host connectivity exists by construction (the bridge already commands remote members). Direct observer→member-host streams are a v2 optimization. The v3 fabric answers this differently for its own product; that fork stays separate.
**D3 — Two auth planes, split; neither lands in AuthMachine.**
AuthMachine's charter is per-binding provider-credential lease lifecycle (`catalog/dsl/auth_machine.rs:1-26`; `meerkat-runtime/src/handles/auth_lease.rs:1-16` records the prior deliberate rejection of cross-domain absorption). Mob control authorization is a foreign fact there. The repo itself names the missing layer: `meerkat-rpc/src/secure_rpc.rs:3-4` — transport policy "does not decide who may perform RPC actions once connected; that belongs to auth/grants." So:
* **Plane (a), host↔mob:** a host bind ceremony: an out-of-band one-time bootstrap token derives a domain-separated `BridgeHostBootstrapProof` bound to the exact supervisor/host/mob/epoch/generation tuple → durable host authority record fenced by the supervisor authority epoch. The raw bearer never crosses the signed-but-unencrypted bridge. Possession-proof + Ed25519 identity; all-or-nothing per host. Comms bootstrap tokens are never principal auth.
* **Plane (b), principal→mob:** typed `ControlScope` grants owned by MobMachine (grant lifecycle = machine facts) and enforced through a **sealed resolved policy** at the controlling host's command dispatch — the `ToolExecutionPolicy`/`ExecutionPolicyGatedDispatcher` pattern (`meerkat-core/src/tool_execution_policy.rs`), including its fail-closed `UnresolvedInherit` discipline. Bridge commands are only ever sent by the already-authorized supervisor, so plane (b) enforcement lives entirely on the controlling host (MobCommand admission + operator surfaces), not on member hosts.
**D4 — Route install: obligations, not epoch re-timing.**
`topology_epoch` keeps advancing at membership/wiring **commit**, exactly as shipped (`mob_machine.rs:3354`, and in `WireMembersRunning` at :5783-5794). Cross-host install convergence is tracked as machine obligations generalizing `pending_recipient_trust: Set` (mob\_machine.rs:159, with the existing `RecordPendingRecipientTrust`/`ResolvePendingRecipientTrust`/`RollbackPendingRecipientTrust` input trio at :789-791). Fail-closed is preserved by the receiver: an envelope from a peer whose trust entry is not (or no longer) installed is classified untrusted and rejected — delivery gating already works this way, per-member, with zero envelope support. "RouteInstallIncomplete" is a typed projection of outstanding obligations, not a new owner. Retry probes durably recorded state before trusting it — the pending-rotation precedent (`SupervisorPendingRotationRecord.accepted_peer_ids`, `meerkat-mob/src/store/mod.rs:165-178`).
**D5 — Event/history protocol: durable-cursor pages served from the owning host's EventStore; long-poll is the single primitive.**
Live session streams are tail-only broadcasts with no cursor resume (three unrelated sequence domains exist today: session-task counter, per-RPC-stream counter, durable store seq), and `session/history` is a snapshot read. The durable substrate already exists on the owning host: `EventStore` preserves full envelope identity with a store-assigned, fsync-gated `seq` and `read_from(session_id, from_seq)` (`meerkat-session/src/event_store.rs:120-186`). Therefore: remote reads are **pages** — `ReadMemberHistory` (transcript pages) and `PollMemberEvents` (event pages with optional bounded long-poll wait, the `CompletionFeed { watermark, list_since, wait_for_advance }` contract shape). There are **no** stream-open/close commands in v1: "streaming" is the controlling host running a poll pump per remote member; one canonical path, no push/poll divergence. Full replay parity requires persistent (event-store-backed) sessions on member hosts; ephemeral member hosts degrade to a bounded live buffer with typed `StaleCursor` on overrun — declared as a typed capability at bind, never a silent difference.
**D6 — The member host is a composition role, not a new runtime type.**
`MeerkatMachine` already is the per-process multi-session runtime (one instance hosts all local mob member sessions; `prepare_bindings` / `prepare_local_session_bindings`, `meerkat-runtime/src/meerkat_machine/runtime_control.rs:1653-1683`). The member host = MeerkatMachine + host comms acceptor (D1) + host bridge responder (§7.2-7.3) + registration client, packaged as a daemon subcommand `rkat mob host`. The background-driver template is `spawn_schedule_host` (`meerkat/src/surface/schedule_host.rs:943-983`). `RuntimeHostInfo` remains the wire projection of the process — and MUST stay anti-authority: `runtime_host_info_does_not_claim_topology_authority` (`meerkat-contracts/src/wire/host.rs:181-210`) pins that no authority vocabulary lands on it. Do not mint `MobHostRuntime`/`AgentHost` types.
Chapter-level decisions extend D1-D6: **R1-R8** (realms/config/credentials, §14), **resources R1-R8** (build portability, §15 — numbered independently), **DL1-DL10** (live channels, §16), **SD-1..SD-8** (surfaces, §17), **O1-O8** (orchestration-adjacent, §18), **L1-L7** (session lifecycle, §19).
## 4.1 Cross-chapter adjudication ledger (v4.1)
Every conflict the chapters' independent drafts produced, decided once. The chapter texts already incorporate these; this ledger is the audit trail.
* **A1 — Spec compile locus:** the materialize spec ships STRUCTURED RESOLVED INPUTS (resolved profile + definition extract + overlay) and the member host re-runs the ONE `build_agent_config` compiler — a member-side "realizer" would be a second parallel build path. All divergence-prone inputs are pre-resolved controlling-side: `Path` skills → `Inline`, prompt tri-state never `Inherit`, model AND provider pinned typed, tool policy never `Inherit`; the member-host factory appends NO host-config-derived prompt sections for materialized members. §14.2/§15.3.
* **A2 — `shell_env` never travels:** typed `SecretBearingField` admission reject; host env is declared by NAME (`required_env_keys`) and verified at preflight. The payload's non-confidentiality posture depends on this.
* **A3-final — `tools.schedule` IS permitted on remote members** (reverses the draft reject): session-target schedules are realm-local and fire correctly on the owning host; member-side mob-target schedules fail TYPED at fire. Only workgraph stays admission-denied (its failure shape is silent split-brain with no typed backstop). §18.5.
* **A4 — One non-portability vocabulary, one enforcement point:** `NonPortableResource { kind }` minted on the machine spawn-exec ladder at the `BeginSpawnExec` choke-point (the tool-seam-only admission is bypassed by RPC/embedder paths today). Inherited-OPEN workgraph resolves to disabled-with-typed-record; EXPLICIT assertion rejects. *(Implementation supersession: the original v4.1 disabled-with-record alternative above was replaced during multi-host implementation. Remote spawns carrying inherited tool-visibility authority hard-reject as `NonPortableInheritedToolFilter`; explicit workgraph assertions are separately rejected, and `non_portable_disabled` remains empty in v1. See the final §15 R6 / §18 O5 dispositions; the original decision is retained here as history.)*
* **A5 — Secrets-on-bridge invariant, refined:** durable credentials/leases/provider secrets NEVER appear on new multi-host bridge payloads (type-level). `BindHost` carries a tuple-bound HMAC, never its descriptor bearer. The sanctioned multi-host exception is single-use, 60s-TTL, channel-pinned live WS tokens (§16), acknowledged as plaintext-exposed in §20. The older `BindMember.bootstrap_token` protocol still sends its bearer directly and is an explicit pre-existing residual, not precedent for new commands; v2 seed = encrypted comms transport (or a separately versioned member-bind proof).
* **A6 — Launch mode has one wire owner:** `MaterializeMember.launch: {Fresh | Resume{session_id}}`; `PortableMemberSpec` structurally excludes the domain `launch_mode`; `Fork` never crosses as a mode (it collapses to rendered text controlling-side, §19.L2).
* **A7 — One durability flag:** `HostCapabilityFlags.durable_sessions` (the host's realm backend is persistent/event-store-backed). Event replay, archive capability, and console lifecycle flags all derive from it.
* **A8 — One member-originated command family:** `MemberOperatorRequest` (member host → controlling host over `supervisor.bridge`, request-id idempotent under the signed requester generation+fence tuple), machine-admitted via `ResolveMemberOperatorAdmission`. It carries member-side operator tool calls AND member-originated spawn requests (§15 R6, §18 O3) — no second upcall channel exists. The controlling host durably records `Pending` before execution and an immutable terminal reply afterward (§15.5).
* **A9 — `ControlScope` v1 = ten variants:** `List, ReadHistory, SubscribeEvents, SendCommand, Cancel, Retire, WireTopology, Live, AdminHost, AdminGrants`. `AdminHost` stays exactly bind/revoke hosts; grant administration is `AdminGrants`. `RewriteTranscript`/`Approve` arrive with their features (both v2).
* **A10 — Portable MCP declarations:** secret-bearing values (stdio `env`, http `headers`) are structurally absent from the wire type — required env-key/header NAMES only, satisfied host-locally; inexpressible profile MCP config is a typed `SecretBearingField` reject.
* **A11 — Preflight adjudicator:** shell observes, `MobHostBindingAuthority` adjudicates (`ResolveMaterializePreflight` from typed observations). One check inventory: model+provider pin, binding resolvable, required env keys present, stdio commands discoverable, memory backend, `durable_sessions` when required, engine/protocol range. No `PreflightMember` verb — materialize-integral.
* **A12 — Digest:** SHA-256 over RFC 8949 canonical CBOR of `PortableMemberSpec` (the envelope-signature canonicalization mechanics — stable across struct evolution; serde\_json is not canonical and is not used). Machine-authorized at `AuthorizeSpawnProfile`; a SIBLING of the spec on the commands (never a field of the bytes it digests); host recomputes (`SpecDigestMismatch`), ack echoes, `CommitSpawnMembership` guards echo == authorized.
* **A13 — Naming:** the console projection family is `mob/hosts`; the bridge verb stays `HostStatus`.
* **A14 — Multi-tenancy:** `MobHostBindingAuthority` is MOB-KEYED (`Map`, `Map<(MobId, AgentIdentity), …>`); one daemon serves many mobs/controlling hosts; same-mob second supervisor = typed `AlreadyBound` until `RevokeHost`.
* **A15 — ErrorCode registry:** exactly four new `ErrorCode` variants — `ScopeDenied`, `HostUnavailable`, `StaleCursor`, `StaleFence` (allocations §17.4) — with all four projections + roundtrip tests + schema/SDK regen in phase 1. Every other new cause stays bridge/domain-level and maps onto existing codes — including `NonPortableResource`, which is spawn-admission machine vocabulary (`MobSpawnMemberAdmissionKind`) surfacing through an existing validation code, while `StaleFence` is a live bridge cause (materialize/release/command paths) that needs a stable console rendering. *(v4.1.1 errata: this line originally read `NonPortableResource` in place of `StaleFence`, contradicting §17.4's worked allocation table; the table was implemented and this line corrected to match — phase-1 implementation review.)* CLI mob-verb typed-exit path and MCP `McpToolError` typed mapping are NEW seams (phase 7), not existing ones.
* **A16 — Principal identity (plane b):** v1 local RPC/REST/stdio surfaces authenticate as the owner (implicit full scope, unchanged — MobKit keeps working); the second-principal class in v1 is remote comms peers. A member upcall is signed by its member peer key for transport admission, but executes in the separate generated **agent-authority** lane and never acquires principal grants (§15.2). Bearer-token → `PrincipalId` auth for `rkat-rpc --tcp` is the named v2 seed (§21.7).
* **A17 — Flow outcomes decoupled from observation:** dispatching a tracked remote turn records a MobMachine obligation (`pending_remote_turn_outcomes`); the poll pump runs while subscriptions OR obligations exist. `terminal_seq` is pinned to the durable `StoredEvent.seq` domain; `durable_sessions=false` hosts are `RejectedHostIncapable` at dispatch classification.
* **A18 — No shadow inputs to machine classification:** `ClassifyFlowStepDispatch` reads `self.member_runtime_modes` (machine state), never a shell-supplied mode.
* **A19 — Disposal order:** `ReleaseMember` = outer quiesce (cancel → runtime retire → drain) THEN the commit-first archive protocol; the retire AND destroy `Observe*RetirementArchived` signals both gain the typed `disposal` field in one change-set. §19.L3/L4.
* **A20 — Revival source:** host-autonomous from the durable spec row at the STORED `(generation, fence)` (partition-resilient); `HostRebound` reconciles stale fences via `ReleaseMember`; new specs only ever arrive machine-issued at a higher fence. §14.6/§15.7.
* **A21 — Live ordering + scope carve-out:** phase 6b lands AFTER phase 6 (pump + single-flight relaxation are prerequisites); §12's direct-observer non-goal explicitly carves out the live MEDIA plane as the one declared D2 exception (§16 DL1).
* **A22 — Fence containment honesty:** nothing validates fence/epoch member-side on delivery in v1, and host-local realm writes sit outside the fence path entirely — stated plainly here and in §20, never laundered.
## 5. Authority model
One row per semantic fact. "Projection" means rebuildable, never consulted for semantic decisions.
| Fact | Owner | Notes |
| - | - | - |
| Mob membership, member lifecycle | `MobMachine` (roster, spawn-exec ladder, kickoff, revival) | unchanged |
| Topology edges + freshness | `MobMachine.wiring_edges` + `topology_epoch` | unchanged; epoch bumps at commit |
| Runtime binding freshness | `MobMachine.runtime_fence_tokens` / `identity_runtime_fence_tokens` + `Generation` | unchanged; no host-local fence rotation |
| **Participating hosts** (new) | `MobMachine`: host set, keys, endpoints, authority epochs, bind phase | §6.1 |
| **Placement** (new) | `MobMachine`: `member_placement` + spawn-exec ladder rung | §6.1; decided inside the existing spawn admission path |
| **Materialization state** (new) | `MobMachine` (controlling side) + `MobHostBindingAuthority` (host side, catalog-generated scoped authority) | §6.3 |
| Identity ↔ peer key ↔ endpoint | `MobMachine` roster (`member_peer_endpoints`, `RegisterMemberPeer`) | pubkey returns in the materialize ack; private keys never cross hosts |
| **Cross-host wiring install** (new) | `MobMachine` obligations (generalize `pending_recipient_trust`) | D4 |
| Peer delivery | `meerkat-comms` (`Envelope`, TrustStore-as-route, receiver-side trust gating) | envelope unchanged in v1 |
| Supervisor/member control | `BridgeCommand` over comms `supervisor.bridge` intent; machine-adjudicated admission both sides | extended, §7 |
| Session execution/history/events | member host's runtime + session service + EventStore | bridge serves projections |
| **Remote event subscription** (new outcome) | `MobMachine` — third outcome on the existing `AuthorizeAgentEventSubscription`/`RejectAgentEventSubscription` seam (mob\_machine.rs:1323-1324) | §7.4 |
| **Control scopes** (new) | `MobMachine` grant facts + sealed `ResolvedControlPolicy` at dispatch | D3(b), §8 |
| Reachability | **projection only** (v1), computed observer-locally on the controlling host | §7.5; lift into the machine only when a machine decision depends on it |
| **Host live endpoints** (new) | `MobMachine.host_live_endpoints` (presence = capability) | §16 DL5; bind-ceremony fact |
| **Remote turn outcomes** (new) | member-host `MobHostBindingAuthority` journal owns record + exact-ack prune; MobMachine owns `pending_remote_turn_outcomes` obligations | §18 O2/A17 |
| **Member upcall admission** (new) | MobMachine `ResolveMemberOperatorAdmission` | §15 R6/A8 |
| **Member-visible build identity** (new) | machine-authorized `spec_digest` over `PortableMemberSpec` (A12) | §14.2/§15.2 |
Phantom-authority note: `PeerDirectoryReachabilityAuthority` (named as owner in the prior draft) does not exist — zero hits at HEAD. Reachability is new work, and it is a projection.
## 6. Machine deltas (catalog first — Rule: no production-only semantics)
All DSL changes land in `meerkat-machine-schema/src/catalog/dsl/` first, then `make machine-codegen` / `machine-check-drift` / `machine-verify`, schema+alphabet parity, and effect dispositions for `seam-inventory`. TLC bounds for the new state must include ≥2 hosts × 3 members.
### 6.1 MobMachine — hosts and placement
New state (field-driven, per the DSL design principle — no phase enums):
```text theme={null}
mob_hosts: Set
host_public_keys: Map
host_endpoints: Map
host_authority_epochs: Map
host_bind_phase: Map // Requested | Bound; absence = unbound
host_capabilities: Map // SINGLE enumeration (all chapters extend THIS line, nowhere else): protocol range, engine_version, durable_sessions, autonomous_members, hard_cancel_member, tracked_input_cancel, memory_store, mcp, resolvable_providers, approval_forwarding. Admission-gating: protocol range, durable_sessions-when-required, autonomous_members-when-required, tracked_input_cancel for durable tracked turns, memory_store/mcp-when-profiled; advisory: resolvable_providers, engine_version. Live capability is NOT a flag: presence in host_live_endpoints IS the capability (§16 DL5)
member_placement: Map // ABSENT = controlling host (local). Present = remote.
member_materialization_failures: Map // mirrors member_restore_failures
host_live_endpoints: Map // §16 DL5; absence = live-incapable
pending_remote_turn_outcomes: Set<(AgentIdentity, InputId)> // §18 O2/A17: keeps the pump obligated
```
`HostId` is a sealed newtype over the host's comms `PeerId` (identity-first; no second id space; sealed ctor validates pubkey derivation). It is distinct from `RuntimeHostInfo.host_id: String`, which stays a wire projection.
New inputs: `BeginHostBind`, `CommitHostBind { host_id, pubkey, endpoint, capabilities, epoch }`, `RevokeHost`, `HostRebound { host_id, epoch }`, `ResolveMemberMaterialization { agent_identity, outcome }`. Spawn-path extension: `ResolveSpawnMemberAdmission` (mob\_machine.rs:661-673) gains placement validation (host bound + capability present, typed reject otherwise); `SpawnExecPhase` (:10433, currently `Opened | MembershipCommitted | Activated`, absence = settled) gains **`MaterializePending`** entered only for remote placements: `Opened → MaterializePending → MembershipCommitted → Activated`. `CommitSpawnMembership` for a remote member carries the materialization-ack facts (real member pubkey, endpoint, session binding), so roster truth is never published before the owning host has confirmed.
New effects (each with an explicit disposition for `seam-inventory`, following mob\_machine.rs:1459-1477): `RequestHostBind` (routed), `RequestMemberMaterialization { agent_identity, generation, fence_token, spec_digest, host }` (routed), `RequestMemberRelease` (routed), `HostRegistered`/`HostRevoked` (external seam), `RouteInstallRequested { edge, host, a_endpoint, b_endpoint, epoch }` (routed).
New invariants: `member_placement ≠ ∅ ⇒ owner_bridge_session_id ≠ None` (§19.L5 — the legacy ops-owner None-skip never applies to placed members); placement targets a bound host (`member_placement` values ⊆ `mob_hosts` with `host_bind_phase = Bound`); a remote member in `MembershipCommitted+` has a `member_peer_endpoints` entry whose signing key matches the materialization ack; `host_authority_epochs` monotonic per host.
### 6.2 MobMachine — wiring install obligations (D4)
```text theme={null}
pending_route_installs: Set // Install only: { edge: WiringEdge, host: HostId }
```
Install inputs mirror the recipient-trust trio: `RecordRouteInstall`, `ResolveRouteInstall { obligation }`, `RollbackRouteInstall`. The pending ledger is Install-only. The existing plural wiring inputs (`WireMembers`, `WireMembersWithTrust`, `UnwireMembers`, `WireExternalPeer`; the singular `WireMember`/`UnwireMember` names are bridge commands, not DSL inputs) stay the only topology mutators; `topology_epoch` semantics are unchanged. Edge usability is enforced by the receiver (uninstalled trust ⇒ untrusted ⇒ rejected), so a pending Install is fail-closed and the ledger makes convergence observable, retryable, and reportable. Unwire uses synchronous `AuthorizeRouteRemovalBeforeUnwire`: every surviving remote Remove lane must ACK while the exact edge and peer material still exist, then the durable unwire commits. Remove never enters the volatile pending ledger. Transport selection per edge is planner work in the wiring planning helper: both endpoints on one host use that host's in-process route; cross-host uses the host acceptor address.
### 6.3 MobHostBindingAuthority — new catalog-generated *scoped* authority (host side)
The member host needs machine-adjudicated admission for **host-addressed** commands (bind, materialize, release, trust install, status), and durable memory for restart/dedup. It has no MobMachine (that lives on the controlling host) and MeerkatMachine authorities are per-session, so this is a process-scoped fact set. Precedent: `session_persistence_version_authority` — a catalog DSL source generated into a production crate, registered for schema parity, **not** a canonical machine. `MobHostBindingAuthority` owns:
```text theme={null}
supervisor bindings: Map (MOB-KEYED, A14 — one daemon, many mobs)
materialized members: Map<(MobId, AgentIdentity), { generation, fence_token, session_id, spec_digest, spec_bytes }>
dedup memory: per (mob, identity): materialize AND release outcomes (replay returns the recorded result; stale tuple → StaleFence; same tuple + different digest → SpecDigestMismatch)
turn-outcome journal: Map<(AgentIdentity, Generation, InputId), RecordedTurnOutcome> (§18 O2)
preflight adjudication: ResolveMaterializePreflight over shell-supplied typed observations (A11)
```
Admission vocabulary reuses the member-side classes and adds fences: `NotBound | StaleSupervisor | SenderMismatch | InvalidBootstrapToken | StaleFence | Unsupported`. Persistence: one SQLite table in the host's realm (shape mirrors `mob_runtime_supervisors`), CAS-capable — this is what makes host restart rebind and materialize-retry dedup durable.
### 6.4 MeerkatMachine — member-side admission extension
Member-addressed bridge command admission is already machine-owned (`ResolveSupervisorBridgeCommandAdmission` → `SupervisorBridgeCommandAdmissionResolved`, realized at `meerkat-runtime/src/comms_drain.rs:645-739`). The new member-addressed commands (`ReadMemberHistory`, `PollMemberEvents`, `HardCancelMember` execution) extend that classification coverage — the typed command manifests (`runtime_alphabet_parity`) force this to be explicit. Today **nothing validates fence/topology-epoch member-side on delivery** (only supervisor identity+epoch); that stays true for delivery in v1 (supervisor-side `StaleFenceToken` on submit covers it), but the new host-addressed materialize/release path validates `(generation, fence_token)` in `MobHostBindingAuthority` — that check is new work, not reuse.
### 6.5 MobMachine — subscriptions and grants
* Event subscription: the adjudication seam exists with two outcomes — `AuthorizeAgentEventSubscription { agent_identity, session_id }` / `RejectAgentEventSubscription { reason: MemberNotFound | NoSessionBinding }` (mob\_machine.rs:1323-1324). Add the **third outcome** `AuthorizeExternalAgentEventSubscription { agent_identity, host }` guarded on `member_placement`/`external_peer_edges`. Extend `AuthorizeAllAgentEventSubscription` to carry the external member set next to `session_bound_runtimes` — today the mob-wide stream **silently omits** external members (`AuthorizedMobEventRouter` filters to session-bound, `meerkat-mob/src/runtime/event_router.rs:48`, handle.rs:2582-2653); this plan kills that silent cap.
* Grants: `operator_grants: Map` with `GrantOperatorScopes` / `RevokeOperatorScopes` inputs. Expiry is data checked at the enforcement seam (shell reads the clock; the sealed policy compares — mirrors how `ToolExecutionPolicy` keeps policy resolution out of the machine while lifecycle stays machine-owned).
## 7. Wire & protocol deltas (meerkat-contracts; one versioned protocol)
`BridgeProtocolVersion` gains **V4**; `SUPPORTED = [V2, V3, V4]`; every new command requires V4 (per-command version + fail-closed decode already exist: `decode_bridge_command`, supervisor\_bridge.rs:318-330). `BridgeCapabilities` (already exchanged on bind, :769-816) advertises: `durable_sessions`, `autonomous_members`, `hard_cancel_member` (flips to true), `tracked_input_cancel` (separate from version support: the host must durably cancel an exact tracked input), and protocol range. **The comms `Envelope` does not change**: it signs `(id, from, to, kind)` (`meerkat-comms/src/types.rs:157`); topology epoch already rides where it belongs — inside control payloads (`BridgeMobPeerOverlayHandoff.topology_epoch`, contracts :939) — and delivery staleness is receiver-side trust gating. If the envelope ever must evolve, the mechanism is the `content_taint` precedent (absence-preserving optional field inside the signed region, fail-closed on old receivers; types.rs:54-61), not a version integer.
New commands (same `BridgeCommand` enum, `deny_unknown_fields`, `#[non_exhaustive]`):
**Member-addressed** (admitted by MeerkatMachine, as today):
* Placed plain `DeliverMemberInput` separates its canonical non-nil transport/idempotency `input_id` from optional `transcript_interaction_id`. `IngressAccepted` always mints a fresh retry-stable transport key and carries only a caller-supplied transcript id (`None` remains absent for ordinary runtime minting); it requests no terminal custody. `TurnCompleted` records controller custody before SubmitWork and uses the same canonical UUID for transport, transcript, waiter, and retained host sidecar, with explicit `outcome_tracking: Interaction`. The receiver rejects non-canonical/nil ids, under-V4 use, a transcript carrier without exact placed residency, and any tracked split between the two ids.
* `ReadMemberHistory { supervisor, epoch, protocol_version, from_index, limit }` → `BridgeReply::MemberHistoryPage` (mirrors `SessionHistoryPage`; transcript snapshot pages). The encoded reply is byte-bounded below the transport ceiling: a multi-row page returns the maximal fitting prefix with exact `next_index`, while one untransportable row rejects as typed `HistoryRowTooLarge { index, encoded_bytes, max_bytes }` rather than returning an empty loop or silently corrupting fork context.
* `PollMemberEvents { supervisor, epoch, protocol_version, cursor: MemberEventCursor | Tail, max, outcome_acks: Vec<(generation, input_id)>, max_outcomes, wait_ms }` → `BridgeReply::MemberEventsPage { generation, events: Vec, next_seq, watermark, turn_outcomes, outcomes_complete }`. Event and outcome windows are independently count-bounded and the encoded reply is byte-bounded below the transport ceiling. `outcome_acks` names only rows consumed from the prior page; the member host prunes those exact durable rows. An absent ack is a no-op with no tombstone, so a journal commit delayed behind the terminal event cannot be lost. Long-poll: `wait_ms` bounded well under the bridge transport budget. Events are served from `EventStore.read_from` when `durable_sessions`, else from a bounded live buffer with typed `StaleCursor` on overrun.
* `HardCancelMember { supervisor, epoch, protocol_version, operation_id, expected_run_id, reason }` — variant exists on the wire today but every receiver rejects it and nothing sends it (provisioner.rs:3297-3312; comms\_drain catch-all). v1 implements the receiver arm through the machine-admitted hard-cancel path and a production sender, gated by the `Cancel` scope. Both identity fields are REQUIRED: the sender observes and pins the current runtime `RunId`, mints one `OperationId`, and every transient transport retry reuses the byte-identical tuple. Receiver semantics are level-triggered and exact-run-scoped: compare `expected_run_id` with the machine-owned current run under the same per-session mutation gate as `InterruptCurrentRun`; while it still matches, reassert the hard cancel; once it is unbound or lifecycle-terminal, ACK. A newer current run therefore proves only that the expected run is terminal and is never interrupted by a stale retry. ACK means exact-run terminal truth, not merely that one interrupt signal was delivered.
**Host-addressed** (new payload family carrying host authority epoch; admitted by `MobHostBindingAuthority`):
* `BindHost { supervisor, epoch, protocol_version, expected_host_peer_id, expected_address, bootstrap_proof }` → capabilities reply. `bootstrap_proof` is a dedicated HMAC newtype; the descriptor's raw `bootstrap_token` is not valid wire vocabulary here.
* `MaterializeMember { spec: PortableMemberSpec (§15.3), spec_digest, generation, fence_token, launch: MaterializeLaunchMode { Fresh | Resume { session_id } } }` → `MemberMaterialized { member_pubkey, member_peer_id, advertised_address, session_id, spec_digest (echo), engine_version, launch_outcome, resolved_auth_binding }`. **Idempotent** on `(spec.agent_identity, generation, fence_token)`: replay returns the recorded result; a lower tuple is a typed `StaleFence` reject; same tuple + different digest is `SpecDigestMismatch` (A12). Member identity has exactly one carrier (`spec.agent_identity`), launch mode has exactly one carrier (A6), and budget has exactly one digest-covered carrier (`spec.overlay.budget_limits`, §18 O4).
* `ReleaseMember { agent_identity, generation, fence_token }` — FULL durable disposal on the owning host (outer quiesce, then commit-first archive; typed `MemberReleased { disposal }` reply — §19.L3); also the reconciliation verb for orphans.
* `InstallPeerTrust { agent_identity, peer: TrustedPeerDescriptor }` / `RemovePeerTrust { ... }` — realize `RouteInstallRequested` on the owning host through the member's machine-gated `apply_trust_mutation` seam (never direct TrustStore writes).
* `HostStatus {}` → materialized-member inventory + health + refreshed `BridgeCapabilities` (including its single `engine_version` carrier); feeds reconciliation, reachability projection, and the periodic poll that doubles as orphan-reconciliation driver (§21.2).
The member-addressed family additionally gains the live-channel commands (§16.4) and `DeliverMemberInput` gains the optional `turn: BridgeTurnDirective` (§18 O1). Exactly ONE member-ORIGINATED family exists: `MemberOperatorRequest` (§15 R6 / A8), machine-admitted controlling-side.
New rejection causes extend `BridgeRejectionCause` (typed only — the `bridge-classifier` gate forbids `ResponseStatus` reinterpretation; the gate is a hardcoded file allowlist, every new reply-consumer file must be added): `StaleFence`, `StaleCursor`, `Unavailable`, `ScopeDenied { required, presented }`, plus the chapter cause families (§14.4 build rejects, §15.6 preflight, §16.4 live, §18/§19 flow-and-launch causes). Console-facing `ErrorCode` gains exactly FOUR variants (A15): `ScopeDenied`, `HostUnavailable`, `StaleCursor`, `StaleFence` — allocations and rendering seams in §17.4.
Cursor type: `MemberEventCursor { generation: Generation, seq: u64 }` — `seq` is the owning host's durable `StoredEvent.seq` for the session bound at that generation. A fresh-session respawn starts a new seq domain. A higher-generation same-session Resume is never an in-place live rebind: the host unregisters/quiesces the old runtime, closes the live service session, awaits the old projection task's terminal drain witness, then records `generation_start_seq = drained watermark + 1` and rebuilds from the persisted snapshot. The host clamps stale/current cursors to that floor, and every page carries the current generation, so even an old terminal emitted during cutover cannot replay as a new-generation fact. A drain fault/timeout occurs before authority commit: no replacement success is emitted and the old durable row remains replay-revivable.
Projections (contracts): `mob/member_status` gains `placement`, reachability fields (§7.5), lifecycle capability flags (§19.L7), and the typed `non_portable_disabled` record (A4); new `mob/hosts` (A13), `mob/member_history` (§17 SD-1), route-install status, and grant DTOs. Remote-served history/event projections carry typed provenance (`HostClaimed` vs `ControllingHostVerified`) so consoles and MobKit can label what only the owning host attests (§20). Blast radius gates: `make regen-schemas`, SDK codegen, `verify-version-parity`, `verify-rpc-surface-alignment` / `verify-rest-surface-alignment`, `verify-sdk-wrapper-freshness`; `verify-sdk-event-inventory` deliberately does NOT fire (no new AgentEvent types, §17 SD-5).
### 7.1 Why the envelope stays untouched (recorded so it is not re-proposed)
The draft proposed signing `version + target host + mob/topology epoch` into the envelope. Rejected: (a) epoch-in-envelope couples the data plane to authority propagation — mid-rotation, honest senders with stale epochs get bounced; the property it buys ("stale topology rejects delivery") already holds receiver-side via trust classification (unwire ⇒ trust removed ⇒ untrusted ⇒ rejected); (b) `target host` has no consumer — there is no relay in v1 and the TCP connection terminates at the host, `to` selects the mailbox; (c) a `CommsProtocolVersion` integer is not the house evolution style and nothing changes that needs it.
### 7.2 Host bind ceremony (plane a)
1. Operator starts `rkat mob host` on Host B. The host loads/mints its host keypair (`Keypair::load_or_generate` in the host identity dir), binds the acceptor, and writes a **host binding descriptor** (the `--comms-binding-out` shape, host flavor: `{kind: "host", address, ed25519 public_key, bootstrap_token}`).
2. Operator (or automation) hands the descriptor to the controlling mob: `BeginHostBind` → routed `RequestHostBind` → derive the exact request-bound HMAC from the descriptor token → bridge `BindHost` with the proof, expected identity, and expected address. The raw token remains out-of-band.
3. Host validates via `MobHostBindingAuthority` (token, sender, address — the `validate_bind_request` shape, comms\_drain.rs:879-965), records the supervisor binding at the offered epoch, replies capabilities.
4. `CommitHostBind` records `{HostId, pubkey, endpoint, capabilities, epoch}` in MobMachine. Supervisor rotation fan-out (already fail-closed with pending-rotation memory) extends its recipient set to bound hosts.
Pairing on the host acceptor (the `{`-sniff JSON handshake) proves possession of the **host** secret and returns the descriptor containing the **host** bootstrap token; members are never paired individually — member trust flows through machine-authorized `InstallPeerTrust`. Because the comms transport is signed but plaintext, this token-bearing pairing branch is loopback-only; remote operators transfer the 0600 descriptor out-of-band or tunnel the loopback listener. The subsequent remote `BindHost` carries only the tuple-bound HMAC proof, so observing it does not disclose the bearer.
### 7.3 Placement and materialization
Placement input: `SpawnMemberSpec.placement: Option`. Defaults: a member-initiated spawn (B2 spawns B21) defaults to the **requesting member's host**; operator/owner spawns default to the controlling host. Admission rejects unbound hosts or missing capabilities (e.g. `autonomous_members` for an `AutonomousHost`-mode profile) with typed causes.
B2-spawns-B21 walkthrough: delegation tool → MobCommand::Spawn → `ResolveSpawnMemberAdmission` (placement=Host B validated) → `BeginSpawnExec` (Opened) → ladder enters `MaterializePending`, machine emits `RequestMemberMaterialization` → bridge `MaterializeMember` to Host B → Host B: `prepare_bindings`, session created via its session service, member comms runtime minted (its own keypair, its own inbox), identity registered on the host acceptor, `MobHostBindingAuthority` records `(identity, generation, fence, session)` → `MemberMaterialized` ack carries pubkey/address/session → `ResolveMemberMaterialization` → `CommitSpawnMembership` publishes roster + `RegisterMemberPeer` publishes the endpoint → `CommitSpawnActivation` → wiring rules fire (§6.2) → B21 visible and controllable in the console.
Key custody: the member's private key is minted and stays on Host B; only the pubkey travels (the `BackendPeer.peer_id`-derives-from-real-key discipline, `meerkat-mob/src/backend.rs:34-53`, generalized). Budget: the spawn's `budget_limits` ride `overlay.budget_limits` inside the digest-covered spec (§15.3); the member host applies them at session build and enforcement stays local to the member's session (aggregate mob budget is a non-goal, §12). Budget-split vocabulary does not exist on the wire (§18 O4 deletion). *(v4.1.2 errata, ADJ-1: `MaterializeMember.budget_seed` originally named here as a payload-level pass-through was DELETED in phase 3 — two wire carriers for one budget fact is the Rule-6 shape, and only the digest-covered overlay copy survives member-host revival; the single carrier is `PortableSpawnOverlay.budget_limits` — phase-3 implementation review.)*
Runtime modes: host-materialized members support both `AutonomousHost` and `TurnDriven` (`MobRuntimeMode`, `meerkat-mob/src/runtime_mode.rs:7-13`) — the member host runs the loop. The forced-TurnDriven normalization (actor.rs:783-791) continues to apply only to legacy peer-only `RuntimeBinding::External` members. The empty `ExternalBackend` provisioner stub (provisioner.rs:2580) is **deleted**; `MultiBackendProvisioner` dispatches `RuntimeBinding` — `Session` (local), `External` (legacy peer-only, unchanged), new `HostMaterialized { host }`.
Failure/orphans: materialize timeout/failure → `member_materialization_failures` + the existing machine-authorized revival flow (observe → classify → realize; `Broken` terminal). Orphan reconciliation: on (re)bind, `HostStatus` reports the host's materialized set; the controlling machine issues `ReleaseMember` for entries it does not recognize at a current fence — the whole-mob-resume external rebind path (commit "external\_tcp: own external members by MobMachine owner bridge session for resume restore" + `AuthorizeMemberPeerRebind`) is the recovery precedent.
### 7.4 Events, history, completion, cancel — closing the observation gap
The verified blocker family (nothing carries a remote member's session events or history across hosts today; `BridgeCommand` has no read/stream variant; the mob-wide merger is local-only; per-member subscribe hard-rejects peer-only members with machine-emitted `NoSessionBinding`; the delivery path **discards the completion handle** at comms\_drain.rs:2701 and the supervisor degrades TurnCompleted to a dispatch-ack; `HardCancelMember` is reject-only):
* **History:** console `mob/member_history` (new RPC/REST/MCP surface, by `AgentIdentity`) → controlling host: local member ⇒ existing session read; remote ⇒ `ReadMemberHistory` proxy. Same page shape either way.
* **Events:** the controlling host runs a **poll pump** per remote member (schedule-driver-style loop) driving `PollMemberEvents` from the last cursor, wrapping results as `AttributedEvent { source, source_fence_token, role, envelope }` (the existing merge item, `meerkat-mob/src/event.rs:509-518`) and feeding `AuthorizedMobEventRouter` next to the local `SelectAll`. Dual cursor: the source `(generation, seq)` is preserved on each item; the merged stream's ingest order is the router's own. A ring overrun or immutable event row larger than the bridge reply budget is projected to subscribers first as `AgentEvent::StreamTruncated` with a typed `RemoteCursorOverrun` / `OversizedRemoteEvent` reason, then the durable cursor advances; loss is never log-only. Console UX is unchanged (`mob/stream_event` push from the controlling host). Pump lifecycle follows the third subscription outcome (§6.5) OR outstanding `pending_remote_turn_outcomes` obligations (A17) — flow completion never depends on a console watching.
* **Completion:** falls out of events — terminal AgentEvents arrive through the pump; `wait_one`/`wait_all`/flow-step completion for remote members consume the merged stream (the `spawn_turn_completed_reply` degradation path is upgraded to it). `DeliverMemberInput` keeps replying at admission (unchanged; the reply deliberately carries no turn payload).
* **Hard cancel:** receiver arm lands (machine-admitted), capability flips, `Cancel` scope gates it at the controlling host. The controlling sender first observes the exact current run, then sends required `{ operation_id, expected_run_id }` identity. The receiver repeatedly applies the immediate interrupt authority only while that exact run remains current and withholds ACK until it is unbound/terminal. This is bounded convergence over an edge-triggered executor signal, not an edge-ack API. Timeout while the expected run remains bound is typed `Unavailable`; a transient resend is byte-identical, and if its prior ACK was lost after run A ended, replay ACKs A's terminal truth without touching a newer run B.
Bridge client concurrency: `MobSupervisorBridge` currently serializes requests through a single-flight `request_lock` (supervisor\_bridge.rs:41-48). Long-polls must not block lifecycle commands: correlation is already by envelope id, so v1 relaxes single-flight to per-request correlation (or a dedicated poller bridge instance per host) — named here because it looks optional and is not.
`bridge-classifier` gate mechanics: `BRIDGE_CLASSIFIER_FILES` (`xtask/src/bridge_classifier.rs:27-31`) is a hardcoded three-file allowlist. Every new file that consumes `BridgeReply` (the poll pump, the history proxy) MUST be added to it, or it silently escapes the no-`ResponseStatus` gate.
### 7.5 Reachability (projection)
Computed on the controlling host from typed outcomes it already sees: bridge request results (verified-ack vs `PeerOffline`/timeout — liveness today is exactly the per-send verified-ACK, `DEFAULT_ACK_TIMEOUT_SECS = 30`, router.rs:52) and pump progress. Surfaced per host and per member as `control_reachability` / `comms_reachability`: `Reachable | Stale | Unreachable | Unknown` (the wire enum exists at supervisor\_bridge.rs:600) + `last_seen` + `freshness_reason`. Freshness is **observer-local monotonic** (receive-time + elapsed); never compare remote wall-clocks across hosts. Reachability never mutates membership. The existing `ExternalMemberReachability` projection (handle.rs:842) and `SupervisorReachability` transport classifier (mob supervisor\_bridge.rs:58) are absorbed/fed, not duplicated.
## 8. Scoped control (plane b)
```rust theme={null}
enum ControlScope { List, ReadHistory, SubscribeEvents, SendCommand, Cancel, Retire, WireTopology, Live, AdminHost, AdminGrants } // A9: AdminHost = bind/revoke hosts ONLY; grant admin = AdminGrants; Live gates the §16 family
```
* Grants are MobMachine facts (§6.5), keyed by `PrincipalId` (comms-principal = PeerId; local operator contexts injected — Gotcha #19: operator authority is injected, not ambient; `MobToolAuthorityContext` + seal at `meerkat-core/src/service/mod.rs:491,511` is the vocabulary to extend).
* Enforcement: a sealed `ResolvedControlPolicy` checked at the controlling host's two chokepoints — MobCommand admission and the operator surfaces (RPC/REST/MCP mob handlers + agent-facing mob tools). Deny = typed `ScopeDenied { required, presented }`. Fail-closed: an unresolvable principal has no scopes.
* Defaults: the owning session (`owner_bridge_session_id`) holds implicit full scope — single-user CLI behavior is unchanged. Every other principal is default-deny until granted. Scope semantics honored end-to-end: `SendCommand` without `ReadHistory` can drive but not read; `SubscribeEvents` without `Cancel` can watch but not stop; `WireTopology` is distinct from `SendCommand`; `AdminHost` (bind/revoke hosts) is distinct from everything.
* Principal identity (A16): v1 local RPC/REST/stdio connections authenticate as the OWNER (implicit full scope — today's behavior, so MobKit and single-user CLI are unaffected by default-deny). Remote comms peers are the second principal class. `MemberOperatorRequest` uses the member PeerId only as a signed transport/admission fact; its operator capabilities come exclusively from the controlling-host-minted generated agent authority, never a principal grant. Bearer-token → `PrincipalId` authentication for `rkat-rpc --tcp` is the named v2 seed (§21.7).
* Grant durability: `operator_grants` persist through the mob store like every MobMachine fact; supervisor rotation does NOT clear grants (principal→mob facts, not epoch-scoped); expiry is data compared at the enforcement seam (a restored mob's expired grants stay expired).
* Ordering rule: **no remote read/mutation surface ships before this lands** (phase 5 precedes phase 6; phase 6b after 6).
## 9. Failure semantics (typed, enumerated)
| Failure | Behavior |
| - | - |
| Materialize timeout / host reject | `MaterializePending → member_materialization_failures[identity]`; spawn aborts via the ladder's abort path or retries idempotently on the same `(identity, generation, fence)`; revival classification governs re-attempts; `Broken` stays terminal. |
| Materialized on host, commit lost | Orphan: host holds it in `MobHostBindingAuthority`; next (re)bind's `HostStatus` reconciliation → `ReleaseMember` at stale fence. Roster never shows a member the machine didn't commit. |
| Partial route install | Obligation remains in `pending_route_installs`; edge unusable at the uninstalled end (receiver rejects untrusted); `RouteInstallIncomplete` projection reports per-host status; retry drains obligations idempotently. |
| Member host restart | Host rebinds (durable `MobHostBindingAuthority` record + `HostRebound` epoch bump); sessions recover from the host's realm-local stores; fences re-issued monotonically (re-fencing is intentional, not idempotent ownership); acceptor registry rebuilt from recovered materialized set; cursors survive (durable seq). |
| Controlling host restart | Existing mob resume + supervisor-authority recovery; hosts re-probed via durably recorded state before being trusted (pending-rotation discipline). |
| Partition | Observation degrades to typed `Unavailable`/`Stale`; deliveries surface `PeerOffline`; membership and topology unchanged; obligations retained. Fail closed, never fail quiet. |
| Hard-cancel target absent / convergence timeout / reply loss | No observed current run rejects typed `Unavailable` before minting a cancel operation. Once `{ operation_id, expected_run_id }` is minted, the receiver ACKs only after that exact run is unbound or lifecycle-terminal; if it remains bound past the bounded convergence window, reply is typed `Unavailable`, never a false ACK. Transient resend reuses the identical tuple, so reply loss after run A ended cannot cancel a newer run B. |
| Stale supervisor / stale fence | Member-side `StaleSupervisor` (exists) and host-side `StaleFence` (new) typed rejects; rotation retry re-validates accepted peers from durable metadata. |
| Cursor overrun (ephemeral member host) | Typed `StaleCursor` + current watermark; consumer restarts from watermark; capability `durable_sessions=false` was declared at bind, so the console can label the gap honestly. |
| Host revoked while members are live | The controller sends authenticated `RevokeHost` first. The host disposes or deregisters every owned member before it atomically deletes the active binding and records a durable `HostRevoked` receipt; cleanup failure leaves the binding active and retryable. Only after the matching receipt does the controller clear its bind + live endpoint. Placed members then become typed-`Unavailable` for control/observation and enter the revival ladder (re-placement is an operator/machine decision, never an automatic default). Exact revoke replay returns the durable receipt; a fresh bind clears it, and an old signed revoke cannot cross that replacement. |
| Host rebinds with a regressed capability set | Capabilities re-recorded at `HostRebound`; members depending on a lost capability enter revival classification with a typed cause — never silently degraded. |
The chapter failure tables extend this one: §14.4 (build rejects), §16.6 (live), §18.8 (flows/policy/schedules/approvals/taint), §19.F (launch/disposal/fork). Together they are the v1 failure surface; a new cross-host interaction without a row in one of these tables is a review reject.
## 10. Phases (exit gates, no calendar)
Each phase exits only with its gates green. Machine phases additionally run: `make machine-codegen`, `make machine-check-drift`, `make machine-verify`, `runtime_schema_parity`, `runtime_alphabet_parity`, `xtask seam-inventory`, `xtask effect-authority`, `xtask ownership-ledger --check-drift`, `xtask rmat-audit --strict`, **`machine-authority-docs-gate` and `verify-machine-poster-coverage`** (any canonical-machine alphabet delta trips the poster regen). Contracts phases additionally run: `make regen-schemas`, `verify-schema-freshness`, `verify-version-parity`, `verify-sdk-codegen-freshness`, `verify-sdk-event-inventory`, `verify-rpc-surface-alignment`, `verify-rest-surface-alignment`, `verify-sdk-wrapper-freshness`. Every phase runs `docs-check`; §21.5 is the docs/gates inventory. Chapter work items fold into these phases — each chapter carries its own phase list (§14.5-§14.6, §15.9, §16.7, §17.7, §18.11, §19.P); the lines below name only the core items.
1. **Adjudication + catalog deltas.** This document is the phase-1 artifact. Land §6 in one catalog change-set (fields, inputs, effects+dispositions, invariants, `MobHostBindingAuthority` DSL, MeerkatMachine admission coverage) + §7 contracts (V4 commands, replies, causes, cursor, projections). TLC bounds: 2 hosts × 3 members.
2. **Host bind + acceptor + host role.** D1 demux in meerkat-comms (registry install/remove from machine effects only; mandatory peer auth; acks signed per-member; pairing branch host-scoped); §7.2 ceremony end-to-end; `rkat mob host` daemon (D6); host binding descriptor.
3. **Placement + materialization.** §7.3 end-to-end including B2→B21/B22 on Host B; `HostMaterialized` binding variant; `ExternalBackend` stub deleted; idempotent retry; orphan reconciliation; revival integration.
4. **Cross-host wiring install.** §6.2 obligations; placement-aware transport selection; `wire_members_batch` local-only rejection removed; unwire symmetry; fail-closed partial install with durable retry.
5. **Control scopes.** §8 grants + sealed policy at both chokepoints; `ScopeDenied`; owner-implicit-full default. **Must land before phase 6 ships any remote read.**
6. **Events / history / completion / cancel.** §7.4: DSL third outcome + all-subscription external fan-out; `ReadMemberHistory` + `PollMemberEvents` + pumps + router merge with dual cursors; completion consumption (incl. the §18 O2 turn-outcome sidecar + A17 obligations); run-scoped level-triggered `HardCancelMember` receiver + sender with required operation/run identity and ACK-after-terminal semantics; bridge single-flight relaxed; remote-source fork context (§19.L2); `BRIDGE_CLASSIFIER_FILES` extended.
6b. **Remote live channels** (§16; ordered AFTER phase 6 — the pump and the single-flight relaxation are prerequisites; the `LiveOrchestrator` + projection-sink four-role extraction may run in parallel as pre-work).
7. **Surfaces.** RPC/REST/MCP/SDKs: by-identity `member_history`, member event streams for remote members (existing `mob/stream_event` now covers them), host register/status, route-install status, grant management (`rkat mob grant/revoke`, host verbs). RPC stays the console↔controlling-host transport; `rkat-rpc --tcp` is never a host↔host control path (one control plane: the bridge).
8. **MobKit projection.** Remote/local members project into the existing console identity records; remote history/events consumed via the new Meerkat surfaces; console contracts stable; no MobKit-owned roster/directory (verified by review, not assumed).
*(v4.1.2 errata, phase-8 consequence pass, verified against meerkat-mobkit HEAD pinning =0.7.25: (1) all three negatives HOLD — member listing always projects per-call from `MobHandle::{list_members_*, roster()}` with no MobKit-owned copy; zero host-directory concepts (`contact_directory.rs` is an unrelated cross-mob gateway federation map, mob\_id→transport); live channels delegate to the handle with no MobKit-side registry. (2) Console contracts are stable by construction: `IdentityRuntime`/`ConsoleFrame` records key on `AgentIdentity`, not session id, so remote members project without shape change. (3) Meerkat is NOT missing a projection surface — the phase-7 library API carries everything MobKit needs (`MobHandle::{member_history, hosts, route_installs, bind_host, revoke_host, hard_cancel_member, member_live_*, grant_scopes/grants}`, `MobMemberStatusResult.placement`, `WireMobErrorDetail`). (4) Recorded DOWNSTREAM asks (MobKit-repo work, out of this plan's scope): bump the meerkat pin past 0.7.25; read remote transcripts via by-identity `member_history` instead of realm-local session-id reads (the console backfill via `resolve_bridge_session_id_observation`, the memory-hygienist `SessionServiceRevisionSeam::read_messages`, and the `IdentityRuntime` session-continuity layer are all in this class); route remote member events/live deltas through the pump-backed surfaces instead of per-member local `subscribe_agent_events`; use `hard_cancel_member` for remote force-cancel (both the RPC path and the HTTP-console `mobkit/force_cancel_member`); surface `placement`; adopt the typed mob-family codes (`WireMobErrorDetail`, -32025..-32028) on the mob verbs instead of `-32000 + format!` laundering — the separate session-lifecycle string-sniff (`is_stopped_session_archive_retire_rejection`) is a pre-existing meerkat-session workaround NOT covered by those codes and needs its own typed seam.)*
## 11. Test matrix (lane-mapped; deterministic lanes are the CI ratchet)
Lane authority: `tests/integration/src/e2e_lanes.rs`. Known trap: e2e-smoke/e2e-system do **not** run in GitHub CI, and external-TCP real-TCP coverage has historically been an ignored smoke lane — so the deterministic lanes below are the regression gate, not the live ones.
**`unit`/`int`/`e2e-fast` (GitHub CI)** — two-hosts-in-one-process harness (two MeerkatMachines + two acceptors over loopback/in-memory transport):
* mixed A/B/C topology: identity-routed send local↔remote both directions; B1↔B2 same-host inproc selection
* many identities, one acceptor: demux by `to`, per-member ack signing, misaddressed reject, unregistered-identity reject
* mandatory peer auth on acceptor cannot be disabled; envelope byte-compat pin (no signed-region drift)
* host bind ceremony: token single-use, sender mismatch, address mismatch, capability capture, epoch record
* remote spawn ladder: B2→B21 full walkthrough; materialize retry deduplicates; stale-fence materialize rejected; orphan reconciliation releases at stale fence; revival classification on materialize failure; `Broken` refuses retry
* wiring: partial install leaves obligation + unusable edge fails closed; retry drains; unwire removes trust and subsequent delivery is rejected; batch wiring with mixed placements
* events: durable cursor resume across pump restart AND member respawn (fresh sessions reset; same-session Resume fences visibility at persisted `generation_start_seq`); mob-wide stream **includes** remote members; completion for remote members via merged stream; long-poll does not block lifecycle commands; `StaleCursor` on ephemeral overrun
* history: remote page read == local page shape; scope matrix — each `ControlScope` grants exactly its verbs, `ScopeDenied` carries required/presented; work-only cannot read history; read-only cannot cancel
* hard cancel: wire decode requires `operation_id` + `expected_run_id`; an edge signal missed before the executor arms is reasserted until the exact run unbinds; ACK is observed only after unbind/terminal; replay the same operation for run A while run B is current and prove B receives no interrupt; transient sender resend preserves both ids byte-for-byte
* rotation: bound hosts in fan-out; pending-rotation retry with a host that rebound to current authority
* TLC (`make machine-verify`) at 2×3 bounds green for all new state
**`e2e-system` (local `make ci` / nightly)** — real multi-process TCP: `rkat mob host` daemon lifecycle; host restart rebind with session recovery and fence advance; partition (kill link) → typed `Unavailable`, membership unchanged, obligations retained; controlling restart resume with host re-probe.
**`e2e-smoke` (live)** — kitchen-sink A/B/C with real providers.
The chapter test matrices extend these lanes (§14 pins, §15.10, §16.8, §17.11, §18.11, §19 tests), all lane-mapped under the same ratchet rule. CI note: the added integration volume will likely require re-sharding the mob int lanes in `cargo.yml` — budgeted in phase 7.
## 12. Non-goals (v1) and v2 seeds
Out of scope, stated so they cannot creep silently: relay/multi-hop envelope routing; dynamic host discovery (registration is explicit); cross-host aggregate budget enforcement (per-session enforcement + later projection only); distributed realms; direct observer→member-host streams for the OBSERVATION plane (history/events — the live-channel MEDIA plane is the one declared D2 exception, §16 DL1 / A21); leaderless-fabric semantics (separate track); outbound connection pooling (the router dials per send — `router.rs:575-643`; a known perf characteristic to measure in e2e-system, correctness-neutral). Chapter non-goals extend this list (§14.7, §15.8, §16.9, §17.10, §18.12, §19.N). v2 seeds, in likely order: encrypted comms transport (A5/§20), bearer-principal RPC auth (§21.7), connection reuse, direct observer streams, bridge-proxied workgraph, approval forwarding (`Approve` scope), remote transcript edits (`RewriteTranscript` scope), WebRTC signaling proxy, richer placement policy (capability/label matching — note `RuntimeHostInfo.placement_labels` has zero writers today and an anti-authority pin; any real capability matching feeds MobMachine facts, not that projection).
## 13. Implementer gotchas (verified traps)
1. `BRIDGE_CLASSIFIER_FILES` is a hardcoded allowlist — extend it for every new bridge-reply consumer (§7.4).
2. `RuntimeHostInfo` must stay anti-authority (pinning test at wire/host.rs:181-210) — do not enrich it with host/placement authority facts.
3. DSL wiring inputs are **plural**; the singular names are bridge commands. Do not invent `WireMember` DSL inputs.
4. No member-side fence/epoch validation exists on delivery today — the host-side `StaleFence` checks in §6.3 are new work.
5. `ExternalBackend` (provisioner.rs:2580) is an empty stub — delete, don't extend.
6. `MobSupervisorBridge` single-flight `request_lock` will serialize long-polls against lifecycle commands unless relaxed (§7.4).
7. Trust rows are never seeded from config and persisted seeds are rejected at startup (comms\_runtime.rs:1620-1632) — all trust flows through `apply_trust_mutation` under machine authority; the acceptor registry follows the same rule.
8. Three sequence domains exist (session-task seq, per-RPC-stream seq, durable store seq) — only the durable store seq is a cursor; never leak the others into the wire cursor.
9. Re-fencing on re-acquire is intentional (fencing mechanism, not churn) — do not "fix" monotonic fence bumps on host rebind.
10. Effects need typed dispositions (`seam-inventory`) and commands need typed classification (`runtime_alphabet_parity`) — budget for these in every machine delta, they are CI failures, not review notes.
Chapter gotcha lists continue in-chapter (§14.8, §16.10, §17.12, §18.12, §19.G) — all verified traps, same standing as the ten above.
## 14. Realms, config, state locality, credentials (host-realm doctrine)
> This section is the authoritative answer to "where does a remote member's state live, which config governs its build, and how do its credentials resolve." It builds on D1–D6 and changes none of them.
### 14.0 Decisions
**R1 — A materialized member's state lives in the member-host daemon's realm; `mob.{mob_id}` is never a store path.**
This is HEAD behavior generalized, not new mechanism: a member session is created through the hosting surface's `SessionService` (`meerkat-mob/src/runtime/provisioner.rs:1986-1989`) and persists in that surface realm's stores. The `mob.{mob_id}` realm id (single owner: `mob_realm_id`, `meerkat-core/src/connection.rs:261-263`, stamped at `meerkat-mob/src/build.rs:198,207`) rides only as: durable session-metadata stamp (`meerkat/src/factory.rs:5470,5507`), comms inproc namespace string (`factory.rs:4560-4565` — "transport string, not a typed realm carrier"), workgraph store scope key (`factory.rs:4907-4935`, path under `realm_scope_root` = host runtime root, `factory.rs:2742-2747`), and credential `preferred_realm` (`factory.rs:3983-3988`). No realm directory is ever materialized for it — and `sanitize_realm_id` would path-mangle `mob.x` → `mob_x` anyway (`meerkat-store/src/realm.rs:267-278`), proof the dot form was never a directory contract. WHY not a dedicated per-mob realm dir on Host B (mapper F6): it would mint new realm manifests/leases/config heads per mob, silently change the effective-config head for member builds, and contradict D6's no-new-realm-semantics posture. Cleanup on `ReleaseMember` uses archive semantics in the daemon realm, not directory deletion — typed, observable, not a scope cut.
Concretely, a member materialized on Host B occupies Host B's daemon realm at `realm_paths_in(state_root, realm)` (`meerkat-store/src/realm.rs:280-291`): `sessions.sqlite3` (session rows), durable `EventStore` at `/.rkat/events` (`meerkat/src/persistence.rs:304-306`), `blobs/`, `artifacts/`, `workgraph.sqlite3`, ops state (under the default Sqlite realm backend the `SqliteRuntimeStore` `runtime_*` tables live INSIDE `sessions.sqlite3` — `meerkat/src/persistence.rs:355-367`; a standalone `runtime.sqlite3` exists only under the JSONL backend, `persistence.rs:315-317`), memory at `/memory` (`factory.rs:5644`), plus its per-process AuthMachine lease (`LeaseKey`, `meerkat-core/src/handles.rs:1301-1327`) published at build on Host B (`factory.rs:4065-4078`). The controlling host sees all of this only as bridge-served projections (D2/D5); there is no wire verb that returns a store handle, so cross-host store access is unrepresentable.
**R2 — `rkat mob host` realm-head contract: workspace-derived default, explicit `--realm` override, `--isolated` rejected.**
The daemon selects its realm exactly like `rkat` CLI: default `RealmSelection::WorkspaceDerived { root: context_root }` → `ws-` (`meerkat-cli/src/main.rs:4016-4024`; derivation `meerkat-store/src/realm.rs:809-813`), state root default `/.rkat/realms` (`main.rs:4064-4066`), global doc at `--user-config-root`-else-`~/.rkat/config.toml` (`main.rs:4087-4100`). WHY not `rkat-rpc`'s `Isolated` default (`meerkat-rpc/src/main.rs:195-199`): `Isolated` generates `realm-` per process (`realm.rs:815-817`) — a fresh throwaway realm every launch, which destroys the member-host restart contract (§10 row "Member host restart" requires sessions, events, and `MobHostBindingAuthority` to survive in the host's realm-local stores). A durable daemon needs a restart-stable realm head; workspace derivation gives one with zero new vocabulary. `rkat mob host --isolated` is a typed CLI rejection (`RealmSelection::Isolated` unrepresentable for the daemon), not a warning. Lease posture: `RealmLeaseGuard` heartbeats block destructive prune only — including unparseable lease files (`realm.rs:102-137`) — so co-locating the daemon with an interactive `rkat` workspace realm is safe-by-construction; the documented operational recommendation is a dedicated context root per daemon so mob-host uptime doesn't extend the workspace's prune-blocked window.
**R3 — Config authority for a remote build (mapper F1): the spec pins member-visible facts; everything else is the member host's effective config. No config snapshot ever ships.**
Every agent build resolves config as head-store-then-inheritance-fold: `FactoryAgentBuilder::resolve_config` (`meerkat/src/service_factory.rs:737-782`) composes `effective_config_over_head(daemon_head_realm, head_config)` (`meerkat-core/src/config_store.rs:167-241`) over the chain resolved by `RealmChain::resolve` (`meerkat-core/src/connection.rs:743-803`). The inheritance HEAD is the daemon's realm, never the mob's. WHY not ship a config snapshot in `MaterializeMember`: it duplicates config authority, names bindings/servers that don't exist on Host B, risks smuggling credential-adjacent facts, and contradicts the host-realm-local doctrine (§3). WHY not host-config-only: the profile's whole point is deterministic member identity; leaving model/tools/policy to per-host config is silent divergence. So the split is explicit and enumerated (§14.3/§14.4): profile-compiled facts travel in the wire spec and are identical on every host by construction; host-mechanics facts (stores, credentials, limits, hooks, retry) are DECLARED host-local. `spec_digest` = SHA-256 over the RFC 8949 canonical CBOR of `PortableMemberSpec` — the SAME canonicalization mechanics the envelope signature already uses (`meerkat-comms/src/types.rs:157-200`), giving a byte-domain that is stable across struct evolution and binary versions (A12; serde\_json is NOT canonical and is not used). It is machine-authorized at `AuthorizeSpawnProfile`, carried as a sibling on `RequestMemberMaterialization`/`MaterializeMember`, recomputed by the member host over the received spec (mismatch ⇒ typed `SpecDigestMismatch`, never a best-effort build), echoed in `MemberMaterialized`, and `CommitSpawnMembership` guards echo == authorized (a lying or corrupted ack cannot commit membership).
**R4 — The profile model is authoritative for mob member builds; the resolving host's `binding.default_model` no longer rewrites it (mapper F2).**
Today the resolved binding's `default_model` overrides the requested model whenever `resume_override_mask.model` is false (`meerkat/src/factory.rs:4024-4033`), and mob profiles default that mask to false (`meerkat-mob/src/profile.rs:271-281`, empty `resume_overrides`; mask set at `build.rs:312`). Cross-host, that means the SAME member spec yields different models purely from each host's local `[realm.*]` config — a silent divergence this plan's discipline forbids. Mechanism: a new typed field `AgentBuildConfig.model_selection: ModelSelection { BindingMayOverride /* default */ | PinnedByProfile }`; `build_agent_config` sets `PinnedByProfile`; the override site (`factory.rs:4024-4033`) consults it. WHY a new enum and not `resume_override_mask.model = true`: the mask is resume semantics ("profile field wins over durable session metadata on resume", `profile.rs:193-200`) — reusing it would conflate two facts in one owner and change resume behavior as a side effect. This applies uniformly (controlling host and member hosts, i.e. also to today's local mobs) — a deliberate, documented behavior change; standalone (non-mob) sessions keep `BindingMayOverride` semantics unchanged.
**R5 — System prompts assemble on the controlling host; the rendered prompt travels (mapper F5).**
`assemble_system_prompt` reads `SkillSource::Path` files from the local filesystem of whichever host runs it (`meerkat-mob/src/build.rs:517-550`, fs read at :533-538). Assembling on Host B would create an undeclared filesystem contract (identical skill files at identical paths on every member host) — a silent divergence vector. Instead the controlling host resolves inline + Path skills into the final prompt string and ships it in the spec; the `SpawnSystemPromptOverride::Replace` seam carries that initial event for fresh materialization (`build.rs:209-217`), while resume restores the persisted transcript exactly and never re-reads or re-injects prompt bytes. For materialized members the member-host factory appends NO host-config-derived prompt sections — the skill-engine inventory section (`factory.rs:5116-5127`, appended via extra\_sections at :5258-5297) is disabled for mob-materialized sessions, so prompt bytes are exactly the spec's bytes (A1; without this, host-level skill repositories silently diverge the prompt and the digest-⇒-identical-prompt test is false). Builtin preload skills (mob-communication / task-workflow / workgraph-workflow) are embedded per binary and derived deterministically from the traveling `ToolConfig` (`build.rs:224-230`) — the skill KEYS are implied, the bytes render from Host B's binary. Acceptable skew is bounded by the bind-time protocol handshake (V4 required; `BridgeCapabilities` protocol range, `meerkat-contracts/src/wire/supervisor_bridge.rs:772-816`); byte-identical builtin skills across binary versions is a declared non-goal, made observable by the host binary version string in the `HostStatus` reply (projection).
**R6 — Credentials never travel; binding references resolve on the member host (mapper F3).**
End-to-end on Host B:
* **Omitted `spec.auth_binding`** (the default; `SpawnMemberSpec.auth_binding: Option` is explicit-only, no ambient promotion — `meerkat-mob/src/runtime/handle.rs:1895-1900`): candidate walk with `preferred_realm = mob.{mob_id}` (`factory.rs:3983-3988` → `resolve_auth_binding_candidates_for_provider`, `connection.rs:1139-1176`). Since `mob.{mob_id}` is virtually never a configured `[realm.*]` section (zero producers at HEAD; consumers only `meerkat-core/src/lib.rs`, `connection.rs`, `meerkat-mob/src/build.rs`), the absent head contributes nothing (`connection.rs:736-742`), the implicit global tail applies (`connection.rs:772-778`), and resolution lands on **Host B's** `~/.rkat` global-doc bindings or the synthetic env\_default (`allow_env_default = true` at both factory call sites, `factory.rs:1744,1765`; synthesis `connection.rs:1168-1170`). Deliberate affordance, zero new machinery: an operator MAY define `[realm."mob."]` in a member host's config to scope that host's credentials per mob — the chain walk already honors it.
* **Explicit `spec.auth_binding`**: travels as the structural `AuthBindingRef { realm, binding, profile, origin }` (no string form, `connection.rs:216-235`); an env\_default-origin ref is rejected before any chain walk (`connection.rs:1030-1034`); resolution is the strict owner-stamped walk of the named realm's chain with `head_required = true` (`connection.rs:1037-1044`) **on Host B's config**, then `ProviderRuntimeRegistry.resolve` enforces owning-realm equality (`meerkat-llm-core/src/provider_runtime/registry.rs:208-213`). The name travels; the material cannot: the token store is machine-local (`dirs::config_dir()/meerkat/credentials`, `meerkat-auth-core/src/auth_store/mod.rs:83-89`, keyed by `TokenKey{realm,binding,profile}`, `meerkat-core/src/auth/token_store.rs:24-29`), env is Host B's process env (`ResolverEnvironment::with_process_env`, `factory.rs:3961`), and the AuthMachine lease is minted on Host B's per-process handle (`factory.rs:4065-4078`). Credential writes stay strict-owner and host-local (`resolve_write_owner`, `connection.rs:1213-1240`); there is no remote `auth login`.
* **env\_default provenance is allowed on member hosts** (same posture as any rkat process) **and recorded**: the `MemberMaterialized` ack gains `resolved_auth_binding: Option` where `None` ⇔ synthetic env\_default — exactly the discipline the factory already applies to the durable metadata stamp (`build_config.auth_binding = Some(resolved)` iff a lease-bearing configured binding, else `None`; `factory.rs:4089-4093`, stamped at :5474/:5511). Projection-only (feeds `mob/member_status`); never consumed for a later build.
* **`shell_env` never crosses hosts** (A2): a remote spec carrying inline shell env is a typed `SecretBearingField` admission reject — the payload's non-confidentiality posture (threat model, §20) rests on the type-level absence of secret-bearing fields. Remote members' shell subprocess environment comes from Host B (process env / host config), with required keys DECLARED by name in the spec (`required_env_keys`) and verified at the Tier-2 preflight. Local (controlling-host) members keep today's `shell_env` semantics unchanged (`handle.rs:1881-1882`).
**R7 — Two-tier availability gate (mapper F4): bind-time advisory provider flags; materialize-time typed build-preflight as the authoritative gate.**
Today there is **NOT FOUND** any credential/model check before spawn commit — the failure surfaces only when `provision_member`'s `create_session` fails (`provisioner.rs:1986-2001`), and `BridgeCapabilities` has no availability vocabulary (`supervisor_bridge.rs:772-816`). v1 adds:
* **Tier 1 (advisory, bind-time):** `HostCapabilityFlags` gains `resolvable_providers: Set` — computed by a presence-level probe on the member host (per provider: default-chain candidate resolution over Host B's effective config + credential-material presence read from env/token store; **no network, no OAuth refresh**). Captured at `CommitHostBind`, refreshed on `HostRebound` and on every `HostStatus` reply via a new `RefreshHostCapabilities` input. `ResolveSpawnMemberAdmission` consults it ONLY for specs that resolve credentials via the default chain (`auth_binding = None`) — the advertised set describes default-chain resolvability and says nothing about explicit bindings, which may resolve from realms the probe never walks. Miss ⇒ typed reject `ProviderUnavailableOnHost { host, provider }`; remedy is provisioning the host and re-probing (bind/status refresh). Staleness is declared: the flags are an admission hint, never the gate.
* **Tier 2 (authoritative, materialize-time):** on Host B, after `MobHostBindingAuthority` admission (fence + dedup) and BEFORE any session/state side effect, the host runs a typed build-preflight: model/provider identity against Host B's registry (`resolve_provider_from_registry` incl. traveling `custom_models`, `factory.rs:3055-3128,3870-3879`; self-hosted binding presence :3068-3093), connection-target resolution (explicit or candidates), and provider credential resolve. Any failure is a typed `MaterializeBuildRejected` — **nothing half-builds**: no `create_session`, no acceptor registration, no materialized-member record; dedup memory records only successes, so retry re-runs preflight. The preflight is shell-executed effect work whose typed outcome feeds the controlling machine (`ResolveMemberMaterialization`), so no host-side semantic state depends on it — authority discipline preserved.
**R8 — `MobHostBindingAuthority` persists in the host realm's `SqliteRuntimeStore` and is the ONLY mob-semantic durable state on a member host.**
Concretely: a new `runtime_mob_host_bindings` table in `SqliteRuntimeStore` (joins the `runtime_*` family at `meerkat-runtime/src/store/sqlite.rs:20-49` — inside `sessions.sqlite3` under the default Sqlite realm backend, `persistence.rs:355-367`), **keyed by mob**: supervisor bindings `Map`, materialized members `Map<(MobId, AgentIdentity), {generation, fence_token, session_id}>`, dedup memory per `(mob, identity)` — one daemon serves many mobs/controlling hosts (A14); a second `BindHost` for the SAME mob from a different supervisor is a typed `AlreadyBound` reject until `RevokeHost`. Record shape mirrors `mob_runtime_supervisors` (`meerkat-mob/src/store/sqlite.rs:60`; record\_json + CAS). WHY `runtime.sqlite3` and not a `mobs/` db: the `mobs/` directory layout (`/mobs/.db` + `realm_profiles.db`) is the CONTROLLING host's mob-storage contract (`meerkat-mob-mcp/src/lib.rs:307-313,405-431`; RPC wiring `meerkat-rpc/src/router.rs:944-947`) — materializing it on member hosts would look exactly like the second roster the constraints forbid (§3). No mob definition, roster, profile, or wiring copy is ever persisted host-side; the record holds only supervisor binding (peer, epoch, phase), materialized members (`identity → {generation, fence_token, session_id}`), and dedup memory. Dedup hardening: the recorded tuple includes `spec_digest`, so a replayed `MaterializeMember` at a matching `(identity, generation, fence)` but different digest is a typed `SpecDigestMismatch` reject — never silently deduplicated to the old member. Controlling-host mob storage is unchanged; `SpawnTooling::Profile` / `override_profile` realm-scoped lookups resolve on the controlling host BEFORE the spec ships (`handle.rs:1888-1894`).
### 14.1 State locality map (one row per fact; all Host B paths under the daemon realm)
| Fact (member on Host B) | Location | Anchor |
| - | - | - |
| Session rows / transcript | `/sessions.sqlite3` (or jsonl dir) | `realm.rs:280-291`, `persistence.rs:302-386` |
| Durable events (cursor domain for D5) | `/.rkat/events` | `persistence.rs:304-306` |
| Ops state (`runtime_*` tables) | inside `/sessions.sqlite3` under the Sqlite backend; `/runtime.sqlite3` under JSONL only | `persistence.rs:355-367`, `:315-317` |
| Blobs / artifacts | `/blobs/`, `/artifacts/` | `persistence.rs:307-386` |
| Workgraph (scope key = `mob.{mob_id}`) | `/workgraph.sqlite3` | `factory.rs:4907-4935,2742-2747` |
| Semantic memory | `/memory` | `factory.rs:5644` |
| Provider tokens | `dirs::config_dir()/meerkat/credentials` (machine-local) | `auth_store/mod.rs:83-89` |
| AuthMachine lease | Host B process, `LeaseKey{realm,binding,profile}` | `handles.rs:1301-1327`, `factory.rs:4065-4078` |
| Host↔mob binding + materialized set + dedup | `runtime_mob_host_bindings` table in the realm's `SqliteRuntimeStore` (inside `sessions.sqlite3` on the Sqlite backend) | R8 |
| Member private key | Host B only; pubkey travels in the ack | §7.3 key custody |
| Mob roster/definition/wiring/profiles | Controlling host ONLY (`/mobs/*.db`, `realm_profiles.db`) | `mob-mcp/src/lib.rs:307-313,405-431` |
### 14.2 The portable spec — what travels, what cannot
`MaterializeMember.spec` is a new contracts type `PortableMemberSpec` (serde-clean, `deny_unknown_fields`), NOT `AgentBuildConfig` (which holds `Arc` dispatchers and live handles and structurally cannot ship). Compilation locus: the controlling host compiles profile+definition → `PortableMemberSpec` (including prompt render, R5); the member host compiles `PortableMemberSpec` → `AgentBuildConfig` via the `build_agent_config` path (`build.rs:203-341`) against ITS factory/effective config.
**Travels (pinned facts):** identity triplet (`mob_id`/profile-name/`agent_identity` — Host B re-derives `comms_name` and the `mob.{mob_id}` stamp through the same single owners, `build.rs:176-198`); `model` (+ `ModelSelection::PinnedByProfile`, R4), `provider` (RESOLVED and pinned controlling-side — never an Option the member host infers from ITS registry; closes the provider-inference divergence at `factory.rs:3100-3113`), `self_hosted_server_id`, `provider_params`, `custom_models` (`definition.models`, `build.rs:298`), `output_schema` (typed `MeerkatSchema`, ingress-validated, `profile.rs:242-243`), **resolved** `tool_access_policy` (`Inherit` is unrepresentable on the wire — encode fails closed, matching the factory's discipline at `build.rs:338-341`), explicit `ToolConfig` category toggles (compiled to overrides on Host B, `build.rs:238-248`; an inherited tool-visibility authority is non-serializable and remote placement carrying one is rejected typed, A4), `auto_compact_threshold`, `resume_overrides`, `max_inline_peer_notifications`, `image_generation_provider` (profile∪definition pre-merged, `build.rs:303-305`), rendered system prompt (R5), declarative `mcp_servers` as `PortableMcpDecl` (secret-bearing values structurally excluded — required env-key/header NAMES only, satisfied from Host B's own config/env; inline secret values are a typed `SecretBearingField` admission reject, A10; processes spawn ON Host B), `labels` + `peer_description` (peer\_meta re-assembled deterministically on Host B, `build.rs:186-196`), `additional_instructions`, `app_context`, `auth_binding: Option`, budget seed (split computed controlling-side, §7.3), `runtime_mode`, `continuity_intent`, and declared host requirements (`required_env_keys` — names only). NOT fields of the spec: `generation`, `fence_token`, and `spec_digest` are SIBLINGS on `MaterializeMember`/`RequestMemberMaterialization` (a digest cannot be a field of the byte-range it digests); `launch_mode` is owned solely by `MaterializeMember.launch: {Fresh | Resume{session_id}}` (A6 — `Fork` is structurally unrepresentable on the wire; the domain `SpawnMemberSpec.launch_mode` never serializes into the spec); `shell_env` never travels (A2 — typed `SecretBearingField` reject; shell subprocess env is host-local, declared via `required_env_keys`).
**Cannot travel (typed, with recomposer):**
| Field | Why | Disposition |
| - | - | - |
| `external_tools: Option>` | in-process only, not persisted (`handle.rs:1901-1902`) | remote placement with `Some` ⇒ admission reject `SpecNotPortable { field: external_tools }`; host-side mob/task tool composition is the member host's role (§7.3), following the rust\_bundles names-only + re-register precedent (`definition.rs:582-585`) |
| `inherited_tool_filter: InheritedToolVisibilityAuthority` | deliberately non-serde authority-minted witnesses (`meerkat-core/src/session.rs:1586-1590`) | remote placement with `Some` ⇒ `SpecNotPortable { field: inherited_tool_filter }`; the serde-clean `tool_access_policy` still travels — v1 non-goal below |
| `llm_client_override` / `agent_llm_client_override` | live client handles (`factory.rs:3115-3123`) | never on the mob spawn path; Host B factory owns clients |
| `resume_session` | live session handle | Host B loads from its own store on resume (§14.6) |
| `mob_tool_authority_context` (sealed authority) | runtime-minted operator authority (`build.rs:249-273`); private generation seal is non-transferable | Sealed authority never crosses the wire. Its `WireMobToolAuthorityContext` facts do travel for visibility; Host B rehydrates an unsealed context. Authoritative minting and dispatch-time re-resolution stay controlling-host-owned (§15 R6). *(Representation erratum: this supersedes the original “re-minted by Host B's composition role, never serialized” shorthand, which conflated the authority with its projection; no private generation seal is serialized.)* |
### 14.3 Config fact table — pinned vs declared-host-local
Resolved from **Host B's effective config** (daemon head ⊕ parent chain ⊕ `~/.rkat` global; `service_factory.rs:737-782`): `budget_limits` fallback when the spec seeds none (`factory.rs:5517-5519`), retry policy + call-timeout fallback (`factory.rs:5521-5530`), hooks, skills-engine roots, memory store, compaction runtime config (except the traveling profile threshold), model-registry base `[models.*]` ⊕ catalog (`factory.rs:3870-3879`), credential/token/env material (R6), MCP server process environment, all stores (§14.1), comms acceptor + keys. Divergence policy: pinned facts are identical everywhere by construction; host-local facts are per-host by DECLARATION (this table is the declaration); no member-visible identity fact (model, provider, prompt, tool policy, output schema) is host-local.
### 14.4 Typed failure taxonomy — "Host B cannot build the member"
Wire mapping: `BridgeRejectionCause` (`supervisor_bridge.rs:440-465`) gains `MaterializeBuildRejected { cause: MemberBuildRejection }` and `SpecDigestMismatch`, alongside the §7 additions. `MemberBuildRejection` is a new closed wire enum; `AuthErrorKind` is already wire-stable (`meerkat-core/src/auth/error.rs:94-113`); `ConnectionTargetErrorKind` is a new wire projection of `ConnectionTargetError` (`connection.rs:647-688`).
| Host B condition | Typed source | Wire cause |
| - | - | - |
| Model unknown to Host B registry, no provider override | `BuildAgentError::UnknownProvider { model }` (`factory.rs:3126-3128`) | `MemberBuildRejection::UnknownProviderForModel { model }` |
| Explicit binding's realm/binding absent or invalid on Host B's chain | `ConnectionTargetError::{UnknownRealm, MissingDefaultBinding, MissingRealm, BindingInvalid, ProviderMismatch, RealmChain}` | `MemberBuildRejection::BindingUnresolvable { kind: ConnectionTargetErrorKind }` |
| Credential material missing / needs login / expired / lease-gated | `ProviderAuthError` chain kept-first (`factory.rs:4042-4062`) → `AuthError` kinds | `MemberBuildRejection::ProviderAuth { kind: AuthErrorKind }` (`missing_secret`, `interactive_login_required`, `expired`, `lease_absent`, …) |
| Self-hosted alias not configured on Host B | typed config error (`factory.rs:3068-3093`) | `MemberBuildRejection::SelfHostedServerMissing { server_id }` |
| env\_default-origin explicit ref | pre-walk reject (`connection.rs:1030-1034`) | `BindingUnresolvable { kind: unknown_realm }` |
| Spec digest disagrees with received spec | R3/R8 | `SpecDigestMismatch` |
Controlling side: every cause lands in `member_materialization_failures` (stable kind recorded, mirroring `member_restore_failures`) and flows through the existing machine-authorized revival classification (`ClassifyBridgeRejectionRecovery` owns recoverable-vs-fatal, `supervisor_bridge.rs:467-472`); `Broken` stays terminal. **A failed materialize never mutates `member_placement`, and there is no silent fallback to controlling-host placement** — re-placement is a new operator/machine decision, never an error-path default.
### 14.5 Bind ceremony additions (folds into §7.2)
Step 3 gains the Tier-1 probe: the host computes `resolvable_providers` and returns it inside the capabilities reply; step 4's `CommitHostBind` records it. `HostRebound` and `HostStatus` refresh it (`RefreshHostCapabilities`). The host binary version has one carrier, `BridgeCapabilities.engine_version`; `HostStatus` has no sibling version field.
### 14.6 Member-host restart / resume (completes §10 row)
On restart, the daemon reopens the SAME workspace-derived realm (R2), rebinds via the durable `runtime_mob_host_bindings` record + `HostRebound` epoch bump, and rebuilds the acceptor registry from its recovered materialized set. **The build input for member revival is the durable spec row** (A20): the `runtime_mob_host_bindings` record stores the spec bytes + digest next to `(generation, fence_token, session_id, generation_start_seq)` (§15.7), so revival is HOST-AUTONOMOUS — the daemon re-runs the one build compiler on the stored spec at the STORED `(generation, fence)` with zero controlling-host contact (members keep working through a controlling-host outage; partition-resilient by construction). Authority is preserved by reconciliation, not by re-issue: fences advance only through the controlling machine, so on `HostRebound` the machine compares the host's reported materialized set against its own facts — matching fences are confirmed, stale fences (the machine respawned/retired the identity while the host was down) get `ReleaseMember`. A NEW spec reaches a member only as a machine-issued `MaterializeMember` at a higher `(generation, fence)`. Host-side, the revival realizes from ITS stores: `build_resumed_agent_config` runs on Host B (`build.rs:346-387`) — durable identity facts (`realm_id`, `auth_binding`, `comms_name`, `mob_member_binding`, provider/model) restore from session metadata (`factory.rs:5470-5475`; single-owner metadata rule), runtime mechanics (dispatchers, MCP, external tool composition) re-compose from the host role, the prompt restores via `SystemPromptOverride::Inherit` (`build.rs:380-381`), and credentials re-resolve on Host B exactly as at materialize (a host that lost its key since then fails typed at resume with the §14.4 taxonomy — surfaced through the revival path, never a half-alive member). Fences arrive re-issued from the machine (re-fencing is intentional, gotcha #9); a same-session Resume continues its durable seq domain but the persisted `generation_start_seq` fences prior rows from the new generation (§7).
### 14.7 Non-goals (v1) — each with typed degradation, never silent
* **No distributed realms / no store replication**: cross-host reads are bridge projections only; a remote store handle is unrepresentable (no wire verb exists).
* **No credential distribution**: no key, token, lease, or secret in any bridge payload (type-level: `PortableMemberSpec` has no credential-material field); an unprovisioned host fails typed `MemberBuildRejection::ProviderAuth { missing_secret }` at materialize preflight.
* **No per-host binding alias map** (mapper F3-C): one binding vocabulary; a name that doesn't resolve on the placed host is `BindingUnresolvable`, and the remedy is out-of-band provisioning of Host B's config + token store.
* **No cross-host inherited tool-visibility handoff**: `SpecNotPortable { inherited_tool_filter }` typed admission reject; `tool_access_policy` travels.
* **No per-spawn in-process tool overlays for remote members**: `SpecNotPortable { external_tools }` typed admission reject.
* **No config snapshot shipping** (mapper F1-B): host-local facts diverge by declaration (§14.3 table), pinned facts cannot diverge.
* **No per-mob realm directories on member hosts** (mapper F6-B): release = archive semantics in the daemon realm.
* **No remote `auth login` / credential writes over the bridge**: strict-owner writes stay host-local (`connection.rs:1213-1240`).
* **No byte-identical builtin skills across binary versions**: skew bounded by V4-at-bind; host binary version observable in `HostStatus`.
### 14.8 Implementer gotchas (verified, additive to §13)
11. `resume_override_mask.model` is resume semantics — do NOT reuse it to pin the mob model; that's what `ModelSelection` is for (R4; `factory.rs:4024-4033` gains the second consult, nothing else changes there).
12. The materialize ack's `resolved_auth_binding: None` ⇔ env\_default mirrors the metadata stamp discipline at `factory.rs:4089-4093` — do not invent a separate provenance enum.
13. The Tier-1 probe must never trigger OAuth refresh or provider network I/O — presence-level reads only, or bind latency and token churn become host-bind side effects.
14. `sanitize_realm_id` mangles dots (`realm.rs:267-278`) — never derive a path from `mob.{mob_id}`; it is not a location.
15. Dedup memory keys on `(identity, generation, fence, spec_digest)` — omitting the digest silently replays a stale spec as "already materialized".
## 15. Resource resolution: profile material, skills, MCP, hooks, memory, models
This section defines what `MaterializeMember.spec` (§7.3) actually carries, what the member host must already hold, and what remote placement typed-rejects. It extends §6.1/§6.3 (machine deltas), §7 (wire deltas), §9 (failure rows), and §11 (tests). All anchors re-verified at HEAD (0.7.22 / da467fca8).
### 15.1 Verified substrate (what a member build consumes today)
* The controlling host builds every member in-process: `MobActor` → `build_agent_config(BuildAgentConfigParams)` → `to_create_session_request` → local `SessionService` (`meerkat-mob/src/runtime/actor.rs:9204-9250`; `meerkat-mob/src/build.rs:143-344, 487-511`). Two in-process overrides apply after compilation: `default_llm_client` (actor.rs:9229-9231) and per-member `auth_binding` (actor.rs:9232-9235).
* Everything the compiler reads is serializable **except five in-process handle families**: `profile.tools.rust_bundles` (names of `Arc` registrations — `meerkat-mob/src/profile.rs:54-60`, resolved at `meerkat-mob/src/runtime/tools.rs:252-259`), `SpawnMemberSpec.external_tools` ("In-process only and not persisted", `meerkat-mob/src/runtime/handle.rs:1901-1902`), the mob-wide `default_external_tools_provider` (`ExternalToolsProvider` Arc alias, `meerkat-mob/src/lib.rs:186`; `meerkat-mob/src/runtime/builder.rs:54, 1984-1988`), the actor `default_llm_client` override, and the surface-injected `MobToolsFactory`/schedule/workgraph dispatchers (`meerkat/src/service_factory.rs:854-874`; Arc fields on `AgentBuildConfig`, `meerkat/src/factory.rs:346-424, 468-500`).
* The compiler consumes definition-level material beyond the Profile: `definition.skills` content for referenced names (`build.rs:517-550` — `SkillSource::Path` is read from the **building host's** filesystem, fail-closed at 530-538, wasm32-rejected at 540-543), `definition.models` (build.rs:298; `meerkat-mob/src/definition.rs:598-605`), `definition.image_generation_provider` (build.rs:303-305), and `definition.profiles.keys()` for default spawn-profile grants (build.rs:249-271).
* The existing machine digest **under-covers cross-host build identity**: `skills_digest` hashes skill NAMES only (`meerkat-mob/src/runtime/spawn_profile_authority.rs:49`) and nothing digests definition.skills content, definition.models, the spawn overlay, or the assembled prompt (`meerkat-machine-schema/src/catalog/dsl/mob_machine.rs:187-198`). "Same digest" does not imply "same agent" across hosts today.
* Prompt gap (historical implementation note): a skill-less profile with no spawn override left `SystemPromptOverride::Inherit`, which resolved through the **building host's** prompt sources. The session contract does not persist or reconcile a singleton current prompt: each `System` is an ordinary ordered transcript message. Rematerialization authors nothing. New System content uses the typed session or mob System-context admission API after resume.
* Profile MCP servers (`McpServerConfig`: stdio `{command,args,env}` / http `{url,headers}`, `meerkat-core/src/mcp_config.rs:41-64`) are materialized into a session-owned router at build (`factory.rs:4667-4701`; feature-absent builds fail closed at 4702-4710). A missing stdio binary does **not** fail the build: the failure arrives asynchronously as typed `PendingFailed` → `McpLifecyclePhase::Failed` (`meerkat-mcp/src/router.rs:1488-1508, 1668-1745`). `wait_for_mcp` defaults false and the mob compiler never sets it (zero `hook`/`wait_for_mcp` hits in build.rs — NOT FOUND).
* Hooks are host config, not profile vocabulary: `hook_engine_override` else layered `{active > context > user}` `.rkat/config.toml` (`factory.rs:5090-5114`; `meerkat/src/sdk.rs:80-129`). `Profile` has no hooks field (`profile.rs:162-251` — NOT FOUND).
* Memory is host-realm-local and session-scoped: `HnswMemoryStore::open(store_path/"memory")`, `MemorySearchScope::for_session(session_id)`, fail-closed `CapabilityUnavailable` when enabled-but-unavailable including feature-absent builds (`factory.rs:5636-5697`).
* Models: the catalog is compiled into the binary (`meerkat_models::canonical()`, `meerkat-models/src/catalog.rs:142-156`), merged per build with `custom_models` (`factory.rs:3870-3879`). Provider credentials resolve entirely host-locally (`ResolverEnvironment::with_process_env` + TokenStore + realm binding candidates + `ProviderRuntimeRegistry.resolve`, `factory.rs:3961-4011`); `AuthBindingRef` is a structural name-ref carrying zero credential material (`meerkat-core/src/connection.rs:216-235`). The `RKAT_TEST_CLIENT=1` env shim exists on whichever host runs the build (`factory.rs:3922-3927`).
* Comms envelopes are Ed25519-signed, **not encrypted**: no tls/noise/encrypt/cipher/aes/x25519 primitive exists anywhere in `meerkat-comms/src` (grep NOT FOUND). Anything placed in a materialize payload transits signed-plaintext TCP.
* Both agent-facing mob tool surfaces are in-process on the controlling host: `MobOperatorToolDispatcher` holds `MobHandle` (`tools.rs:341-346`; twelve operator tools at :358-519; per-call spawn-scope re-check at :865-872) and mob-mcp's `AgentMobToolSurface` holds `Arc` (`meerkat-mob-mcp/src/agent_tools.rs:103, 417`). **No member→controlling-host command path exists over comms at HEAD** — the prior draft's claim that delegation "already travels via comms" is false; this subsection adds that lane (R6).
* Per-turn `injected_context` already crosses hosts end-to-end: `BridgeDeliveryPayload.injected_context` (`meerkat-mob/src/runtime/provisioner.rs:3378`) lowers as InjectedContext-role appends before the peer append (`meerkat-runtime/src/comms_drain.rs:818-821`). No new work; MobKit's `mob/turn_start` / `mob/submit_work` lane is placement-agnostic as shipped.
### 15.2 Decisions
**R1 — The spec is fully-resolved, self-contained build material plus a declared member-host dependency set. No definition distribution.**
`MaterializeMember.spec` carries everything `build_agent_config` reads, resolved by the controlling host: the effective `Profile` (realm-refs, `override_profile`, and inherited-filter category opening already resolved), a definition extract (`models`, `image_generation_provider`, referenced `skills` with `Path` pre-resolved to `Inline`), and the spawn overlay. WHY: the traveling inputs are serde-clean once the two non-serde authority objects are excluded — `InheritedToolVisibilityAuthority` derives no serde and has a pub(crate) ctor (`meerkat-core/src/session.rs:1586-1598`), so a remote spawn carrying `inherited_tool_filter: Some` is a typed `SpecNotPortable` admission reject (§14.2), and `MobToolAuthorityContext` travels only as a sealed wire PROJECTION for member-side visibility composition (dispatch authority re-resolved controlling-side, R6) — `Path` skills are already read at build time on the building host (build.rs:530-538), and a digest is only meaningful cross-host if it covers the shipped resolved bytes. A reference-shaped spec would require a definition-distribution mechanism this plan does not have and would inherit the proven digest under-coverage. Host-local-by-nature resources (credentials, stdio binaries, realm backend) are NOT shipped — they are declared dependencies checked by R4 preflight. The member host runs the **same** `build_agent_config` compiler on the shipped inputs: one compiler, one owner, no serialized shadow of `AgentBuildConfig` (which cannot serialize — factory.rs:295-547).
**R2 — A new machine-owned resolved-spec digest pins build identity; the ack must echo it.**
SHA-256 over the RFC 8949 canonical CBOR of `PortableMemberSpec` (the envelope-signature canonicalization mechanics, `meerkat-comms/src/types.rs:157-200` — stable across struct evolution; serde\_json is not canonical and is not used; A12), computed after overlay resolution, authorized as an additional fact on the existing `AuthorizeSpawnProfile` ladder and echoed exactly through `SpawnProfileAuthorized` (the `require_authorized_effect` exact-match discipline, spawn\_profile\_authority.rs:86-121). `RequestMemberMaterialization.spec_digest` (§6.1) is pinned to this digest; `MemberMaterialized` must echo it or `CommitSpawnMembership` refuses. WHY: the shipped-material digest is what makes the plan's idempotency key `(identity, generation, fence)` sound — the existing `spawn_profile_authority_material_digests` treat materially different builds as identical (names-only skills digest, no definition/overlay coverage), so reusing it would make "replay returns the recorded result" unsafe.
**R3 — `SystemPromptOverride::Inherit` is unrepresentable in a remote spec.**
The spec's `system_prompt` is always `Set(...)` or `Disable`. When the local path would leave `Inherit` (no spawn override, empty profile skills — build.rs:216), the controlling host assembles the base prompt from **its own** config/AGENTS.md chain and ships `Set(base)`. When profile skills are non-empty, the member host recomputes `Set(joined skill sections)` byte-identically from the shipped Inline skills (same compiler, build.rs:209-215 + 517-550). For materialized members the member-host factory appends NO host-config-derived prompt sections: the skill-engine inventory section that enumerates the host's config-level skill repositories (`factory.rs:5116-5127`, appended via extra\_sections at :5258-5297) is disabled for mob-materialized sessions (A1) — without this, host skill-repo config silently diverges prompt bytes under an equal digest. What remains member-composed (builtin-skill sections, tool instructions) derives only from the traveling spec + binary, i.e. deterministic given `(spec_digest, engine_version)` (R8). WHY: without this, the same spec digest yields different prompts per host via the member host's config/AGENTS.md — an invisible divergence that defeats the digest's purpose. Member-host prompt influence becomes an explicit non-feature, not an accident of the Inherit fallback.
**R4 — Member-host dependencies are validated by a materialize-time typed preflight, layered over coarse bind-time capability flags.**
`HostCapabilityFlags` (§6.1) gains `memory_store`, `mcp`, and `engine_version` — feature-compiled facts checkable at placement admission (profile.tools.memory ⇒ `memory_store`; non-empty `mcp_servers` ⇒ `mcp`). Per-spec facts that cannot be known at bind are preflighted on the member host **before** any session is created, adjudicated by `MobHostBindingAuthority` from shell-supplied typed observations: model resolvable in the member binary's effective registry (canonical() + shipped custom\_models), `auth_binding` name-ref resolvable via the same `ProviderRuntimeRegistry` path the build uses, every stdio MCP `command` discoverable, realm `mob.{id}` attachable with a persistent backend when `durable_sessions` was declared. Preflight is TOCTOU-approximate by nature and stated as such; it bounds the failure window, it does not eliminate it. WHY: the async-MCP path is silent at spawn granularity (router.rs:1488-1508 acks nothing; failure lands minutes later at :1723-1743), and a provider-auth failure would otherwise first surface as a turn error on a host the console may not yet observe — both contradict §9's fail-quiet prohibition. Rejects feed `member_materialization_failures` and the revival ladder exactly like any materialize failure (§9 row 1).
> **Realm-scope erratum (§14 R1):** The “realm `mob.{id}` attachable” preflight assumption above is superseded. Preflight checks the daemon's already-open backend and its durability fact, not a newly attached per-mob realm. `mob_realm_id` supplies logical metadata, comms namespace, and preferred-credential scope; it creates neither a member-host directory nor a new session service.
**R5 — Secret-bearing fields are barred from remote specs; the payload is declared non-confidential.**
Remote placement admission typed-rejects a spec whose `shell_env`, any `McpStdioConfig.env`, or any `McpHttpConfig.headers` is non-empty (`SecretBearingField { field }`). Operators provision environment on the member host (the `rkat mob host` process env, which spawned stdio servers and shell subprocesses inherit) — mirroring how provider keys already never leave the host (D3; factory.rs:3961-4011). WHY: these three fields are exactly where operators put tokens, and the transport is signed plaintext (7.6.1). Silently stripping them would be a capability lie; shipping them would leak secrets to the wire and durably into two hosts' stores. A member-host env-indirection vocabulary and transport confidentiality are explicitly separate tracks (§12 seed), not smuggled into this payload.
**R6 — Process-local tool families get per-family dispositions; `tools.mob` gets a bridge operator-upcall lane because the settled B2→B21 goal requires it.**
| Resource | v1 disposition |
| - | - |
| `profile.tools.rust_bundles` non-empty | Typed placement-admission reject `NonPortableResource::RustBundles` (in-process Arc registrations, tools.rs:252-259 — code cannot ship) |
| `SpawnMemberSpec.external_tools` set | Typed reject `NonPortableResource::PerSpawnExternalTools` (handle.rs:1901-1902) |
| Mob has `default_external_tools_provider` installed | Typed reject `NonPortableResource::MobDefaultExternalTools` for any remote placement in that mob (builder.rs:54; silently omitting the embedder's tools is the exact silent-degradation §9 forbids). Remote members' external surface = shipped `profile.tools.mcp_servers` only |
| `profile.tools.mcp` allowlist non-empty | Typed reject `NonPortableResource::HostSurfaceMcpAllowlist` — it scopes the controlling-host default external-tools provider (tools.rs:292-302), an object that does not exist on member hosts in v1. `McpProvenanceFilter` stays controlling-host-only |
| Actor `default_llm_client` override installed | Typed reject `NonPortableResource::DefaultLlmClientOverride` (actor.rs:9229-9231). Remote members always resolve LLM clients member-host-side via the shipped `auth_binding` name-ref; deterministic test harnesses use `RKAT_TEST_CLIENT=1` on the member host (factory.rs:3922-3927) |
| `profile.tools.workgraph` EXPLICITLY asserted true | Typed reject `NonPortableResource::WorkgraphTools` (evaluated on the spawn-exec ladder choke-point, A4). A remote spawn carrying `inherited_tool_filter` is rejected earlier as `NonPortableInheritedToolFilter`: its authority-minted witnesses are deliberately non-serializable, so the runtime never silently drops or approximates them. Consequently `non_portable_disabled` is an explicitly empty record in v1. `profile.tools.schedule` is PERMITTED remotely (A3-final, §18.5): session-target schedules fire host-locally; member-side mob-target schedules fail typed at fire. These dispatchers bind to the building surface's realm-local services (service\_factory.rs:865-874); binding them to the member host's own `mob.{id}` realm store would fork the mob's graph/schedule state — the surface-owned-truth failure. Bridge-proxied variants are a §12 seed. |
| `profile.tools.mob` true | **Supported via a member-operator upcall lane.** The member host materializes a forwarding dispatcher exposing the same twelve operator tools (tools.rs:358-519); each call travels as a new supervisor-addressed `MemberOperatorRequest` over the existing `supervisor.bridge` comms intent to the controlling host, which executes it through the **same** local `MobOperatorToolDispatcher` admission (per-call authority re-check, tools.rs:865-872). The upcall carries **no authority claim**: the controlling host binds the sender's envelope signature to the roster peer key for that identity and looks up the `MobToolAuthorityContext` **it minted and durably recorded** at spec-compile time (mint stays controlling-host-only; the shipped context in the spec exists solely so the member-side build composes visibility — dispatch authority is re-resolved from the controlling host's own record, never from the wire). WHY not reject: §1's target topology and §7.3's walkthrough pin "B2 spawns B21/B22 onto Host B", and no comms path exists at HEAD (7.6.1) — this is the minimal lane that keeps the settled goal without mounting `MobToolsFactory` remotely or minting a second authority owner |
Composition with plane (b): the upcall lane is the **agent** authority lane (generated `MobToolAuthorityContext`), distinct from principal `ControlScope` grants (§8). One fact, one owner: agent mob-operator capability = machine-generated authority context; console principal capability = grants. Phase-5 grants do not gate member upcalls and member upcalls never satisfy principal scope checks.
**R7 — Hooks and host skill repositories are host policy, declared as such.**
A member materialized on Host B runs Host B's layered hook config (sdk.rs:80-129) and sees Host B's config-level skill repositories in its inventory; profiles carry no hook vocabulary (profile.rs:162-251 — NOT FOUND) and this plan does not add one. WHY: hooks and skill repos are operational host policy in the same class as the host's filesystem and process env — the digest deliberately does not cover them, and this is recorded here so the divergence is a documented contract, not a discovered surprise. Profile-carried hook material would be new vocabulary with new secret-bearing surface (command paths, env) and is explicitly out of scope (§15.8).
**R8 — Engine version is a recorded, surfaced fact; digest identity is scoped to it. No equality requirement.**
`BridgeCapabilities` gains `engine_version` (the workspace/`ContractVersion` string) at bind; `MemberMaterialized` echoes it; `HostStatus` carries the refreshed capabilities record and therefore does not add an engine-version sibling (one carrier per message; A-weak9 — this is also realms R5's binary-version observability). Build identity claims are always the pair `(spec_digest, engine_version)`: embedded builtin skills (`mob-communication`, `task-workflow`, `workgraph-workflow`, build.rs:224-230) and `canonical()` model data (catalog.rs:142-156) are compiled into the binary, so equal specs on unequal binaries are honestly different builds. An ack `engine_version` differing from the bound record is a typed `HostEngineVersionChanged` materialize failure that routes through host rebind (the host restarted upgraded mid-flight). WHY hard equality is rejected: it would make every rolling upgrade a placement outage; the one failure class that matters operationally — model unknown on the member binary — is already caught precisely by R4 preflight (`ModelUnresolvable`).
### 15.3 `PortableMemberSpec` (contracts; `deny_unknown_fields`; every field enumerated against `BuildAgentConfigParams` build.rs:92-126, `SpawnMemberSpec` handle.rs:1844-1907, and the actor post-mapping actor.rs:9227-9235)
```text theme={null}
PortableMemberSpec {
mob_id, profile_name, agent_identity, // comms_name/realm re-derived member-side via the
// single owners (MemberCommsName, meerkat_core::mob_realm_id
// — connection.rs:261; build.rs:24-34,172-184)
profile: Profile, // fully resolved: realm-ref/override_profile/inherited-filter
// category-opening applied; portability-validated (R5/R6:
// rust_bundles empty, mcp allowlist empty, mcp_servers env/headers empty)
definition_extract: {
models: BTreeMap, // definition.rs:598-605
image_generation_provider: Option, // definition.rs:606-611
skills: BTreeMap, // referenced names only; Path resolved to Inline
// controlling-host-side, read failure fails the
// spawn closed (build.rs:530-538 precedent)
profile_names: Vec, // for default spawn-profile grant parity checks only;
// authority itself ships minted (below)
},
overlay: {
context, labels, additional_instructions,
system_prompt: SystemPromptOverride, // never Inherit (R3)
tool_access_policy: Option, // never Inherit (build.rs:338-341 discipline)
mob_tool_authority_context: Option, // sealed-type wire PROJECTION; minted by the controlling host, shipped for
// member-side visibility composition (the documented
// rehydration lane, build.rs:103-110); dispatch
// authority re-resolved controlling-host-side (R6)
auth_binding: Option, // name-ref only; zero credential material (D3)
budget_limits: Option, // split computed controlling-host (§7.3); seed rides
// the MaterializeMember payload as already specified
runtime_mode: MobRuntimeMode, continuity_intent, // launch mode is NOT here: owned solely by
// MaterializeMember.launch {Fresh|Resume} (A6)
resume_override_mask semantics: profile.resume_overrides apply against the DURABLE SPEC as profile truth
// (7.6.7)
}
}
```
Not in the spec, deliberately: the kickoff/initial message (delivered on the first real turn via `DeliverMemberInput`, matching `InitialTurnPolicy::Defer` — build.rs:500-507); `shell_env` and MCP env/headers (R5); anything Arc-shaped (R6); hooks (R7); credentials (D3).
### 15.4 Machine deltas (catalog DSL; extends §6.1/§6.3)
MobMachine:
* New field `spawn_profile_authority_resolved_spec_digests: Map>`; `AuthorizeSpawnProfile` input and `SpawnProfileAuthorized` effect gain `resolved_spec_digest: Option` (exact-match echo extends `require_authorized_effect`, spawn\_profile\_authority.rs:86-121). Invariant: remote placement ⇒ `Some` before `Spawn` admits.
* `ResolveSpawnMemberAdmission` (§6.1) gains the portability validation — and placement/portability admission is enforced INSIDE the machine spawn-exec ladder at `enqueue_spawn`'s `BeginSpawnExec` opener, the one choke-point every spawn path crosses (agent tools, RPC `mob/spawn*`, embedder `MobHandle::spawn_spec` — the RPC/embedder paths bypass the tool-seam admission today, meerkat-mob-mcp/src/lib.rs:978-984; A4): typed reject causes `NonPortableResource { kind: RustBundles | PerSpawnExternalTools | MobDefaultExternalTools | DefaultLlmClientOverride | HostSurfaceMcpAllowlist | WorkgraphTools }` and `SecretBearingField { field: ShellEnv | McpStdioEnv | McpHttpHeaders }`, alongside §6.1's `MissingHostCapability`. Schedule tools are portable under A3-final/§18.5: session-target schedules remain host-local and member-side mob-target schedules fail typed at fire.
* `HostCapabilityFlags` (§6.1) gains `memory_store`, `mcp`, `engine_version`.
* Invariants: `RequestMemberMaterialization.spec_digest` = the authorized resolved digest for that identity; `CommitSpawnMembership` (remote arm) requires the ack-echoed `spec_digest` to equal it and records the echoed `engine_version`; ack `engine_version` ≠ bound `host_capabilities.engine_version` ⇒ typed `HostEngineVersionChanged` materialize failure feeding rebind.
MobHostBindingAuthority (§6.3):
* Materialized-members value extends to `{ generation, fence_token, session_id, spec_digest }`.
* New input `ResolveMaterializePreflight { agent_identity, generation, fence_token, spec_digest, observations: MaterializePreflightObservations }` → effects `MaterializeAdmitted` / `MaterializeRejected { cause }` (dispositions declared for `seam-inventory`). Observations are shell-read facts (binary lookup, registry resolve outcome, realm backend probe) fed typed into the authority — the authority decides, the shell observes (RMAT read-seam discipline).
* Dedup extension: same `(identity, generation, fence)` + same `spec_digest` ⇒ replay returns the recorded result; same tuple + different digest ⇒ typed `SpecDigestMismatch` (one idempotency key can never name two builds).
* Persistence: the host-side SQLite row gains the spec bytes + digest + engine-version-at-build (7.6.7).
Controlling-host durable records (store, not DSL — the `SupervisorPendingRotationRecord` precedent, `meerkat-mob/src/store/mod.rs`): the minted mob-operator authority **facts** per remote identity+generation, re-minted into a sealed context after each machine admission (R6); and the member-operator request ledger keyed `(mob_id, agent_identity, requester_generation, requester_fence_token, request_id)`. Authority facts are covered by the resolved spec/machine transition witness. Request rows carry the typed-operation digest and move only `Pending → Terminal`; they are never capacity-evicted and are scrubbed only with the mob.
### 15.5 Wire deltas (meerkat-contracts; V4; regen-schemas/SDK/parity gates apply)
* `PortableMemberSpec` as §15.3 (`deny_unknown_fields`); `MaterializeMember.spec` becomes this type.
* `MemberMaterialized` gains `spec_digest: String`, `engine_version: String`.
* `BridgeCapabilities` gains `memory_store: bool`, `mcp: bool`, `engine_version: String`.
* `BridgeRejectionCause` gains `ModelUnresolvable { model }`, `AuthBindingUnresolvable { realm, binding }`, `McpCommandMissing { server }`, `RealmBackendUnavailable`, `SpecDigestMismatch`, `HostEngineVersionChanged` (typed only; `bridge-classifier` gate applies, and every new `BridgeReply` consumer joins `BRIDGE_CLASSIFIER_FILES` per Gotcha #1).
* New supervisor-addressed payload family over the `supervisor.bridge` intent: `MemberOperatorRequest { agent_identity, requester_generation, requester_fence_token, request_id, op: MemberOperatorOp }` / `MemberOperatorReply` — `MemberOperatorOp` is a closed enum mirroring the twelve operator tools (tools.rs:358-519). Direction is member-host → controlling host. The member runtime samples its current host binding stamp once per logical request; every transient resend is byte-identical, including generation, fence, request id, and operation. Admission is MACHINE-owned (A8): `ResolveMemberOperatorAdmission { agent_identity, requester_generation, requester_fence_token, sender_peer_id, request_id }` requires the signed generation+fence to equal the current identity binding, then checks roster peer-key binding, placement presence, and non-revoked host. A delayed envelope from a prior incarnation therefore rejects `StaleGeneration`/`StaleFence` even when `Resume` reused the same session and peer key. Only after admission does the shell read the generation-pinned authority-facts record and re-mint the sealed agent context; no authority claim rides the wire.
Idempotency is durable and fail-closed. The responder atomically inserts `Pending { op_digest }` under `(mob_id, agent_identity, requester_generation, requester_fence_token, request_id)` **before** any effect. Same key + different digest is a stable request conflict and never overwrites the original. A matching immutable `Terminal { reply }` replays verbatim across reply loss and controlling-host restart. A recovered or concurrently observed `Pending` is never execution authority: it CAS-terminalizes to explicit `upcall_indeterminate` and is not re-executed (including `SpawnMember`, `SpawnManyMembers`, and `MobRunFlow`). The actual completed reply is sent only after its terminal CAS succeeds or a durable terminal winner is reloaded. If terminal persistence fails after the effect, the caller receives non-success and the row remains `Pending`; the next delivery converges to the stable indeterminate terminal. If the initial Pending insert fails, no effect ran and the rejection is explicitly retryable. There is no capacity eviction; mob destroy is the sole scrub boundary. Reply-budget exhaustion on the member side remains typed `upcall_timeout`.
### 15.6 Preflight protocol and failure semantics (extends §9)
| Failure | Behavior |
| - | - |
| Portability violation (R5/R6) at spawn admission | Typed `ResolveSpawnMemberAdmission` reject before the ladder opens; nothing materializes; no roster entry |
| Model unknown on member binary | Preflight `MaterializeRejected { ModelUnresolvable }` → `member_materialization_failures` → revival classification (§9 row 1) |
| Auth binding unresolvable on member host | Preflight `AuthBindingUnresolvable`; if it resolves at preflight but fails at build (TOCTOU), `BuildAgentError::LlmClient` maps to the same typed materialize failure — never a first-turn surprise on an unobserved host |
| MCP stdio command missing | Preflight `McpCommandMissing { server }`; **connect** failures after a passing preflight remain the shipped async typed path — `McpLifecyclePhase::Failed` on the member host (router.rs:1668-1745), visible in the merged event stream (§7.4 pump) and in `HostStatus` member health; the materialize ack does NOT wait for connect (`wait_for_mcp` stays false for mob builds) and never lies about it |
| Realm backend cannot satisfy declared `durable_sessions` | Preflight `RealmBackendUnavailable`; D5's typed capability truth is enforced at materialize, not discovered at first cursor read |
| Memory enabled, member build lacks capability | Caught coarse at placement admission (`MissingHostCapability { memory_store }`); the member-side factory backstop stays fail-closed (`CapabilityUnavailable`, factory.rs:5676-5697) and maps to a typed materialize failure |
| Declared `required_env_keys` name absent from host env/config | Preflight `EnvKeyMissing { key }` (names only ever travel; values never — A2) |
| Spec replay, digest mismatch | `SpecDigestMismatch` typed reject; retry must re-authorize a new spec at a new fence |
| Host upgraded between bind and materialize | `HostEngineVersionChanged` → rebind path (`HostRebound`), then retry |
| Upcall from unregistered/mismatched sender | Typed reject at controlling-host admission (roster key binding); no authority lookup is attempted |
### 15.7 Durability and revival on the member host
The materialization spec is durable **on the member host**: the `MobHostBindingAuthority` SQLite row stores the spec bytes keyed by digest next to `(generation, fence_token, session_id)`. Member-host restart revival (§9 row "Member host restart") rebuilds the agent by re-running `build_agent_config` on the stored spec — `profile.tools.mcp_servers` recompose exactly as designed (their durability rationale, profile.rs:44-53), while the persisted transcript, including every ordered System message, is restored byte-for-byte and materialization injects nothing. **No controlling-host contact is required.** `profile.resume_overrides` (profile.rs:193-200) apply against the stored spec as profile truth; "updated profile" reaches a remote member only as a new `MaterializeMember` at a higher `(generation, fence)`, which replaces the stored spec atomically with its dedup row. Realm provisioning: the member host creates/attaches realm `mob.{id}` locally through the single owner `meerkat_core::mob_realm_id` (connection.rs:261; build.rs:24-34); memory lives under that realm's store path with unchanged session-scoped `MemorySearchScope::for_session` semantics (factory.rs:5644-5656) — session scoping means remote members' memory behavior is byte-identical to local, just physically on the owning host.
> **Realm-scope erratum (§14 R1):** The “creates/attaches realm `mob.{id}`” and “that realm's store path” wording above is superseded. Revival uses the daemon's existing session service and stores; memory lives at that service's `/memory`, with unchanged session-scoped search semantics. The logical `mob_realm_id` stamp is not a physical storage location.
### 15.8 v1 non-goals (typed degradation, never silent)
Listed in §12 spirit: no secret indirection vocabulary and no transport confidentiality (attempting to ship `shell_env`/MCP `env`/`headers` remotely ⇒ typed `SecretBearingField` admission reject — never silent stripping); no cross-host workgraph tools (⇒ typed `NonPortableResource` reject; a bridge-proxied dispatcher is a v2 seed), while schedule tools follow A3-final/§18.5 (session-target schedules are host-local and member-side mob-target schedules fail typed at fire); no remote rust bundles / per-spawn external tools / mob-default external tools / injected LLM clients (⇒ typed `NonPortableResource` rejects); no profile hook vocabulary (member hosts run their own hooks by declared policy R7 — the divergence is contractual, not accidental); no engine-version equality requirement (mismatch is recorded and surfaced; only `ModelUnresolvable` hard-fails); no cross-host memory or shared semantic index (memory is member-host-realm-local; session scoping preserves observable parity); no mobpack-based definition distribution (the spec is self-contained by R1; mobpack synergy is a v2 seed).
### 15.9 Phase assignments (§10)
* **Phase 1** (catalog + contracts): §15.4 DSL deltas (resolved-spec digest field/input/effect echo, admission portability causes, `HostCapabilityFlags` extension, `MobHostBindingAuthority` preflight input/effects with dispositions), §15.5 wire types (`PortableMemberSpec`, ack echo fields, rejection causes, `MemberOperatorRequest`/`Reply` + `MemberOperatorOp`). TLC bounds unchanged (2 hosts × 3 members).
* **Phase 2** (host bind): capability advertising (`memory_store`, `mcp`, `engine_version`) in the bind ceremony reply; recorded via `CommitHostBind`.
* **Phase 3** (placement + materialization): the spec compiler on the controlling host (Path→Inline resolution, R3 prompt base resolution, authority mint + durable record), member-side preflight ladder + dedup, durable spec row + revival recompose, portability/secret admission rejects, and the member-operator upcall lane — it gates this phase's own exit criterion (B2→B21/B22 on Host B).
* **Phase 5** (scopes): pin the lane separation — member upcalls stay generated-authority-adjudicated; principal grants neither gate nor are satisfied by them (one owner per fact).
* **Phase 6** (events): MCP `Failed` lifecycle visibility for remote members falls out of the pump; no additional work beyond §7.4 itself.
* **Phase 7** (surfaces): `mob/member_status` MCP/tool-surface parity fields for remote members ride the existing projection work; `rkat mob host` surfaces preflight results in host status output.
### 15.10 Tests (extends §11; deterministic lanes are the ratchet)
**unit**: spec digest determinism + canonicalization; Path→Inline resolution fail-closed (unreadable path fails the spawn, build.rs:530-538 parity); R3 — compiler output for remote placement never contains `Inherit` (prompt and tool policy); portability matrix — each `NonPortableResource` kind and `SecretBearingField` field produces exactly its typed cause; materialize dedup — same tuple+digest replays recorded result, differing digest ⇒ `SpecDigestMismatch`; upcall admission — unknown identity / sender-key mismatch / stale generation / stale fence rejected before authority lookup; request ledger — Pending-before-effect, conflicting digest preservation, same-key concurrency, post-effect store failure, immutable terminal replay, full generation+fence key, and retention beyond 1,024 rows; `MemberOperatorOp` covers exactly the operator tool surface (closed-enum pin).
**int / e2e-fast** (two-hosts-in-one-process harness): same-spec-two-hosts build equality — digest match ⇒ identical tool-catalog names and system-prompt bytes on both MeerkatMachines (same binary); skill-less remote member's prompt contains the controlling host's base and NOT the member host's AGENTS.md; materialize preflight rejects each of `ModelUnresolvable` / `AuthBindingUnresolvable` / `McpCommandMissing` / `RealmBackendUnavailable` and each feeds `member_materialization_failures` + revival classification; MCP connect failure after passing preflight ⇒ member runs, `Failed` lifecycle event appears in the merged mob stream, materialize ack unaffected; member-host revival recomposes MCP router + prompt from the durable spec row with zero bridge traffic asserted; B2→B21 upcall spawn end-to-end (spawn, list, retire via upcalls); reply-loss + controlling restart replays the persisted terminal with one effect; an effect-before-terminal crash window recovers indeterminate without re-execution; a held old envelope rejects after a same-session generation/fence bump even though the member key is unchanged; real SQLite reopen preserves terminals and mob destroy scrubs the ledger; remote member with `tools.mob=false` exposes no upcall surface; `resume_overrides` re-apply only from a new spec at a higher generation; TLC green at 2×3 bounds for the new digest/preflight state.
**e2e-system** (real multi-process TCP): materialize against a real member host with the stdio MCP binary present vs absent; host restart revival from the SQLite spec row; engine version captured at bind and echoed at materialize; mid-flight host upgrade ⇒ `HostEngineVersionChanged` ⇒ rebind ⇒ retry succeeds.
**e2e-smoke** (live): remote member resolves a real provider via name-ref `auth_binding` on the member host; no credential material observed on the wire (payload capture assertion).
## 16. Live channels (realtime) for remote members
> Scope: the `live/*` realtime plane (audio/text live channels) for mob members whose sessions live on a member host. The v4 plan text was previously silent on this plane — `live/*` appears nowhere in §§1-13. This section closes that gap without re-litigating D1-D6.
### 16.0 Verified substrate (what exists, what fails today)
* Live is an **RPC-process-only surface**: `rkat-rpc` flags `--live-ws` / `--live-ws-scheme` / `--live-tool-timeout-ms` (`meerkat-rpc/src/main.rs:53-81`); every `live/*` dispatch arm is registered only when a transport is attached (`live_enabled()`, `meerkat-rpc/src/router.rs:1064-1079`; arms :1958-2077; pinned by `assert_no_live_methods`, router.rs:10232-10240). REST mounts no live endpoints; the CLI has no live wiring (NOT FOUND outside meerkat-live + rkat-rpc main).
* `live/open` resolves the target session **strictly process-locally**: the B17 gate `runtime.session_state()` consults staged sessions + the local `SessionService` list and returns `INVALID_PARAMS "session {id} not found"` otherwise (`meerkat-rpc/src/handlers/live.rs:871-886` over `meerkat-rpc/src/session_runtime.rs:6902-6948`). A console `live/open` against a remote member's session **fails today at this gate** — and that behavior is *correct* under §3 (realms are not distributed; the session genuinely does not exist in the controlling host's realm). We do not "fix" `live/open`; we add an identity-addressed mob surface (DL3).
* The bootstrap indirection is already designed: `LiveOpenResult.transport` carries `WireLiveTransportBootstrap::Websocket { url, token }` with an **absolute URL** (`meerkat-contracts/src/wire/live.rs:155-206`), built as `"{base_url}{LIVE_WS_PATH}?token=..&channel=..&format=.."` (`handlers/live.rs:1196-1202`) where `base_url = "{scheme}://{listener.local_addr()}"` (`main.rs:511-519`). SDK clients connect that URL directly — the SDK deliberately does not own the WS transport (`sdks/typescript/src/client.ts:3073-3080`).
* Channel binding + one-active-channel-per-session are **owning-session MeerkatMachine facts** (`live_active_channel_by_session` / `live_channel_session_by_channel`, `meerkat-machine-schema/src/catalog/dsl/meerkat_machine.rs:2726-2760`; `ResolveLiveOpenAdmissionAccepted` guards `session_not_active`/`channel_not_bound`, dsl:17402-17421). WS bearer tokens are machine-adjudicated in the same session's machine: mint via `RecordLiveWebsocketTokenIssued`, single-use/TTL/channel-pinned admission via `ResolveLiveWebsocketTokenAdmission` (dsl:17981-18069; `TOKEN_TTL` = 60s, `meerkat-live/src/transport.rs:57`).
* The WS wire protocol carries only typed input inbound (Text = `LiveInputChunkWire`, Binary = negotiated `pcm_24k_mono`) and observations outbound (`transport.rs:5-33`). The control verbs (`commit_input`, `interrupt`, `truncate`, `refresh`, `close`, `status`) exist **only** as RPC methods (router.rs:1987-2077).
* Realtime gates evaluate on the process that owns the session: B19 `ModelNotRealtime` from the catalog `realtime` flag (`meerkat/src/session_runtime/live_orchestration.rs:46-70`; exactly one catalog row is `realtime: true` — `gpt-realtime-2`, `meerkat-models/src/capabilities/openai.rs:312`); B18 provider-adapter support is factory-owned (`OpenAiRealtimeSessionFactory::supports_provider == Provider::OpenAI`, `meerkat-openai/src/live.rs:3043-3050`; #302 no-factory fail-closed check, `handlers/live.rs:888-901`). Realtime credentials resolve **per open** from the owning process's realm config chain (`meerkat-rpc/src/live_wiring.rs:25-38`, wired at `main.rs:376-390`); the factory holds no credential material.
* Live observations project into the owning session's canonical stores via the **mandatory** projection sink (`main.rs:401-436`; `LiveAdapterHost` requires the sink at construction, `meerkat-live/src/host.rs:1535-1546`), so live-derived transcripts/status land in the owning host's session/EventStore.
* The supervisor bridge has **no live/audio/realtime commands** (`BridgeCommand`, `meerkat-contracts/src/wire/supervisor_bridge.rs:238-251` — NOT FOUND for any streaming/media variant) and the bridge client is single-flight (`request_lock`, `meerkat-mob/src/runtime/supervisor_bridge.rs:35-49`). `meerkat-live` has no production WS client (the only `tokio_tungstenite::connect_async` hits are inside `mod tests`, `transport.rs:928+`; the sole outbound WS client in the workspace is the OpenAI adapter's provider connection).
* `live/open` already anticipates mob-owned sessions locally: `ensure_live_peer_ingress` skips session-owned drain reconfiguration when `peer_ingress_owner.is_mob_owned()` (`meerkat-rpc/src/session_runtime.rs:4344-4365`, skip at :4357).
* The `--live-ws` bind bypasses `TcpBindPolicy`: `validate_tcp_bind_policy` is applied only to `--tcp` (`main.rs:184-194`); the live-ws listener binds any address unconditionally (`main.rs:462-469`) and is plaintext (`wss` is advertisement-only for a TLS-terminating proxy, `main.rs:75-81`).
### 16.1 Decisions
**DL1 — Media plane: direct-to-owning-host bootstrap. The bridge carries the open and the control verbs; it never carries frames.**
The controlling host proxies `open` over the bridge; the **owning host runs its local open pipeline unchanged** (machine admission → host channel → B19/B18 prechecks → per-open credential resolution → token mint → bootstrap build) and the returned `LiveOpenResult` — owning host's absolute WS URL + token — is passed back **verbatim**. The console's client then connects the WS directly to the member host.
WHY: (a) the indirection is already designed in — absolute URL + direct-connect SDK contract (wire/live.rs:176-206; client.ts:3073-3080); pointing it at another host requires **zero wire-shape change**. (b) Token authority is owning-session machine state (dsl:17981-18069); direct bootstrap keeps mint, single-use admission, and listener co-located. A controlling-host-minted token for a member-host listener is unrepresentable without new shared-state machinery — we do not invent it. (c) The relay alternative has no substrate and is costed honestly: the bridge is single-flight request/response over signed envelopes with a 30s verified-ack budget (supervisor\_bridge.rs:35-49), there are no streaming bridge channels, `meerkat-live` has no production WS client, and D5's long-poll page primitive is latency-unfit for realtime audio. A relay would be a new transport plus a shadow channel registry beside `LiveAdapterHost` — parallel authority, doubled latency/jitter on the one plane where that is fatal. (d) This is a **scoped, declared exception to D2's single-endpoint property, for the media plane only**. Control (open/close/verbs) and observation (transcripts/events/history) stay proxied through the controlling host exactly per D2/§7.4. §12 already treats direct observer→member-host streams as the v2 optimization because long-poll suffices there; for realtime audio the ordering inverts — the deferred thing becomes the only thing that works.
**DL2 — Carrier: member-addressed V4 bridge commands, admitted by MeerkatMachine; MobMachine owns zero live-channel facts.**
`OpenMemberLiveChannel` / `CloseMemberLiveChannel` / `MemberLiveChannelStatus` / `ControlMemberLiveChannel` extend the §6.4 member-addressed family (`ResolveSupervisorBridgeCommandAdmission` classification; `runtime_alphabet_parity` forces explicit coverage).
WHY: the channel binding and the one-active-channel invariant are session-machine facts (dsl:2726-2760, 17402-17421) — admission must land where the binding lives. A host-addressed adjudicator (`MobHostBindingAuthority`) would be a second owner for the same fact, violating §3's no-parallel-authority constraint. The controlling host is a **scope gate + conduit**: it holds no live channel state, not even a projection map (a remote channel's status is a proxied point read, DL3). Host-scoped live facts (the advertised WS endpoint) are bind-ceremony data (DL5), not command admission.
**DL3 — Console surface: identity-addressed `mob/member_live_open|close|status|control`, placement-blind; `live/*` is untouched.**
WHY: `live/*` is session-scoped and process-local by design; a remote member's `session_id` names a session in another host's realm, so the B17 not-found (handlers/live.rs:871-886) is honest, not a bug. Identity → `(placement, session)` resolution is MobMachine truth (`member_placement` §6.1 + materialization-ack session binding §7.3; `MobMemberStatusResult.current_session_id` already projects the binding, `meerkat-contracts/src/wire/mob.rs:2024-2043`). The mob handlers own MobMachine access and are the plane-(b) scope chokepoint (§8) — so the placement branch lives there: **local** placement resolves the member's session and invokes the same internal pipeline the `live/*` handler uses; **remote** placement dispatches the bridge command. One verb family, same `LiveOpenResult` shape either way; the console never branches on placement.
**DL4 — One canonical pipeline: complete the `LiveOrchestrator` extraction so RPC handler and member-host bridge responder share one open/close/control path.**
WHY: the open sequencing today is welded into the RPC handler (`handle_live_open`, handlers/live.rs:842-1300) against `meerkat_rpc::SessionRuntime`, while its inner seams already live in the facade (`LiveOrchestrator`, `meerkat/src/session_runtime/live_orchestration.rs:498`; the RPC runtime delegates `live_open_config_for_session`/`precheck_live_open` to it, `meerkat-rpc/src/session_runtime.rs:3058-3096`; the facade already has the optional `live` feature pulling `meerkat-live`, `meerkat/Cargo.toml:34,126`). The member host is `rkat mob host` (D6), not `rkat-rpc` — it must execute the **identical** pipeline including the fail-closed open-failure cleanup (`close_live_channel_after_open_failure`, handlers/live.rs:725-830) and `ensure_live_peer_ingress`'s mob-owned skip (session\_runtime.rs:4357). Extraction is the concrete seam that makes "one path" true rather than aspirational; without it the bridge responder would be a shadow copy of a 500-line handler. The extraction scope explicitly includes the projection-sink roles: `SessionServiceProjectionSink` is today the ONLY production implementation of `LiveProjectionSink` + `LiveChannelCloseFeedback` + `LiveChannelStatusFeedback` + `LiveWsTokenAuthority` and its constructor takes `Arc` (`meerkat-rpc/src/live_projection_sink.rs:104-113`, wired into all four roles at main.rs:406-418) — the member host cannot run token mint or projection without a facade-owned implementation over its own session service. Decoupling those four roles from the RPC runtime is 6b pre-work, scheduled in §16.7.
**DL5 — Capability declaration: bind-time fact = the advertised live WS endpoint; model/provider realtime gates stay owning-host open-time typed rejects, round-tripped as typed bridge causes.**
`CommitHostBind` (and `HostRebound`) carry an optional live endpoint; MobMachine records `host_live_endpoints: Map`. **Absence = live-incapable — no boolean shadow flag.** Before dispatching `OpenMemberLiveChannel`, the controlling host requires `host_live_endpoints[placement]` to be present; typed `LiveTransportUnavailable` reject otherwise, with **no bridge dispatch**.
WHY: the endpoint is the one fact the controlling host must know *before* proxying, and its presence is the capability (single fact, single owner — a separate `live_websocket` boolean would be dual truth). Provider-adapter support is deliberately factory-owned ("B18 is owned by the concrete realtime factory... because the factory mints the adapter", live\_orchestration.rs:44-49) and model realtime capability is catalog-owned per open (live\_orchestration.rs:46-58) — the session's model can be reconfigured after bind, so declaring provider/model sets at bind would duplicate those owners and drift. The listener→factory coupling is enforced at host boot exactly as `rkat-rpc` does today: a configured live transport without the `openai-realtime` feature is a typed startup refusal (main.rs:313-324, mirrored in the host role). D5's declared-degradation discipline is satisfied: the console can label a live-incapable host honestly from bind-time truth, and the owning host's typed open-time errors are the fail-closed backstop for drift.
**DL6 — Advertised-URL correctness: the host role requires an operator-declared advertised base URL; `local_addr` derivation stays the single-host default.**
`rkat mob host --live-ws ` **requires** `--live-ws-advertise ` (typed startup error when missing); that URL (with its scheme) is what lands in the bind descriptor, in `host_live_endpoints`, and in the owning host's `LiveWsConfig.base_url` — so minted bootstrap URLs are correct by construction. Standalone `rkat-rpc` keeps today's `scheme://local_addr` derivation (main.rs:511-519).
WHY: `local_addr`-derived URLs break cross-host (0.0.0.0 binds, NAT, hostnames, TLS proxies). This mirrors `host_endpoints` (§6.1): advertised, never derived. The URL is a MobMachine bind fact — it must NOT be added to `RuntimeHostInfo` (anti-authority pin, `meerkat-contracts/src/wire/host.rs:181-210`, gotcha #2).
**DL7 — Exposure posture: bind-policy parity now; wss-behind-proxy is the production posture, not a hard floor.**
Close the pre-existing gap: apply `validate_tcp_bind_policy("live-ws", ...)` / `--allow-remote` to the live-ws bind in **both** `rkat-rpc` and the host role (today only `--tcp` is validated, main.rs:184-194 vs :462-469). Keep token-only auth on the WS upgrade; record the advertised scheme in the bind fact; document TLS-terminating-proxy fronting (`wss`) as the production posture.
WHY: this matches the shipped `secure_rpc` doctrine — transport exposure is an explicit opt-in, not an auth mechanism (main.rs:48-52). Hard-requiring `wss` would make every member host depend on external TLS termination (Meerkat ships none; the scheme is advertisement-only, main.rs:75-79) — an operational floor that defeats the LAN/dev topologies mobs target. The replay window is bounded by machine-adjudicated single-use, 60s-TTL, channel-pinned tokens (transport.rs:57; dsl:18024-18069). **Behavioral change, named:** standalone `rkat-rpc --live-ws 0.0.0.0:x` without `--allow-remote`, silently accepted today, becomes a typed startup error.
**Wire exposure, named (A5):** `MemberLiveChannelOpened` round-trips the WS bearer token over the controlling↔member comms link, which is Ed25519-signed but NOT encrypted (no cipher exists in meerkat-comms — verified NOT FOUND). An on-path reader of that link can read the token during its single-use / 60s-TTL / channel-pinned window. This is the ONE sanctioned exception to the secrets-never-on-bridge invariant — a short-lived capability token, never a durable credential — recorded in the threat model (§20) with the mitigations (token bounds; tunnel the inter-host link on hostile networks) and the v2 seed (encrypted comms transport) that closes it.
**DL8 — Scope (plane b): new `ControlScope::Live` gates the entire live family; bootstrap issuance is itself the scope-gated act.**
`mob/member_live_open|close|status|control` all require `Live` at the controlling host's chokepoints (sealed `ResolvedControlPolicy`, §8); deny = `ScopeDenied { required, presented }`. Owner-implicit-full default unchanged.
WHY: a WS bootstrap is a **duplex bearer capability** — typed input inbound plus media/transcript outbound on one socket (transport.rs:5-33). It does not decompose into `SendCommand`/`SubscribeEvents` halves, and a composite check would misrepresent the grant: handing out the token *is* the authorization event, after which the controlling host is out of the loop by design (DL1). Barge-in `interrupt` on a live channel is media-plane conversation control and sits inside `Live`; lifecycle `HardCancelMember` stays under `Cancel`. The §8 ordering rule applies verbatim: **no remote live surface ships before phase 5 lands.**
**DL9 — v1 transport scope: WebSocket only; `transport=webrtc` for a remote member is a typed reject.**
WHY: WebRTC signaling is bound to the local RPC connection and local `LiveWebrtcState` with its own machine-adjudicated token family (handlers/live.rs:1204-1287; dsl:2748-2755, 17700+); proxying `live/webrtc/answer` doubles the proxied command family for a feature-gated transport and round-trips SDP through the single-flight bridge. WS covers the full capability matrix. Reject = `LiveTransportUnsupported { requested: webrtc }`; the signaling proxy is a named v2 seed (§12), mirroring how direct observer streams were scoped.
**DL10 — Input plane: no bridge-carried input or media frames; turn-level control verbs only.**
`ControlMemberLiveChannel.verb ∈ { CommitInput, Interrupt, Truncate, Refresh }` — the RPC-only turn-level verbs. Frame-level input (text chunks and audio) rides the direct WS the client already holds (Text = `LiveInputChunkWire`, transport.rs:22-28). There is no bridge `SendInput`.
WHY: the bridge is single-flight (supervisor\_bridge.rs:35-49); per-chunk input would serialize against lifecycle commands — the exact substrate-unfitness that killed the relay option. This is not a surface rejection: the WS *is* the input plane for live channels on every placement, and `live/send_input` remains available locally by session id exactly as today.
### 16.2 Observation parity (nothing new to build)
Live transcripts, close feedback, and status project into the **owning** session's canonical stores through the mandatory `SessionServiceProjectionSink` (main.rs:401-436; host.rs:1535-1546). On a member host running persistent sessions (D5), those facts land in its EventStore and flow to the console via the already-planned §7.4 `PollMemberEvents` pump and `ReadMemberHistory` pages. **There is no separate live event proxy.** Live tool calls raised mid-turn dispatch into the owning session's dispatcher on the owning host (`RuntimeLiveToolDispatcher`, session-scoped, process-local) with the host-owned per-call timeout — unchanged.
### 16.3 Machine deltas (catalog DSL; amends §6)
* **§6.1 MobMachine** — one new field: `host_live_endpoints: Map` (sealed newtype over a scheme-qualified `ws|wss` absolute base URL; ctor validates scheme+shape). Populated/refreshed only by the existing `CommitHostBind` / `HostRebound` inputs (payloads gain the optional endpoint); cleared by `RevokeHost`. No new inputs, no new effects. Invariants: `keys(host_live_endpoints) ⊆ { h ∈ mob_hosts : host_bind_phase[h] = Bound }`; rebind without the endpoint clears the entry (capability is re-declared each bind, per D5's restart-truthfulness rule).
* **§6.4 MeerkatMachine** — member-addressed admission classification coverage for the four new commands (`OpenMemberLiveChannel`, `CloseMemberLiveChannel`, `MemberLiveChannelStatus`, `ControlMemberLiveChannel`) in `ResolveSupervisorBridgeCommandAdmission`; the typed command manifests (`runtime_alphabet_parity`) force this. Fence posture matches the rest of the member-addressed family in v1: supervisor identity + epoch on delivery, supervisor-side `StaleFenceToken` on submit (§6.4's recorded status quo — no new member-side fence check is smuggled in here).
* **§6.5 / §8 MobMachine grants** — `ControlScope` vocabulary gains `Live`; flows through the existing `GrantOperatorScopes` / `RevokeOperatorScopes` inputs and the sealed policy unchanged (data extension, no machine-shape change).
* **Owning-side live authorities: explicitly unchanged.** `ResolveLiveOpenAdmission*`, `RecordLiveWebsocketTokenIssued`, `ResolveLiveWebsocketTokenAdmission*`, refresh/close/command authorities (dsl:17402-18069) run on the owning host exactly as shipped. The controlling host **never** mints or validates live tokens and holds **no** channel map — remote channel status is a proxied point read, not a projection cache. `MobHostBindingAuthority` (§6.3) gains nothing: live commands are member-addressed (DL2).
Gates: `make machine-codegen` / `machine-check-drift` / `machine-verify`, `runtime_schema_parity`, `runtime_alphabet_parity`, `seam-inventory` dispositions for the extended bind payload; TLC bounds unchanged (2 hosts × 3 members, one live-capable and one live-incapable host in the new-state instances).
### 16.4 Wire deltas (meerkat-contracts; rides the §7 V4 protocol bump)
New `BridgeCommand` variants (member-addressed, V4-required, `deny_unknown_fields`):
* `OpenMemberLiveChannel(BridgeLiveOpenPayload { supervisor, epoch, protocol_version, turning_mode: Option, transport: Option, seed_max_chars: Option })` — mirrors `LiveOpenParams` minus `session_id` (wire/live.rs:123-129; identity addressing replaces it).
* `CloseMemberLiveChannel(BridgeLiveChannelPayload { supervisor, epoch, protocol_version, channel_id })`
* `MemberLiveChannelStatus(BridgeLiveChannelPayload)`
* `ControlMemberLiveChannel(BridgeLiveControlPayload { supervisor, epoch, protocol_version, channel_id, verb: BridgeLiveControlVerb })` with `BridgeLiveControlVerb = CommitInput | Interrupt | Truncate(mirror of live/truncate params) | Refresh`.
New `BridgeReply` variants: `MemberLiveChannelOpened { result: LiveOpenResult }` (embeds the existing contracts type **verbatim** — same crate, wire/live.rs:155-160 — so the owning host's absolute URL + token round-trip untouched), `MemberLiveChannelClosed { status }`, `MemberLiveChannelStatusReport { ... mirrors live/status result }`, `MemberLiveChannelControlled { ... mirrors the per-verb results }`.
New `BridgeRejectionCause` variants (typed only — the `bridge-classifier` gate forbids `ResponseStatus` reinterpretation): `ModelNotRealtime { model, provider }`, `LiveAdapterUnavailable { provider }`, `LiveTransportUnavailable`, `LiveChannelAlreadyBound`, `LiveChannelNotFound`, `LiveTransportUnsupported { requested }`. Every owning-host typed failure maps onto one of these — never a generic failure; the controlling host maps them back onto the same error classes the local path emits (handlers/live.rs:1021-1050 vocabulary).
Host bind payloads (§7.2): the host binding descriptor and `BindHost`/rebind replies gain the optional advertised live endpoint (DL5/DL6).
Console DTOs: `mob/member_live_open { mob, member, turning_mode?, transport? } → LiveOpenResult`, `mob/member_live_close`, `mob/member_live_status`, `mob/member_live_control` (RPC + generated SDKs; the CLI wraps the RPC family). REST and MCP intentionally expose no live mutation/status verbs in v1, matching §17.2 SD-2/SD-4. Gates: `make regen-schemas`, `verify-schema-freshness`, `verify-version-parity`, `verify-sdk-codegen-freshness`, `verify-rpc-surface-alignment`, `verify-rest-surface-alignment`. **`BRIDGE_CLASSIFIER_FILES` (`xtask/src/bridge_classifier.rs:27-31`) must gain every new `BridgeReply`-consuming file** — the mob-side live proxy module at minimum (gotcha #1).
### 16.5 Member-host live composition (amends D6 role recipe)
`rkat mob host` gains the live plane as composition, not authority: `--live-ws ` + **required** `--live-ws-advertise ` (DL6) + `--live-ws-scheme` folded into the advertise URL; `LiveAdapterHost` with the mandatory projection sink over the host's own session service (the main.rs:401-436 wiring shape); `build_per_open_realtime_session_factory` over the **member host's own** realm config chain (live\_wiring.rs:25-38) — consistent with §3, the OpenAI binding must exist in the member host's realm; `openai-realtime` feature preflight (typed startup refusal without it, main.rs:313-324 pattern); live-ws bind gated by `TcpBindPolicy` (DL7). The bridge responder's live arms call the extracted `LiveOrchestrator` pipeline (DL4), including `ensure_live_peer_ingress` — whose mob-owned-ingress skip (session\_runtime.rs:4357) must hold for host-materialized members so a remote live open never reconfigures a mob-owned comms drain.
### 16.6 Failure semantics (amends §9 table)
| Failure | Behavior |
| - | - |
| Open targeting a member on a live-incapable host | Typed `LiveTransportUnavailable` at the controlling host from bind-time truth (`host_live_endpoints` absent); **no bridge dispatch**, no hang. |
| Open on a non-realtime model / no provider adapter / no factory on the owning host | Owning host's B19/B18/#302 typed errors (handlers/live.rs:888-901, 1021-1050) → `ModelNotRealtime` / `LiveAdapterUnavailable` bridge causes → console gets the same error class as the local path. Fail-closed backstop for bind-time drift. *(v4.1.2 errata, ADJ-P6B-6: the #302 no-factory case maps to `LiveTransportUnavailable`, not `LiveAdapterUnavailable` — at that stage no provider has been resolved, and fabricating a provider string would be folklore.)* |
| Duplicate open (channel already active) | Owning-machine `AlreadyBound` (dsl:17407-17414) → `LiveChannelAlreadyBound` → typed console error, mirroring local `INVALID_PARAMS`. |
| Partial open failure on the owning host (factory error, token authority reject, transport build) | Owning host runs `close_live_channel_after_open_failure` locally (handlers/live.rs:725-830) — committed close or generated eviction, never a leaked binding; typed cause crosses the bridge. |
| Bridge timeout/partition during proxied open | Typed `Unavailable` at the controlling host; no controlling-side state to clean (there is none). A reply-lost open may leave an owning-host channel: the unconsumed token expires in 60s (transport.rs:57); the binding is discoverable via `MemberLiveChannelStatus` and clearable via `CloseMemberLiveChannel` — caller-driven reconciliation (mechanism/policy split), pinned by a deterministic test. |
| Console WS cannot reach the advertised URL | Client-side connect failure after a prompt typed-success open — the open itself **never hangs** and never silently relays; the advertised URL is operator-declared truth (DL6) and reachability of it is an operator fact surfaced by the failing connect. |
| Member host restart with a console WS attached | Socket dies with the process (transport registry is process-memory, host.rs:1578-1586); post-rebind `MemberLiveChannelStatus` reports no active channel and a fresh open must succeed — pinned by an e2e-system test; any recovered stale machine binding is evicted through the existing generated eviction seam (`abandon_live_open_admission`, `meerkat-runtime/src/meerkat_machine/runtime_control.rs:414`). |
| Member retire/destroy/respawn (generation bump) with an active channel | Owning-host session teardown closes the channel through the shipped close-feedback path; the WS closes; a subsequent open binds the new generation's session. Pinned by test. |
| `transport=webrtc` requested for a remote member | Typed `LiveTransportUnsupported { requested: webrtc }` at the controlling host; no bridge dispatch. *(v4.1.2 errata, ADJ-P6B-12: the reject is placement-blind — the mob verb family rejects webrtc for local members too, since a family that branches on placement leaks placement through behavior; session-scoped `live/open` keeps local webrtc. ADJ-P6B-2 additionally split the STATUS payload to `BridgeLiveStatusPayload { channel_id: Option }` so a reply-lost open's orphan is discoverable without a controlling-side channel map; CLOSE keeps the required-id payload.)* |
| Principal without `Live` scope | `ScopeDenied { required: Live, presented }` at the controlling-host chokepoint; the bridge command is never sent; no token is ever minted for an unauthorized principal. |
| Token misuse against the member-host listener (replay, wrong channel, expiry, double-consume) | Owning-machine admission rejects fail-closed exactly as today (dsl:18024-18069 guards) — unchanged by distribution, verified cross-host by tests. |
### 16.7 Phase placement (amends §10)
* **Phase 1 additions:** §16.3 catalog deltas (bind-payload endpoint field, §6.4 classification coverage, `ControlScope::Live`) + §16.4 contracts (commands, replies, causes, console DTOs) in the same change-sets.
* **Phase 2 additions:** host-role live composition (§16.5); `--live-ws-advertise`; bind descriptor + ceremony carry the live endpoint; `TcpBindPolicy` gap closure in both binaries (DL7).
* **Phase 5 addition:** `Live` scope in the grant vocabulary + sealed-policy enforcement rows for the live verb family.
* **New Phase 6b — Remote live channels** (gated on phase 5 per §8's ordering rule AND on phase 6's deliverables — the §7.4 pump carries live observation parity (§16.2) and the bridge single-flight relaxation is what keeps live control verbs from serializing behind long-polls; 6b therefore lands AFTER phase 6, with only the `LiveOrchestrator`/sink extraction pre-work parallelizable): `LiveOrchestrator` pipeline extraction (DL4); member-host bridge responder arms; controlling-host proxy dispatch + placement branch + typed cause mapping; reply-loss reconciliation; `BRIDGE_CLASSIFIER_FILES` extension. Exit gates: the phase-wide machine/contracts gates plus the deterministic test rows below.
* **Phase 7 additions:** `mob/member_live_*` on RPC + generated SDKs (+ `rkat mob live ...` CLI verbs); REST/MCP remain intentionally absent per §17.2 SD-2/SD-4; surface-alignment gates.
* **Phase 8:** MobKit consumes the new surfaces; still no MobKit-owned live registry.
### 16.8 Test matrix additions (amends §11; deterministic lanes are the ratchet)
**`unit`/`int`/`e2e-fast`** (two-hosts-in-one-process harness; deterministic realtime via a scripted `RealtimeSessionFactory` fake — precedent: `RealtimeMockSessionFactory`, `meerkat-openai/tests/mock_realtime_ws/server.rs:254-296`, and `RecordingProviderRealtimeFactory`, `meerkat/src/factory.rs:6792`; lift into a shared fixture):
* V4 live command/reply/cause encode-decode + fail-closed decode on V2/V3 receivers; admission classification for all four commands (alphabet parity green)
* proxied open returns the owning host's bootstrap **verbatim**: URL base = advertised URL (not local\_addr), token minted by the owning session's machine; direct WS connect to the member-host listener drives text+audio end-to-end against the scripted factory
* cross-host token misuse matrix: replay against wrong channel, expired token, double-consume, token minted by host A presented to host B — all fail closed
* duplicate proxied open → `LiveChannelAlreadyBound`; open on non-realtime model → `ModelNotRealtime` round-trip; open on live-incapable host → `LiveTransportUnavailable` with zero bridge traffic (asserted); webrtc-on-remote → `LiveTransportUnsupported`
* control-verb proxy: commit\_input/interrupt/truncate/refresh round-trip and mutate the owning channel; long-poll pumps (phase 6) do not block live control verbs (single-flight relaxation covers both)
* scope matrix: `Live` grants exactly the live family; `SendCommand`+`SubscribeEvents` without `Live` → `ScopeDenied{required: Live}`; owner-implicit-full unchanged
* observation parity: transcripts projected by the owning sink arrive at the console via the §7.4 pump/pages — no live-specific event path exists (asserted by absence)
* reply-lost open: drop the bridge reply after owning-side open; status discovers the channel; close clears it; retry opens cleanly
* mob-owned-ingress skip holds for a host-materialized member on remote open (no drain reconfigure)
* bind-policy: non-loopback `--live-ws` without `--allow-remote` refused (both binaries); host role without `--live-ws-advertise` refused; advertise-URL newtype rejects non-ws/wss schemes
* pinning: `live/*` RPC methods still not advertised without a local transport (router.rs:10232-10240 stays green); `live/open` with a remote member's session id still returns not-found (B17 unchanged)
**`e2e-system`** (real multi-process TCP): real `rkat mob host` with a live listener; console opens by identity, connects the real WS across processes (scripted factory — no provider); member-host restart with WS attached → socket closes, rebind re-declares the endpoint, reopen succeeds; partition during proxied open → typed `Unavailable`, reconcile via status+close.
**`e2e-smoke`** (live, ignored-by-default): remote member on `gpt-realtime-2` — full audio turn cross-host with real OpenAI credentials in the member host's realm (extends the existing live smoke family, `tests/integration/src/e2e_lanes.rs:189-190,213`).
### 16.9 Non-goals (v1) — typed degradation, never silent
* **Remote WebRTC transport:** `LiveTransportUnsupported { requested: webrtc }` typed reject at the controlling host. v2 seed: `live/webrtc/answer` signaling proxy over the bridge.
* **Media relay through the controlling host:** no relay path exists and none is silently substituted — an unreachable advertised URL surfaces as the client's WS connect failure after a prompt typed-success open (never a hang, never proxied audio). v2 seed only if operator evidence demands it.
* **Bridge-carried input/media frames:** no `SendInput` bridge verb; the WS is the input plane on every placement. `live/send_input` remains a local-session RPC verb; for remote members' session ids it honestly reports not-found in the controlling realm.
* **In-process TLS termination:** `wss` stays advertisement-only (main.rs:75-79); production posture is a TLS-terminating proxy fronting the member-host listener, recorded in the advertised URL's scheme.
* **`mob/member_status` live-channel enrichment:** status assembly stays bridge-free; live channel state is the dedicated `mob/member_live_status` point read (avoids per-status bridge fan-out and any controlling-side channel cache).
* **Agent-facing live delegation tools:** NOT FOUND today (`meerkat-mob-mcp/src/agent_tools.rs` has no live tools) and none are added — live channels are a console/operator surface in v1; an agent asking for one gets the ordinary unknown-tool error. Live *tool dispatch during* a live turn is unaffected (owning-host `RuntimeLiveToolDispatcher`, unchanged).
* **Cross-host token mint/validation:** unrepresentable by construction — token state lives only in the owning session's machine; there is no API through which the controlling host could mint one, so there is nothing to degrade.
* **Aggregate cross-host live budget:** per-session enforcement only, consistent with §7.3's budget stance.
### 16.10 Implementer gotchas (extends §13)
11. The `--live-ws` bind-policy gap (main.rs:462-469 vs :184-194) is pre-existing; closing it changes standalone `rkat-rpc` behavior for non-loopback binds (now requires `--allow-remote`). Ship the change with its own release note; do not quietly fold it in.
12. The advertised live endpoint is a MobMachine bind fact — do **not** put it on `RuntimeHostInfo` (anti-authority pin, wire/host.rs:181-210).
13. `live/open`'s B17 not-found for remote session ids is correct; the fix is the identity-addressed mob surface, never a placement branch inside `live/*`.
14. `MemberLiveChannelOpened` must embed `LiveOpenResult` (contracts-owned, wire/live.rs:155) rather than re-declaring fields — one wire shape for local and remote opens, and the `Unknown{debug}` transport floor (wire/live.rs:190-206) rides along for free.
15. Every new `BridgeReply` consumer file goes into `BRIDGE_CLASSIFIER_FILES` (xtask/src/bridge\_classifier.rs:27-31) — the mob live proxy is a new consumer by construction.
16. The deterministic realtime fake lives in `meerkat-openai/tests` today (mock\_realtime\_ws); the cross-host lanes need it as a shared fixture — budget the lift, don't copy-paste the mock.
## 17. Surface consequence matrix — RPC / REST / MCP-server / agent tools / SDKs / WASM / CLI
> Referenced by phase 7 (§10). Doctrine for this section: **surfaces stay thin skins over `SessionService`/`MobHandle`. Remote coverage lands UNDER existing methods wherever `AgentIdentity` already keys the API — there are no parallel "remote-flavored" verbs. Only genuinely new nouns (hosts, grants, route installs, member history) get new methods, and those register in the generated catalogs so every ratchet fires.** Every mob surface change falls into exactly one of three consequence classes: **(a) transparently remote** (zero wire change; placement resolves behind the existing method), **(b) new noun** (full catalog cascade), **(c) structurally untouched** (asserted, so drift into it is reviewable).
### 17.1 Class (a): transparently remote — zero wire change
WHY this class exists: every member-targeted mob method already keys on `MobMemberParams { mob_id, agent_identity }` (`meerkat-contracts/src/wire/mob.rs:644-648`) — identity, not session id, not host. Placement resolution therefore happens in exactly one place: **MobCommand admission on the controlling host's MobActor** (local member ⇒ existing path; `member_placement[identity]` present ⇒ bridge proxy per §7). No surface learns routing. The *only* observable surface change in this class is behavioral (remote members now answer) plus the new typed error causes (§17.4) surfacing through existing methods.
| Surface | Verbs that become remote-covering with zero wire change | Placement resolved at | New typed rejects surfaced |
| - | - | - | - |
| RPC (`mob_enabled` catalog, `meerkat-contracts/src/rpc_catalog.rs:612-880`) | `mob/member_status`, `mob/member_send`, `mob/ingress_interaction`, `mob/append_system_context`, `mob/turn_start`, `mob/force_cancel`, `mob/retire`, `mob/respawn`, `mob/submit_work`, `mob/cancel_work`, `mob/cancel_all_work`, `mob/wait_kickoff`, `mob/wait_ready`, `mob/members`, `mob/events`, `mob/snapshot`, `mob/list_members_matching`, `mob/spawn` / `mob/spawn_many` / `mob/ensure_member` / `mob/reconcile` (placement is NOT zero-wire-change — see the carrier list below), `mob/wire` / `mob/wire_members_batch` / `mob/unwire` (cross-host edges, §6.2), `mob/stream_open` (per-member scope already optional: `MobStreamOpenParams { mob_id, agent_identity: Option }`, wire/mob.rs:2283-2291) | MobActor command admission (controlling host) | `ScopeDenied`, `HostUnavailable`, `StaleFence`; `StaleCursor` on stream/page resume |
| REST (deliberate subset, `meerkat-contracts/src/rest_catalog.rs:436-507`) | `GET /mob/{id}/events` (SSE — now includes remote members once the §7.4 merge lands), `POST /mob/{id}/wire-members-batch`, `POST /mob/{id}/wait-kickoff`, `POST /mob/{id}/spawn-helper`, `POST /mob/{id}/fork-helper`, `GET/POST /mob/{id}/members/{agent_identity}/status\|cancel\|respawn` | same | same, via `ErrorCode::http_status()` (§17.4) |
| MCP server (roster-gated re-exports, `meerkat-mob-mcp/src/public_mcp.rs:497-524`; `meerkat-mcp-server/src/lib.rs:1905-1907`) | all 28 `meerkat_mob_*` public tools (spawn, retire, respawn, wire, wire\_members\_batch, unwire, member\_send, append\_system\_context, events, force\_cancel, wait\_kickoff, wait\_ready, flows/run family, profiles); `meerkat_mob_event_stream_open` already takes `member_id` (lib.rs:1884-1903) | same | same, via the new typed `McpToolError` mapping (§17.4) |
| Agent-facing tools (`AgentMobToolSurface`, `meerkat-mob-mcp/src/agent_tools.rs:46-61,102`) | `delegate`, `mob_spawn_member` (gains `placement` in its input schema — LLM-visible, §17.2 SD-7), `mob_retire_member`, `mob_check_member`, `mob_list_members`, `mob_wire` / `mob_unwire` (cross-host edges; identity-keyed schemas unchanged) | same | same, as ordinary typed tool errors |
| SDKs (TS `sdks/typescript/src/mob.ts:162-326`, Py `sdks/python/meerkat/mob.py:221-458`, web `sdks/web/src/mob.ts:874-1250`) | every existing Mob-class method (`member_status`, `member().send/subscribe`, `respawn`, `forceCancel`, `wire`, `subscribeMemberEvents`, `subscribeEvents`, …) | same (they are RPC/WASM clients) | new error codes via existing error classes (§17.4) |
| CLI (`MobCommands`, `meerkat-cli/src/main.rs:2666-2890`) | `MemberStatus`, `ForceCancel`, `Respawn`, `Logs`, `Attach`, `Status`, `WaitKickoff`, `SpawnHelper`, `ForkHelper`, `Run`/`RunFlow`/`FlowStatus`/`Runs` — no flag changes | same | new `cli_exit_code()` values (§17.4) |
**Placement wire carriers (class (b)-lite; additive optional fields, every one enumerated because every DTO is `deny_unknown_fields`):** `MobSpawnParams` (wire/mob.rs:683-720), `MobSpawnSpecParams` (:733-753), `MobMemberSpecWire` via `MobEnsureMemberParams` (:1404-1411), agent-tool `SpawnMemberArgs`/`DelegateArgs` (agent\_tools.rs:1973/:1940 — LLM-visible schema delta, SD-7), MCP `MeerkatMobSpawnInput`/`MeerkatMobSpawnManyInput` (public\_mcp.rs:342-350), and wasm `SpawnSpecInput` (meerkat-web-runtime/src/lib.rs:2113-2133, which typed-rejects it, §17.6) each gain `placement: Option`; each lands with its schema regen in phase 3.
Streaming semantics are unchanged on the wire: remote member events re-emit on the existing `mob/stream_event` notification (rpc\_catalog.rs:972-977) with the same `StreamScopeFrame::MobMember` and canonical `mob:` scope\_id (`meerkat-core/src/event.rs:1905-1927, 1966-1983`). The durable `(generation, seq)` cursor rides **only** in bridge page replies and the new DTOs — never in `StreamScopeFrame`, whose `agent_runtime_id`/`fence_token`/`generation` fields are `skip_serializing` today (event.rs:1917-1926), and never in the two non-durable sequence domains (gotcha §13.8).
### 17.2 Class (b): new nouns — decisions (each fork closed)
*(v4.1.2 errata, ADJ-P7-1: two wire additions landed in phase 7 itself rather than earlier phases — `MobHardCancelParams { mob, member, reason }`/`MobHardCancelResult` (no DTO existed; `reason` is honest — the handle verb consumes it) and `MobMemberLiveStatusParams { channel_id: Option }` (the ADJ-P6B-2 reply-loss discovery read must not be amputated on the wire; close keeps the required-id payload). `WireHostCapabilityFlags` was amended to `u64` + `BTreeSet` to match domain truth — no silent caps — and gained `tracked_input_cancel` as the separately negotiated exact-cancellation capability required by durable tracked turns. `MobHostStatus.endpoint/authority_epoch/capabilities` are `Option` — a Requested-phase host has committed no bind facts, and required fields would fabricate empties.)*
**SD-1 — Member history is a NEW by-identity method `mob/member_history`; it does not overload `session/history`.** (Naming reconciled per A13: the host projection family is `mob/hosts`; the bridge verb stays `HostStatus`.)
WHY: every `session/*` method is keyed by realm-local session id (rpc\_catalog.rs:104-429); a remote member's session id belongs to another host's realm (§3 constraint 1), so overloading `session/history` would break the session-id-is-local invariant and make `SessionService` placement-aware. `mob/member_history` / `MemberHistory` currently exists on **no** surface (NOT FOUND across meerkat-contracts, meerkat-rpc, meerkat-rest, meerkat-mob-mcp, meerkat-mcp-server src). Params follow the `MobMemberParams` convention: `MobMemberHistoryParams { mob_id, agent_identity, from_index: Option, limit: Option }` (`deny_unknown_fields`). Result is a **new** `MobMemberHistoryResult` wrapping the *same transcript page body* the bridge `BridgeReply::MemberHistoryPage` carries (§7), plus typed provenance: `{ page, generation: u64, placement: Option, next_index: Option, complete: bool }` — one shape local and remote (pinned by test, §17.11), and provenance has a typed home instead of leaking into folklore fields.
**SD-2 — REST posture: observation GETs land on REST; ALL admin (host bind/revoke, grants) is RPC+CLI only, recorded in the catalog.**
WHY: REST already serves member observation (`/mob/{id}/members/{agent_identity}/status`, SSE events — rest\_catalog.rs:436-507) and full session history (`/sessions/{id}/history`, rest\_catalog.rs:166-173); a REST console that can watch a remote member but not read its history is a visible regression. Conversely REST has never carried mob create/wire/retire admin, and the transcript-edit family is the documented RPC-only precedent (rest\_catalog.rs:174-181). New REST paths (GET-only): `GET /mob/{id}/members/{agent_identity}/history` → `MobMemberHistoryResult`; `GET /mob/{id}/hosts` → `MobHostsResult`; `GET /mob/{id}/route-installs` → `MobRouteInstallsResult`. Zero new REST mutations. The posture is recorded as a `NOTE:` comment in `rest_catalog.rs` in the transcript-edit style so `verify-rest-surface-alignment` readers see intent, not accident; each path lands **in the same change** as its axum `router()` arm (`meerkat-rest/src/lib.rs:2021`) or the gate fails (scripts/verify\_rest\_surface\_alignment.py:1-16).
**SD-3 — New RPC methods (8), all under the existing `mob_enabled` catalog flag; no new `RpcMethodCatalogOptions` field.**
WHY mob\_enabled: multi-host is not a build feature; the router parity test drives the catalog from `cfg!(feature = "mob")` (meerkat-rpc/src/router.rs:10328) and already documents the pattern for advertised-but-runtime-unavailable methods — dispatch to a typed capability error, never a catalog hole (the `skills/list` precedent, router.rs:10337-10341). A single-host runtime answers `mob/bind_host` with typed `CapabilityUnavailable`, not silence.
| Method | Params → Result | Lifecycle (`RpcRequestLifecycleRule`, request\_lifecycle.rs:17-18) | Phase |
| - | - | - | - |
| `mob/member_history` | `MobMemberHistoryParams` → `MobMemberHistoryResult` | `INLINE_OBSERVATION` (default) | 7 |
| `mob/hosts` | `MobIdParams` → `MobHostsResult { hosts: Vec }` | `INLINE_OBSERVATION` | 7 |
| `mob/route_installs` | `MobIdParams` → `MobRouteInstallsResult { outstanding: Vec, complete: bool }` | `INLINE_OBSERVATION` | 7 |
| `mob/bind_host` | `MobBindHostParams { mob_id, descriptor: WireHostBindingDescriptor { address, public_key, bootstrap_token } }` → `MobBindHostResult { host_id, capabilities, authority_epoch }` | `LONG_RUNNING_PUBLISH_ON_SUCCESS` (remote ceremony) | 7 |
| `mob/revoke_host` | `MobRevokeHostParams { mob_id, host_id }` → `MobRevokeHostResult { host_id, released_members: Vec }` | `LONG_RUNNING_PUBLISH_ON_SUCCESS` | 7 |
| `mob/grant_scopes` | `MobGrantScopesParams { mob_id, principal, scopes: Vec, expires_at_ms: Option }` → `MobGrantScopesResult` | `INLINE_OBSERVATION` | **5** |
| `mob/revoke_scopes` | `MobRevokeScopesParams { mob_id, principal, scopes: Option> }` (absent = all) → `MobRevokeScopesResult` | `INLINE_OBSERVATION` | **5** |
| `mob/grants` | `MobIdParams` → `MobGrantsResult { grants: Vec }` | `INLINE_OBSERVATION` | **5** |
Grant methods ship in phase 5 with enforcement (§8 ordering rule) and require the new `ControlScope::AdminGrants` (A9 — `AdminHost` stays exactly '(bind/revoke hosts)'; grant administration is its own scope, so `ScopeDenied { required: AdminGrants, presented }` is well-formed for the most privileged verb family). A default-deny plane with no grant surface is unusable the moment a second principal appears. Method names mirror the §6.5 machine inputs (`GrantOperatorScopes`/`RevokeOperatorScopes`). Every method above triggers the full six-step cascade: contracts types → `rpc_catalog.rs` descriptor → router dispatch arm (forced same-change by the parity sweep, router.rs:10325-10382) → `docs/api/rpc.mdx` row (scripts/verify\_rpc\_surface\_alignment.py:12-16) → hand-written TS **and** Python wrappers (scripts/verify\_sdk\_wrapper\_freshness.py:34-41) → `make regen-schemas`.
**SD-4 — MCP server: observation tools only; host/grant admin is deliberately NOT on the MCP surface in v1.**
WHY: MCP servers are routinely wired into agent tool contexts; an LLM-reachable `AdminHost`/grant mutation is a plane-(b) escalation vector, and the sealed-policy chokepoints (§8) are easiest to reason about while the granting surface set is minimal. This is the first deliberate asymmetry in the otherwise near-full `meerkat_mob_*` mirror (public\_mcp.rs:497-524) — recorded here and in a roster comment so it reads as intent. New tools (3): `meerkat_mob_member_history`, `meerkat_mob_hosts`, `meerkat_mob_route_installs`. Mechanism per tool: descriptor in `public_tools_list()` (public\_mcp.rs:467) + `DISPATCH_TOOL_NAMES` test slice (:496-524, pinned by `public_tool_surfaces_have_no_drift`) + dispatch arm in `handle_public_tools_call`; request lifecycles are inherited automatically because `contributed_tool_lifecycles` keys off the surface's own advertised list and unknown names fail closed (`meerkat-mcp-server/src/lib.rs:1824-1870`). Note: `meerkat_history` stays session-id keyed (lib.rs:1745-1750) and **cannot** serve a remote member — the by-identity tool is the only MCP path to remote history. There is **no** MCP alignment gate script (verify\_mcp\_\* NOT FOUND in scripts/): advertise↔dispatch parity is enforced in-process by the roster tests, but `docs/api/mcp.mdx` must be hand-updated in phase 7 — named as a work item, not left to memory.
**SD-5 — Reachability/placement reach the wire as typed optional DTO fields + polled projections; NO new AgentEvent types; extending `external_member: Option` is prohibited.**
WHY: §7.5 defines reachability as an observer-local projection — persisting liveness churn into the durable mob event log would contradict that (projection-promotion), and any new `WireEvent` type fires `verify_sdk_event_inventory` across three SDKs for no benefit (scripts/verify\_sdk\_event\_inventory.py:1-24 — gate scope is exactly `WireEvent.known_event_types`). `MobMemberStatusResult` (wire/mob.rs:2022-2045) gains typed optional fields, all `#[serde(default, skip_serializing_if = "Option::is_none")]` so released SDK parsers are untouched: `placement: Option`, `control_reachability: Option`, `comms_reachability: Option`, `last_seen_ms: Option`, `freshness_reason: Option` — following the tri-state `WirePeerConnectivity` precedent already on this DTO (wire/mob.rs:1998-2020). The untyped `external_member: Option` field on the same DTO is the natural folklore dumping ground (Rule-4 pattern); stuffing placement there is forbidden and pinned by a test asserting placement facts appear only in the typed fields. `MobHostStatus` (new): `{ host_id, endpoint, bind_phase, authority_epoch, capabilities: WireHostCapabilityFlags (single enumeration owner: §6.1 — durable_sessions, autonomous_members, hard_cancel_member, tracked_input_cancel, live transports, engine_version, resolvable_providers, protocol range), control_reachability, last_seen_ms, freshness_reason, materialized_member_count }` — this is where consoles read `durable_sessions=false` to label ephemeral-host cursor gaps honestly (§9 cursor-overrun row).
**SD-6 — SDK wrappers: full TS + Python wrappers for every new method; `internal_exclusions` is not widened.**
WHY: the exclusion set's existing entries are transport-internal only (`initialize`, `session/stream_open|close`, `mob/stream_open|close` — scripts/verify\_sdk\_wrapper\_freshness.py:35-41); admitting app-facing admin verbs would weaken the ratchet for every future method. TS and Python SDKs are rkat-rpc clients (sdks/typescript/src/client.ts:2, 276), so the console keeps exactly one endpoint (D2) with zero SDK transport work.
**SD-7 — `SpawnMemberSpec.placement` is an LLM-visible schema change on `mob_spawn_member`/`delegate`; agent tools get NO host/grant verbs.**
`SpawnMemberSpec` (`meerkat-mob/src/runtime/handle.rs:1844`) gains `placement: Option<...>` (§7.3); the agent-tool input schemas re-derive from it, so the delegation tools' advertised schemas change — a deliberate, reviewed prompt-surface delta. `AgentMobToolSurface` (agent\_tools.rs:102) is a **named plane-(b) chokepoint** (§8): scope enforcement gates it exactly like the RPC/REST/MCP handlers. Agents never administer hosts or grants — those verbs simply do not exist in the agent tool roster (agent\_tools.rs:46-61 stays host/grant-free).
**SD-8 — Capability advertisement: one new flag `RuntimeHostFeatureFlags.multi_host_mobs: bool`; no new `CapabilityId`.**
WHY: `CapabilityId` (meerkat-capabilities/src/lib.rs:28-49) is the mobpack-manifest requirement vocabulary; mobpacks do not declare placement in v1 (non-goal), so minting a manifest capability would advertise a contract nothing consumes. `RuntimeHostFeatureFlags` (wire/host.rs:36-51, already carrying `mobs`/`external_members`) is the wire flag structure consoles read via `runtime/capabilities` (rpc\_catalog.rs:207-211). The flag is a non-authority projection; per-host capability detail lives in `mob/hosts` (SD-5). `RuntimeHostInfo` stays anti-authority — the pin `runtime_host_info_does_not_claim_topology_authority` (wire/host.rs:181-210) forbids topology/registry vocabulary on it, and `multi_host_mobs` as a feature flag on `RuntimeHostCapabilities` carries none of the forbidden tokens.
### 17.3 Class (c): structurally untouched (asserted)
All `session/*`, `turn/*`, `events/*`, `auth/*`, `config/*`, `schedule/*`, `workgraph/*`, `live/*`, `comms/send|peers`, blob/artifact/approval methods (rpc\_catalog.rs:104-429 and siblings) are keyed by realm-local ids and stay placement-blind — a remote member's session is read **only** through `mob/member_history` and the merged mob stream. `runtime/host_info` / `runtime/capabilities` / `runtime/health` (rpc\_catalog.rs:202-216) remain read-only projections; the anti-authority pin (wire/host.rs:181-210) must keep passing — placement facts never land on `RuntimeHostInfo` (`placement_labels` keeps zero writers, §12). `rkat-rpc --tcp` remains a console transport with loopback-default bind policy (`meerkat-rpc/src/main.rs:45-52, 184-190`); it is never a host↔host control path — the bridge over the comms acceptor is the only inter-host control plane (§10 phase 7 note, restated as a member-host posture test in §17.11).
### 17.4 Typed error rendering across surfaces
The §7 bridge causes surface to users through the closed wire `ErrorCode` enum (`meerkat-contracts/src/error/mod.rs:25-41`), which centralizes all four renderings as const matches — extending it forces exhaustive updates at compile time (`jsonrpc_code`, `from_jsonrpc_code`, `http_status`, `cli_exit_code`; :44-129). New codes:
| ErrorCode | JSON-RPC | HTTP (REST) | CLI exit | Meaning |
| - | - | - | - | - |
| `ScopeDenied` | -32025 | 403 | 45 | plane-(b) denial; wire detail carries `{ required, presented }` |
| `HostUnavailable` | -32026 | 503 | 46 | member host unreachable/timeout; observation degrades typed, never quiet (§9 partition row) |
| `StaleCursor` | -32027 | 410 | 47 | event cursor overran an ephemeral host's buffer; reply carries current watermark (§9) |
| `StaleFence` | -32028 | 409 | 48 | materialize/command carried a superseded `(generation, fence)` tuple (§6.3) |
MCP tool rendering is NEW WORK, not an existing seam (the draft's `WireError` claim was refuted — zero consumers in the MCP crates): the actual envelope is `McpToolError { code: i32, message, data }` (meerkat-mob-mcp/src/lib.rs:3934-3980) and mob errors are string-laundered via `invalid_params(err.to_string())` today (public\_mcp.rs:541,566). Phase 7 adds typed constructors + a non-laundering `MobError → ErrorCode → McpToolError` mapping so `ScopeDenied{required,presented}` / `HostUnavailable` / `StaleCursor` / `StaleFence` carry stable codes + typed data over MCP. SDK error classes key on the same codes (TS + Python; regenerated `errors.json` via `make regen-schemas`). REST mapping flows through the existing `ErrorCode::http_status()` seam used at `meerkat-rest/src/lib.rs:2249, 2263` — no per-handler status folklore.
### 17.5 SDK consequences (TS / Python / web)
* **TS + Python:** `Mob.member_history()` wrapper (+ `hosts()`, `route_installs()`, `bind_host()`, `revoke_host()`, `grant_scopes()`, `revoke_scopes()`, `grants()`) — forced by the wrapper gate the moment the catalog entries land (SD-6). No SDK Mob class has any history method today (NOT FOUND in sdks/web/src/mob.ts, sdks/typescript/src/mob.ts, sdks/python/meerkat/mob.py).
* **Generated types:** all three `generated/` dirs regenerate (`scripts/verify-sdk-codegen-freshness:11-15`); no new `WireEvent` types means `verify_sdk_event_inventory` does not fire (SD-5).
* **Released-SDK compat:** every touched DTO extension is optional + `skip_serializing_if`. The web SDK's closed-key parsers (`requireOnlyKeys`, sdks/web/src/mob.ts:96-107) guard exactly the spawn-response entry shape (:466-495) and event-source shapes (:514-548) — **neither shape is touched by this plan's wire deltas**; `MobMemberStatusResult` is parsed tolerantly by named fields. Rule recorded: any future field on a `requireOnlyKeys` shape must update the web SDK parser in the same change or released browsers throw on contract-valid payloads.
* **Web SDK is console-only and RPC-free:** `sdks/web/src` must stay free of RPC-shaped method literals (scripts/verify\_rpc\_surface\_alignment.py:27-29). Its multi-host work is generated-type regeneration plus the handwritten placement/status and spawn-response parsers needed for those generated contracts; it still gains no RPC console methods. No `bind_host`/grant/member-history additions to the web Mob class in v1 — the browser observes only what the embedded runtime owns.
### 17.6 WASM / browser runtime
`meerkat-web-runtime` is an entirely in-process runtime — no TCP acceptor is representable in a browser (wasm exports enumerate the full in-browser mob surface, `meerkat-web-runtime/src/lib.rs:1941-2801`; nothing listens). Consequences:
* **Typed placement reject (phase 3, with the field itself):** `mob_spawn` parses `specs_json` in-browser (lib.rs:2077); when a spec carries `placement`, it returns a typed unsupported-capability error (D5 doctrine: typed degradation, never silent strip). Silently ignoring placement would be the exact silent-cap pattern §6.5 kills for event streams.
* **No host exports:** `mob_bind_host`/grant/member-history wasm exports are not added; browser mobs are declared single-host in §17.12.
* **Compile-time obligation:** every new contracts type (HostRef, cursors, host DTOs, error codes) must stay wasm32-clean — `wasm-check` runs on every code change and `wasm-contract` on contracts/web paths (.github/workflows/cargo.yml:518-541).
### 17.7 CLI
* **Phase 2 (ceremony operability):** `rkat mob host` daemon (D6) — flags `--identity-dir`, `--listen-tcp`, `--advertise-tcp`, `--descriptor-out`, reusing the shipped `rkat run` acceptor flag family and descriptor mechanics (`--comms-listen-tcp`/`--comms-binding-out`, meerkat-cli/src/main.rs:1727-1790) with the host-flavored descriptor `{kind: "host", address, public_key, bootstrap_token}` (§7.2). Plus the controlling-side ceremony verbs `rkat mob bind-host --descriptor `, `rkat mob revoke-host`, `rkat mob hosts` — the CLI drives mob APIs in-process, so these can precede the RPC methods and make the phase-2 exit gate operable end-to-end.
* **Phase 5:** `rkat mob grant`, `rkat mob revoke-grant`, `rkat mob grants` (land with enforcement; owner-implicit-full-scope keeps single-user CLI behavior unchanged, §8).
* **Phase 7:** `rkat mob member-history `, `rkat mob route-installs `.
* **Unchanged verbs** (remote-covering for free): `MemberStatus`, `ForceCancel`, `Respawn`, `Logs`, `Attach`, `Status`, `WaitKickoff`, `SpawnHelper`, `ForkHelper` (main.rs:2666-2890 — no host/grant/member-history verbs exist today, NOT FOUND in the `MobCommands` enum; `grant` has zero hits in main.rs).
* New exit codes 45-48 require NEW WORK, not an existing seam: `ErrorCode::cli_exit_code()` has zero consumers in `rkat` today (the binary exits EXIT\_SUCCESS=0 / EXIT\_ERROR=1 / EXIT\_BUDGET\_EXHAUSTED=2, main.rs:89-91, and mob handlers anyhow-stringify). Phase 7 adds a typed `MobError → ErrorCode → exit` path for the mob verb family; the pre-existing `EXIT_BUDGET_EXHAUSTED=2` vs `cli_exit_code(BudgetExhausted)=21` inconsistency is recorded and NOT silently changed for non-mob verbs.
### 17.8 Member-host surface posture (stated, non-contractual)
The member host runs **nothing user-facing by default**: no RPC, REST, or MCP listener — the supervisor bridge over the host comms acceptor is the only control plane (D6; `rkat mob host` must not embed `rkat-rpc --tcp`, whose bind policy is transport exposure, not principal auth — meerkat-rpc/src/main.rs:49-52 and `secure_rpc.rs:3-4`). Because member sessions materialize into the host's realm-local stores (§3), a local `rkat session list`/`rkat session show` against that realm incidentally displays them. This is **unsupported-but-harmless local observability, not a contract**: no gate, no docs promise, and mutating a materialized member's session locally is UNDEFENDED in v1 — stated plainly, not laundered: nothing validates fence/epoch member-side on delivery (§6.4), the new `StaleFence` checks gate only host-addressed materialize/release, and local realm writes sit entirely outside the fence path. A host-local operator with realm access can mutate placed members' sessions; that is inside the compromised/trusted-host boundary recorded in the threat model (§20). Daemon lifecycle (start/stop/restart-rebind) is the OS/service manager's job, not a Meerkat surface; restart-rebind semantics are §9's member-host-restart row.
### 17.9 CI gate cascade per addition class
| Addition class | Gates that fire (all in `make ci` / CI `ratchets`+`fmt-governance` jobs, Makefile:351) |
| - | - |
| contracts type (DTOs, `ErrorCode`, flags) | `make regen-schemas` → `verify-schema-freshness`, `verify-version-parity`, `verify-sdk-codegen-freshness`, wasm-check (every code change) |
| RPC method | + `rpc_catalog.rs` descriptor, router dispatch-parity test (router.rs:10325-10382, in-crate), `verify-rpc-surface-alignment` (docs/api/rpc.mdx table + typed refs + SDK literal map), `verify-sdk-wrapper-freshness` (TS+Py) |
| REST path | + `rest_catalog.rs` + `router()` arm same-change, `verify-rest-surface-alignment` (catalog↔OpenAPI↔router, per path+method) |
| MCP tool | in-process roster parity (`public_tool_surfaces_have_no_drift`, public\_mcp.rs:490-496; fail-closed unknown-name lifecycle, mcp-server lib.rs:1824-1870); `docs/api/mcp.mdx` hand-update (no script gate exists — NOT FOUND) |
| new AgentEvent type | `verify_sdk_event_inventory` — **deliberately not triggered** by this plan (SD-5) |
| bridge-reply consumer file | `BRIDGE_CLASSIFIER_FILES` extension (`xtask/src/bridge_classifier.rs:26-31`) — the history proxy + poll pump files (§7.4) |
| machine deltas | none originate in this section — §6 owns them; surfaces consume projections only |
### 17.10 v1 non-goals for surfaces (typed degradation, never silent)
* **REST admin** (bind/revoke host, grant/revoke): not served; recorded `NOTE:` in rest\_catalog.rs (SD-2). Absent path = honest 404 by documented intent, not phantom catalog entry.
* **MCP host/grant admin tools:** absent from the roster; unknown tool names fail closed as unknown tools (mcp-server lib.rs:1824-1870). Symmetry may return post-v1 behind proven `AdminHost` scope.
* **Browser mobs are single-host:** placement-bearing spawns get a typed unsupported reject in `meerkat-web-runtime`; no host/grant wasm exports; no RPC literals in sdks/web (gate-enforced).
* **No direct observer→member-host endpoints** (restates D2); consoles always talk to the controlling host.
* **No new MCP alignment gate** in v1: in-process roster parity is the enforcement; docs/api/mcp.mdx is a named hand-update.
* **Member-host local surfaces non-contractual** (§17.8): visible sessions are a realm-local side effect, typed-undefined for mutation, never advertised.
* **No aggregated cross-mob host registry endpoint:** hosts are per-mob MobMachine facts; `mob/hosts` is mob-scoped. A fleet view is a MobKit projection concern.
* **No mobpack-manifest placement capability** (`CapabilityId` untouched); packs that need placement fail closed at spawn admission with the §7.3 typed reject, not at pack load.
### 17.11 Surface test additions (lane-mapped; deterministic lanes are the ratchet — e2e-system/e2e-smoke do not run in GitHub CI)
**unit:** `ErrorCode` round-trip for the 4 new codes across all four const maps (+`from_jsonrpc_code`); new DTO serde round-trips incl. `deny_unknown_fields` rejects; `MobMemberStatusResult` decodes pre-extension payloads (absent optional fields) and placement never serializes into `external_member` (SD-5 pin); `runtime_host_info_does_not_claim_topology_authority` still green with `multi_host_mobs` flag.
**int (meerkat-rpc):** catalog dispatch-parity sweep auto-covers the 8 new methods; scope matrix per method — `ReadHistory`-only principal reads but cannot cancel, `SendCommand`-only drives but cannot read, deny carries `{required, presented}`; single-host runtime answers `mob/bind_host` with typed `CapabilityUnavailable`.
**int (meerkat-rest):** three new GET handlers; 403/410/503/409 mappings flow through `ErrorCode::http_status()`; alignment gate covers catalog↔router↔OpenAPI.
**int (meerkat-mob-mcp / mcp-server):** roster parity auto-pins the 3 new tools; `meerkat_mob_member_history` remote==local page shape; host/grant tool names dispatch as unknown (fail-closed asymmetry pin); agent-tool schema snapshot: `mob_spawn_member` exposes `placement`, spawn to unbound host surfaces the typed admission reject.
**e2e-fast (two-hosts-in-one-process harness, §11):** console flow on the controlling host — `mob/member_history` page-walk across a member respawn (generation bump), `mob/hosts` labels `durable_sessions=false`, `mob/route_installs` drains to `complete`; `mob/stream_open{agent_identity}` for a remote member delivers `mob:`-scoped events with unchanged notification shape.
**e2e-system (local/nightly):** `rkat mob host` daemon + `rkat mob bind-host` descriptor hand-off + `rkat mob member-history` over real TCP; assert the member-host process exposes no RPC/REST/MCP listener (§17.8); CLI exit codes 45-48 observed.
**e2e-smoke (live):** A/B/C kitchen-sink incl. console history/status of remotely-spawned B21.
**SDK lanes:** TS/Py wrapper tests in the sdk jobs; web `wasm-contract` covers the placement typed-reject; `sdk-web` suite re-runs closed-key parser tests unchanged (proof the touched DTOs avoid `requireOnlyKeys` shapes).
### 17.12 Surface gotchas (append to §13)
14. `verify_sdk_wrapper_freshness` requires the method literal in **hand-written** SDK source — generated mirrors do not count (verify\_rpc\_surface\_alignment.py:20-24). Budget real wrappers, not codegen output.
15. REST catalog entries and `router()` arms must land in the same change (phantom-404 precedent, rest\_catalog.rs:174-181).
16. MCP has no external alignment gate — the roster tests catch dispatch drift, but `docs/api/mcp.mdx` drift is caught by nobody; it is a named phase-7 work item.
17. `external_member: Option` on `MobMemberStatusResult` is a folklore trap — placement/reachability go in the typed fields only (SD-5 pin test).
18. The web SDK's `requireOnlyKeys` shapes are breaking-on-extend for released browsers — check every touched DTO against sdks/web/src/mob.ts:466-548 before adding fields.
## 18. Orchestration-adjacent systems: flows, workgraph, schedules, approvals, tool policy, budget, taint
> Scope: how every orchestration-adjacent subsystem behaves when a mob spans hosts. Builds on D1–D6 (settled). Decisions in this section are numbered O1–O8. Every "today" claim is anchored at HEAD (0.7.22 / da467fca8).
### 18.1 Flows across remote members (O1, O2)
**Substrate.** A flow step dispatches through `FlowEngine::execute_single_target_with_retries` → `FlowTurnExecutor::dispatch` and awaits a per-dispatch terminal (`meerkat-mob/src/runtime/flow.rs:840-881`). `ActorFlowTurnExecutor::dispatch` branches on runtime mode: `AutonomousHost` uses the in-process `interaction_event_injector.inject_with_subscription` (`meerkat-mob/src/runtime/actor_turn_executor.rs:281-316`); `TurnDriven` calls `provisioner.start_flow_step` with a **local** `event_tx` mpsc channel in `StartTurnRequest` (:334-365). Completion is a per-dispatch subscription classifying terminal `AgentEvent`s — `RunCompleted` / `ExtractionSucceeded` / `ExtractionFailed` / `InteractionComplete` / `InteractionCallbackPending` / `RunFailed` / `InteractionFailed`; channel close without a terminal is a typed step failure (:98-217, close at :211-215). Steps target **roles** and carry `allowed_tools`/`blocked_tools` (`meerkat-mob/src/definition.rs:393, :411-414`) lowered into a `TurnToolOverlay` (`flow.rs:1919-1930`; type at `meerkat-core/src/service/mod.rs:1379`); the overlay is typed-rejected for `AutonomousHost` members because the injector seam cannot carry it (`actor_turn_executor.rs:275-279`; `inject_with_subscription` has no overlay parameter — `meerkat-core/src/event_injector.rs:80-86`). Remote members have **no working step path today**: the provisioner trait default forwards `start_flow_step` → `start_turn` (`meerkat-mob/src/runtime/provisioner.rs:277-286`), and the peer-only `start_turn` rejects any `event_tx` with `UnsupportedForMode` ("tracked turn event streams are not supported for peer-only members in phase 1", :3328-3333) — pinned as a whole-run failure discovered only after the run starts (`meerkat-mob/src/runtime/tests.rs:23945-24018`). This section supersedes that failure shape. Frame loops (`repeat_until`) and hard-timeout disposition stay controlling-host-local and machine-owned (`ClassifyTurnTimeoutDisposition`, catalog `mob_machine.rs:900`; shell realization `actor_turn_executor.rs:409-457`) — no frame-engine distribution.
**O1 — Remote flow steps ride the one delivery command, upgraded with a typed turn directive; dispatch disposition is machine-classified before delivery.**
WHY: the lead constraint is one member-delivery path, and `DeliverMemberInput` already carries `input_id`, content, handling mode, and injected context (`provisioner.rs:3366-3390`); minting a second "flow turn" command would fork admission, dedup, and epoch handling.
* `BridgeDeliveryPayload` gains one additive optional field: `turn: Option }>` (absent-omitted, the `injected_context` byte-compat precedent, `meerkat-contracts/src/wire/supervisor_bridge.rs:852-859`). **Silent overlay drop is structurally impossible**: the payload is `deny_unknown_fields` (:844), so a pre-V4 receiver rejects a directive-bearing delivery at decode — a typed `BridgeCommandRejected` step failure, never a turn that runs ungated. Directive-bearing deliveries require protocol V4.
* Member-side, a directive-bearing delivery is admitted as a **tracked turn**: `TurnDriven` members admit through the host's session turn admission carrying `StartTurnRuntimeSemantics { handling_mode, tool_overlay }` and a host-local `event_tx` — the local TurnDriven mechanics relocated to the host that runs the loop; `AutonomousHost` members admit via `inject_with_subscription`. Overlay + `AutonomousHost` stays typed-rejected **regardless of placement** (same seam limitation on every host).
* Dispatch disposition becomes machine-owned: new MobMachine input `ClassifyFlowStepDispatch { run_id, step_id, target, overlay_present }` → effect `FlowStepDispatchClassified { dispatch: Local | RemoteTurnDirective | RejectedOverlayAutonomous | RejectedHostIncapable }`, adjudicated against machine-owned facts ONLY: the guard reads `self.member_runtime_modes[target]` (machine state written at `CommitSpawnMembership`), `self.member_placement`, and `self.host_capabilities` — runtime mode is NEVER a shell-supplied input (a roster-cache claim would be shadow truth on the exact seam this decision promotes to machine ownership); `overlay_present` is the one legitimately shell-observed request fact (the `ClassifyTurnTimeoutDisposition` shape). The executor consults it **before** any delivery, so an unsupported combination fails the step typed at dispatch instead of surfacing as today's mid-run `UnsupportedForMode`. A run-open whole-flow sweep is deliberately rejected: role→member resolution happens at dispatch and membership mutates mid-run — a sweep would fabricate certainty; per-dispatch classification is the honest gate.
**O2 — Step completion: durable member-side turn-outcome journal, served as a sidecar on the D5 poll pump. `EventEnvelope` gains no field.**
WHY: `EventEnvelope` carries no input/turn correlation (`meerkat-core/src/event.rs:94-103`) and the envelope/page rows are frozen; watermark-window attribution is unsound under queued concurrent inputs and idempotent redelivery (`BridgeDeliveryOutcome::Deduplicated`, `supervisor_bridge.rs:866-875`). Only the **terminal** needs attribution — display events don't.
* At directive admission, the member host atomically creates the per-turn subscription (the remote twin of the admission-time atomicity at `actor_turn_executor.rs:299-316/:334-365` — today's receiver discards the completion handle, `meerkat-runtime/src/comms_drain.rs:2701`) and, at terminal, records `(agent_identity, generation, input_id) → RecordedTurnOutcome { terminal_seq, outcome }` durably in `MobHostBindingAuthority` (§6.3).
* `BridgeReply::MemberEventsPage` (§7.4) gains a bounded `turn_outcomes: Vec` page plus `outcomes_complete`; `PollMemberEvents` carries the exact prior-page `(generation, input_id)` acknowledgements and an independent `max_outcomes`. `terminal_seq` is pinned to the durable `StoredEvent.seq` domain (never the session-task or per-stream counters, gotcha 8). The host prunes only acknowledged durable rows; unknown/already-pruned acknowledgements are no-ops and create no negative memory. Lost replies replay unacknowledged rows, while a delayed journal commit remains visible on a later page. Both one-record admission and the combined encoded reply are byte-bounded below transport `MAX_PAYLOAD_SIZE`: each failed terminal carries `detail: { text, original_utf8_bytes, truncated }`, and the member host retains the largest UTF-8 prefix that keeps the complete journal row at or below 64 KiB **before** `record_turn_outcome`. Pathological input-id framing that cannot leave room even for an empty detail is rejected before runtime acceptance; an accepted turn is never rejected after its effect because its provider detail was large. The terminal discriminant, exact acknowledgement key, and original UTF-8 byte length remain unchanged/typed. Retention is also hard-bounded while a controller is offline: each member admits at most 256 retained-plus-in-flight directed outcomes (at most 64 KiB each). The 257th directive rejects before runtime acceptance with typed `OutcomeJournalFull { retained, limit }`; no unacknowledged completion authority is evicted. Hosts without `durable_sessions` never receive directives at all (`RejectedHostIncapable` at dispatch classification), so the field is always well-defined. The controlling host's poll pump (one loop, one command — no second path) feeds outcome records to the remote flow ticket registry; unattributed event rows keep flowing to the merged stream for display. If an immutable terminal event row itself exceeds the bridge page budget, the pump advances that exact typed omission and resolves failed turns from the bounded sidecar instead of waiting for the ordinary turn timeout.
* **Pump lifecycle is decoupled from observation** (A17): dispatching a directive-bearing delivery records a MobMachine obligation `pending_remote_turn_outcomes: Set<(AgentIdentity, InputId)>` (recorded at `FlowStepDispatchClassified{RemoteTurnDirective}` realization, resolved when the pump consumes the outcome record or the timeout ladder disposes the step). The pump for a member runs while EITHER an event subscription is authorized OR obligations are outstanding — a flow step never depends on a console watching (the Rule-8 dropped-observation shape is unrepresentable).
* Terminal classification is extracted into a single shared `meerkat-core` turn-terminality classifier consumed by both the local executor bridge (`actor_turn_executor.rs:98-217`) and the member-host journal writer. WHY: two hand-maintained terminal-event lists are exactly the split-terminality failure shape; one authority, zero divergence window.
* Semantics: keyed on `(generation, input_id)` — redelivery converges on the one journal row; a generation bump (rematerialize) scopes the journal, so an old generation's outcome can never attribute to a new turn and the step fails typed on generation change. Member-host restart mid-turn: the turn is dead, no record ever appears, and the step resolves through the existing timeout ladder (machine-owned disposition) — typed, honest. A directive-bearing delivery the member runtime cannot host as a tracked turn is rejected at admission with new cause `TurnDirectiveUnsupported` (extends `#[non_exhaustive]` `BridgeDeliveryRejectionCause`, `supervisor_bridge.rs:885-896`).
### 18.2 Tool access policy: Inherit locality and the sealed wire form (O3)
**Substrate.** `ToolExecutionPolicy::resolve` fails closed on `Inherit` (`meerkat-core/src/tool_execution_policy.rs:92-102`, `UnresolvedInherit` :33-45, sealed private ctor :57-75). The factory resolves before persisting metadata and installs `ExecutionPolicyGatedDispatcher` as the outermost gate (`meerkat/src/factory.rs:5421-5432`, persist :5461-5463, gate :5818-5822) — so a persisted effective policy is never `Inherit`. The agent-facing spawn seam resolves `Inherit` to the **parent's** persisted policy via the **local** session service (`meerkat-mob-mcp/src/agent_tools.rs:86-94, :657-697`; local read :671-680; metadata read fault fails the spawn closed :681-691). The trap: the mob compiler maps residual `Inherit`/`None` to `None` = **unrestricted** (`meerkat-mob/src/build.rs:332-341`), and this path never reaches the factory's `UnresolvedInherit` gate — so any wire shape that can carry `Inherit` resolves to unrestricted on the compiling host. Explicit policy presence is a MobMachine-privileged admission fact requiring manage scope (`catalog/dsl/mob_machine.rs:1996-2039`), computed from the **requesting args** at the tool seam (`meerkat-mob/src/runtime/tools.rs:585-586`, `handle.rs:398-401`, mapped at `handle.rs:6550-6564`).
**O3 — Inherit resolves in the process that owns the parent session's realm; the materialize wire carries a resolved-only sealed type; admission facts and resolved policy travel as separate fields.**
WHY: the parent's persisted metadata is realm-local (§3: realms are not distributed) — for B2-spawns-B21 the parent's realm is on Host B, so a controlling-host resolution would need a cross-host metadata read in the spawn critical path, racing parent policy changes and duplicating the D2 read plane into the control plane. The existing seam already resolves locally and fail-closed; keep it, relocate nothing.
* `MaterializeMember.spec.tool_access_policy: Option` where `WireResolvedToolAccessPolicy = AllowList(names) | DenyList(names)` — **`Inherit` is unrepresentable at decode**; `None` = unrestricted (host authority). Decode-time rejection is the only sound gate given `build.rs:332-341`; the member-host factory's `UnresolvedInherit` seal (`factory.rs:5421-5432`) remains the backstop, and the member host persists the received policy into its own `SessionMetadata.tooling.tool_access_policy` so B21-spawns-B211 inherits correctly **on Host B** with zero controlling-host involvement.
* Member-host-originated spawn requests ride the ONE member-upcall lane — `MemberOperatorRequest` (§15, exact-generation/fence machine admission plus durable request-id ledger; A8: there is exactly one member→controlling command family, and this is it) — carrying **two separate facts**: `requested_tool_access_policy_present: bool` (the admission fact computed at the tool seam from the agent's args and checked through the re-minted generated agent authority) and `resolved_tool_access_policy: Option` (the resolution output). Principal `ControlScope` grants are not consulted on this lane (§15.2). WHY: resolving `Inherit` on Host B yields an explicit policy the agent never requested; deriving the privileged-presence fact from the resolved spec would launder an inherited policy into a stronger authority demand (or worse, the inverse). Two facts, two owners, no laundering.
* Containment invariant (tested both directions): a restricted parent on Host B spawning onto Host B **or** Host A yields a child whose sealed gate equals the parent's effective policy; no host pair produces an `Inherit`-derived unrestricted child.
### 18.3 Budget (O4)
**Substrate.** Only `budget_limits` is real end-to-end: spec → `CreateSessionRequest.build.budget_limits` (`meerkat-mob/src/runtime/actor.rs:793-803`) → per-session `Budget` enforcement in the agent loop (`meerkat-core/src/budget.rs:12-20`). `budget_split_policy` (`meerkat-mob/src/launch.rs:63-77`; spec field `handle.rs:1874`; wire `meerkat-contracts/src/wire/mob.rs:713`) is functionally **unconsumed**: every actor spawn handler underscore-discards it (`actor.rs:8869, :9783, :14430`) and no split computation exists anywhere at HEAD (NOT FOUND workspace-wide outside a wire round-trip test). §7.3's sentence "the spawn's budget split is computed on the controlling host and seeded in the materialize payload" **overstates HEAD and is corrected here** (see the §7.3 delta below).
**O4 — `PortableSpawnOverlay.budget_limits` is the single budget carrier; `BudgetSplitPolicy` is deleted.**
WHY single-carrier pass-through: the overlay is digest-covered and survives member-host revival, while enforcement is loop-internal per-session state and therefore naturally member-host-local. A payload-level sibling would create two authorities for one budget fact. Cross-host aggregate budget is already a §12 non-goal; implementing split would require a new seam reading a live session's remaining budget (core `Budget` is loop-internal atomic state, not service-readable) — scope this plan explicitly defers. WHY delete: an accepted-then-discarded budget instruction is a fail-quiet containment gap — an agent that sets `Fixed(1000)` believes containment exists and gets nothing. Pre-1.0 dogma: no dead vocabulary, no shims. `BudgetSplitPolicy` (launch.rs:63-77), `SpawnMemberSpec.budget_split_policy` (+ builder, handle.rs:1874/:2032-2033), the tool-schema/args plumbing (tools.rs:378, :586, :903-904, :970-971), the RPC param (`meerkat-rpc/src/handlers/mob.rs:436-438, :502-503`), `WireBudgetSplitPolicy` (wire/mob.rs:713, emit.rs:1392), and the machine admission fact `privileged_budget_split_policy_present` (catalog dsl `mob_machine.rs:1997/:2018/:2031`) are all removed in one change-set. If split computation ever lands (v2 seed), it returns as controlling-host-only planning that **computes `BudgetLimits`** — policy never crosses the wire even then.
* §7.3 sentence replacement: "Budget: the spawn's `budget_limits` ride `PortableSpawnOverlay.budget_limits` inside the digest-covered spec; the member host applies them to `CreateSessionRequest.build.budget_limits` (the shipped `with_spawn_budget_limits` path, actor.rs:793-803) and enforcement is local to the member's session (aggregate mob budget is a non-goal, §12). Budget-split vocabulary and payload-level budget siblings do not exist on the wire."
### 18.4 WorkGraph (O5)
**Substrate.** The workgraph store is a **host-local** SQLite file keyed by a realm-id string: the factory (absent a supplied dispatcher) fails closed without a typed `RealmId`, then opens `realm_scope_root/workgraph.sqlite3` with `WorkGraphService::with_scope(store, realm_id, namespace)` (`meerkat/src/factory.rs:4907-4913, :4919-4935`; `with_scope` `meerkat-workgraph/src/service.rs:39-49`; reads keyed `(realm_id, namespace)` `meerkat-workgraph/src/store.rs:91-96`; realm bundle row `meerkat/src/persistence.rs:362-364`). Mob members build with `realm_id = mob.{id}` (`meerkat-mob/src/build.rs:24-34, :198, :207`), workgraph tools per profile (`:243-244`), and the `workgraph-workflow` skill preloaded (`:224-229`) — which tells members they share one task list. The hazard: a member materialized on Host B under the same `mob.{id}` realm string opens **Host B's** `workgraph.sqlite3` — the shared task list silently splits into disjoint stores while the skill keeps asserting unity. Fail-quiet split-brain.
**O5 — Cross-host shared workgraph is a typed v1 non-goal, made fail-closed at spawn admission: remote placement of a workgraph-tooled profile is machine-denied.**
WHY: bridging the store means a new bridge command family with CAS/ordering semantics over the network (`update_item_cas`, store.rs:75-80) for every tool call, cutting against §3's realm-locality constraint and widening phase scope for a coordination pattern that degrades acceptably (operators split roles or disable workgraph on remote profiles). The settled alternative is not "silent divergence" — it is a machine reject.
* The denial is guarded INSIDE the machine spawn-exec ladder at the `BeginSpawnExec` opener — the one choke-point every spawn path crosses (A4; the tool-seam-only `ResolveSpawnMemberAdmission` dispatch is bypassed today by RPC `mob/spawn*` and embedder `MobHandle::spawn_spec`, meerkat-mob-mcp/src/lib.rs:978-984, so guarding only there would leave the split-brain reachable). Input fact `workgraph_required: bool` is the EXPLICIT resolved `profile.tools.workgraph` assertion captured before inherited category opening. Explicit workgraph emits the merged `SpawnMemberAdmissionResolved { admission: NonPortableResource { kind: WorkgraphTools } }`; a remote spawn carrying the non-serializable inherited tool-visibility authority is hard-denied first as `NonPortableInheritedToolFilter`. No skill suppression logic is needed because neither rejected combination builds, and `non_portable_disabled` remains a typed empty record in v1. Upcall spawns are unaffected because `MemberOperatorSpawnSpec` carries no inherited-filter field.
* Degradation is operator-explicit: place the member locally, or set `tools.workgraph = false` on the remote profile (the member then preloads `task-workflow` per `build.rs:227-229` — session-local builtin tasks, no cross-host semantics). v2 seed: a bridge-proxied workgraph service (controlling-host store as the single owner), designed only if a machine decision ever depends on shared work state.
### 18.5 Schedules (O6)
**Substrate.** `TargetBinding = Session | Identity | Mob | HostRunnable` (`meerkat-schedule/src/types.rs:1147-1152`); `MobTargetBinding` covers Member/Flow/SpawnHelper/ForkHelper (:1397-1425). The schedule driver is a per-process loop (`spawn_schedule_host`, `meerkat/src/surface/schedule_host.rs:943-983`); surfaces wire `NoopScheduleMobHost` by default — mob targets probe `Missing` and deliveries fail typed `MobRejected` (:290-341) — and only mob-binding surfaces attach `MobMcpScheduleHost`, which delivers Member via `runtime.member_send` and Flow via `run_flow`, with completion polled from `flow_status` (`meerkat-mob-mcp/src/schedule_host.rs:263-313`, completion future :552-562; CLI production wiring `meerkat-cli/src/main.rs:10517`). Schedule stores are realm-local (`meerkat/src/persistence.rs:359-361`). HostRunnable targets dispatch through a process-local registry attached via `with_runnable_host`; no registry ⇒ typed `TargetMissing` (`meerkat-schedule/src/runnable.rs:108-117` trait, :126-129 registry; `schedule_host.rs:364-371, :665-700`). Scheduled spawn/fork helpers capture the **creating session's** effective tool access policy at schedule creation because they later fire under host authority (`meerkat/src/factory.rs:4848-4875`).
**O6 — Mob-target schedules exist only on the controlling host; member hosts run realm-local drivers with the Noop mob host; HostRunnable never crosses hosts; scheduled deliveries to remote members inherit phases 3–6 verbs with zero schedule-specific bridge work.** (Adjudication A3-final: `tools.schedule` IS permitted on remote members — session-target schedules are realm-local facts that fire correctly on the owning host, and the one cross-host shape, a mob-target schedule created member-side, fails TYPED at fire as pinned below. The earlier blanket `NonPortableResource::ScheduleTools` reject is superseded; only workgraph stays admission-denied because its failure shape is silent split-brain with no typed fire-time backstop.)
WHY: `MobMcpScheduleHost` binds the local mob runtime — the single MobMachine owner (D6); a second mob scheduler on a member host would be a second command authority. Scheduled Member/Flow delivery goes through identity-addressed runtime verbs (`member_send`, `run_flow`), so remote-member reachability for schedules is exactly as good as those verbs get in phases 3–6; scheduled-flow completion polls `flow_status`, which is controlling-host-local flow-run state — already correct.
* `rkat mob host` (D6, phase 2) **does** spawn a schedule host: realm-local stores, real session-target delivery (member sessions may carry `tools.schedule`, `build.rs:245-246`, and their session-target schedules must fire on the host owning the session realm), `NoopScheduleMobHost`, and no runnable registry in v1. A mob-target schedule created on a member host fails typed at fire (probe `Missing` → `MarkMisfired` per the `MissingTargetPolicy` default, types.rs:1138-1142; delivery `MobRejected`) — pinned as correct behavior, not fixed.
* HostRunnable host-locality is a **typed invariant**: the registry is process state; `host_runnable` targets never route across hosts; absent registry keeps failing `TargetMissing`. Pinned by test.
* Scheduled-helper containment: the captured creator policy is already **resolved** (the factory refuses to build with unresolved `Inherit` and persists resolved-only metadata — factory.rs:5421-5432/:5461-5463), so when a scheduled SpawnHelper/ForkHelper resolves to remote placement in phases 3+, the captured policy rides `MaterializeMember` as `WireResolvedToolAccessPolicy` unchanged — the same sealed-resolved rule as every other spawn (O3). No schedule-specific policy path.
### 18.6 Approvals (O7)
**Substrate.** Approvals surface **only** on the RPC plane (`approval/request|list|get|decide`, `meerkat-rpc/src/router.rs:1878-1889`) and persist per-RPC-runtime at `store_path/approvals.json` (`meerkat-rpc/src/session_runtime.rs:1402-1435`); REST advertises `approvals: false` (`meerkat-rest/src/lib.rs:3328`). The lifecycle authority is the catalog `ApprovalLifecycleMachine` generated into core (`meerkat-machine-schema/src/catalog/dsl/approval_lifecycle.rs:32+`), owned per-process by `ApprovalService`. There is **no bridge approval vocabulary** (NOT FOUND: zero `approval` hits in `meerkat-contracts/src/wire/supervisor_bridge.rs` and `meerkat-mob/src/runtime/bridge_protocol.rs`), no in-loop producer, and the mob-side forwarding hooks are Declared-only ("this slice does not execute forwarding", `meerkat-mob/src/runtime/handle.rs:867-893`). §8's `ControlScope` has no `Approve` verb.
**O7 — Approvals stay host-local in v1; the gap is a bind-declared capability, never silence.**
WHY: the lead's conditional resolves against proxying — approvals are not event-carried, so they cannot ride the D5 pump; proxying would need a new command family, an `Approve` ControlScope verb, and a single-host decision-authority fencing story (the generated lifecycle authority is per-process — split-brain on `decide` otherwise), all for a surface with **no in-loop producer at HEAD**.
* v1 semantics: an approval record lives on the host whose RPC runtime created it. `HostCapabilityFlags` (§6.1) gains `approval_forwarding: bool`, advertised `false` by v1 member hosts at bind and projected to the console so remote members' approval state is **labeled unavailable**, mirroring the D5 `durable_sessions` degradation pattern — typed capability, not silent difference.
* v2 seed (recorded so it is not re-invented): `ApprovalOwnerRef::ExternalMember { mob_id, member_ref }` (`meerkat-core/src/approval.rs:169-172`) + the Declared forwarding hooks are the reserved vocabulary; pulling it forward requires adding `Approve` to §8's enum, bridge `list/decide` proxy commands, `BRIDGE_CLASSIFIER_FILES` entries, and placing decision authority on exactly the controlling host.
### 18.7 Outbound content taint (O8)
**Substrate.** `BridgeCommand::DeclareMemberOutboundTaint` is fully shipped (`meerkat-contracts/src/wire/supervisor_bridge.rs:250`, payload :284, additive-V3 note :163): the actor installs on a local member's comms runtime directly or relays over the bridge for external-bound members (`meerkat-mob/src/runtime/actor.rs:12043-12061` local, :12063-12087 relay); the member-side receiver validates supervisor authority then calls `set_outbound_content_taint` **fail-closed** — an unsupported runtime rejects rather than drops (`meerkat-runtime/src/comms_drain.rs:3268-3311`). The declaration rides inside the signed envelope region (`meerkat-comms/src/types.rs:54-61`) and does not survive respawn (`meerkat-mob/src/runtime/handle.rs:4838-4841`). No RPC/MCP surface exposes it — embedder-facing `MobHandle`/`MobCommand` only (NOT FOUND in `meerkat-rpc/src`, `meerkat-mob-mcp/src`).
**O8 — Taint generalizes over D1 with one branch extension and stays member-addressed; re-declaration remains embedder-owned; the mob caches no declaration.**
* A host-materialized member is the **bridge case** of the existing local-vs-bridge branch: `provisioner_comms` returns nothing (the member's comms runtime lives on Host B), so the relay path applies — extend `runtime_binding_for_entry`/peer-spec derivation (`actor.rs:12065-12072`) to the new `HostMaterialized { host }` binding (peer = member pubkey @ host acceptor address, D1 demux). Admission classification is pinned **member-addressed** (member-side MeerkatMachine/comms-drain admission, exactly the shipped receiver) — never host-addressed; the taint fact is per-member.
* Ownership: the embedding application at the **controlling** host owns the outbound-taint fact for all mob members regardless of placement and is the only declarer; member hosts never self-declare, and the member host's runtime keeps owning its own session-content bookkeeping as today. Lapse-on-rematerialize is the shipped, documented semantic (fresh context ⇒ no declaration, handle.rs:4838-4841) and stays symmetric across hosts: the embedder re-declares keyed on the member lifecycle signal (`MemberMaterialized` at the new generation, §7.3). The mob deliberately keeps **no** declaration cache to replay — that would be shell shadow state over an embedder-owned fact. Covered by an e2e-fast re-declaration test (§18.11).
### 18.8 Failure semantics (additions to §9)
| Failure | Behavior |
| - | - |
| Flow step to a remote member on a pre-V4 host | `deny_unknown_fields` decode reject on the directive-bearing payload → typed `BridgeCommandRejected` at dispatch → recorded step failure; the overlay can never be silently dropped |
| Overlay-bearing step targeting an `AutonomousHost` member (any placement) | `FlowStepDispatchClassified::RejectedOverlayAutonomous` before delivery; step fails typed at dispatch |
| Directive-bearing delivery the member runtime cannot host as a tracked turn | admission reject `TurnDirectiveUnsupported`; supersedes the mid-run `UnsupportedForMode` shape (provisioner.rs:3328-3333) |
| Member host restarts mid flow-step | the turn is dead; no journal record ever appears; step resolves through the existing machine-owned timeout ladder — typed timeout, no phantom completion |
| Flow turn redelivered (`Deduplicated`) | journal keyed `(identity, generation, input_id)` converges on the single recorded outcome |
| Generation bump during an in-flight step | journal and cursor are generation-scoped; old-generation outcomes never attribute forward; step fails typed on generation change |
| Remote placement of an explicitly workgraph-tooled profile | `SpawnMemberAdmissionResolved { NonPortableResource: WorkgraphTools }` on the spawn-exec ladder (every spawn path) — split-brain unrepresentable; remote placement carrying an inherited tool-visibility authority is rejected separately as `NonPortableInheritedToolFilter` (A4) |
| Mob-target schedule created on a member host | probe `Missing` (Noop host) → `MarkMisfired`; delivery `MobRejected` — typed, pinned |
| `host_runnable` schedule with no registry | `TargetMissing` typed (unchanged, pinned) |
| `Inherit` on the materialize wire | unrepresentable at decode (`WireResolvedToolAccessPolicy`); member-host factory `UnresolvedInherit` is the backstop |
| Approval involving a remotely-placed member | record stays on the creating host; `approval_forwarding=false` declared at bind; console labels the gap |
| Taint declaration after remote respawn | lapsed by design; embedder re-declares on the lifecycle signal; unsupported member runtime rejects fail-closed |
### 18.9 Machine deltas (folds into §6; catalog first, dispositions and TLC bounds per §6 preamble)
* **MobMachine** — `ClassifyFlowStepDispatch { run_id, step_id, target, overlay_present }` input → `FlowStepDispatchClassified { run_id, step_id, target, dispatch: Enum }` effect (`Local | RemoteTurnDirective | RejectedOverlayAutonomous | RejectedHostIncapable`), guarded on `member_runtime_modes` + `member_placement` + `host_capabilities` (all machine facts; `RejectedHostIncapable` covers a `durable_sessions=false` host — tracked turns need the durable outcome journal); disposition `local seam SurfaceResultAlignment` (the `FlowFrameTerminalStatusClassified` precedent, catalog mob\_machine.rs:1412).
* **MobMachine** — `ResolveSpawnMemberAdmission` gains `workgraph_required: bool` + `placement_remote: bool` input facts and a Denied transition guarded `placement_remote && workgraph_required`, emitting `MobSpawnMemberAdmissionKind::NonPortableResource { kind: WorkgraphTools }` (merged vocabulary, A4), evaluated on the spawn-exec ladder choke-point.
* **MobMachine** — `privileged_budget_split_policy_present` deleted from the `ResolveSpawnMemberAdmission` input and both privileged-args guards (catalog mob\_machine.rs:1997/:2018/:2031) as part of the O4 vocabulary removal.
* **§6.1 field extension** — `HostCapabilityFlags` gains `approval_forwarding: bool` (v1 member hosts advertise `false`).
* **MobHostBindingAuthority (§6.3)** — new facts: `turn_outcomes: Map<(AgentIdentity, Generation, InputId), RecordedTurnOutcome { terminal_seq, outcome }>` with `RecordTurnOutcome` and exact `AcknowledgeTurnOutcome` inputs. Redelivery never creates a second row; acknowledgement removes only an existing exact row and an absent acknowledgement retains no tombstone. Acknowledged rows are pruned immediately, with remaining generation-scoped retention also pruned at `ReleaseMember`/generation retirement; admission class `TurnDirectiveUnsupported` added to the §6.3 vocabulary. Persisted in the same host-realm SQLite table family.
* No Schedule/Occurrence machine changes; no ApprovalLifecycle changes; no new ControlScope verbs (explicitly: `Approve` is NOT added in v1).
### 18.10 Wire deltas (folds into §7; V4; `regen-schemas` + parity gates per §10 preamble)
* `BridgeDeliveryPayload` + `turn: Option }>` (absent-omitted; fail-closed on old receivers via existing `deny_unknown_fields`).
* `BridgeDeliveryRejectionCause` + `TurnDirectiveUnsupported { detail }`.
* `PollMemberEvents` exact `outcome_acks` + independent `max_outcomes`; `BridgeReply::MemberEventsPage` bounded `turn_outcomes: Vec` + `outcomes_complete` sidecar metadata (**`EventEnvelope` and event page rows unchanged** — restating the §7 envelope freeze).
* `MaterializeMember.spec.tool_access_policy: Option` — `Inherit` unrepresentable; `MaterializeMember.spec.overlay.budget_limits: Option` is the sole digest-covered budget carrier.
* Cross-host spawn request carries `requested_tool_access_policy_present: bool` separately from `resolved_tool_access_policy` (O3 no-laundering rule).
* `BridgeCapabilities`/`HostCapabilityFlags` + `approval_forwarding: bool`.
* **Removals**: `WireBudgetSplitPolicy` (wire/mob.rs:713, emit.rs:1392), RPC `budget_split_policy` param (handlers/mob.rs:436-438) — clean break, no tombstones.
### 18.11 Phase and test additions (folds into §10/§11)
Phase assignments (no new phase; the flow work is the completion-consumption line item §10.6 already reserves, made concrete):
* **Phase 1**: all §18.9 catalog deltas + §18.10 contracts deltas + the §7.3 budget-sentence correction; TLC bounds unchanged (2 hosts × 3 members) now covering flow-dispatch classification and the workgraph-placement denial.
* **Phase 2**: `rkat mob host` spawns the schedule host (realm-local stores, Noop mob host, no runnable registry).
* **Phase 3**: sealed policy + budget seed in materialize (member host persists received policy and applies budget seed via the `with_spawn_budget_limits` shape); presence-fact/resolved-policy split on member-host-originated spawns; workgraph placement gate live; BudgetSplitPolicy code removal (launch.rs, spec/builder, tools, RPC); taint relay branch extended to `HostMaterialized`.
* **Phase 5**: no deltas from this dimension (recorded: no `Approve` scope).
* **Phase 6**: remote flow dispatch end-to-end — directive send, member-side tracked-turn admission + shared terminal classifier + durable journal, pump sidecar consumption, remote flow ticket, `ClassifyFlowStepDispatch` wiring, deletion of the shell-side autonomous-overlay reject (`actor_turn_executor.rs:275-279`) in favor of the machine classification; supersede the pinned peer-only failure test (tests.rs:23945-24018) with the new typed shapes; `BRIDGE_CLASSIFIER_FILES` (xtask/src/bridge\_classifier.rs:27-31) extended with every new `BridgeReply` consumer (journal/pump/flow-executor files). *(v4.1.2 errata, ADJ-P6-13: the shell-side overlay reject was deleted in phase 6 as planned — the guards moved into the machine (`mob_machine.rs` `ClassifyFlowStepDispatch`), so the `actor_turn_executor.rs:275-279` anchor is superseded.)*
* **Phase 7**: console `approval_forwarding` labeling; docs.
Test rows (lane-mapped; deterministic lanes are the ratchet, §11 preamble):
* **unit**: `WireResolvedToolAccessPolicy` decode rejects `inherit` (serde, fail-closed); shared terminal classifier covers all seven terminal events + channel-close; journal dedup/generation-scoping; `ClassifyFlowStepDispatch` kernel matrix; workgraph-denial guard; TLC green at 2×3 with the new inputs.
* **int / e2e-fast** (two-hosts-in-one-process harness): flow run with a remote TurnDriven member completes via journal+sidecar, overlay enforced member-side (denied tool ⇒ `access_denied` inside the remote turn); remote AutonomousHost step without overlay completes; overlay+autonomous rejected at dispatch classification (local and remote, same cause); attribution correct under interleaved queued inputs (peer send racing a flow step); `Deduplicated` redelivery converges on one outcome; generation bump mid-step fails typed; pre-V4 directive decode-reject shape; restricted parent on Host B spawns onto Host A and Host B — both children contained, wire never carries `Inherit`; budget seed enforced member-side (`max_tool_calls` ⇒ typed budget stop); workgraph remote placement denied / local allowed / `tools.workgraph=false` remote allowed with `task-workflow` preload; scheduled `member_send` and `run_flow` to a remote member deliver via runtime verbs, scheduled-flow completion via `flow_status`; mob-target schedule on member host fails typed (Noop pin); `host_runnable` absent-registry `TargetMissing` pin; taint re-declaration after remote respawn (declare → rematerialize → verify lapse → re-declare → envelope carries claim); console labels `approval_forwarding=false`.
* **e2e-system** (real multi-process TCP): flow run across `rkat mob host` with member-host restart mid-step ⇒ typed step timeout, run ledger honest; member-host schedule driver fires a session-target schedule from the host's realm store.
* **e2e-smoke** (live): the §11 kitchen-sink A/B/C run includes one cross-host flow with an overlay-bearing step.
### 18.12 Non-goals additions (folds into §12) and gotchas (folds into §13)
Non-goals (each with typed degradation, never silence): cross-host shared workgraph (remote placement of workgraph-tooled profiles is machine-**denied** at spawn admission; degrade by local placement or `tools.workgraph=false`); approval forwarding (approvals stay host-local; `approval_forwarding=false` declared at bind, console-labeled; v2 seed = `ApprovalOwnerRef::ExternalMember` + Declared hooks + `Approve` scope); budget split computation (vocabulary **deleted**; `budget_limits` is the only budget primitive, enforced member-locally; aggregate mob budget stays a non-goal); mob-target scheduling on member hosts (Noop host, typed `Missing`/`MobRejected`, pinned); cross-host `host_runnable` dispatch (never; typed `TargetMissing` pinned).
Gotchas: **11.** `BridgeDeliveryPayload` is `deny_unknown_fields` (supervisor\_bridge.rs:844) — the turn directive is fail-closed against old hosts by construction; do not "help" by tolerating unknown fields. **12.** `inject_with_subscription` has no overlay parameter (event\_injector.rs:80-86) — autonomous members cannot enforce overlays on any host; keep the typed reject, do not smuggle overlays through injection. **13.** Workgraph realm scope is a string keyed into a host-local sqlite (store.rs:91-96; factory.rs:4919-4935) — the same `mob.{id}` on two hosts is two disjoint stores; the spawn-admission gate is the only thing standing between you and silent split-brain. **14.** Only surfaces binding `MobMcpState` get real mob schedule delivery; `NoopScheduleMobHost` on member hosts is correct, not a bug — do not wire a second mob scheduler. **15.** Terminal classification must have exactly one owner (the shared core classifier) — a second terminal-event list on the member host is the split-terminality bug class by construction.
## 19. Member session lifecycle across hosts: launch modes, disposal, transcript edits, compaction, memory, claims
Scope rule for this whole section: **everything that executes inside the member's agent loop or its session service runs on the host that owns the session's realm** (§3: realms are not distributed). Compaction, memory indexing, durable archive adjudication, transcript revisions, and session-identity claims are all realm-local by construction — verified below. The only new cross-host machinery this dimension needs is (a) `launch_mode` semantics inside `MaterializeMember`, (b) a truthful durable-disposal contract for `ReleaseMember`, and (c) one typed field on an existing MobMachine signal so the machine stops recording archives that never happened.
Substrate at HEAD, verified:
* `MemberLaunchMode` = `Fresh | Resume { bridge_session_id } | Fork { source_member_id, fork_context }`; `ForkContext` = `FullHistory | LastMessages { count }` (`meerkat-mob/src/launch.rs:20-35,49-61`). Fork is **not** `Session::fork()` copy-on-write: the source history is read via the controlling host's local session service (`SessionServiceHistoryExt::read_history`, actor.rs:9179-9191), rendered to text (`render_fork_context`, actor.rs:911), and prepended to the child's initial prompt as a plain `ContentBlock::Text` **before** provisioning (actor.rs:9240-9248). The child gets a fresh session.
* The fork source resolve is local-only: roster → `MemberRef::bridge_session_id()` (`meerkat-mob/src/event.rs:114-119`); a source without a session yields **untyped** `MobError::Internal("fork source ... has no session")` (actor.rs:9146-9150); `LastMessages` reads local `view.state.message_count` (actor.rs:9158-9176).
* `Resume` validation is entirely realm-local: `is_member_active` fast-path (actor.rs:8978-8987), `AutonomousHost` requires a local interaction-event injector (8988-8997), comms-runtime presence when wiring exists (8999-9012), inactive fallback = `load_persisted_session` + `build_resumed_agent_config` (9038-9074); the persistent-unsupported/missing-snapshot exits are untyped `Internal` today (9044-9048, 9128-9130).
* Local member retire archives durably and loudly: disposal pipeline `StopHostLoop → NotifyPeers → ArchiveSession` (`meerkat-mob/src/runtime/disposal.rs:28-32`), `ArchiveSession` failure is critical (actor.rs:14257-14275), realized as `SessionBackend::retire_member → archive_with_authority_then_unregister → archive_with_mob_lifecycle_authority` (provisioner.rs:2185-2195, 857-936) with a deliberate carve-out: **adopted host-owned sessions** (a `Resume` over a store the mob does not own) get runtime-retire + binding release only (provisioner.rs:868-897). `dispose_archive_session` tolerates `NotFound` as success (actor.rs:15697-15716).
* Remote (peer-only) retire today is runtime-retire only: controlling side sends `BridgeCommand::RetireMember` then `mark_member_retired` (provisioner.rs:3197-3248); the receiver executes `adapter.retire_runtime(session_id)` — no SessionDocumentMachine archive (`meerkat-runtime/src/comms_drain.rs:2788-2825`); `DestroyMember` likewise maps to `RuntimeControlPlane::destroy` (comms\_drain.rs:2827-2864).
* **And yet** the machine records an archive either way: `ObserveMemberRetirementArchived` is signalled whenever the `ArchiveSession` step "completed" (actor.rs:15285-15289, 15725-15737) — which is true for the host-owned carve-out and for the remote runtime-only retire. The archived fact is a fiction in both cases.
* Archive truth is owned by the canonical SessionDocumentMachine: `Archived` is the absorbing terminal, re-archive is the explicit `AlreadyArchived` verdict (`meerkat-machine-schema/src/catalog/dsl/session_document.rs:343-383`), realized fail-closed durable-document-commit-FIRST / runtime-retire-SECOND by `MachineSessionArchiveProtocol` (`meerkat-session/src/persistent.rs:1521-1600`) and read back via `session_archived_by_authority` / `reject_if_archived_session` (persistent.rs:1608-1650). All per-session, realm-local.
* Transcript edits (`session/fork_at`, `fork_replace`, `rewrite_transcript`, `transcript_revision`, `transcript_revisions`, `restore_transcript_revision`) are RPC-only (`meerkat-contracts/src/rpc_catalog.rs:135-160`; REST deliberately dropped them — rest\_catalog.rs:174-181), implemented only by `PersistentSessionService` (persistent.rs:5652-5655; the core trait defaults are typed `Unsupported`, `meerkat-core/src/service/mod.rs:2016-2062`), and admitted runtime-locally (staged-session check + `resolve_transcript_edit_admission` on the local `MeerkatMachine`, `meerkat-rpc/src/session_runtime.rs:7563-7614`).
* Compaction runs inside the agent loop of whichever host runs the session (`CheckCompaction` effect → `run_compaction`, `meerkat-core/src/agent/state.rs:1233-1265`); `DefaultCompactor` + optional curator are wired at `AgentFactory` build time (`meerkat/src/factory.rs:5700-5716`); `CompactionCurator` is an in-process trait with no wire form (`meerkat-core/src/compact.rs:175-192`). Compaction discards index into `MemoryIndexScope::for_session` in the same host's realm (agent/state.rs:1365-1373).
* `MemoryStore::drop_scope` / `enumerate_scoped` have **zero production callers** at HEAD: workspace grep hits only `meerkat-core/src/memory.rs`, `meerkat-memory/src/simple.rs`, `meerkat-memory/src/hnsw.rs`. NOT FOUND: any caller in mob/session/runtime/cli/rpc/rest/facade/mcp-server/mob-mcp. Nobody cleans member memory on retire — locally or remotely. Pre-existing, symmetric.
* Session-identity claims are process-scoped by design: `SessionClaimHandle` + `DefaultSessionClaimRegistry::global()` (`meerkat-core/src/handles.rs:2096-2148`), one registry per `MeerkatMachine` (`meerkat-runtime/src/meerkat_machine/mod.rs:2365-2373`; re-export `meerkat-runtime/src/handles/session_claim.rs:1-17`), acquired at comms-runtime construction and held RAII (`meerkat-comms/src/runtime/comms_runtime.rs:1573,1890-1893`).
* Whole-mob resume is controlling-realm-local: `reconcile_resume` census over the local session service (builder.rs:2638-2652), orphan archive (2662-2675), recreate-from-snapshot for members **with** `member_session_bindings` entries (the skip at 2680-2687 applies only to members without a binding). Ops-owner anchoring for members without a local session goes through `owner_bridge_session_id` with a **silent skip when it is `None`** (actor.rs:8541-8562, skip at 8553-8554) and a fail-closed divergence check (8582-8600).
* Owner-archive cascade runs controlling-host-local: `destroy_bridge_session_mobs` + orphan scavenging (`meerkat-mob-mcp/src/lib.rs:1784-1816, 1826-1863`), remote teardown bounded by `remote_destroy_cleanup_deadline` (actor.rs:15816-15823).
### 19.L1 — `MaterializeMember.launch_mode` carries `Fresh | Resume` only; `Fork` is unrepresentable on the wire; `Resume` executes on the member host.
> Current amendment: the shipping private wire is `Fresh {}` or `Resume { session_id, resume_from_role? }`. The optional predecessor role is one-request authority for an exact cold durable role migration. Omission remains strict same-role resume. `Fork` is still structurally absent. The following section records the original v4.1 launch design before that additive trusted-host field.
Wire enum (V4): `MaterializeLaunchMode { Fresh, Resume { session_id } }`. WHY the split: fork collapses to prompt text on the controlling host **before** any provisioning happens (actor.rs:9240-9248) — the rendered context belongs to the separate first-turn/kickoff input, delivered after materialization via `DeliverMemberInput` (§15.3), not to `PortableMemberSpec`, with no new member-side fork machinery — while the entire resume validation chain is realm-local by nature (live-session check, injector capability, comms presence, persisted snapshot: actor.rs:8978-9074) and the session being resumed lives in the member host's realm. A `Fork` payload on this wire would mean shipping one host's history into another host's admission path — exactly what §3 forbids. The exclusion is structural at BOTH carriers: `MaterializeMember.launch` is the closed `{Fresh, Resume}` wire enum, and `PortableMemberSpec` (§14.2/§15.3) has NO launch-mode field at all — the domain `SpawnMemberSpec.launch_mode: MemberLaunchMode` (serde-representable including `Fork`, handle.rs:1870, launch.rs:20-35) never serializes into the spec, so the second launch-mode channel the domain type would otherwise open is closed by construction (one semantic fact, one wire owner). `deny_unknown_fields` + per-command V4 decode make a fork payload a fail-closed decode error; if a future variant ever reaches admission, `MobHostBindingAuthority` rejects it with typed `LaunchModeUnsupported`.
> **Carrier erratum:** The original “materialize spec's initial prompt” wording in L1/L2 and “spec-carried prompt text” in the Phase 3 checklist conflated build material with kickoff input. This corrects the carrier description only: the v4.1 Fresh/Resume launch decision and Phase 3 remote-target / Phase 6 remote-source sequence are unchanged.
Member-side `Resume` realization mirrors the local durability chain against **its** realm, but a newly admitted higher generation never adopts a live runtime in place: any live incarnation is quiesced and its event projection drained before `load_persisted_session` + `build_resumed_agent_config`. `AutonomousHost` still requires interaction-event-injector capability and wiring still requires comms presence. The ack is where the typed causes live — the local path's untyped `Internal` exits (actor.rs:9044-9048, 9128-9130) must NOT be inherited: `ResumeSessionNotFound` (no snapshot), `CapabilityMissing { capability }` (injector absent for autonomous mode; `durable_sessions` absent for snapshot restore), `LaunchModeUnsupported`. `MemberMaterialized.launch_outcome` retains the wire vocabulary `Fresh | ResumedLive | ResumedFromSnapshot` for compatibility with already-recorded replies, while new higher-generation same-session admissions record `ResumedFromSnapshot`. Idempotency is the existing tuple rule (§7, `MaterializeMember`): replay on the same `(agent_identity, generation, fence_token)` returns the recorded result including the recorded `launch_outcome`.
Controlling-side admission: `ResolveSpawnMemberAdmission` placement validation (§6.1) additionally rejects a `Resume` whose session id is bound (in `member_session_bindings` + `member_placement`) to a **different** placement than the spawn requests — typed `LaunchModePlacementMismatch`. A resume id the machine has no placement fact for crosses the wire and is adjudicated member-side (member-side validation is authoritative; the controlling check only catches provable mismatches early).
### 19.L2 — Cross-host fork: remote TARGET works at phase 3 by construction; remote SOURCE is a proxied `ReadMemberHistory` read landing with phase 6; the untyped no-session error is retyped now.
This deliberately revises the "cross-host fork is a v1 non-goal" prior, on evidence: the prior priced a `FullHistory` CoW that **does not exist in this codebase** — mob fork never touches `Session::fork()`; it is a history read + text render (launch.rs:49-61 doc contract; actor.rs:9179-9191, 9240-9248). A remote *source* therefore uses the already-planned paged `ReadMemberHistory` bridge command (§7.4); fork adds **no new wire surface**. Decision:
* **Remote target** (fork from a local source onto a remote host): works as soon as phase 3 lands — the controlling-side rendered fork text travels in the separate first-turn/kickoff input via `DeliverMemberInput` after materialization, not in `PortableMemberSpec` (§15.3). Nothing to build.
* **Remote source**: the fork context read routes through the same placement switch as `mob/member_history` (§7.4) — local member ⇒ local `read_history`; remote member ⇒ `ReadMemberHistory` pages, accumulated controlling-side against the first page's pinned `message_count`, then rendered by the same `render_fork_context`. `LastMessages` is tail-addressed in its first request; normally that one byte-bounded page is complete, while large rows may require monotonic `next_index` continuation. A single row that cannot fit the transport-safe reply budget fails typed rather than timing out or silently dropping fork context. Ships with phase 6.
* **Typed reject, both eras**: `MobError::ForkSourceUnavailable { source_member_id, cause: NoSession | RemoteReadUnavailable }` replaces the untyped `MobError::Internal("fork source ... has no session")` (actor.rs:9146-9150). `NoSession` covers peer-only external sources (unchanged semantics, now typed); `RemoteReadUnavailable` covers a remote source before phase 6 lands or when the owning host is unreachable (`Unavailable` bridge outcome). The retype lands in phase 3 so the phase-3→6 window never ships the untyped shape.
Authorization: fork runs under the spawn admission path; the bridge read is issued by the already-authorized supervisor (D3 plane-b enforcement happens at the controlling host's MobCommand dispatch — the spawner needs spawn authority, not `ReadHistory` scope; the mob is reading its own member's history to provision its own member).
### 19.L3 — `ReleaseMember` performs full mob-owned durable disposal on the owning host; disposal class is a typed reply fact. (Amends §7 host-addressed commands and §7.3.)
For `HostMaterialized` members the mob minted the session, so the mob archives it — the exact ownership discriminator that already exists locally: `archive_with_authority_then_unregister`'s authority-ownership read (provisioner.rs:868-897) separates mob-owned durable records (archive) from adopted host-owned sessions (runtime retire + binding release only). `ReleaseMember` realization on the owning host, in order:
1. `MobHostBindingAuthority` admission: `(agent_identity, generation, fence_token)` validated; replay at the recorded tuple returns the **recorded disposal** (dedup memory extended to remember release outcomes, mirroring materialize dedup, §6.3); a lower tuple is typed `StaleFence`.
2. Two-layer disposal order, mirroring the local realization EXACTLY (an implementer following a flat 'archive-first' rule would archive a still-running session and race): OUTER quiesce first — cancel any active turn → control-plane runtime retire → drain — the `retire_runtime_before_archive` shape (provisioner.rs:866 → :627-686); THEN the archive protocol, whose commit-first ordering is protocol-INTERNAL (`MachineSessionArchiveProtocol`, persistent.rs:1521-1600: the SessionDocumentMachine `ArchiveSessionDocument` durable commit lands in the member host's realm before the machine-owned document `Retire` terminal). A failure in either layer fails the release with a typed cause.
3. Idempotency mirrors local tolerance: `NotFound` and `AlreadyArchived` (session\_document.rs:369-383) resolve to success-class outcomes (actor.rs:15708-15716 precedent).
4. Claims/memory: the member's comms-runtime teardown releases its session-identity claim via RAII (comms\_runtime.rs:1573) — no bridge involvement; no memory cleanup (L6).
Reply: `BridgeReply::MemberReleased { disposal: MemberSessionDisposal }` with `MemberSessionDisposal = Archived | AlreadyArchived | RuntimeReleasedOnly { cause: HostOwnedSession | NoDurableSessions }`. An ephemeral member host (`durable_sessions=false` at bind) performs runtime retire + binding/claim release and answers `RuntimeReleasedOnly { NoDurableSessions }` — typed capability degradation, declared at bind, never silent.
One disposal verb per membership class: `HostMaterialized` members are released **only** via host-addressed `ReleaseMember` (fence-validated, host-dedup'd); the member-addressed `RetireMember`/`DestroyMember` commands are never sent to them and keep their exact current semantics for legacy peer-only `External` members (comms\_drain.rs:2788-2864 unchanged). `ReleaseMember` doubles as the orphan-reconciliation verb (§7.3, §9) — same admission, same reply.
Cascade mapping: single-member retire of a `HostMaterialized` member keeps `ArchiveSession` criticality (actor.rs:14257-14275) — no `MemberReleased`, or a reply the tolerance rules don't absorb, fails the retire loudly with the typed bridge cause. The owner-archive destroy cascade (`destroy_bridge_session_mobs`, mob-mcp lib.rs:1784-1816; scavenging :1826-1863) stays bounded best-effort under `remote_destroy_cleanup_deadline` (actor.rs:15816-15823) with per-member failures recorded; anything missed is reclaimed by the next `HostStatus` reconciliation at a stale fence.
### 19.L4 — `ObserveMemberRetirementArchived` AND its destroy sibling stop lying: both signals gain the typed `disposal` field, total over local and remote. (Amends §6.1 machine deltas.)
Today the shell signals it whenever the `ArchiveSession` step completed (actor.rs:15285-15289) — which includes the local host-owned carve-out (no mob archive happened, provisioner.rs:868-897) and, if remote retire shipped as-is, the runtime-only bridge retire (comms\_drain.rs:2802). Catalog delta: BOTH DSL signals — `ObserveMemberRetirementArchived` (`meerkat-machine-schema/src/catalog/dsl/mob_machine.rs:1071`, transitions :4909, :4938) and `ObserveDestroyMemberRetirementArchived` (mob\_machine.rs:1072, transitions :5039-:5161; the destroy path drives it from the identical step-completion gate via `dispose_member_for_destroy` → `dispose_archive_session`, actor.rs:15356-15372) — gain `disposal: MemberSessionDisposal`; fixing retire while the destroy sibling stands would leave the same fiction reachable through destroy-over-remote-members, so both land in one change-set (no divergence window) — the same vocabulary as the wire reply. The shell may populate it **only** from a typed disposal outcome it actually observed: the local provisioner path classifies mob-archived vs host-owned-released; the remote path copies `MemberReleased.disposal` from the ack (`AlreadyArchived` folds into `Archived` for the machine fact — both mean the durable terminal holds). No inference, no default. `mob/member_status` projects the recorded class so consoles can distinguish "durably archived" from "released, durable lifecycle owned elsewhere". Alphabet/schema parity and `seam-inventory` dispositions per §6 rules.
### 19.L5 — Whole-mob resume and ops-owner anchoring are placement-gated. (Amends §7.3 recovery and §6.1 invariants.)
* `reconcile_resume`'s census and orphan-archive only ever see the controlling realm (builder.rs:2638-2675) — correct for local members, and remote session ids in `member_session_bindings` are harmless there (the archive set is `active_local − machine`). But the missing-session **recreate** loop is a live trap: `HostMaterialized` members DO have `member_session_bindings` entries (from the materialize ack, §6.1), are never in the local `active_ids`, and would enter local snapshot restore (builder.rs:2680-2687 only skips members **without** a binding) — producing a bogus local recreate of a remote member. The loop gains a placement gate: `member_placement` present ⇒ skip local recreate entirely; remote members reconcile via the (re)bind `HostStatus` sweep + `ReleaseMember`-at-stale-fence (§7.3) and, where the host reports the session live, plain rebinding — Resume of a host-materialized member **is** the rebind/recovery path, not a new mechanism.
* Ops-owner anchoring extends the owner-bridge-session invariant (commit `093af8cf1`; actor.rs:8541-8562, fail-closed check 8565-8633): `HostMaterialized` members bind their controlling-side ops owner to the MobMachine-owned `owner_bridge_session_id` exactly like peer-only members. The **silent skip when `owner_bridge_session_id` is `None`** (actor.rs:8553-8554) does not extend to them: §6.1 gains the invariant `member_placement ≠ ∅ ⇒ owner_bridge_session_id ≠ None`, enforced at `ResolveSpawnMemberAdmission` (typed placement reject when a remote spawn is requested on a mob created without owner-bridge authority), so the restore path can fail closed instead of skipping.
### 19.L6 — Host-local by construction: compaction, memory, claims. (Statements + §12/§13 amendments; zero new machinery.)
* **Compaction** is member-host-local because it is loop-internal (agent/state.rs:1233-1265): the member host's provider credentials fund `DefaultCompactor`'s summarization call (wired at its `AgentFactory` build, factory.rs:5700-5716). `CompactionCurator` is an in-process trait with no wire form (compact.rs:175-192) — a curator configured on the controlling host **cannot** apply to remote members. v1 decision: curated compaction for remote members is configured on the member host process (`rkat mob host` composition), or not at all; `DefaultCompactor` is always the member-host default. There is no silent difference to type: curator presence is a host build choice, never a mob fact, and compaction failure semantics (`CompactionFailed` event, history preserved) are identical on every host.
* **Memory**: compaction discards index under `MemoryIndexScope::for_session` in the owning host's realm (agent/state.rs:1365-1373). Cleanup is a pre-existing repo-wide gap — `drop_scope`/`enumerate_scoped` have zero production callers (NOT FOUND outside `meerkat-core/src/memory.rs` and `meerkat-memory` impls/tests) — and multi-host does not widen it, only multiplies where orphaned scopes live. v1 records the binding constraint so the future fix cannot be built wrong: memory cleanup executes **on the owning host inside its release/archive path** (the L3 realization is the insertion point), never as a bridge-served remote store handle (§3).
* **Claims**: `SessionClaimHandle` stays process-scoped — one registry per `MeerkatMachine` (mod.rs:2365-2373), process-global default for bare factory callers (handles.rs:2125-2148). The member host's own machine registry claims each materialized session at comms construction and releases via RAII on teardown (comms\_runtime.rs:1573, 1890-1893) — including on `ReleaseMember`. Cross-host duplicate-materialization protection is `MobHostBindingAuthority`'s `(identity, generation, fence)` dedup + fence monotonicity (§6.3), which is the distributed analog of the in-process claim. New gotcha (§13): the host acceptor registry and `MobHostBindingAuthority` must not grow into a distributed claim registry — "this session id is active" remains a per-process fact.
### 19.L7 — Transcript edits on remote member sessions are a typed v1 non-goal. (Amends §12.)
The admission chain is irreducibly host-local three times over: staged-session materialization check + `MeerkatMachine` transcript-edit admission on the local `runtime_adapter` (session\_runtime.rs:7563-7614), sole implementation in `PersistentSessionService` (persistent.rs:5652-5655) over the owning realm's revision store, and no `ControlScope` for rewrite exists (§8 enum). Shipping console parity would mean a five-command bridge family with revision paging, CAS-parent semantics, and a new scope — none of which has a named consumer. Decision:
* v1: the family is owning-host-local. Raw session-id RPC verbs keep realm-local `NotFound` — truthful under §3, the id names no session in this realm. The by-identity console surface never dangles the verbs: `mob/member_status` already gains `placement` (§7 projections); it additionally carries the host's `durable_sessions`-derived lifecycle capability flags so consoles label transcript editing (and revision browsing) unavailable for remote members instead of attempting and mis-reading `NotFound`. Precedent for a deliberately absent surface: the REST catalog's recorded drop of this same family (rest\_catalog.rs:174-181).
* v2 seed: `RewriteMemberTranscript` / `ListMemberTranscriptRevisions` / `RestoreMemberTranscriptRevision` bridge commands gated on a new `ControlScope::RewriteTranscript`, member-side admission reusing the existing machinery — only after phase 5, per the §8 ordering rule.
### 19.F Failure semantics (rows to add to the §9 table)
| Failure | Behavior |
| - | - |
| `Resume` names a session the member host cannot produce | Typed ack `ResumeSessionNotFound`; spawn aborts via the ladder / `member_materialization_failures`; never a silent `Fresh` fallback. |
| `Resume` of an autonomous-mode member on a host without an interaction injector; snapshot restore on `durable_sessions=false` | Typed ack `CapabilityMissing { capability }`; admission already rejects provably-missing capabilities controlling-side (§7.3). |
| `Fork` variant reaches the materialize wire | Fail-closed V4 decode error; admission-level `LaunchModeUnsupported` if ever constructed. |
| Fork source has no readable session / owning host unreachable | Typed `ForkSourceUnavailable` with cause `NoSession` or `RemoteReadUnavailable`; spawn does not proceed with partial context. |
| `ReleaseMember` on an ephemeral host | `MemberReleased { RuntimeReleasedOnly { NoDurableSessions } }`; machine records the typed disposal class; console labels it; capability was declared at bind. |
| `ReleaseMember` replay / already archived | Recorded-disposal replay at the same fence; `AlreadyArchived`/`NotFound` fold to success-class (actor.rs:15708-15716 tolerance, session\_document.rs `AlreadyArchived` verdict). |
| Remote spawn requested on a mob without `owner_bridge_session_id` | Typed admission reject (L5 invariant); the silent ops-owner skip never applies to `HostMaterialized`. |
| Transcript-edit verb against a remote member's session id | Realm-local `NotFound` (truthful); `mob/member_status` capability flags prevent consoles from offering the verb. |
### 19.P Work items by phase
* **Phase 1 (catalog + contracts):** `MaterializeLaunchMode` wire enum + `launch_outcome` on `MemberMaterialized`; `MemberReleased { disposal }` reply + `MemberSessionDisposal` vocabulary; rejection causes `ResumeSessionNotFound`, `CapabilityMissing`, `LaunchModeUnsupported`, `LaunchModePlacementMismatch`; `disposal` field on the `ObserveMemberRetirementArchived` DSL signal + transitions; `HostCapabilityFlags.durable_sessions`; §6.1 invariant `member_placement ≠ ∅ ⇒ owner_bridge_session_id ≠ None`; `MobHostBindingAuthority` release-dedup memory; `mob/member_status` lifecycle capability flags; `MemberHistoryPage.message_count` pinned in the page mirror.
* **Phase 3 (placement + materialization):** member-side `Resume` realization + typed ack causes; `ForkSourceUnavailable` retype (replaces actor.rs:9146-9150 `Internal`); remote-target fork (falls out of controlling-side rendered first-turn/kickoff input, delivered after materialization by `DeliverMemberInput`, not `PortableMemberSpec` — covered by tests, no new code); `ReleaseMember` full disposal realization (archive-first order, idempotency, ephemeral degradation) + controlling-side truthful `disposal` classification for local AND remote retire; `reconcile_resume` placement gate on the recreate loop; ops-owner anchoring fail-closed for `HostMaterialized`; member-host claim acquisition/release through its machine registry.
* **Phase 6 (events/history):** remote-source fork context via `ReadMemberHistory` pages (shared placement switch with `mob/member_history`); `LastMessages` offset math over the mirrored `message_count`.
* **Phase 7 (surfaces):** console labeling of per-member lifecycle capabilities (transcript edits, revisions, resume-after-restart) from `mob/member_status`; `rkat` mob status output includes disposal class and placement.
### 19.N Non-goals (rows to add to §12)
* Remote transcript-edit family (rewrite/revisions/restore/fork\_at/fork\_replace over the bridge) — typed degradation: realm-local `NotFound` + `mob/member_status` capability labeling; v2 seed: bridge command family behind `ControlScope::RewriteTranscript`.
* Cross-host `CompactionCurator` propagation — curators are member-host process composition; `DefaultCompactor` is the universal default; no wire form exists or is added.
* Memory-scope cleanup on retire — pre-existing zero-caller gap, symmetric local/remote; constraint recorded: future cleanup is owning-host-only inside the L3 release path.
* Distributed session-claim registry — claims stay process-scoped; the cross-host analog is `MobHostBindingAuthority` fencing/dedup.
* Cross-host `Session::fork()` copy-on-write — does not exist even locally (fork is a rendered-context spawn); nothing degrades because nothing is removed.
### 19.G Gotchas (rows to add to §13)
11. `ObserveMemberRetirementArchived` fires today on `ArchiveSession` step completion even when the realization was runtime-retire-only (host-owned carve-out, provisioner.rs:868-897) — when adding the `disposal` field, classify from the typed provisioner/bridge outcome, never from step completion.
12. `reconcile_resume`'s recreate loop keys on `member_session_bindings` presence (builder.rs:2680-2687) — `HostMaterialized` members HAVE bindings; forgetting the placement gate silently recreates remote members locally.
13. The ops-owner `None`-skip (actor.rs:8553-8554) is load-bearing for legacy peer-only mobs; do not delete it — fence it off for `HostMaterialized` via the admission invariant instead.
14. `MemberHistoryPage` must mirror `SessionHistoryPage.message_count` (service/mod.rs:1497-1505) or remote `LastMessages` fork needs a second round-trip that nobody will remember to add.
## 20. Threat model and security posture
This section answers the question no chapter owned: what does an attacker get at each trust boundary, and what is the containment claim. Every statement here is consistent with the mechanisms in §§6-19; where v1 is deliberately undefended, it says so plainly (A22 discipline — never laundered).
### 20.1 The containment invariant
**A compromised member host controls exactly its placed members — never the mob.** Proven against each channel it holds:
* **Peer impersonation — YES, bounded to placed members.** The host holds every placed member's private key (§7.3 key custody: minted and stays on the host), so it can sign envelopes as those members to every peer that trusts them. It holds NO other keys: not the supervisor's (the bridge authority keypair lives in the controlling host's `SupervisorAuthorityRecord`), not other hosts' members', not the mob owner's. Its reach is exactly the wiring graph its members legitimately have.
* **Observation fabrication — YES, labeled.** The host is the only source of its members' history/events (D2 bridge-served projections carry no cryptographic provenance in v1). It can fabricate console-visible transcripts for its own members. Mitigation, not prevention: every remote-served projection is typed `HostClaimed` (vs `ControllingHostVerified`, §7 projections), so consoles and MobKit can render the trust distinction; signed event provenance is a v2 seed.
* **Ack lying — bounded by machine guards.** A lying `MemberMaterialized` cannot commit arbitrary state: `CommitSpawnMembership` guards the digest echo against the machine-authorized value (A12), the fence tuple against machine facts, and membership/roster/wiring mutations only ever happen on the controlling machine. A host can lie about `launch_outcome` or health — observation-grade facts, `HostClaimed` by construction.
* **Upcall abuse — bounded by generated agent authority and incarnation fencing.** `MemberOperatorRequest` first requires the envelope signer, requester generation, and requester fence to match the MobMachine's current member binding; it then re-mints only the controlling-host-recorded `MobToolAuthorityContext` facts for that identity+generation. A compromised host can invoke exactly the operator capabilities that member has as an agent, and no principal `ControlScope` grant is minted or consulted. It cannot mint grants, bind hosts, touch fences, replay an old incarnation through a reused peer key, or expand its capability set through wire claims (there are none).
* **Authority — NO.** Fences, generations, `topology_epoch`, roster, placement, grants, and rotation state advance only through the controlling MobMachine. A member host presents observations and requests; it decides nothing mob-semantic. `MobHostBindingAuthority` is host-LOCAL state about its own bindings, not mob authority.
* **Durable provider credentials — NO transfer.** Durable provider credentials/secrets and auth leases stay host-local and never enter new multi-host bridge payloads (A2/A5/A10, type-level). This is not a blanket no-token claim: A5/§20.2 retain the single-use, 60s-TTL, channel-pinned live WS bootstrap-token exception and the pre-existing `BindMember.bootstrap_token` bearer residual on the signed-but-unencrypted link. A compromised host exposes ITS OWN realm's credentials — the ones its operator provisioned — which is the pre-existing local-machine trust boundary, not a new one.
**A compromised controlling host controls the mob.** That is by design (supervised model, D2): it holds the supervisor authority key and the MobMachine. Multi-host does not widen this — it is identical to the single-host trust model today.
### 20.2 Undefended in v1 (stated, not laundered)
* **Member-side fence validation on delivery** does not exist (§6.4/A22): a stale supervisor is rejected by epoch, but delivery commands carry no member-fence check. Exposure: a controlling host racing its own respawns, not a third party (delivery is supervisor-signed).
* **Host-local realm writes**: an operator (or attacker) with filesystem/process access on a member host can mutate placed members' sessions outside the fence path (§17.8). This is inside the host trust boundary — hosts are TRUSTED EXECUTORS for their placed members, admitted by explicit operator ceremony.
* **Transport confidentiality**: the comms link is Ed25519-signed plaintext (no cipher exists in meerkat-comms — verified NOT FOUND). On-path readers see envelope contents: peer messages, bridge payloads, event pages, and live WS tokens (A5; single-use, 60s TTL, channel-pinned). New host binding does not add another bearer: `BindHost.bootstrap_proof` is tuple-bound HMAC output. The older member-binding protocol still exposes its raw `BindMember.bootstrap_token`; that pre-existing residual is intentionally not expanded into the new host wire and requires a separately versioned remediation. Operational posture for hostile networks: tunnel inter-host links (WireGuard/TLS); v2 seed: encrypted comms transport, which closes this whole row.
* **Acceptor pre-auth surface**: the TCP acceptor peeks/classifies before signature verification (pairing branch). §21.2 bounds it operationally; it is not authenticated-only ingress.
### 20.3 Ceremony material custody
* **Host binding descriptor** (contains the one-time bootstrap token): written 0600 by `rkat mob host`, transferred out-of-band by the operator, used locally to derive a tuple-bound HMAC, and consumed single-use when that proof admits `BindHost`; the raw bearer never enters the remote command and is invalid thereafter (the `canonicalize_bridge_address` strip precedent applies — tokens never persist in advertised addresses). A leaked descriptor after bind is inert; a leaked descriptor before bind can let an attacker claim the host — revocable via `RevokeHost`, visible in `mob/hosts`.
* **Host identity dir**: 0700 dir / 0600 key, same discipline as member identity dirs (`Keypair::load_or_generate`).
* **Host revocation and key rotation (v1)**: `RevokeHost` is the compromise-recovery verb — clears the bind, live endpoint, and capability record; placed members enter the revival ladder (§9 row). Key rotation = revoke + fresh ceremony with a new descriptor. Online host-key rotation is a v2 seed.
* **Grants**: issued only through `AdminGrants`-scoped surfaces (owner-implicit in v1, A16); persisted in the controlling host's mob store; never on member hosts.
## 21. Operations, packaging, and governance
### 21.1 Rolling upgrades and protocol skew
`BridgeCapabilities` is `deny_unknown_fields`, so a NEWER host's bind reply with new capability flags is a decode-breaking read for an OLDER controlling host. The upgrade order is therefore fixed: **controlling host first, member hosts after** — a controlling host must understand every capability shape it can receive; member hosts advertise version RANGES and the controlling host speaks the highest common version per host. Rules:
* Spawn/placement admission onto a bound host whose `supported_protocol_versions` excludes V4 is a typed reject (protocol range is an admission-gating flag, §6.1).
* Rebind re-records capabilities (`HostRebound`); a range/capability regression routes affected members through revival classification with a typed cause (§9 row) — never silent degradation.
* Mid-flight upgrade: an ack `engine_version` differing from the bound record is typed `HostEngineVersionChanged` → rebind → retry (§15 R8).
* Procedure for a live mob: upgrade the controlling host (members keep running — the bridge tolerates supervisor restarts via durable authority records); then per member host: drain new placements (operator choice), upgrade, restart → rebind → capability re-record → host-autonomous member revival (A20); respawn members only where `engine_version` sensitivity matters (operator policy — equal specs on unequal binaries are honestly different builds, §15 R8).
### 21.2 Ingress hardening and resource governance
* **Acceptor pre-auth bounds** (the peek/classify + pairing parse run before signature verification): connection cap + per-connection read deadline + the existing `MAX_PAYLOAD_SIZE` frame bound + a pairing-attempt rate limit per source. These are host-role config (§21.3), mechanical shell limits — no machine facts.
* **Poll-pump fan-out (v1)**: one pump per remote member per mob on the controlling host, under a fixed per-mob ceiling of eight concurrent polls. Six permits serve observation-only 10s long-polls; two permits are reserved for machine-owned remote-turn / placed-kickoff / completion-cleanup custody, whose polls use a 250ms window. Before contending for its global class permits, every pump crosses a one-per-host weak semaphore for that class, so hundreds of blackholed members on host A can occupy or queue at most one global observation slot and one global custody slot while a healthy host B remains independently eligible. A queued pump reclassifies when its liveness facts change, releasing the old host-class gate before it competes in the new lane; the combined in-flight ceiling remains eight. Per-host batched polling and a configurable cap require a new observation transport/config seam and remain v2 work alongside outbound connection reuse (§12); v1 does not claim either one.
* **Placed cleanup fan-out (v1)**: placed kickoff and ordinary-completion reconcilers admit at most one due row per host into a rotating eight-host window. Per-host row cursors prevent a blackholed low row from starving sibling custody, and the host cursor advances beyond the last admitted host so large fleets do not repeatedly overlap the same window. Mob Stop is two-phase: first persist `KickoffCancelRequested` and join every volatile kickoff producer, then—only after exact kickoff custody drains—run a bounded rotating window of exact fenced interrupts off the actor loop. No controller-wide mutex spans bridge I/O; same-key host cancellation tombstones arbitrate cancellation racing a delayed kickoff delivery. Retire/Complete/Destroy instead proceed to authenticated host release/revoke and dispose exact kickoff custody, so a blackholed ordinary cancel cannot deadlock teardown.
* **Ephemeral live buffer** (`durable_sessions=false` hosts): bounded ring, size in host config; overrun is the typed `StaleCursor` path — sizing changes the window, never the semantics.
* **One periodic host poll, two consumers**: the reachability probe (§7.5) and orphan reconciliation share the SAME `HostStatus` poll loop — reconciliation is therefore continuous, not rebind-only (a healthy host that never restarts still gets its orphans released at stale fences). Mass-rebind after a controlling restart staggers polls with jitter.
### 21.3 Host daemon configuration (the layered-config row for the new role)
`[mob_host]` in `.rkat/config.toml`, per the Config::default → file → env → flags doctrine (flags override file):
```text theme={null}
[mob_host]
realm = "…" # else workspace-derived (R2); Isolated is a typed startup reject
listen_tcp = "0.0.0.0:7801"
advertise_tcp = "tcp://host-b.example:7801" # REQUIRED for non-loopback (advertised, never derived)
live_ws = "0.0.0.0:7802" # optional; presence = live-capable
live_ws_advertise= "wss://host-b.example" # REQUIRED with live_ws (§16 DL6); fixed live path is appended
identity_dir = "/host-identity" # 0700; key 0600
descriptor_out = "./host-binding.json" # 0600 — contains the bootstrap token (§20.3)
max_connections / read_deadline_ms / pairing_rate / live_buffer_events # §21.2 bounds
```
Restart reconstructs the full posture from this config plus the realm-local `MobHostBindingAuthority` record — nothing about the daemon's identity, bindings, or members depends on process memory.
### 21.4 Crate placement and release blast radius
| New thing | Owning crate | Notes |
| - | - | - |
| Host acceptor + identity registry | `meerkat-comms` (new `host_acceptor` module) | public API addition; registry install/remove is projection-only |
| `MobHostBindingAuthority` DSL | catalog (`meerkat-machine-schema/src/catalog/dsl/`) → generated into `meerkat-mob` | scoped authority, §21.5 governance row |
| Host role composition + `rkat mob host` | `meerkat-mob` (host module) + `meerkat-cli` | D6: composition, no new crate |
| `PortableMemberSpec`, cursors, host/grant DTOs, `ErrorCode` variants | `meerkat-contracts` | must stay wasm32-clean (`wasm-check` runs on every code change; host-side --all-features gates are known to miss wasm32 cfg regressions) |
| `LiveOrchestrator` + projection-sink four-role extraction | `meerkat` (facade) + `meerkat-live` | §16 DL4/B2 pre-work |
| Poll pumps, history proxy, flow ticket sidecar | `meerkat-mob` | every new `BridgeReply` consumer file joins `BRIDGE_CLASSIFIER_FILES` |
**No new publishable crate in v1** — the 38-crate publish order and `check-rust-release-packaging` are untouched. Every change-set that adds test files or dependencies regenerates Bazel BUILD metadata + `MODULE.bazel.lock` (pre-commit hook; skipping it cascade-fails the BuildBuddy lanes on stale locks). Feature-gating keeps `test-minimal` / `test-surface-modularity` green: the host role rides the existing `mob` feature; live rides `live`/`openai-realtime`.
### 21.5 Docs and governance-gate inventory
Documentation that encodes facts this plan changes, all under `docs-check`: `docs/reference/capability-matrix.mdx` (capability degradation rows), `docs/reference/mob-architecture.mdx`, `docs/reference/comms-reference.mdx` (acceptor, trust), `docs/reference/session-contracts.mdx` (remote observation semantics), `docs/reference/runtime-architecture.mdx` (host role), `docs/reference/machine-authority.mdx` (new state/inputs/effects + the scoped-authority note below), `docs/api/rpc.mdx` (regenerated — 8+ new methods), `docs/api/mcp.mdx` (HAND-updated; no script gate exists for MCP — named work item, §17 SD-4). The dogma-skill mirror (`sync-meerkat-dogma-skill-docs`) fires if doctrine text changes.
**`MobHostBindingAuthority` governance treatment (decided here, not ad hoc):** it follows the `session_persistence_version_authority` precedent exactly — catalog DSL source, generated production module, registered for production-schema parity, an `ownership-ledger` entry for each fact it owns, **not** in `canonical_machine_schemas()` and therefore no poster requirement (`verify-machine-poster-coverage` keys on the canonical alphabet); `machine-authority.mdx` gains one paragraph naming it as a scoped authority. Its effects carry `seam-inventory` dispositions like any machine's.
### 21.6 MobKit consequence pass (expands phase 8)
* **Ordering**: Meerkat release with phases 1-7 → MobKit bumps its pin → MobKit consumes. No MobKit change ships against an unreleased wire shape.
* **Unaffected by default**: MobKit talks local RPC to the controlling host = owner principal (A16) — default-deny grants change nothing for it. Its per-turn injection lane (`injected_context`) already bridges to remote members via `DeliverMemberInput` (shipped, §15.1). Its resume-strand logic operates on controlling-host session projections — placement-blind by construction.
* **New consumption**: placement + reachability + lifecycle-capability fields on `member_status`; `mob/member_history`; `mob/hosts`; grants surfaces (as an `AdminGrants`-scoped operator UI, later); `HostClaimed` provenance labeling in console rendering (§20.1 — MobKit must not render host-claimed transcripts as verified).
* **Fleet view across mobs** is a MobKit projection concern (hosts are per-mob MobMachine facts; there is no cross-mob host registry endpoint, §17.10).
### 21.7 RPC/REST principal identity (the recorded v2 seed)
v1 leaves local RPC/REST/stdio as owner-principal surfaces (A16). The v2 seed, named so it is not re-invented: bearer-token → `PrincipalId` authentication on `rkat-rpc --tcp`, layered on `secure_rpc`'s `TcpBindPolicy` (whose own doc already says post-connect authorization "belongs to auth/grants"), with tokens minted/revoked through the same `AdminGrants`-scoped surfaces and mapped into the ONE grant vocabulary (§8). No parallel ACL system.
# Realm Config Inheritance Plan
Source: https://docs.rkat.ai/architecture/realm-config-inheritance-plan
Historical implementation record for deterministic realm configuration inheritance and credential provenance.
# Realm Config Inheritance - Historical Implementation Record
> Current status (2026-08-20): **Implemented.** Realm inheritance shipped in
> v0.7.9. This document preserves the reviewed implementation rationale; its
> line references and phase language are design-era evidence, not the current
> source map. Current behavior lives in `meerkat-core/src/connection.rs`,
> `meerkat-core/src/config.rs`, and `meerkat-core/src/config_store.rs` and is
> documented in [Realm inheritance](/concepts/realm-inheritance).
> Decisions: **(A)** owning-realm credential provenance, **(B)** general parent-chain inheritance.
> Historical review status: red-team verdict = **amend\_then\_ship**. Architecture sound; 14 must-fixes folded in below before implementation.
> Source artifacts: `realm_synthesis.json` (design), `realm_redteam_verdict.json` (red-team). This doc is the merged, authoritative plan.
## Canonical design
CANONICAL DESIGN — "One chain, two consumers": a single typed RealmChain (parent edge on RealmConfigSection, terminal `global` root) drives BOTH (a) an eager whole-Config composition (EffectiveConfig) for top-level fields the agent reads flat, AND (b) a lazy per-resolution walk for connection/credential facts that feeds materialize\_connection\_target the OWNING realm's own RealmConnectionSet.
WHY THIS SHAPE (resolving the three judges' fatal flaws):
1. DECISION A provenance is STRUCTURALLY free and keeps the load-bearing typed invariant intact. The only realm-stamping site that matters (materialize\_connection\_target, connection.rs:977) sets AuthBindingRef.realm = realm.realm\_id. The lazy walk hands materialize the OWNING realm's *own* RealmConnectionSet (its realm\_id IS the owner). Therefore registry.rs:208 `if auth_binding.realm != realm.realm_id` (a STRICT typed equality, verified) STAYS UNCHANGED and KEEPS HOLDING. The eager-flatten designs (Approach 1 sidecar maps, Approach 2 single-set-multi-owner) BOTH forced a downgrade of that equality to an unwrap\_or/membership check — a silent fallback alongside typed validation = dogma violation. We reject eager flattening of the connection set precisely to preserve this. No new field on AuthBindingRef, BindingOrigin, or RealmConnectionSet → ZERO wire/schema churn for the credential path.
2. DUAL-TRUTH (top-level model/mcp/skills/hooks/limits unreachable from the connection resolver) is resolved by introducing ONE chain authority (RealmChain) consumed by both layers. Top-level inheritance is done by an EAGER EffectiveConfig composition produced once at the ConfigStore seam (the agent then reads the already-composed flat Config exactly as today — zero changes to the \~30 flat read sites at factory.rs:1022/3039/etc). Connection facts use the SAME RealmChain lazily. There are not two chain definitions; there is one RealmChain::resolve, called by the EffectiveConfig composer and by the connection resolvers.
3. AUTH/BACKEND cross-realm credential-redirect hazard is resolved by NO-INHERIT for auth & backend profiles: a binding's referenced backend\_profile/auth\_profile are resolved ONLY within the owning realm's own section (the realm that defines the binding). This makes binding-owner == backend-owner == auth-owner an INVARIANT, so lookup\_auth\_binding (which co-resolves binding+backend+auth in ONE RealmConnectionSet, connection.rs:1200) never sees a split. A child that wants a parent's backend/auth must inherit the whole binding (which carries them) or redeclare all three. This is the only coherent rule given the single-set lookup coupling.
ABSTRACTION-LEVEL MODEL (filesystem-free):
* A realm is a node in a config graph. `RealmConfigSection.parent: Option` is the single outgoing edge (linear chain, no diamonds). A realm with parent=None that is not itself `global` implicitly parents to the reserved `global` realm. `global.parent` MUST be None (root). env\_default is NOT a chain node; it remains the single typed post-chain fallback owner.
* RealmChain::resolve(config, head) walks edges head→…→global, dedup + depth-capped + cycle-checked, producing an ordered `Vec` (index 0 = most-derived consuming realm; last = global if present). This is the ONE deterministic ordering that replaces the flat cross-realm scan (connection.rs:912) AND the \[preferred,"default"] list (connection.rs:678-683).
* EffectiveConfig: a composer folds the full Config along the chain (parent-first, child-wins per the merge table) to produce the flat Config the agent reads. It is produced by a ChainComposingConfigStore decorator that wraps a per-realm config-section locator. The locator is INJECTED (dyn RealmConfigSource), so the filesystem projection (`realm_paths_in` → `//config.toml`) stays in meerkat-cli/meerkat-store, never in core. WASM injects a degenerate single-realm source (its synthesized config.realm\['default'], lib.rs:441) so its chain is \[default] with no global → behaviorally identical to today.
* Connection/credential resolution: the resolvers walk RealmChain and, per resolved binding, materialize using the OWNING realm's own RealmConnectionSet::from\_config(owner\_id, owner\_section). AuthBindingRef.realm = owner. Downstream (TokenKey/LeaseKey/FileTokenStore) is unchanged and already correct.
STATE NEVER INHERITS: composition reads only config sections of ancestor realms. Sessions/leases/event-log/ops-snapshots/.rkat projection are keyed by the CONSUMING realm via realm\_paths\_in(head) only; the composer never touches ancestor RealmPaths except to READ config. The one state-adjacent fact that follows the OWNING realm is the credential TokenKey.realm (decision A) — that is provenance of where a credential is DEFINED, not inheritance of credential state.
GLOBAL vs env\_default stay DISTINCT and typed: `global` is a durable Configured realm at the chain root (may hold persisted OAuth tokens at `/global/.json`, may publish a durable lease). env\_default is SyntheticEnvDefault, ephemeral, never a chain node, appended last only when allow\_env\_default and nothing on the chain matched. Both recognized by typed predicates (RealmId::is\_global mirroring is\_env\_default), never by raw string compares elsewhere. A `parent` edge targeting the env\_default slug is rejected at chain construction.
CLI LOGIN migrates from hardcoded "dev" to `global`: a single login lands at `/global/.json` and every realm inherits it via the default chain. Pre-1.0 clean break: existing dev/\* tokens orphaned, one-time re-login.
## Provenance design (decision A)
CARRIER (unchanged): AuthBindingRef.realm: RealmId (connection.rs:196) remains THE single fact that determines credential/token scope. TokenKey::from\_auth\_binding (auth/token\_store.rs:59) copies it verbatim; FileTokenStore::path\_for projects `//[@profile].json` (file.rs:33); LeaseKey::from\_auth\_binding (handles.rs:1320) mirrors it. NO new field is added to AuthBindingRef or BindingOrigin.
MECHANISM (the one change): make AuthBindingRef.realm the DEFINING realm, not the consuming realm, by feeding materialize\_connection\_target the OWNING realm's OWN RealmConnectionSet. materialize stamps realm.realm\_id (connection.rs:977-982, UNCHANGED). Because the lazy walk builds RealmConnectionSet::from\_config(owner\_id, owner\_section) for the chain member that defines the binding, realm\_id IS the owner. The existing weak provenance (stamp whatever realm the flat scan happened to find it in) becomes STRONG provenance (the defining chain member). In the degenerate single-realm case behavior is byte-identical.
WHY NO new field / NO eager-flattened multi-owner set: a single RealmConnectionSet can carry only ONE realm\_id. Approach 1 bolted on sidecar binding\_owner maps and Approach 2 used a single set with a separate provenance map — both then had to relax registry.rs:208 `auth_binding.realm != realm.realm_id` (a STRICT typed equality, verified at the live consumer) into an unwrap\_or/membership check, i.e. a silent fallback alongside typed validation (dogma violation). The lazy owning-set design keeps that equality a genuine invariant: the set fed to registry.resolve IS the owner's set, so realm\_id == auth\_binding.realm by construction. This is the decisive reason the credential layer is lazy, not eager.
RESOLVER (meerkat-auth-core/src/resolver.rs): ZERO logic change. It does no realm selection — it consumes binding.auth\_binding\_ref().realm verbatim (resolver.rs:437 → TokenKey). Once the connection layer stamps the owning realm, ManagedStore reads `//.json`, the AuthMachine LeaseKey is keyed by owner, all automatically. One-canonical-path: provenance is decided ONCE upstream, never re-derived at the credential layer (the flat cross-realm scan that smelled like weak provenance is DELETED, not extended).
BOTH stamping sites + the model-swap filter are converged in ONE pass (judge requirement): (1) core materialize via the chain walk; (2) factory resolve\_selected\_binding\_for\_provider (factory.rs:1838, image/web\_search) re-rooted to walk selected\_realm's chain and stamp the owning member; (3) factory model-swap filter (factory.rs:3114) relaxed to accept inherited owners on the chain. Any one left consuming-stamped misses inherited creds.
SOURCE-KIND CAVEAT (must be pinned by a test, stated in design): decision A only BITES for ManagedStore (and the OAuth-lease / Command-freshness LeaseKey) because those are realm-namespaced via TokenKey/LeaseKey. CredentialSourceSpec::Env (resolver.rs:45, reads process env globally with RKAT\_ override), InlineSecret, and Command MATERIAL are realm-AGNOSTIC — an inherited Env-source binding yields identical creds regardless of owning realm. The design states this explicitly so no operator expects env-key provenance to be realm-scoped. An RCT pins it.
## Cycle & determinism
CYCLE / DEPTH / MISSING-PARENT (RealmChain::resolve, private ctor, fail-closed typed Result, NO unwrap/panic):
* Seed: insert head into `seen: BTreeSet` first (so a realm naming itself as parent is caught as a 1-cycle), push head into ordered `Vec`.
* Loop: read section.parent for the current node. (a) parent not in config.realm AND parent != global → RealmChainError::MissingParent. (b) parent == env\_default slug → RealmChainError::ParentIsEnvDefault (env\_default may never be a chain node). (c) seen.insert(parent) == false → `RealmChainError::Cycle` with the captured path. (d) push parent, continue.
* Depth guard: MAX\_REALM\_CHAIN\_DEPTH = 16; exceeding → RealmChainError::DepthExceeded (belt-and-suspenders even though `seen` bounds finite configs; bounds work + stack).
* Termination: when current node's parent is None. If that terminal node is `global`, done. Else append `global` IFF config.realm.contains\_key(global) AND global ∉ seen (a realm may legitimately reach global via explicit edges; dedup prevents double-visit). If global absent from config, chain terminates at the explicit root and env\_default fallback applies post-chain.
* global invariant: if the head or any visited node is `global` and global.parent.is\_some() → RealmChainError::GlobalHasParent.
* ITERATIVE (no recursion) → wasm stack-safe and depth-guard-safe.
DETERMINISM:
* The chain is a LINEAR sequence (parent is `Option`, a single edge, NOT a list) → order is fully determined by the edges, ZERO dependence on BTreeMap-key iteration order. This is strictly MORE deterministic than the deleted flat scan (which depended on config.realm BTreeMap alpha order at connection.rs:912).
* Folding for EffectiveConfig is parent-first (root→head), making child-wins a deterministic last-write-wins over BTreeMaps; within each section BTreeMap iteration preserves existing determinism. No HashMap/HashSet anywhere (seen is BTreeSet).
* Connection candidate ordering = the chain sequence head→…→global, then env\_default — one deterministic order replacing the old (preferred, "default", alpha-scan, env\_default).
* selected\_binding\_id\_for\_provider iterates realm.bindings (BTreeMap) → deterministic per-member pick. First-marked provider\_default wins by id order (unchanged).
## Contract / schema / wasm / machine
CONTRACT/SCHEMA:
* RealmConfigSection.parent is SCHEMA-INVISIBLE. Config.realm is wire-projected through the opaque ConfigContractSchema.realm: `BTreeMap` (contracts/wire/config.rs:62); RealmConfigSection is absent from wire-types.json/params.json/rest-openapi.json (verified). → NO regen-schemas trip from the parent field, verify-schema-freshness does NOT fire, verify-version-parity (version-string only) does NOT fire.
* AuthBindingRef / BindingOrigin: UNCHANGED (provenance rides the existing realm field, no new variant/field). So the REST bare-AuthBindingRef path (rest-openapi.json via RestCreateSessionRequest, emit.rs:904) and the lossy WireAuthBindingRef projection (drops origin, wire/connection.rs:41) are BOTH untouched → no SDK codegen ripple, no verify-rest/rpc-surface-alignment churn.
* RealmConnectionSet: gains NO field (lazy approach) → its hand-written WireRealmConnectionSet projection (wire/connection.rs:247) and the auth\_binding\_wire.rs struct-literal test are untouched. This is the deliberate economy vs Approach 2 (which broke the struct literal + forced threading provenance into meerkat-llm-core + relaxing registry.rs:208).
* New ConnectionTargetError::RealmChain(RealmChainError) variant: internal Result, NOT wire-exposed → no schema impact.
* DEFENSIVE GATE STEP after implementation: run `make regen-schemas && make verify-version-parity` and ASSERT a NO-OP git diff (do not assume). If any schema file changes, a wire type leaked unexpectedly — STOP and re-audit.
* SDK config-set round-trip: a client setting \[realm.X] config cannot see/validate `parent` (opaque Value map by design) — acceptable; an RCT pins that the unknown field round-trips through the opaque map.
WASM:
* meerkat-core stays wasm32-clean. RealmChain, RealmChainError, compose\_effective\_config, the per-area merge, and ChainComposingConfigStore are pure in-memory data/logic over Config (no std::fs, no tokio). RealmConfigSource and any IO are injected behind the existing cfg(not(wasm32)) gates (FileConfigStore already cfg-gated, config.rs:121). The async\_trait pattern uses the existing `#[cfg_attr(target_arch="wasm32", async_trait(?Send))]` shape (config\_store.rs:35).
* WASM web-runtime synthesizes only config.realm\['default'] (lib.rs:441) and injects a degenerate single-realm RealmConfigSource with no global → its chain is \[default], EffectiveConfig == the single doc, behaviorally identical to today. Provenance still flows via AuthBindingRef.realm into the external resolver. ManagedStore stays cfg(not(wasm32)) (resolver.rs:237). GATE: BuildBuddy wasm-check-submit (buildbuddy.yml:439).
MACHINE-AUTHORITY:
* Realm/parent resolution + Config composition touch NO canonical machine (verified: connection.rs/config.rs have no MachineSchema/generated reducer; AuthMachine, catalog/dsl/auth\_machine.rs, governs only per-binding LEASE lifecycle keyed `:`, consuming an already-resolved identity, doing no realm selection). Freeze governance RESPECTED: chain invariants (cycle/depth/single-parent/global-root/env-default-not-parent) enforced by PLAIN RUST — typed RealmChain newtype with private field + fallible ctor + typed RealmChainError. NO new DSL/machine domain.
* RMAT: ForbiddenShellAuthorityReads (xtask rmat\_policy.rs:446) targets machine-owned field reads (.phase(), apply\_mode, etc.); parent-chain reads config DATA, not machine-owned semantic state → does NOT trip the read-seam gate, PROVIDED resolution is not (wrongly) routed through a machine field read. GATE: `make rmat-audit`.
* The provenance change DOES re-key the AuthMachine LeaseKey to the OWNING realm for inherited bindings — correct (the lease for an inherited binding lives at its owning realm), pre-1.0 clean break (any in-flight lease previously keyed under consuming realm is re-keyed, no migration).
REGEN/GATE SEQUENCE: (1) `make regen-schemas` → assert no-op diff. (2) `make verify-version-parity` → green. (3) `make verify-schema-freshness` → green. (4) `./scripts/repo-cargo clippy --workspace -- -D warnings`. (5) `MEERKAT_BUILDBUDDY=1 make test` (unit+int). (6) `make rmat-audit`. (7) BuildBuddy wasm-check.
## Merge-semantics table
| area | rule | rationale |
| - | - | - |
| `realm..backend` (`[realm.X.backend.*]` map) | no-inherit (resolved within owning realm's own section) | A binding's backend\_profile is resolved ONLY in the realm that DEFINES the binding (the owner). Makes binding-owner==backend-owner an invariant so lookup\_auth\_binding's single-set co-resolution (connection.rs:1200) never splits credential realm from connection-config realm. Resolves judge fatal flaw #1 for Approaches 1/2. A child wanting a parent backend inherits the whole binding (which carries it). |
| `realm..auth` (`[realm.X.auth.*]` map) | no-inherit (resolved within owning realm's own section) | Same as backend: auth\_profile (incl. its CredentialSourceSpec) is resolved only in the owning realm's section. Prevents a child redefining auth profile key K from silently re-sourcing a parent-owned binding's credentials (Approach 2's credential-redirect hazard). binding-owner==auth-owner invariant. |
| `realm..binding` (`[realm.X.binding.*]` map) | map-key-union child-wins; OWNING realm = first chain member (child-first) that defines the binding id | The inheritance unit. The lazy walk visits head→…→global; the first member defining the requested/selected binding id is the owner; materialize is fed THAT member's own RealmConnectionSet so AuthBindingRef.realm=owner (decision A). For the EffectiveConfig.realm map, child-wins union makes inherited sections visible. Per-section from\_config still validates provider agreement in isolation before any cross-realm interaction (preserves ProviderMismatch semantics). |
| `realm..default_binding` (`Option`) | child-wins-scalar (nearest-child non-None wins; resolves against owner's binding set) | Single pointer. Converge BOTH selection policies (resolve\_realm\_binding\_target\_for\_provider's default\_binding-only inline pick :703 and selected\_binding\_id\_for\_provider's default\_binding>provider\_default>sole :825) onto selected\_binding\_id\_for\_provider applied per chain member, so there is ONE canonical selection policy. |
| `realm..parent` (`Option`) | no-inherit (structural metadata of THIS node only) | The parent edge defines the graph; inheriting it would corrupt the chain. Consumed by RealmChain::resolve only, never folded into any effective section or doc. |
| Config.models (ModelDefaults per-provider default model strings) | map/field-key-union child-wins (per provider) | The user's headline model-config-inheritance goal. Each provider key (anthropic/openai/gemini) folds independently so a child overrides its anthropic default while inheriting the parent's openai default. Reworks today's whole-replace (config.rs:315) to field-level. Composed eagerly into EffectiveConfig; the agent's flat reads (factory.rs:3039) see the merged result with no code change. |
| Config.agent.model + agent. + Config.max\_tokens | child-wins-scalar (nearest-child non-default wins) | Scalar/optional model-shaping fields. Mirrors existing last-non-default-wins (config.rs:280-294) applied parent→child. Presence-sensitive non-Option fields (model, max\_tokens\_per\_turn, max\_tokens) need toml::Value presence parsing to distinguish omitted from default (open question). |
| Config.hooks (HooksConfig: entries Vec + scalar timeouts) | list-append (entries parent-first then child); scalars child-wins | PRESERVES the one pre-existing additive layering (append\_entries\_from :1927, load\_layered\_hooks :183). Parent hooks run before child hooks (child most-specific/last). Folds the orphaned load\_layered\_hooks into the one canonical chain path. Child cannot delete a parent hook. Dedup by hook identity to avoid double-run (open question on identity key). |
| Config.tools.mcp\_servers (id→server map) + tools.\*\_enabled toggles | map-key-union child-wins (servers); child-wins-scalar (toggles) | MCP servers keyed by name: child adds servers + overrides a same-named parent server while inheriting the rest. Reworks merge\_tools whole-replace (:312) to union. Enabled toggles: a child explicitly disabling a tool overrides parent. Deterministic via BTreeMap iteration. |
| Config.skills (SkillsConfig sources/registries) | list-append parent-first, child-wins on source-identity collision | Skill sources additive like hooks: child inherits parent sources + appends its own; on a name/path/url collision child shadows the one source without dropping the rest. Dedup identity = source path/url/name (open question). |
| Config.limits (LimitsConfig, incl. max\_sessions) | child-wins-scalar (per-field) | Per-field override so a child tightens max\_sessions while inheriting other caps. Reworks today's whole-section whole-replace (:333) which makes partial child override impossible along a chain. |
| Config. | child-wins-scalar (per-field); model\_fallback keeps catalog-reset semantics | Runtime/infra sections: per-field child-wins (Option::is\_some or !=Default). These ARE config (not state) so they inherit, though most are realm-uniform in practice. model\_fallback preserves use\_catalog\_default\_chain reset (:352) applied along the chain. |
| Config.model\_fallback (ModelFallbackConfig) | child-wins with catalog-default-chain reset precedence | If a child sets use\_catalog\_default\_chain, it resets to default (matches :352); else child-wins-whole when non-default. Special-cased to preserve the existing reset contract. |
| STATE: sessions, leases, event log, ops snapshots, .rkat/sessions projection, RealmPaths session/manifest dirs, TokenStore RECORDS | no-inherit (ABSOLUTE) | Composition reads only ancestor config sections/docs. All state keyed by the CONSUMING (head) realm via realm\_paths\_in(head); the composer never touches ancestor RealmPaths except to READ config. The TokenStore NAMESPACE KEY (TokenKey.realm) is the OWNING realm for credential RECORDS (decision A) — that is provenance of where a credential is defined, NOT inheritance of credential/session state. A child gains the parent's binding DEFINITION and resolves that binding's token at the parent's credential namespace; it does NOT gain the parent's sessions/leases. |
## Type / function changes
* **RealmConfigSection.parent** `[meerkat-core/src/connection.rs:1272 (struct body, add after default_binding at :1280)]` — Add `#[serde(default, skip_serializing_if = "Option::is_none")] pub parent: Option`. Typed newtype, NOT String. Schema-INVISIBLE: Config.realm is wire-projected through the opaque ConfigContractSchema.realm: `BTreeMap` (contracts/wire/config.rs:62), and RealmConfigSection is absent from every emitted schema (verified). NO regen needed for this field alone. RealmConfigSection already derives schemars under feature=schema (:1271) so the field compiles under that feature with no extra annotation.
* **GLOBAL\_REALM\_SLUG + RealmId::is\_global + RealmId::global** `[meerkat-core/src/connection.rs:148 (next to ENV_DEFAULT_REALM_SLUG) and :150-159 (impl RealmId block)]` — Add `pub const GLOBAL_REALM_SLUG: &str = "global";`. Add `RealmId::is_global(&self) -> bool { self.as_str() == GLOBAL_REALM_SLUG }` mirroring is\_env\_default. Add `RealmId::global() -> RealmId { RealmId::from_known_valid(GLOBAL_REALM_SLUG) }` (infallible mint via the existing private-ctor path used for env\_default at :1168).
* **RealmChain (new type)** `[meerkat-core/src/connection.rs (new, near RealmConfigSection ~:1262)]` — `pub struct RealmChain { realms: Vec }` — PRIVATE field, single fallible ctor `pub fn resolve(config: &Config, head: &RealmId) -> Result`. Walks parent edges (BTreeSet seen-dedup + Vec order), appends `global` iff config.realm contains it and not already seen, fail-closed on cycle/depth/missing-parent/global-has-parent/parent-is-env-default. Accessor `pub fn realms(&self) -> &[RealmId]`. Iterates head→global (child-first). NO unwrap/panic.
* **RealmChainError (new typed error)** `[meerkat-core/src/connection.rs (new, IdentityError-adjacent family)]` — `#[derive(Debug,Clone,Error,PartialEq,Eq)] pub enum RealmChainError { Cycle{chain:Vec}, DepthExceeded{head:String,max:usize}, MissingParent{realm:String,parent:String}, GlobalHasParent{realm:String}, ParentIsEnvDefault{realm:String} }`. NOT wire-exposed (internal Result). Add `MAX_REALM_CHAIN_DEPTH: usize = 16` const next to GLOBAL\_REALM\_SLUG. Add `From` for ConnectionTargetError (new variant `ConnectionTargetError::RealmChain(RealmChainError)` at connection.rs:617).
* **compose\_effective\_config (new fn)** `[meerkat-core/src/config.rs (new, near merge :278)]` — `pub fn compose_effective_config(sections: &BTreeMap, head: &RealmId) -> Result` — takes the per-realm Config docs along the chain (root-first), folds them via per-area merge rules (see merge table) into the effective flat Config the agent reads. Reuses RealmChain::resolve on the head's chain (the realm graph is read from the head doc's .realm map plus injected ancestor docs). The `realm` MAP itself is also merged (map-key-union child-wins) so an inherited \[realm.X] section is visible to the connection resolvers.
* **Config::merge rework (field-level child-wins + realm-map fold)** `[meerkat-core/src/config.rs:278-357]` — Rework so it (1) ACTUALLY folds self.realm (today it is silently dropped — verified, merge never references self.realm) via map-key-union child-wins; (2) uses FIELD-LEVEL child-wins for limits/store/comms/compaction/rest (today whole-section whole-replace at :324-337 prevents partial child override); (3) keeps hooks.entries.extend append (:350) and adds skills/mcp\_servers append/union; (4) keeps model\_fallback catalog-reset (:352). Presence-aware where serde(default) loses omitted-vs-default: parse ancestor docs from toml::Value for the presence-sensitive scalar fields (see open questions). This is the SINGLE merge engine; compose\_effective\_config calls it parent-first.
* **RealmConfigSource (new trait) + ChainComposingConfigStore (new decorator)** `[meerkat-core/src/config_store.rs (new, near ConfigStore trait :37)]` — `pub trait RealmConfigSource: Send+Sync { async fn config_for_realm(&self, realm: &RealmId) -> Result