# 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, ConfigError>; }` (?Send on wasm32 via the existing async\_trait cfg pattern). `pub struct ChainComposingConfigStore { head: RealmId, source: Arc, catalog: ModelCatalog }` implementing ConfigStore: get() resolves the head's chain, fetches each member Config via source, composes via compose\_effective\_config, returns the effective Config. set()/patch() operate on the HEAD realm's own doc only (writes never compose). NO fs in core — source is injected. * **resolve\_realm\_binding\_target\_for\_provider — chain-aware + owning-set** `[meerkat-core/src/connection.rs:665-750]` — Replace candidate list `[preferred,"default"]` (:674-684) and the inline default\_binding-ONLY pick (:700-719) with: build RealmChain from head (preferred or "default" or global); iterate chain members; per member build RealmConnectionSet::from\_config(member, section) and call selected\_binding\_id\_for\_provider (CONVERGING the two divergent selection policies onto one); FIRST member yielding a provider binding is the owner; materialize with THAT member's own set. Explicit\_realm path: walk its chain so an explicit realm inherits a binding from its parent, stamping the OWNING member. * **resolve\_auth\_binding\_candidates\_for\_provider — chain-aware + owning-set** `[meerkat-core/src/connection.rs:886-966]` — DELETE the flat scan `for realm_id in config.realm.keys()` (:912-914), the literal "default" push (:911), and push\_candidate\_realm\_ids (:862, now unused). Build RealmChain from head; for each chain member build its OWN RealmConnectionSet and run selected\_binding\_id\_for\_provider; push one materialized candidate per member that has a provider binding (each carrying owner=member via materialize). env\_default append (:942) UNCHANGED as the single typed terminal fallback. * **resolve\_auth\_binding\_or\_default\_for\_provider — explicit ref chain-aware** `[meerkat-core/src/connection.rs:755-795]` — Explicit-AuthBindingRef branch (:762-783): if the named realm's own section does not define the binding, walk its chain to find the OWNING member, build that member's set, materialize with owner=member. Keep is\_env\_default rejection (:764). One canonical path: provenance decided here, once, upstream. * **materialize\_connection\_target — UNCHANGED logic, owner fed in** `[meerkat-core/src/connection.rs:968-1010]` — NO code change: it already stamps realm.realm\_id (:978). Correctness comes from callers feeding it the OWNING realm's own RealmConnectionSet (realm\_id==owner). registry.rs:208 equality continues to hold. Add a debug\_assert documenting the single-writer invariant. * **factory resolve\_selected\_binding\_for\_provider — chain-aware (SECOND stamping site)** `[meerkat/src/factory.rs:1806-1843]` — Replace single-section from\_config on selected\_realm (:1814-1821) + `realm: selected_realm` stamp (:1838) with a chain walk rooted at selected\_realm: find the owning member that defines a usable binding (reuse selected\_binding\_id\_for\_provider :1688), build that member's OWN set, stamp realm=OWNER. Without this, image/web\_search inherited bindings stamp the consuming realm and miss parent creds (judge fatal flaw). image (:1903) and web\_search (:1936) callers flow through this fix automatically. * **factory model-swap candidate filter — accept inherited owners** `[meerkat/src/factory.rs:3099-3126]` — The catalog\_default\_chain filter `target.auth_binding.realm == *realm` (:3114) rejects inherited candidates (owner != preferred). Change to: accept any candidate on the preferred realm's chain (i.e. drop the equality, keep `!is_env_default()`), since the chain walk already scopes candidates to ancestors. Otherwise hot model-swap silently drops every inherited binding (judge fatal flaw). * **CLI login realm: dev → global (collapse 3 literals)** `[meerkat-cli/src/main.rs:4755, :4003, :5391, :5742]` — CLI\_INTERACTIVE\_OAUTH\_REALM\_ID (:4755) → RealmId::global() (typed). auth\_config\_realm\_or\_default (:4003) unwrap\_or("dev") → unwrap\_or(GLOBAL\_REALM\_SLUG). ensure\_cli\_interactive\_oauth\_config (:4886) + resolve\_configured\_cli\_interactive\_oauth\_target (:4977) take a target RealmId and synthesize/read \[realm.global] into the GLOBAL realm's ConfigStore (new seam: resolve a store for global, not always scope). noninteractive\_login (:5322) gains a realm param (currently ignores override); TokenKey::parse("dev",..) at :5391/:5742 → resolved owner. All `dev:` user strings (:5417/:5489/:5687/:5699) → resolved owner. Update pinned test (not bypass) at :16447. * **WireRealmConnectionSet / WireAuthBindingRef** `[meerkat-contracts/src/wire/connection.rs:237 and :22]` — NO CHANGE. RealmConnectionSet gains NO field (lazy approach), so the hand-written WireRealmConnectionSet projection (:247) is untouched and realm/get returns the head realm's OWN composed-but-uncomposed section as today. WireAuthBindingRef (drops origin) untouched. This is the deliberate economy that yields zero SDK codegen / surface-alignment churn. ## MUST-FIX amendments (red-team) * **MF-01** (amends canonical\_design + P2; add gate to P2) * finding: Verified at connection.rs:681-682 and :911: TWO distinct legacy fallbacks exist. The flat scan `for realm_id in config.realm.keys()` (the plan deletes it) AND a hard-coded literal `"default"` realm candidate pushed unconditionally in BOTH resolvers (`candidates.push("default")` and `push_candidate_realm_ids(..., Some("default"))`). The plan's type\_changes for resolve\_realm\_binding\_target\_for\_provider does mention replacing `[preferred,"default"]`, but RCT-09 only pins the flat-scan removal, and no RCT pins that a `[realm.default]` shared-creds config is NO LONGER consulted for a head realm that does not parent to it. An operator with `[realm.default]` holding shared OpenAI creds, running in realm `prod` (no openai binding, no parent edge), resolves openai today via the implicit `default` candidate; after the cut the chain is \[prod, global?] and resolution falls to env\_default or hard-fails silently. No existing test covers `default` as a non-head fallback. * fix: State explicitly in canonical\_design that the literal `"default"` realm candidate is REMOVED in BOTH resolvers alongside the flat scan (it is a distinct mechanism, not the same one). Decide and document the head-default: with WorkspaceDerived realms the head is `ws-{hash}`, not `default`/`dev`, so the implicit-global-tail is the only bridge. Add RCT-25 (below) proving a `[realm.default]` section is no longer consulted and the same creds work once moved to `[realm.global]`. The migration (MF-08) must rewrite the config SECTION too, not only copy the credential directory, or legacy single-realm users lose their only binding. * **MF-02** (amends cycle\_and\_determinism + P2) * finding: Verified at connection.rs:764: the explicit-AuthBindingRef branch does `config.realm.get(realm_id).ok_or_else(|| UnknownRealm)` — an absent explicit realm hard-errors UnknownRealm, while the non-explicit path (resolve\_realm\_binding\_target\_for\_provider) uses `continue` on an absent realm. The pinned test `auth_binding_candidates_scan_configured_realms_before_env_default` (connection.rs:2026) sets preferred\_realm=`missing` (absent from config) and asserts candidates\[0].realm==`dev` via the flat scan. The plan lists this test for rewrite in P2 but never specifies the TARGET behavior when the HEAD realm is absent from config: does RealmChain::resolve error, yield a single-node chain \[head] contributing nothing, or implicitly append global? RealmChain::resolve reads `section.parent` but `config.realm.get(head)` is None so there is no section to read. * fix: Specify: an absent head yields a single-node chain \[head] that contributes no sections (head's own section None → skipped), then implicit-global-tail (if global present) + env\_default apply exactly as today's per-member `continue` path. Do NOT make an absent head a hard MissingParent/UnknownRealm error in the non-explicit path. Preserve the per-member asymmetry: explicit-named absent realm still errors UnknownRealm; non-explicit/preferred absent realm falls through. Pin with RCT-26. * **MF-03** (amends type\_changes (ConnectionTargetError) + P5/P6 gate) * finding: Verified at meerkat-rest/src/auth\_endpoints.rs:106: `target_error_status` is an EXHAUSTIVE match over ConnectionTargetError with no wildcard, and ConnectionTargetError (connection.rs:618) is NOT #\[non\_exhaustive]. Adding `ConnectionTargetError::RealmChain(RealmChainError)` (plan type\_changes) is a hard COMPILE ERROR in meerkat-rest. (The RPC mapping at handlers/auth.rs:200 uses `_ =>` so RPC compiles — the critiques overstated an RPC compile break.) meerkat-rest is in no phase before P8, so this surfaces only at the broad lane. * fix: Add meerkat-rest/src/auth\_endpoints.rs:106 to the edit-site list; add the RealmChain arm to target\_error\_status (map to BAD\_REQUEST or NOT\_FOUND per the inner error). Add `-p meerkat-rest -p meerkat-rpc` to the P5/P6 gates so the compile break and behavior change are caught before P8. * **MF-04** (amends new phase P5b (REST/RPC auth re-root)) * finding: Verified: REST resolve\_binding\_identity (auth\_endpoints.rs:77-104) and RPC resolve\_binding\_identity (handlers/auth.rs:151-163) both build `AuthBindingRef { realm: realm_id.clone(), ... }` from the REQUEST realm and call `realm.lookup_auth_binding` on a SINGLE realm section (resolve\_realm, single-section from\_config). These are the OAuth/credential PERSISTENCE paths (require\_managed\_store\_source → PreparedTokenCommitSnapshot → TokenKey). After decision A makes provenance = owning realm, a client POSTing creds for child realm `team` whose binding lives only in `global` either 404s (single-section lookup misses) or persists the TokenKey under `team` (request realm) while the chain-aware factory build path reads from `global` (owner) — credential written to one dir, read from another = silent auth failure on two network surfaces. REST resolve\_oauth\_target (auth\_endpoints.rs:127) also calls resolve\_realm\_binding\_target\_for\_provider with an explicit realm, so the explicit-realm chain-widening behavior change hits REST/RPC too. Both files are in NO phase. * fix: Add a phase (e.g. P5b) re-rooting resolve\_binding\_identity in BOTH meerkat-rest and meerkat-rpc onto the chain: resolve the owning chain member for (realm, binding) and stamp AuthBindingRef.realm = owner before lookup/persist, mirroring the factory. Decide explicitly whether REST/RPC credential-persist should inherit (walk chain) or stay strict-owner (write only to the named realm); document it. Add RCT-27 (REST) and RCT-28 (RPC). * **MF-05** (amends new phase P5b/P6 (per-surface composer wiring)) * finding: Verified: ChainComposingConfigStore is wired only at the CLI (P6, meerkat-cli/src/main.rs:3737). REST builds its own store at lib.rs:415 (FileConfigStore → TaggedConfigStore → ConfigRuntime), RPC at main.rs:226 (FileConfigStore → ConfigRuntime, set\_config\_runtime at :348), MCP-server at lib.rs:450 — NONE wrap the composer. ConfigRuntime.get() (config\_runtime.rs:128) just returns store.get(). So top-level inheritance (model/mcp/hooks/skills/limits) AND the implicit-global-tail are silently dead on REST/RPC/MCP: config.realm on those surfaces contains only the head realm's own doc, so the connection-layer implicit-global-tail won't fire either (config.realm has no `global` key). Divergent resolution across interchangeable surfaces is exactly the regression class the dogma forbids. * fix: Wrap ChainComposingConfigStore at EVERY surface's store-construction site (rest lib.rs:415, rpc main.rs:226, mcp-server lib.rs:450), OR compose inside ConfigRuntime once. Add surface-level RCTs (RCT-29) asserting an inherited \[realm.global] model/mcp/cred is visible through each surface's config\_runtime.get(). Note the set(get()) hazard in MF-06 — the composer must NOT be the symmetric store the write path round-trips. * **MF-06** (amends type\_changes (ChainComposingConfigStore / new EffectiveConfigReader) + P3) * finding: Verified: ConfigStore (config\_store.rs:37) is a SYMMETRIC trait — get()→Config, set(Config), patch(delta)→Config. The RPC config/set handler (handlers/config.rs:285) does `prior = config_runtime.get()` then `config_runtime.set(config, ...)`. If ChainComposingConfigStore.get() composes the chain (inherited mcp/hooks/skills flattened in) and set() writes head-only (plan: 'writes never compose'), then any read-modify-write round-trips the COMPOSED blob into the head doc, durably materializing every parent-inherited entry into the child. This permanently flattens inheritance and violates user decision 3 (children CANNOT remove inherited mcp/hook entries — after one set(get()) they own frozen copies they can never shed, and future parent edits stop propagating). Same shape exists wherever config is read-then-written. * fix: Do NOT have ChainComposingConfigStore implement the symmetric ConfigStore trait. Introduce a read-only EffectiveConfigReader (get\_effective) consumed ONLY by the agent build path (factory) and surface config/get-for-resolution, and keep ConfigStore.get() returning the RAW head doc everywhere a write may follow. The RPC/REST prior-capture and patch path must read the raw head, never the composed view. ChainComposingConfigStore.set/patch must persist only the delta against the head doc's PRE-composition state. Add RCT-30 pinning that a config write after a composed read does NOT flatten inherited entries into the head doc. * **MF-07** (amends type\_changes (RealmConfigSource) + P3/P6) * finding: Verified at config\_store.rs:191: FileConfigStore::get() returns `Ok(Config::default())` when the file is absent, NOT None. The plan's RealmConfigSource trait returns `Result>` and RCT-16 asserts 'absent ancestor doc = empty pass-through (NOT Config::default clobber)'. If P6's filesystem RealmConfigSource delegates to FileConfigStore::get for ancestor docs, an absent ancestor yields a full Config::default() folded into the chain. Because compose folds parent-first and model\_fallback has a catalog-reset branch (config.rs:352, if other.model\_fallback.use\_catalog\_default\_chain → reset), a default ancestor can RESET model\_fallback mid-chain and clobber any field where 'default' is meaningful. RCT-16 passes against a hand-rolled mock source but the real CLI source built on FileConfigStore violates it. * fix: The filesystem RealmConfigSource MUST check tokio::fs::try\_exists itself and return None on absence (not call FileConfigStore::get). Document that config\_for\_realm returns None iff the file is absent, Some(parsed) otherwise. Strengthen RCT-16 to assert a None ancestor is byte-identical to that ancestor being omitted from the chain AND that model\_fallback catalog-reset is not triggered by an absent ancestor. Add an RCT (RCT-31) exercising the REAL FileConfigStore-backed source, not only the mock. * **MF-08** (amends new phase P6b (migration) + canonical\_design/provenance\_design prose) * finding: User-confirmed decision 1 is MIGRATE dev creds to global (one-time copy, not orphan). The plan CONTRADICTS this: canonical\_design says 'existing dev/\* tokens orphaned, one-time re-login', provenance\_design repeats 'pre-1.0 clean break, one-time re-login', and open\_question #6 re-litigates it. Verified: no phase contains migration code (grep for 'migrat' across P0-P8 = zero), and no RCT covers dev→global copy. Credential root is `/meerkat/credentials//.json` (mod.rs:78, file.rs path\_for), so migration is a `credentials/dev/* → credentials/global/*` directory copy. Without it, every existing logged-in install fails auth on first run after upgrade because the resolver now stamps owner=global and reads credentials/global/ which is empty. * fix: Add an explicit migration phase (P6b) implementing the one-time idempotent copy: copy `credentials/dev/` → `credentials/global/` IFF the target is absent (no clobber); leave dev/ intact or remove per decision. Reconcile canonical\_design/provenance\_design to say COPY, not orphan; remove open\_question #6 (it is a settled decision). Add RCT-32: creates a dev token on disk, runs migration, asserts the credential resolves at owning realm global; re-running migration is a no-op; an existing global cred is not clobbered. * **MF-09** (amends P5 (add the gate edit to the writes) + RCT-20 fixture) * finding: Verified at factory.rs:1894 (image) and :1924 (web\_search): the chain-capable branch (resolve\_realm\_binding\_for\_provider with Some(selected\_realm)) is taken ONLY when `!config.realm.contains_key(selected_realm)`. When the selected realm EXISTS but its own section defines no image/web\_search binding (the inheritance case), it falls to resolve\_selected\_image/web\_search\_binding\_for\_provider → resolve\_selected\_binding\_for\_provider (factory.rs:1806) which does single-section from\_config(selected\_realm) and stamps realm=selected\_realm. The plan edits resolve\_selected\_binding\_for\_provider (P5) to walk the chain but does NOT edit the :1894/:1924 contains\_key gate that decides which path is taken. So a child realm that EXISTS but inherits its image/web\_search binding silently gets no binding. RCT-20 will pass if its fixture defines the binding directly in selected\_realm, masking the gate bug. * fix: Edit the contains\_key gates at factory.rs:1894 and :1924 so the chain walk is taken whenever the selected realm's OWN section lacks the binding, not only when the realm is absent (or fold the chain walk into resolve\_selected\_binding\_for\_provider and route through it unconditionally for present realms). RCT-20's fixture MUST define the image binding ONLY in the parent while selecting the child, to exercise the inherited path. * **MF-10** (amends P5 (factory self\_hosted) + P6 (CLI doctor); add to edit-site list) * finding: Verified two unlisted explicit-realm callers that P2's chain-widening behaviorally changes: meerkat/src/factory.rs:3349 configured\_self\_hosted\_connection has a `config.realm.contains_key(realm_id)` guard then calls resolve\_realm\_binding\_target\_for\_provider(Some(realm\_id), ...); its fallthrough swallows errors to Ok(None) at :3377-3383 (silent). And CLI doctor self\_hosted at meerkat-cli/src/main.rs:5981/5999 passes Some(preferred\_realm). After P2, a self\_hosted binding defined only in a PARENT of the explicit realm now resolves where it previously errored (owner=parent stamping) — a behavior change for `rkat doctor` and self-hosted client construction, with the Ok(None) swallow potentially masking a chain-resolution failure. No RCT covers self\_hosted inheritance. These are concrete execution seams the plan's edit-site enumeration misses. * fix: Enumerate ALL explicit-realm callers in the plan (factory.rs:3349, cli main.rs:5981/5999, rest auth\_endpoints.rs:127, rpc handlers/auth.rs:188) and decide per-caller whether chain-widening is desired for self\_hosted (or explicit no-inherit if that is the product choice). Edit the contains\_key guard at factory.rs:3349 for the inherited case, and verify the Ok(None)-on-error swallow at :3377 does not mask a chain-resolution failure. Add RCT-33 for self\_hosted inheritance via the explicit-realm seam. * **MF-11** (amends merge\_semantics\_table + type\_changes (Config::merge) + P3) * finding: Verified the merge ambiguity is load-bearing: merge\_semantics\_table marks realm.X.backend and realm.X.auth as 'no-inherit (resolved within owning realm's own section)' (RCT-08), but compose\_effective\_config type\_changes + RCT-15 say Config::merge 'folds self.realm via map-key-union child-wins' so 'an inherited \[realm.X] section is visible'. RealmConnectionSet::from\_config (connection.rs:1057-1064, the actual line) resolves each binding's backend\_profile/auth\_profile via backends.get()/auth\_profiles.get() WITHIN the same section and errors UnknownBackend/UnknownAuth if absent. If 'fold the realm map child-wins' is read as unioning the backend/auth/binding SUB-maps across realms, no-inherit is destroyed and the credential-redirect hazard re-opens. If it means only outer realm-key union, the phrase is a no-op when the head doc already has the keys. The plan never states the level unambiguously, and RCT-08 + RCT-15 can both pass under a wrong implementation. Additionally, a child that declares a binding referencing a parent-only backend\_profile will fail from\_config(child) with a confusing UnknownBackend, with unspecified failure semantics. * fix: State explicitly: compose\_effective\_config unions ONLY the outer realm→section keys (adds ancestor realm entries absent from the head); it MUST NOT union backend/auth/binding sub-maps across realms. Each ancestor RealmConfigSection stays an intact map entry; the chain walk visits them as separate owning sets. Make the no-inherit-for-auth/backend rule fail-closed and validated: a child binding whose backend\_profile/auth\_profile is not present in the child's OWN section either errors with a typed message naming the missing profile and realm, or requires redeclaring backend+auth. Add RCT-34: a child binding referencing a parent-only backend\_profile fails closed with a typed error (distinct from RCT-08's inherited-WHOLE-binding case). * **MF-12** (amends cycle\_and\_determinism + P2 gate) * finding: Nearest-child-wins precedence is unpinned across BOTH chain consumers. resolve\_auth\_binding\_candidates\_for\_provider returns an ORDERED Vec the factory iterates as fallbacks (build path consumes candidates\[0]; model-swap at factory.rs:3114 finds first matching). Today's order is preferred → literal 'default' → alpha-scan → env\_default; the plan replaces it with chain order \[head, parents..., global, env]. RCT-09 only asserts unrelated siblings are excluded; RCT-06/07 assert owner stamping. NONE assert the relative ORDER when MULTIPLE chain members each define a usable provider binding (head defines anthropic binding A, global defines anthropic binding B — which is selected first?). cycle\_and\_determinism discusses sequence determinism but never pins the PRODUCT choice of nearest-wins. A regression here silently switches which real credential/account the agent authenticates with. Also default\_binding has divergent semantics: the connection path uses selected\_binding\_id\_for\_provider PER member (checks default\_binding against THAT member's own bindings), so a child default\_binding pointing at a parent-owned binding id silently no-ops in the connection path while appearing 'set' in EffectiveConfig. * fix: Add RCT-35 pinning nearest-child-wins: when head and an ancestor both define a usable provider binding, candidate\[0] is the head, ancestor next, global last, env terminal. Make nearest-child-wins an explicit invariant shared by compose\_effective\_config.default\_binding selection AND the candidate resolver, with shared fixtures. Decide and document child.default\_binding pointing at a parent-owned binding id: either resolve against the COMPOSED chain or document it as owner-section-scoped only; pin with an RCT. * **MF-13** (amends P6 + canonical\_design (global location); remove open\_question #5) * finding: Verified the global-doc location conflicts with the workspace-rooted CLI store, breaking the headline cross-workspace promise of decision 2. default\_cli\_state\_root (main.rs:3733) = context\_root/.rkat/realms (context\_root defaults to current directory = WORKSPACE-local). resolve\_config\_store (main.rs:3737-3753) uses realm\_paths\_in(scope.locator.state\_root, realm) → `/.rkat/realms//config.toml`. P6 says the RealmConfigSource is 'realm\_paths\_in based', so realm\_paths\_in(state\_root, 'global') → `/.rkat/realms/global/config.toml` — a PER-WORKSPACE file, NOT the single \~/.rkat doc decision 2 mandates. global\_config\_path() (config.rs:217) = \~/.rkat/config.toml exists but is never wired into resolve\_config\_store. So login-once-to-global inherited across workspaces cannot work: each workspace gets an isolated global doc, or the source reads a location login never wrote to. open\_question #5 re-litigates this settled decision. * fix: Make the global node's RealmConfigSource entry HOME-rooted, not state\_root-rooted: read the single dedicated doc at \~/.rkat (reuse global\_config\_path / a \~/.rkat/realms/global location independent of the workspace state\_root). The composer must compose \[workspace head doc] over \[fixed home-rooted global doc]. Login must WRITE to the SAME home-rooted location the composer reads. Remove open\_question #5 (settled by decision 2). Add RCT-36: login under workspace A is visible to a composed get() under workspace B (cross-workspace), asserting the exact physical write path equals the composer's global source path; realm\_paths\_in(state\_root,'global') is NOT the global source. * **MF-14** (amends P3 + open\_question #2 resolution) * finding: User decision 3 (children CANNOT remove inherited mcp/hook entries — no tombstones) is tested only POSITIVELY. RCT-13 (hooks append parent-first) and RCT-14 (mcp map-union child-wins) verify inherited entries are PRESENT and overridable, but no RCT asserts a child with NO mcp\_servers / NO hooks STILL inherits all parent entries, or that a child setting an empty \[tools]/\[hooks] table does NOT drop inherited entries. Verified today merge\_tools whole-replaces mcp\_servers when non-empty (config.rs:380-382), so the rework to union must be tested for the no-removal property specifically. A future regression (child empty mcp map silently dropping inherited servers) would pass RCT-13/14 yet violate decision 3 — a child silently disabling an inherited security hook is an auth/safety regression. * fix: Add RCT-37: a child realm with NO mcp\_servers and NO hooks still inherits ALL parent mcp\_servers and hook entries; a child setting an empty \[tools]/\[hooks] table does not remove inherited entries (emptiness != removal, no tombstone honored). Resolve the hooks/skills dedup identity key (open\_question #2) as a typed function before P3 so an inherited-then-redeclared hook does not double-run; extend RCT-13 to a 3-level chain asserting the exact ordered, deduped entry list. ## RCT contracts — failing-tests-first ### Original (RCT-01..24) * **RCT-01** \[meerkat-core] `realm_config_section_parent_roundtrips_and_defaults_none` — RealmConfigSection round-trips parent: `Option` through TOML/serde; absent parent == None; present parent = "global" parses to RealmId. Config.realm map serde unchanged. * **RCT-02** \[meerkat-core] `global_realm_is_typed_and_distinct_from_env_default` — RealmId::is\_global / RealmId::global / GLOBAL\_REALM\_SLUG are typed; global is distinct from env\_default (is\_global != is\_env\_default); both recognized by predicate not raw compare. * **RCT-03** \[meerkat-core] `realm_chain_resolves_linear_order_with_implicit_global_tail` — RealmChain::resolve walks a linear chain head→parent→global in deterministic order; parentless non-global head implicitly appends global when present; global.parent=None terminates. * **RCT-04** \[meerkat-core] `realm_chain_detects_cycle_depth_missing_global_and_env_default` — RealmChain::resolve fails closed: self-parent → Cycle; A→B→A → Cycle; missing parent → MissingParent; global with parent → GlobalHasParent; parent=env\_default → ParentIsEnvDefault; depth>16 → DepthExceeded. No panic. * **RCT-05** \[meerkat-core] `realm_chain_omits_absent_global_and_falls_through_to_env_default` — Absent \[realm.global] → chain terminates at explicit root, no global contribution; env\_default fallback still applies post-chain when allow\_env\_default. * **RCT-06** \[meerkat-core] `inherited_binding_stamps_owning_realm_not_consuming_realm` — OWNING != CONSUMING provenance: a binding defined ONLY in parent realm P, resolved while preferred realm is child C, yields AuthBindingRef.realm == P (the owner), NOT C. selected via the auth\_binding-omitted path. * **RCT-07** \[meerkat-core] `resolved_target_realm_equals_owning_connection_set_realm_id` — materialize is fed the owner's own RealmConnectionSet so registry-equality invariant auth\_binding.realm == realm.realm\_id HOLDS for inherited bindings (no relaxation). * **RCT-08** \[meerkat-core] `inherited_binding_resolves_backend_auth_in_owning_realm_only` — no-inherit for auth/backend: a binding inherited from parent P resolves its backend\_profile/auth\_profile in P's OWN section; a child C redefining auth-profile-key K does NOT re-source P's binding (binding-owner==backend-owner==auth-owner). * **RCT-09** \[meerkat-core] `candidates_follow_parent_chain_not_flat_realm_scan` — the flat cross-realm scan is replaced by the chain: a provider binding in an UNRELATED sibling realm (not an ancestor of head) is NOT auto-discovered; only chain members + env\_default are candidates, in chain order. * **RCT-10** \[meerkat-core] `single_target_path_uses_unified_selection_policy` — single canonical selection policy: resolve\_realm\_binding\_target\_for\_provider now honors provider\_default and single-unambiguous (not default\_binding-only) — same policy as selected\_binding\_id\_for\_provider, applied per chain member. * **RCT-11** \[meerkat-core] `explicit_inherited_binding_resolves_at_owning_realm` — explicit AuthBindingRef naming child realm C whose binding is defined only in parent P resolves at P (owner stamped P); explicit env\_default ref still rejected. * **RCT-12** \[meerkat-core] `effective_config_unions_model_defaults_child_wins` — compose\_effective\_config: Config.models per-provider union child-wins (child anthropic default + inherited parent openai default both present); agent.model child-wins-scalar. * **RCT-13** \[meerkat-core] `effective_config_appends_hooks_parent_first` — compose\_effective\_config: hooks.entries append parent-first-then-child; child cannot delete parent hook; scalar hook timeouts child-wins. * **RCT-14** \[meerkat-core] `effective_config_unions_mcp_limits_skills` — compose\_effective\_config: tools.mcp\_servers map-union child-wins per name; limits per-field child-wins (child tightens max\_sessions, inherits other caps); skills append child-wins-on-collision. * **RCT-15** \[meerkat-core] `config_merge_folds_realm_map_child_wins` — Config::merge now folds self.realm (map-key-union child-wins) instead of silently dropping it; inherited \[realm.X] section visible after merge. * **RCT-16** \[meerkat-core] `chain_composing_store_produces_effective_config_no_default_clobber` — ChainComposingConfigStore.get() composes the head's chain via injected RealmConfigSource into the effective flat Config; absent ancestor doc = empty pass-through (NOT Config::default clobber). * **RCT-17** \[meerkat-auth-core] `managed_store_inherited_binding_reads_owning_realm_token` — ManagedStore credential of an inherited binding resolves the TokenStore at the OWNING realm dir (drop hardcoded 'dev'): binding owned by realm P → TokenKey.realm==P → store key under P. * **RCT-18** \[meerkat-auth-core] `env_source_binding_is_realm_agnostic` — Env-source binding is realm-AGNOSTIC: an inherited Env-source binding resolves identical creds regardless of owning realm (decision A is a no-op for Env/InlineSecret/Command material). * **RCT-19** \[meerkat-auth-core] `file_token_store_path_for_uses_owning_realm` — FileTokenStore::path\_for projects `//[@profile].json` (currently ZERO tests on file.rs); owning realm drives the on-disk dir. * **RCT-20** \[meerkat] `factory_image_binding_stamps_owning_realm` — factory resolve\_selected\_binding\_for\_provider (image/web\_search) stamps the OWNING realm for an inherited binding, not selected\_realm; registry.resolve equality holds at the image path. * **RCT-21** \[meerkat] `model_swap_keeps_inherited_chain_candidates` — factory model-swap candidate filter accepts inherited candidates (owner != preferred) on the chain; hot model-swap does not drop inherited bindings. * **RCT-22** \[meerkat] `build_agent_inherits_global_binding_resolves_at_global` — end-to-end build\_agent with preferred realm C inheriting binding from global resolves credentials at global (owning) realm and builds the client. * **RCT-23** \[meerkat-contracts] `config_set_roundtrips_parent_through_opaque_realm_map` — SDK/wire config-set round-trips an unknown \[realm.X].parent through the opaque ConfigContractSchema.realm Value map without loss; regen-schemas no-op. * **RCT-24** \[meerkat-cli] `cli_login_targets_global_and_realm_flag_composes_chain` — CLI login lands credentials under global (resolved owner) not 'dev'; status/test/refresh/logout read the same owning realm; --realm selects a realm AND composes its chain while NOT touching RuntimeScope (updated pin). ### Added by red-team (RCT-25..38) * **RCT-25** \[meerkat-core] `default_realm_literal_not_consulted_for_unrelated_head_then_works_under_global` — A \[realm.default] section holding a provider binding is NO LONGER auto-consulted for a head realm that does not parent to `default` (the literal `"default"` candidate is removed in both resolve\_realm\_binding\_target\_for\_provider and resolve\_auth\_binding\_candidates\_for\_provider, distinct from the flat scan); the SAME binding moved to \[realm.global] resolves via the implicit-global-tail. * **RCT-26** \[meerkat-core] `absent_head_realm_yields_single_node_chain_then_env_default` — When the preferred/head realm is absent from config.realm, RealmChain::resolve yields \[head] (head section None → contributes nothing) plus implicit-global-tail if global present; non-explicit resolution falls through to env\_default when allow\_env\_default; an EXPLICITLY-named absent realm still errors UnknownRealm. Rewrites the pinned auth\_binding\_candidates\_scan\_configured\_realms\_before\_env\_default (connection.rs:2026). * **RCT-27** \[meerkat-rest] `rest_oauth_persist_for_inherited_binding_stamps_owning_realm_and_maps_realmchain_status` — POST OAuth/credential target for a child realm whose binding is defined only in a parent resolves at the OWNING realm (AuthBindingRef.realm=owner, TokenKey under owner dir) per the documented design, OR is rejected with a defined status; target\_error\_status maps the new ConnectionTargetError::RealmChain variant to a sane HTTP status (compiles). * **RCT-28** \[meerkat-rpc] `rpc_oauth_persist_for_inherited_binding_stamps_owning_realm` — RPC auth credential-persist (resolve\_binding\_identity / resolve\_oauth\_target) for an inherited binding resolves at the owning chain member and stamps AuthBindingRef.realm=owner before persist; the RealmChain error variant maps to a defined RPC error code. * **RCT-29** \[meerkat-rest] `rest_config_runtime_get_reflects_inherited_global_section` — An inherited \[realm.global] model/mcp/credential is visible through the REST surface's config\_runtime.get() effective config (ChainComposingConfigStore wired at the surface), matching what the factory build path resolves for the same head realm. (Companion RPC/MCP variants assert the same per-surface composer wiring.) * **RCT-30** \[meerkat-core] `config_set_after_composed_read_does_not_flatten_inherited_entries` — A read-modify-write through the config surface (get effective → apply delta → set) does NOT durably materialize parent-inherited mcp/hook/skill entries into the head doc; after reload the head doc on disk contains ONLY its own fields plus the patched one, with inherited entries still resolved (not copied) on the next composed read. Proves get-composes/set-head-only is not round-trippable into a flatten. * **RCT-31** \[meerkat-cli] `filesystem_realm_config_source_composes_real_temp_chain_no_default_clobber` — The PRODUCTION filesystem RealmConfigSource over a real temp state\_root with global/config.toml (binding B) and child/config.toml (parent=global, no binding) returns None for an absent ancestor (not Config::default), composes child top-level fields over global without default-clobber, and build\_agent stamps owner=global for B. * **RCT-32** \[meerkat-cli] `dev_to_global_credential_migration_idempotent_no_clobber` — One-time migration copies `credentials/dev/` → `credentials/global/` IFF the target is absent; an existing global credential is NOT clobbered; re-running migration is a no-op; after migration a pre-existing dev token resolves at owning realm global. (Implements user decision 1.) * **RCT-33** \[meerkat] `self_hosted_explicit_realm_inherits_binding_at_owning_realm` — configured\_self\_hosted\_connection (factory.rs:3349) for an explicit realm that EXISTS but inherits its self\_hosted binding from a parent resolves at the owning realm via the chain walk (not single-section), and the Ok(None)-on-error swallow does not mask a chain-resolution failure; the contains\_key guard takes the chain path when the realm's own section lacks the binding. * **RCT-34** \[meerkat-core] `child_binding_referencing_parent_only_backend_profile_fails_closed` — A child binding whose backend\_profile/auth\_profile is defined only in a parent realm fails closed with a typed error naming the missing profile and realm (proving compose\_effective\_config unions ONLY outer realm-keys and does NOT merge backend/auth/binding sub-maps across realms); distinct from RCT-08's inherited-whole-binding case. * **RCT-35** \[meerkat-core] `nearest_child_wins_when_head_and_ancestor_both_define_binding` — When head and an ancestor both define a usable provider binding, candidate\[0] is the head (nearest-child-wins), ancestor next, global last, env terminal; the same nearest-wins precedence governs compose\_effective\_config.default\_binding selection and the candidate resolver over shared fixtures. * **RCT-36** \[meerkat-cli] `global_login_under_workspace_a_inherited_in_workspace_b` — Login writes \[realm.global] to the home-rooted global doc (assert exact path = \~/.rkat-rooted, NOT `/.rkat/realms/global`); a fresh ChainComposingConfigStore in a DIFFERENT workspace composes that global doc into its effective config and resolves the global-owned binding (cross-workspace inheritance, decision 2); RuntimeScope realm decoupling preserved. * **RCT-37** \[meerkat-core] `child_cannot_remove_inherited_mcp_or_hook_entries` — A child realm with no mcp\_servers and no hooks inherits ALL parent mcp\_servers and hook entries; a child setting an empty \[tools]/\[hooks] table does not drop inherited entries (emptiness != removal, no tombstone). Negative property for user decision 3. * **RCT-38** \[meerkat-core] `explicit_env_default_ref_rejected_before_chain_walk` — An explicit AuthBindingRef with realm==env\_default (origin SyntheticEnvDefault) is rejected with UnknownRealm BEFORE any chain walk in the rewritten resolve\_auth\_binding\_or\_default\_for\_provider; no durable lease/token is created. Standalone negative test (split out of RCT-11) guarding the is\_env\_default check at connection.rs:764 against reorder during the explicit-path rewrite. ## Phases & gates ### P0 — Contracts & failing RCTs (Gate 0) (depends\_on: none) * goal: Write ALL 24 RCTs as compiling-but-failing tests pinning every invariant BEFORE any implementation. Add the type STUBS they reference (RealmConfigSection.parent field, GLOBAL\_REALM\_SLUG/is\_global/global, RealmChain/RealmChainError signatures returning unimplemented!() — wait, no panic: return a placeholder Err so library code stays panic-free; tests assert the real behavior and FAIL). * writes: meerkat-core: RealmConfigSection.parent field (connection.rs:1272), meerkat-core: GLOBAL\_REALM\_SLUG + RealmId::is\_global/global + MAX\_REALM\_CHAIN\_DEPTH (connection.rs:148), meerkat-core: RealmChain/RealmChainError type + ctor signature returning a typed Err placeholder (connection.rs), meerkat-core: compose\_effective\_config + Config::merge realm-fold signatures (config.rs), meerkat-core: RealmConfigSource trait + ChainComposingConfigStore skeleton (config\_store.rs), RCT-01..05, 12..16, 23 as failing tests; RCT-06..11 as failing tests; RCT-17..19 (auth-core), 20..22 (meerkat), 24 (cli) as failing tests * GATE: ./scripts/repo-cargo nextest run -p meerkat-core -p meerkat-auth-core -p meerkat -p meerkat-contracts -p meerkat-cli compiles; the 24 RCTs RUN and FAIL with the expected assertions (not compile errors). `make regen-schemas` no-op diff confirmed for the parent field. ### P1 — RealmChain (deterministic walk + fail-closed) (depends\_on: P0) * goal: Implement RealmChain::resolve: linear walk, BTreeSet dedup, depth cap, implicit global tail, all typed errors. Pure, wasm-clean. * writes: meerkat-core/src/connection.rs: RealmChain::resolve full impl + RealmChainError + `From` for ConnectionTargetError * GATE: ./scripts/repo-cargo nextest run -p meerkat-core -- realm\_chain — RCT-02,03,04,05 GREEN. clippy -p meerkat-core clean. ### P2 — Connection resolvers chain-aware + owning-set (decision A core) (depends\_on: P1) * goal: Rewrite resolve\_auth\_binding\_candidates\_for\_provider (delete flat scan), resolve\_realm\_binding\_target\_for\_provider (unify selection), resolve\_auth\_binding\_or\_default\_for\_provider (explicit-ref chain) to walk RealmChain and feed materialize each owner's OWN RealmConnectionSet. materialize unchanged. * writes: meerkat-core/src/connection.rs:886-966 (candidates), meerkat-core/src/connection.rs:665-750 (single-target), meerkat-core/src/connection.rs:755-795 (explicit ref), delete push\_candidate\_realm\_ids (:862) * GATE: ./scripts/repo-cargo nextest run -p meerkat-core — RCT-06,07,08,09,10,11 GREEN; ALL pre-existing connection.rs tests either GREEN or deliberately REWRITTEN (auth\_binding\_candidates\_scan\_configured\_realms\_before\_env\_default :2027 rewritten to chain order). clippy clean. ### P3 — EffectiveConfig composition (top-level inheritance, the headline feature) (depends\_on: P1) * goal: Implement compose\_effective\_config + Config::merge rework (fold realm map, field-level child-wins, hooks/skills append, mcp/limits union, presence-aware scalars) + RealmConfigSource trait + ChainComposingConfigStore.get/set/patch. * writes: meerkat-core/src/config.rs: compose\_effective\_config + reworked merge + presence-aware helpers, meerkat-core/src/config\_store.rs: RealmConfigSource + ChainComposingConfigStore * GATE: ./scripts/repo-cargo nextest run -p meerkat-core — RCT-12,13,14,15,16 GREEN; pre-existing config.rs merge tests (:2387,:2435-2746) GREEN or rewritten. clippy clean. ### P4 — Credential resolver provenance verification (bottom-up, auth-core) (depends\_on: P2) * goal: NO resolver logic change; ADD the missing RCTs proving owning-realm flows through TokenKey/FileTokenStore and Env is realm-agnostic. Fixes any test-double that hardcoded 'dev'. * writes: meerkat-auth-core/src/resolver.rs tests: RCT-17, RCT-18, meerkat-auth-core/src/auth\_store/file.rs tests: RCT-19 (first-ever tests on file.rs) * GATE: ./scripts/repo-cargo nextest run -p meerkat-auth-core — RCT-17,18,19 GREEN; existing managed\_store\_\* lifecycle tests GREEN. clippy clean. ### P5 — Factory: second stamping site + model-swap filter + ConfigStore wiring (depends\_on: P3, P4) * goal: Re-root resolve\_selected\_binding\_for\_provider to walk selected\_realm's chain and stamp owner; relax model-swap filter; wire ChainComposingConfigStore so factory consumes the effective Config. * writes: meerkat/src/factory.rs:1806-1843 (image/web\_search owning-realm stamp), meerkat/src/factory.rs:3099-3126 (model-swap filter), meerkat/src/factory.rs build path: consume effective Config via ChainComposingConfigStore * GATE: ./scripts/repo-cargo nextest run -p meerkat — RCT-20,21,22 GREEN; existing factory provenance tests (factory.rs:7249,:7299,:6888 rewritten to chain) GREEN. clippy clean. ### P6 — CLI login → global + ConfigStore chain wiring at surface (depends\_on: P5) * goal: Collapse the 3 'dev' literals to RealmId::global(); login writes \[realm.global] into the global realm's store; resolve\_config\_store composes the chain; update the pinned --realm test (not bypass). * writes: meerkat-cli/src/main.rs:4755,4003,4886,4977,5322,5391,5742 + user strings, meerkat-cli/src/main.rs:3737 resolve\_config\_store wraps FileConfigStore in ChainComposingConfigStore via a filesystem RealmConfigSource (realm\_paths\_in based), meerkat-cli/src/main.rs:16447 updated pin * GATE: ./scripts/repo-cargo nextest run -p meerkat-cli (cargo int — rkat is bin-only) — RCT-24 GREEN; existing CLI login/oauth-config tests (:14004-14359) GREEN or rewritten. clippy clean. ### P7 — Wire/SDK round-trip + WASM degenerate chain (depends\_on: P5) * goal: Pin opaque-Value round-trip of parent; confirm WASM single-realm chain is identical to today; inject WASM RealmConfigSource. * writes: meerkat-contracts: RCT-23, meerkat-web-runtime/src/lib.rs: degenerate RealmConfigSource (no global) wiring * GATE: ./scripts/repo-cargo nextest run -p meerkat-contracts — RCT-23 GREEN. `make regen-schemas && make verify-version-parity && make verify-schema-freshness` ALL no-op/green. BuildBuddy wasm-check-submit green. ### P8 — Broad final verification + governance gates (depends\_on: P6, P7) * goal: Full-workspace verification, RMAT, e2e. * writes: no new code; fix any cross-crate fallout surfaced by the broad lanes * GATE: ./scripts/repo-cargo clippy --workspace -- -D warnings; MEERKAT\_BUILDBUDDY=1 make test (unit+int); make e2e-fast; make rmat-audit; make regen-schemas (no-op diff); make verify-version-parity. ALL green, ZERO failures (never dismiss as pre-existing). ### Phase/gate corrections (red-team) — incl. NEW phases P5b, P6b * Add a new phase P5b 'Surface auth re-root + composer wiring': wrap ChainComposingConfigStore (or a read-only EffectiveConfigReader per MF-06) at REST lib.rs:415, RPC main.rs:226, MCP-server lib.rs:450; re-root resolve\_binding\_identity in meerkat-rest/src/auth\_endpoints.rs:77 and meerkat-rpc/src/handlers/auth.rs:151 onto the owning-chain stamp; add target\_error\_status RealmChain arm at auth\_endpoints.rs:106. Gate runs -p meerkat-rest -p meerkat-rpc -p meerkat-mcp-server with RCT-27/28/29/30. * Add a new phase P6b 'dev→global credential migration': one-time idempotent copy of credentials/dev/\* → credentials/global/\* (no clobber); reconcile canonical\_design/provenance\_design prose from 'orphan' to 'copy'. Gate runs RCT-32. This phase is REQUIRED by user decision 1 and is currently entirely absent. * P2 gate: add RCT-25 (literal default removed), RCT-26 (absent head), RCT-35 (nearest-child-wins), RCT-38 (explicit env\_default rejection) to the green set; explicitly state the rewritten target behavior for auth\_binding\_candidates\_scan\_configured\_realms\_before\_env\_default (connection.rs:2026) rather than only listing it for rewrite. * P3 gate: forbid REWRITING the existing file-layer merge tests (config.rs:2387, :2419, :2480, :3395) — the single reworked merge engine must leave global-file→project layering precedence unchanged; only NEW realm-fold tests may be added. Add RCT-30 (no set(get()) flatten), RCT-31 (real fs source no-default-clobber), RCT-34 (sub-map no-inherit fail-closed), RCT-37 (no-removal). Resolve the hooks/skills dedup identity key (open\_question #2) before this gate. * P5 gate: add the factory.rs:1894/:1924 image/web\_search contains\_key gate edit to the writes (MF-09); add factory.rs:3349 self\_hosted explicit-realm seam to the audit (MF-10); require RCT-20's fixture to define the image binding ONLY in the parent. Add RCT-33. * P6 gate: resolve the global-doc location to home-rooted \~/.rkat (decision 2, MF-13) — the RealmConfigSource global node must NOT be realm\_paths\_in(state\_root,'global'); enumerate and migrate ALL CLI\_INTERACTIVE\_OAUTH\_REALM\_ID read sites (main.rs:4887, :4981, :5031) and auth\_config\_realm\_or\_default callsites together. Add RCT-36 (cross-workspace). * P8 gate: add `make e2e-auth` (and e2e-system for the token-store path) — this is an auth/credential-provenance change and the listed P8 lanes (unit+int, e2e-fast) do not exercise the real FileTokenStore owning-realm directory layout or OAuth lease re-keying. Add a deterministic int-lane RCT round-tripping a real FileTokenStore at the owning-realm dir for an inherited binding. * Gate 0 (P0): require each RCT to fail with the SPECIFIC assertion it pins, not a generic Err/unimplemented from the RealmChain::resolve typed-Err placeholder — otherwise a uniform-Err stub yields a false-green Gate 0. Split the type-stub compile check from the behavior-red check for RCT-03/05 (success-order/tail-omission) which a blanket-Err placeholder cannot fail-as-intended. ## Risks & mitigations * **risk:** Eager-flatten temptation re-introduced for the connection set (sidecar maps or single-multi-owner set), forcing the registry.rs:208 equality downgrade — the exact dogma violation the judges flagged. * mitigation: HARD RULE in the design: RealmConnectionSet gains NO field; materialize is ALWAYS fed the owner's own from\_config set; RCT-07 pins that auth\_binding.realm == resolved set realm\_id for inherited bindings WITHOUT relaxing the equality. Any PR diff touching registry.rs:208 is a red flag. * **risk:** Top-level config inheritance becomes a SECOND uncoordinated chain authority (dual-truth), diverging from the connection-fact chain ordering. * mitigation: ONE RealmChain::resolve is the sole chain authority; compose\_effective\_config and the connection resolvers BOTH call it. RCT-09 (connection order) and RCT-12/13/14 (effective config) share the same chain fixtures so divergence fails a test. * **risk:** auth/backend cross-realm credential-redirect (a child redefining auth-profile-key K silently re-sources a parent-owned binding). * mitigation: no-inherit for auth/backend: binding's profiles resolved ONLY in the owning realm's section → binding-owner==backend-owner==auth-owner invariant. RCT-08 pins it with a child-redefines-K counterexample. * **risk:** Second stamping site (factory.rs:1838) or model-swap filter (factory.rs:3114) left consuming-stamped → decision A silently broken for image/web\_search and inherited bindings dropped on hot swap. * mitigation: P5 converges BOTH in one pass; RCT-20 (image owning-realm) and RCT-21 (model-swap keeps inherited) gate it. Grep for `realm: selected_realm` and `auth_binding.realm == *realm` in final review. * **risk:** serde(default) loses omitted-vs-default for non-Option scalars (model, max\_tokens, limits) → a child's default value 'overrides' an inherited non-default via naive presence check. * mitigation: Presence-aware merge for the affected scalar fields: parse ancestor docs from toml::Value to detect key presence before applying child-wins-scalar (the same pattern that forced merge\_\*\_from\_toml\_presence helpers, config.rs:412). RCT-12 includes an omitted-vs-explicit-default case. (Surfaced as open question for product confirmation of which fields are presence-sensitive.) * **risk:** Login-to-global requires resolve\_config\_store to open a store OTHER than scope.locator.realm; partial wiring (login writes one realm, resolution composes from another) is the concrete trap. * mitigation: P6 introduces an explicit global-realm store seam; RCT-24 asserts login lands at global AND status/test/refresh read the same owning realm; the 5 token-path sites move together in one phase. * **risk:** ChainComposingConfigStore re-reads ancestor docs on every get() — cost + cache-invalidation vs patch()/mcp-reload live config. * mitigation: get() composes fresh (always-correct, depth-capped, cheap); set()/patch() operate on the HEAD doc only (writes never compose), so a patch to an ancestor is naturally reflected on the next composed get(). No memoization in v1 (correctness over micro-opt); RCT-16 pins compose-on-get semantics. * **risk:** Deleting the flat scan breaks operators who relied on sibling-realm binding pickup. * mitigation: Documented behavior tightening: shared bindings move to \[realm.global] (inherited by all via the implicit tail) or a named parent. RCT-09 pins the narrowing; CLI login-to-global makes the common shared-creds case work by default. * **risk:** Existing pinned tests (connection.rs:2027, factory.rs:6888, cli:16447) encode the OLD flat-scan / dev-realm behavior. * mitigation: REWRITE (not bypass) each to the chain/owning-realm semantics in the phase that owns the change; never #\[ignore] them. Listed explicitly in P2/P5/P6 gates. * **risk:** CLI tests run only under cargo int (rkat bin-only), so a core-only 'green' skips every login/realm CLI test. * mitigation: P6 gate runs -p meerkat-cli under the int lane; P8 runs MEERKAT\_BUILDBUDDY=1 make test (unit+int) workspace-wide. ### Residual risks (red-team) * The lazy owning-set core (decision A) is genuinely sound and VERIFIED: feeding materialize\_connection\_target the owner's own RealmConnectionSet keeps the strict equality at meerkat-llm-core/src/provider\_runtime/registry.rs:208 (`if auth_binding.realm != realm.realm_id`) a real invariant without relaxation, adds no AuthBindingRef/RealmConnectionSet field, and yields zero wire/schema churn. This part needs no rework — guard it: any PR diff touching registry.rs:208 (qualify the crate path everywhere, it is meerkat-llm-core not core) is a red flag, and RCT-07 should ideally exercise the llm-core registry.resolve path across the core→llm-core boundary, not only core's materialize stamping. * Several critiques framed settled user decisions as 'open questions' (plan open\_question #5 = global location, #6 = orphan vs migrate). These are NOT relitigation candidates — they are plan-vs-decision contradictions that MUST be fixed (MF-08, MF-13). Do not let the implementer treat them as live design choices. * DISMISSED as overstated: the RPC compile break. handlers/auth.rs:200 target\_error\_response uses a wildcard `_ =>`, so the new ConnectionTargetError::RealmChain variant does NOT break RPC compilation — only REST's exhaustive target\_error\_status (auth\_endpoints.rs:106) breaks. The RPC BEHAVIOR change (credential-persist provenance) is still real and must be covered (MF-04). * DISMISSED as minor/non-blocking: the pinned-test line is 16447 (the plan is correct; one critique's '16448' was wrong). The registry crate misattribution (registry.rs:208 is in meerkat-llm-core) is a documentation fix, not a behavioral risk, but qualify it so an implementer does not edit meerkat-tools/src/registry.rs or meerkat-core by mistake. * validate\_explicit\_realm\_id (runtime\_bootstrap.rs:127) does not reserve `global`/`env_default` — a user can run `rkat --realm global`, creating a session realm whose state lands under `/global`, potentially colliding with the credential-namespace assumptions of decision A, and the chain resolver only catches global.parent=Some lazily at resolve time. Lower severity than the must-fixes (fail-closed at resolve), but worth adding global/env\_default to the reserved set and validating global.parent==None at config LOAD time for a clear config error instead of a runtime auth failure. * Presence-sensitive scalar field set (open\_question #4) remains the single reworked merge engine's sharpest edge: a child explicitly setting agent.model to the empty-string default to mean 'use catalog default' will be treated as unset by a naive `!= Default` check and the parent's model leaks through. Enumerate the exact set (agent.model, max\_tokens, agent.max\_tokens\_per\_turn, limits.\*) and require toml::Value presence parsing per field before P3; add per-field omitted-vs-explicit-default RCT cases. This is correctness, not polish. * The 'one chain, two consumers' invariant (EffectiveConfig composer chain == connection resolver re-walk) is asserted via shared fixtures but never pinned by a single equality test. Strongly prefer threading the composer's already-computed `Vec` through to the connection resolvers (literally one walk, two readers) over recomputing RealmChain::resolve from the composed config; if recomputed, add a direct equivalence RCT (walk over raw per-realm sources == walk over composed config) to prevent a lossy/ordering-sensitive fold from silently diverging the two. * The child-wins union on the .realm MAP can let a stale \[realm.parent] copy in a child's own doc shadow the authoritative parent-file section (which still passes the realm-ID equality at registry.rs:208 because it only compares IDs, not section provenance). Enforce that a member's own `[realm.]` section comes ONLY from `config_for_realm()`, and reject (fail-closed) a doc declaring a \[realm.X] section for X != its owning realm. Folds into MF-11's outer-key-only union rule. ## Settled product decisions (do NOT relitigate) 1. MIGRATE existing `dev` creds to `global` (one-time idempotent copy, no clobber). Not orphan. 2. `global` realm = dedicated **home-rooted** doc at `~/.rkat` (single well-known location), not per-workspace. 3. Children can add/override but **cannot remove** inherited mcp/hook entries (no tombstones). 4. CLI login defaults to `global`. `--realm` selects+composes a chain but does NOT touch RuntimeScope. 5. **REST/RPC credential PERSIST = strict-owner write** (MF-04 resolved 2026-06-16). Credential *reads* inherit down the chain; credential *writes* land ONLY in the realm that DEFINES the binding. Persisting for a child realm whose binding is inherited is REJECTED with a typed error naming the owning realm (prevents a child-scoped request from overwriting a parent/`global` credential). RCT-27/28 assert the rejection + owning-realm stamp. ## Greenlight The ARCHITECTURE is sound and the core mechanism is verified against the code: the lazy owning-set walk preserves the load-bearing strict equality at meerkat-llm-core/src/provider\_runtime/registry.rs:208 without relaxation, RealmConfigSection.parent is genuinely schema-invisible (wire/config.rs:62 projects realm as opaque `BTreeMap`), Config::merge does silently drop self.realm today, and the WASM single-realm path is degenerate-clean. Direction: GREENLIGHT after must-fixes — do NOT rework the design. But it is NOT yet implementable: 14 must-fixes block it, four of them ship a wrong/insecure auth result a real config will hit on day one — (a) the dev→global MIGRATION is entirely absent and the plan prose actively contradicts the user's copy-not-orphan decision, so every existing install loses auth on upgrade; (b) the global config doc is wired workspace-rooted (realm\_paths\_in) while decision 2 mandates a single \~/.rkat doc, so the headline cross-workspace-login promise is silently dead; (c) ChainComposingConfigStore is wired only in CLI, leaving top-level inheritance dead and surface-divergent on REST/RPC/MCP, and REST/RPC credential-persist endpoints (resolve\_binding\_identity) still single-section-stamp the request realm, writing inherited credentials to the wrong dir; (d) the symmetric ConfigStore.get()-composes/set()-head-only design lets one read-modify-write permanently flatten inherited entries into the child doc, violating the no-tombstone decision. Add phases P5b (surface re-root + composer wiring) and P6b (migration), split ConfigStore into a read-only effective reader plus a head writer, fix the global-doc location to home-rooted, edit the image/web\_search and self\_hosted contains\_key gates, fix the REST exhaustive-match compile break, resolve the realm-map fold level to outer-key-only with fail-closed sub-map validation, and add RCTs 25-38 to close the coverage holes (absent-head, literal-default removal, nearest-child-wins, no-removal, real-fs source, cross-workspace, REST/RPC provenance, migration idempotence). SINGLE BIGGEST REMAINING RISK: the cross-surface gap — the plan was scoped to CLI+factory while the same resolution must hold identically on REST/RPC/MCP credential-persist and config-get, and the symmetric ConfigStore trait makes 'compose on read' silently corrupt the child doc on the very next write; getting the read/write split and per-surface wiring wrong reintroduces dual-truth on the network surfaces where users actually persist credentials." # Stopped-Phase Revival Source: https://docs.rkat.ai/architecture/stopped-phase-revival-plan Root-cause design record for the 0.7.19–0.7.23 resume-strand class: machine-owned revival of stopped sessions and cold-load snapshot reconciliation # Stopped-Phase Revival - Historical Implementation Record (0.7.24) > Current status (2026-08-20): **Implemented.** Machine-owned stopped-phase > revival shipped in v0.7.24. The cold-revival runtime rebind follow-up shipped > in v0.7.26. This document is the historical root-cause and implementation > record; current code lives in > `meerkat-runtime/src/meerkat_machine/session_management.rs`, with the stopped > lattice pinned by > `meerkat-machine-schema/tests/stopped_phase_revival_lattice.rs` and Mob > revival tests. Root cause (verified, wf\_63cf2cb3-27c + field forensics): the MeerkatMachine session DSL classifies Stopped as terminal (only exits: UnregisterSessionStopped→Idle teardown, Destroy→Destroyed), yet both resume seeds deliver registered sessions IN Stopped (warm: RuntimeExecutorExitedFrom\*→Stopped, entry stays registered; cold: RecoverRuntimeAuthorityStopped Initializing→Stopped from durable snapshot). Every registration input accepts-and-preserves Stopped (RegisterSessionIdempotent, PrepareBindingsStopped, EnsureSessionWithExecutorStopped self-loops), so the resume build proceeds until the first Stopped-intolerant input — field: PublishLocalEndpoint (per\_phase \[Idle, Attached, Running], dsl \~20194) via factory.rs:4628 install\_peer\_comms\_on → wedge, background repair retries forever. Five releases of symptoms are this one missing re-admission arc plus ad-hoc per-input tolerance arms (HydrateSessionLlmStateStopped comment at dsl:6417-6424 documents the whack-a-mole). ## Fix: machine-owned revival at the two intent-to-use seams DSL edits in meerkat-machine-schema/src/catalog/dsl/meerkat\_machine.rs AND its mirror meerkat-runtime/src/meerkat\_machine/dsl.rs (machine-check-drift enforces parity): 1. RegisterSession (\~5611) / RegisterSessionIdempotent (\~5639): remove Stopped from per\_phase. Add: * RegisterSessionResumesStopped: guard lifecycle\_phase==Stopped, same\_session (self.session\_id == Some(session\_id)), not\_draining (registration\_phase != Draining). update: registration\_phase=Queuing; runtime\_stop\_deferred=false. PRESERVES session\_id, active\_runtime\_id/fence/generation/epoch, hydrated LLM identity/capability surface. to Idle. emit a `RuntimeNotice` with kind `Recover` and detail "stopped session re-admitted for resume". Semantics: a stopped executor is a fact about the previous epoch; a re-registered session with no executor is Idle. * RegisterSessionNewBindingFromStopped: guards new\_session\_binding + not\_draining; update = RegisterSession's full LLM-state reset block AND clear binding fields (active\_runtime\_id/fence/generation/epoch = None — new tenant, previous tenant's binding facts must not leak; verifier amendment). to Idle. 2. EnsureSessionWithExecutorStopped (\~9357): empty self-loop → revival mirroring the Idle arm: not\_draining guard; update registration\_phase=Active, runtime\_stop\_deferred=false; to Attached; emit a `RuntimeNotice` with kind `Recover`. (Fixes schedule delivery + mob re-dispatch with NO shell change — the executor claim at mod.rs:1101-1122 then grants.) 3. DELETE PrepareBindingsStopped (\~7546); remove Stopped from PrepareBindingsIdempotent per\_phase (\~7409). Post-revival PrepareBindings never legitimately sees Stopped; reaching it is a loud GuardRejected (ordering bug), never a silent rebind. 4. DELETE tolerance arms whose purpose was the missing arc: HydrateSessionLlmStateStopped (\~6425), PublishCommittedVisibleSetStopped (\~8252), SetSilentIntentsStopped (\~9395); remove Stopped from SetModelRoutingBaseline (\~6756), StagePersistentFilter (\~7361), RequestDeferredTools (\~7370) admissions. KEEP all Retired arms (Retired resume-build is the archive-completion flow, different invariant, out of scope). AMENDMENT-5 caution: stage\_persistent\_filter / request\_deferred\_tools / publish\_committed\_visible\_set are public MeerkatMachine APIs — sweep callers; each caller must either be post-revival or handle typed rejection. 5. AMENDMENT-1 (load-bearing): add RetireRequestedFromStopped (Stopped→Retired, mirroring RetireRequestedFromIdle guard family \~8315) and DELETE the shell phase probes / early-return matches in meerkat-mob/src/runtime/session\_service.rs (:93-104, :116-128). Registered-Stopped retire currently hits RuntimeControlPlane::retire at :105 → Retire has no Stopped arm → guard-reject. Sequence with provisioner.rs:950: add Stopped to the ask-21d disposal match ONLY after the Retire arm lands. 6. AMENDMENT-2: revival must persist durable lifecycle via machine-emitted typed effect (pattern: durability\_authority.action == DeleteSnapshot keying the unregister persist, session\_management.rs:1667-1675). stage\_session\_dsl\_input DISCARDS committed effects (dsl\_effects.rs:90-99) — route revival effects to the persistence channel; persist\_current\_machine\_lifecycle("resume") after revival commit. Without it, a revived-but-not-yet-turned session leaves durable Stopped visible cross-process (the ask-21d split-state read class). ## Class tests (can't-happen-again battery) * TOTALITY SWEEP (class-killer): walk canonical\_machine\_schemas() MeerkatMachine transitions; assert the set of inputs admissible in phase Stopped == explicit allowlist DERIVED FROM THE DSL (not asserted): registration/revival inputs, ensure, BeginUnregisterSessionRetainsSnapshot, drain-feedback trio (conditional on Draining), UnregisterSession, Destroy, Retire (new), ResolveRuntimeOpsLifecycle Durability, observability self-loops (PublishEvent, ResolveRuntimeCompletionResult\*, SetPeerIngressContext, Abort/Wait...). NOTE: StopRuntimeExecutor has NO Stopped arm today — must NOT appear in allowlist (verifier: sweep red on day one otherwise). * MACHINE UNITS: revival preserves identity/bindings/LLM state, registration==Queuing; ensure revives to Attached+Active; revival refused while Draining (BOTH entry orders: Begin at Stopped; Begin at Attached then executor exit → Stopped+Draining); new-binding-from-Stopped resets LLM state AND bindings; UnregisterSessionStopped still clears; retire-from-Stopped → Retired. * RUNTIME WARM: prepare\_bindings\_after\_stop\_yields\_live\_machine\_and\_installs\_peer\_comms (THE field repro: register+ensure+stop via stop\_runtime\_executor, prepare\_bindings, assert phase != Stopped, install\_generated\_peer\_comms\_on\_target succeeds). Assert visible\_phase != Stopped after revival (projection sync, dsl:10573-10586). ensure\_after\_stop\_attaches\_new\_executor\_and\_admits\_input (schedule/mob re-dispatch class: accept\_input\_with\_completion admits, turn completes). * RUNTIME COLD: durable Stopped snapshot (stop+unregister retains snapshot) → fresh machine over same store → prepare\_bindings → live phase, epoch cursor continuity, full comms build succeeds. Variant: seed snapshot verbatim (old-binary pattern). * RACE PIN: StopRuntimeExecutor landing between revival and comms install re-enters Stopped and fails the build with the SAME "guard rejected ... PublishLocalEndpoint" string — correct fail-closed; pin so the old signature is distinguishable from regression. * MOB CLASS: respawn/refresh-resume of stopped member (incl. comms wiring AddDirectPeerEndpoint post-revival); archive\_of\_stopped\_member\_durably\_retires; mob-seam routed PrepareBindings against Stopped member → typed rejection surfaced (composition.rs:346-358 route has no revival — production safe only because prepare\_local\_session\_bindings stages RegisterSession first; pin it). * STOP-WINDOW GUARDS: stop→cleanup→unregister still retains snapshot from Stopped; register\_during\_drain\_rejects\_typed then succeeds after drain. Red-verification: write tests + fix; then temporarily restore RegisterSessionIdempotent per\_phase Stopped + empty EnsureSessionWithExecutorStopped and confirm the warm runtime test deadlock/fails with the field error string. ## Codegen cascade make machine-codegen (kernels, specs/machines/meerkat\_machine/model.tla, mob-seam composition artifacts) + cargo xtask protocol-codegen (ulimit -s 65520 on macOS). Gates: machine-check-drift, machine-authority-docs-gate, verify-machine-poster-coverage, audit-generated-headers, runtime-authority-bypass, rmat-audit. TLC step bounds may need adjustment (authority-lane precedent). No contracts wire-type changes. ## Bug B (historical): stale runtime snapshot vs store head — SUPERSEDED meerkat-session/src/persistent.rs load\_authoritative\_session\_base\_with\_replay\_info preferred the runtime snapshot whenever present, no freshness comparison; quarantine only covered snapshot-absent. Field: snapshot froze at 83 msgs, store head 91; resume loaded 83, save rejected by the append-only guard — permanent wedge. The historical fix used a `SessionDocumentMachine` read-source input with three typed observations — 1. store\_head\_extends\_snapshot: the head strictly extends the snapshot (prefix transcript digest equality, the save guard's own continuity proof); 2. store\_head\_has\_atomic\_physical\_ownership: the store can prove the head row is its current authenticated physical authority rather than an uncommitted projection; 3. session\_is\_live: a live session's snapshot lag is transient (the live runtime recommits past it); only a COLD load defers to the head. That embedded-checkpoint read-source vocabulary was removed in 0.8.11. Current stores issue committed and provisional-tail authority directly; recovery classifies a store-bound candidate and never treats a deserialized Session checkpoint stamp as authority. Pins: test\_stale\_prefix\_runtime\_snapshot\_defers\_to\_extending\_store\_head (red-verified against the old unconditional snapshot preference), test\_unowned\_ahead\_store\_head\_stays\_snapshot\_served, test\_diverged\_runtime\_snapshot\_stays\_authoritative, test\_authoritative\_load\_ignores\_newer\_raw\_store\_projection\_when\_runtime\_exists (live), and the rewritten cold tail of test\_live\_runtime\_list\_status\_and\_resume\_fail\_closed\_on\_stale\_raw\_store\_metadata — whose former expectation (resume failing closed against the longer persisted row) WAS the field wedge, now asserted to resume successfully. # WorkGraph Attention Goals Source: https://docs.rkat.ai/architecture/workgraph-attention-goals Implemented architecture for Codex-style goals without duplicating WorkGraph or leaking mob semantics into core. # WorkGraph Attention Goals Status: Implemented architecture record. Initial WorkGraph goals, attention, host controls, turn overlays, tool scoping, and mob lowering shipped starting in v0.6.24. The durable WorkGraph-to-Mob Flow execution bridge shipped starting in v0.8.16. Automatic idle polling and hidden continuation production remain future work; the runtime has typed continuation admission, while ordinary turns resolve and revalidate attention before injecting their overlay. ## Summary Codex-style goals should not become a new durable goal graph in Meerkat. The durable "what must be true" part is already WorkGraph-shaped: objective, status, blockers, claims, evidence, readiness, and terminal state. The missing primitive is attention: ```text theme={null} Goal = WorkGraph commitment + runtime attention binding ``` This document defines the goal feature as an optional `meerkat-workgraph` capability: * `WorkGraphLifecycleMachine` continues to own durable work item truth. * A WorkGraph-owned attention binding owns which execution owner should keep attending to which work item, in which stance. * `MeerkatMachine` continues to own actual runtime admission, wake, turn execution, and context delivery. * `meerkat-mob` lowers durable `AgentIdentity` bindings into WorkGraph owner keys. Core Meerkat never needs to name `MobId`. The key invariant: ```text theme={null} Work item terminality is not attention state. Attention state is not runtime admission. Runtime projection is not WorkGraph truth. Mob routing is not goal ownership. ``` ## Motivation Codex `/goal` persists a high-level objective and keeps the agent moving by injecting hidden continuation context whenever the thread becomes idle. That is useful, but a direct port is wrong for Meerkat. Meerkat has two important differences: * session-only operation is first-class; * most substantial work can be identity-first, mob-shaped, and role-specialized. A reviewer in a mob should not internalize "ship the match-3 game" as its own goal. It should see the parent objective as a claim to test and should optimize for finding reasons the claim is false. Likewise, a coder should build toward a child work item, and a planner may coordinate the root. This requires a typed distinction between: * durable commitment truth; * the actor currently expected to attend to it; * the role or stance used when projecting that commitment into a turn. ## Existing Authorities WorkGraph already owns durable commitment semantics. Its public docs say it answers what work exists, what is blocked, what is ready, who claimed it, what evidence supports completion, and what is terminally done, failed, or cancelled. It owns item lifecycle, dependency legality, readiness, claims, terminal state, evidence references, event history, and projections. Runtime already owns session admission and turn execution. `Input::Continuation` and `WakeIfIdle` already exist as runtime primitives, and `StartTurnRuntimeSemantics` is the runtime/session carrier for handling mode, tool overlays, context appends, and turn metadata. Mob already owns multi-agent topology: roster, stable member identity, runtime-binding rotation, wiring, flows, and member lifecycle. Public mob APIs use `AgentIdentity`; per-runtime details may rotate. Dogma requires a single semantic owner. A side map called "goal bindings" that decides pause, resume, completion, wake, projection mode, or authority would be a hidden lifecycle machine. If the binding has semantic state, that state needs a named authority. ## Decision Do not introduce `MobGoal`, `ThreadGoal`, or a generic core `GoalLifecycleMachine`. Instead, add WorkGraph attention as an optional WorkGraph-owned capability: ```rust theme={null} struct WorkAttentionBinding { binding_id: WorkAttentionBindingId, work_ref: WorkItemRef, target: WorkAttentionTarget, mode: WorkAttentionMode, status: WorkAttentionStatus, machine_state: WorkAttentionMachineState, delegated_authority: AttentionDelegatedAuthority, projection_policy: AttentionProjectionPolicy, created_at: DateTime, updated_at: DateTime, } ``` The binding points at a WorkGraph item. It does not copy the item's title, status, blockers, evidence, or terminal state. ```rust theme={null} struct WorkItemRef { realm_id: String, namespace: WorkNamespace, item_id: WorkItemId, } ``` The binding target is typed at the surface that owns the identity. The persisted WorkGraph attention store may normalize to `WorkOwnerKey`, but public callers must use domain handles: ```rust theme={null} enum WorkAttentionTarget { Session { session_id: SessionId }, LoweredOwner { owner_key: WorkOwnerKey }, } ``` `Session { session_id }` is valid in session/runtime surfaces. Lowered owner keys are stored only after the owning feature has validated the source identity. `AgentIdentity` bindings are created through `meerkat-mob`, which validates the identity and lowers it to a WorkGraph owner key. Core Meerkat does not need an `AgentIdentity` type and never names `MobId`. ## Attention Modes Attention mode controls projection and tool authority. It is not WorkGraph item status. ```rust theme={null} enum WorkAttentionMode { Pursue, Coordinate, Review, Falsify, Judge, Observe, } ``` Mode meanings: | Mode | Projection | | - | - | | `Pursue` | Treat this work item as owned work to advance. | | `Coordinate` | Manage decomposition, routing, and evidence collection. | | `Review` | Inspect whether the work item or child item satisfies its claim. | | `Falsify` | Act adversarially; find reasons the claim is not satisfied. | | `Judge` | Evaluate evidence under an explicit completion policy. | | `Observe` | Provide context only; do not optimize for or against completion. | The parent work item can be the same for several agents, but their attention modes must differ. A reviewer receives the parent objective as a claim to test, not as a motivational target. ## Attention Status Attention status answers whether the runtime should continue projecting this binding. It does not close or block the WorkGraph item. ```rust theme={null} enum WorkAttentionStatus { Active, Paused { until: Option> }, Superseded, Stopped, } ``` Terminal WorkGraph item states automatically make attention inactive for continuation. The WorkGraph item remains the authority for completion, cancellation, failure, blockers, and evidence. Pause/resume should be attention-state transitions. They must not be encoded as labels, descriptions, namespace strings, or prompt text. Timed resume should target the binding, not the WorkGraph item: either `Paused { until }` becomes eligible again when the clock passes, or `meerkat-schedule` resumes the binding. A WorkGraph item may use its own `snoozed_until` for item-level readiness, but that fact must not substitute for one actor's attention pause. ## Machine Ownership The implemented architecture uses a generated authority path and does not add a generic core goal machine. Current ownership shape: ```text theme={null} WorkGraphLifecycleMachine Owns work item state, readiness, claims, evidence, terminality, and completion policy operands. WorkAttentionLifecycleMachine Owns attention binding active/paused/superseded/stopped state and binding revision. This is a WorkGraph-owned attention machine, not a generic core GoalLifecycleMachine. workgraph_attention_bundle composition Owns the legal WorkGraph-item-close to attention-stop handoff. trusted WorkGraph/runtime host seam Revalidates attention and projection eligibility before turn-overlay or continuation admission. The current implementation keeps continuation enqueueing as trusted runtime-host authority; no automatic idle-polling producer ships yet. MeerkatMachine Owns input admission, WakeIfIdle, active turn state, continuation, and delivery of typed context appends. MobMachine Owns AgentIdentity membership and runtime binding resolution. WorkExecutionLifecycleMachine Owns the durable handoff from one WorkGraph item to one exact Mob Flow attempt: launch request, uncertainty, terminal observation, evidence projection request, and closure feedback. It does not own the Flow run or the WorkGraph item's lifecycle. ``` Attention is WorkGraph-owned commitment metadata. Runtime wake is runtime-owned execution behavior. The generated composition is the seam between them. It must not be a store-only side table or handwritten reducer. The important dogma line is not "never create another generated machine"; it is "do not create a second durable goal authority beside WorkGraph." ## Runtime Handoff Runtime must treat attention as an observed input, not as its own commitment truth. The shipped trusted-host path performs the revalidation and admission steps below for explicit continuation requests and ordinary turn overlays. The automatic idle trigger in step 1 remains future work. Idle continuation flow: 1. Runtime becomes idle, or a host explicitly requests continuation for an attention binding. 2. Runtime or the host-facing runtime surface asks the optional WorkGraph attention service for an eligible active binding for this `SessionId`. 3. For each candidate, the service re-reads the WorkGraph item and verifies: * item still exists; * item is not terminal; * attention binding is active; * target still resolves to the current session or identity binding; * projection policy permits continuation. 4. Runtime enqueues `Input::Continuation` through the normal runtime admission path. 5. Runtime starts the turn with typed attention context in `StartTurnRuntimeSemantics`. 6. Session service applies the typed context at the turn boundary. If any check fails, the handoff fails closed. No stale snapshot should wake an agent. ## Projection Contract Runtime should not inject raw WorkGraph fields as hidden instructions. Descriptions, labels, and evidence summaries are user-authored or agent-authored data, not policy. The attention service should emit a typed projection: ```rust theme={null} struct AttentionContextProjection { binding_id: WorkAttentionBindingId, work_ref: WorkItemRef, mode: WorkAttentionMode, binding_revision: u64, item_revision: u64, parent_refs: Vec, parent_context: Vec, evidence_refs: Vec, authority: ProjectedAttentionAuthority, text: AttentionProjectionText, } ``` Projection requirements: * source truth is named; * rebuild trigger is item/binding revision; * staleness policy is explicit; * role stance is typed; * max rendered shape is bounded; * parent objective text is framed as data, not instruction priority. Example falsifier projection: ```text theme={null} Assigned stance: falsify Work item under test: Ship a match-3 game. Your task: Find evidence that this work item is not yet satisfied. Do not optimize for release. Report blockers, missing tests, incorrect behavior, and unsafe assumptions. You may add evidence to the review item. You may not close the parent item unless explicitly granted judge authority. ``` ## Completion Authority Completion is WorkGraph terminal state plus policy. It is not "the model said complete" unless policy grants that model authority to close the item. This proposal requires explicit completion policy on the WorkGraph item: ```rust theme={null} enum WorkCompletionPolicy { SelfAttest, HostConfirmed, PrincipalConfirmed, Supervisor { owner_key: WorkOwnerKey }, ReviewerQuorum { threshold: u16 }, } ``` The attention binding may carry delegated authority, but delegation is not the canonical completion policy: ```rust theme={null} enum AttentionDelegatedAuthority { AddEvidence, CloseOwnReviewItem, RequestClosure, CloseIfPolicyAllows, } ``` For adversarial roles, the normal authority should be evidence contribution, not parent closure. A reviewer may close its own review child item and attach blocking or non-blocking evidence. The parent item closes only when its policy allows it. WorkGraph host surfaces expose observability; CLI and trusted in-process hosts add narrow goal and attention controls. Trusted principal-gated goal policies belong to in-process host authority. Public REST/RPC clients can inspect goal status and attention bindings, but cannot smuggle principal authority through JSON. ## Session-Only Shape Session-only Meerkat remains first-class. Creating a session goal: 1. Host creates a WorkGraph item in a realm namespace such as `session/{session_id}`. 2. Host creates an active attention binding targeting `SessionId`. 3. Runtime observes the binding and continues the session while eligible. 4. The session agent can add evidence, block the item, or close it only if the authority policy permits. Without WorkGraph enabled, this feature is unavailable unless a separate explicit session-local objective contract is designed. There must be no silent fallback to hidden local goal state, because that would create two goal systems. ## Mob Shape There is no mob-bound goal lifecycle. A mob objective is a WorkGraph root item, often scoped by namespace or linked through a typed external reference. Mobs route attention; they do not own work terminality. Example: ```text theme={null} Root WorkGraph item: Ship a match-3 game. Planner attention: target = AgentIdentity(planner) work = root item mode = Coordinate Coder attention: target = AgentIdentity(coder) work = child implementation item mode = Pursue Reviewer attention: target = AgentIdentity(reviewer) work = child implementation item plus parent context mode = Falsify ``` Current `meerkat-mob` support lowers `AgentIdentity` targets into durable owner-key attention targets. For ordinary turns, `meerkat/src/surface.rs` resolves those keys against the session's `mob_id` and `agent_identity` labels, selects the newest matching session generation deterministically, and revalidates the current attention projection before composing the turn overlay. Automatic idle continuation for a lowered owner remains future work. Mob docs should avoid saying the persisted mob owns "work" in the WorkGraph sense. Mob owns roster, wiring, member lifecycle, flows, and routing. WorkGraph owns shared durable work. ### Flow Execution Seam Flow execution uses an explicit durable association rather than a prompt-level WorkGraph id. The controlling host commits `WorkExecutionBinding` before launching the Flow. The binding fixes the Mob and Flow identities, effective run-config digest, deterministic run id, activation parameters, idempotency key, and retry predecessor. `WorkExecutionLifecycleMachine` emits the next cross-system obligation. The composition shell realizes that obligation and feeds the typed outcome back: ```text theme={null} FlowLaunchRequested -> Mob external-delivery admission -> deterministic pending MobRun persistence -> durable realization claim -> Flow running, completed, failed, canceled, or launch uncertain EvidenceProjectionRequested -> idempotent WorkGraph evidence write WorkClosureRequested -> WorkGraphLifecycleMachine closure admission -> WorkClosed or EvidenceProjected ``` No Flow status is copied into WorkGraph as a second authority. Reconciliation reads the canonical Mob run and advances only through generated execution transitions. A completed Flow never bypasses WorkGraph completion policy. The pending run precedes the realization claim. This ordering makes the cross-store crash boundary recoverable without pretending to have a distributed transaction: before pending-run persistence, `begun` proves target absence; after it, the exact run is the durable target fact. A `realizing` record with no run fails closed as quarantine. The public bridge cannot resolve that state from caller testimony. Recovery requires the deterministic run to become observable or a future sealed host authority to prove a safe resolution. ## Tool Authority Current WorkGraph exposure is coarse: enabling WorkGraph gives a member create, claim, update, block, close, link, and evidence tools. That is too broad for adversarial roles. Attention projection should narrow tool authority by mode and work item: | Mode | Typical authority | | - | - | | `Pursue` | Claim/update assigned item; add evidence; close if policy allows. | | `Coordinate` | Create/link child items; assign attention; collect evidence. | | `Review` | Read target/ancestors; add evidence; close review child item. | | `Falsify` | Read target/ancestors; add blocking evidence; cannot close parent. | | `Judge` | Evaluate evidence; close target only under explicit policy. | | `Observe` | Read-only context. | Attention-aware filtering should be the final capability-narrowing layer: ```text theme={null} base tool registry -> ToolCategoryOverride / session config -> mob profile capabilities -> attention projection scope -> per-call WorkGraph service authorization ``` `ToolCategoryOverride` remains coarse-grained capability exposure. Mob profiles remain role defaults. The final authorization check must know the attention binding id, mode, work item scope, and requested operation before a WorkGraph mutation reaches `WorkGraphService`. ## Public Surfaces Suggested user-facing vocabulary: * `/goal `: create WorkGraph item plus session attention binding. * `/goal pause`: pause attention binding, not WorkGraph item. * `/goal resume`: reactivate attention binding if item is still non-terminal. * `/goal clear`: stop the attention binding; item remains unless user explicitly cancels it. Supersession is reserved for future binding replacement flows. * `/goal status`: show attention status plus referenced WorkGraph item status. Current trusted host surface: * `workgraph/goal/create`: transactionally create a WorkGraph item and its initial attention binding. * `workgraph/goal/status`: return referenced item status plus attention status. * `workgraph/goal/confirm`: attach host/principal confirmation evidence. * `workgraph/goal/request_close`: request policy-gated item closure. * `workgraph/attention/list`: list active attention bindings. * `workgraph/attention/pause`: pause a binding without mutating the item. * `workgraph/attention/resume`: resume a paused binding if the item remains non-terminal. * trusted runtime-host attention continuation: enqueue a hidden continuation for a session-bound binding. These are WorkGraph-feature host APIs. Public REST and JSON-RPC entrypoints expose goal/attention observability only; mutating goal/attention calls are trusted in-process host/runtime authority and are not part of the public catalog. They are unavailable when WorkGraph is not compiled or enabled. The CLI mirrors the narrow session-first subset under `rkat workgraph`: `goal-create`, `goal-status`, `goal-confirm`, `goal-close`, `attention-list`, `attention-pause`, and `attention-resume`. The runtime has a typed trusted-host continuation seam, but no automatic idle-polling producer or REST-backed CLI command ships today. CLI confirmation does not accept raw principal authority. The CLI intentionally does not expose the broad WorkGraph mutation surface as the goal UX. Core host goal creation targets a session handle. Mob/agent targets are not accepted through this core host contract; `meerkat-mob` must validate `AgentIdentity` and lower it to a WorkGraph owner binding through its own feature surface. This keeps core Meerkat from freezing a public `WorkOwnerKey::Agent` or `WorkOwnerKey::Mob` bypass. ## Failure Modes The design must fail closed when: * WorkGraph is disabled; * WorkGraph item is missing; * item revision changed in a way that invalidates the projection; * item is terminal; * attention binding is paused/stopped/superseded; * target identity no longer resolves; * runtime is not idle; * queued user or peer work has priority; * projection cannot be built safely; * authority policy would allow self-review of a parent item. ## Non-Goals * Do not add `MobGoal`. * Do not add a core `GoalLifecycleMachine` duplicating WorkGraph. * Do not make WorkGraph readiness equivalent to runtime wakeup. * Do not use namespace strings as semantic proof of session, mob, or agent ownership. * Do not inject a parent objective as every member's own goal. * Do not let reviewer/adversarial profiles depend only on prompt text for their stance. * Do not provide a local fallback goal table when WorkGraph is disabled. ## Resolved Recommendations 1. Model attention as WorkGraph-owned state with `WorkAttentionLifecycleMachine` and a trusted runtime-host continuation seam. Do not add a generic core goal machine. 2. Model pause/resume on the attention binding. Timed pauses may use `Paused { until }` or schedule-driven resume, but should not mutate item readiness. 3. Expose a narrow host/principal mutation surface for goal creation, pause/resume/stop/reassign, status, confirmation evidence, and policy-gated closure. Do not expose a broad WorkGraph editor as the first goal surface. 4. Put completion policy on the WorkGraph item. Let attention bindings carry delegated authority only. 5. Apply attention-aware tool filtering after session config, category overrides, and mob profile tooling, with a final per-call WorkGraph authorization check. ## Acceptance Bar The implementation remains conformant only if: * WorkGraph remains the only owner of durable work truth. * Runtime remains the only owner of turn admission and continuation. * Attention binding lifecycle is WorkGraph-owned and generated, currently via `WorkAttentionLifecycleMachine`. * The WorkGraph/runtime handoff has one named seam that revalidates attention eligibility and enters runtime through normal continuation admission. * Mob remains optional and feature-owned. * Session-only operation remains first-class. * Adversarial roles receive typed falsification/review projections, not the parent objective as their own motivational goal. # CLI commands Source: https://docs.rkat.ai/cli/commands Command reference for the task-first, Unix-native rkat CLI. `rkat` is designed for three main workflows: * interactive terminal use * scripts and CI * Unix pipes and chained command composition The default path is prompt-first: ```bash theme={null} rkat "summarize this repository" ``` You can also use subcommands explicitly: ```bash theme={null} rkat run "summarize this repository" rkat run --resume last "keep going" rkat mob pack ./mobs/release-triage -o dist/release-triage.mobpack ``` ## Global flags The top-level parser exposes the same realm and convention-root flags to each command family: ```bash theme={null} rkat [--realm ] [--isolated] [--instance ] \ [--realm-backend ] \ [--state-root ] \ [--context-root ] \ [--user-config-root ] \ [--default-model ] \ ``` `--default-model ` persists the default agent model into the scope-resolved config (project/user/realm resolution included) and exits when given without a command — `rkat --default-model claude-fable-5` is the one-liner for switching what every future run talks to. The model is validated against the catalog and configured custom models. By default, CLI commands use the current directory as the invocation context and derive a stable workspace realm id from that path. For project-local state, `StorageLayout` walks upward to the nearest ancestor containing a `.rkat` entry and stores realms under `/.rkat/realms//`. When no ancestor contains `.rkat`, the context root is the project root fallback. `--context-root` changes the derived workspace identity and the starting point for that ancestor search; it does not necessarily change the project root when both contexts are inside the same project. `--state-root` bypasses discovery and changes only where realm directories are stored. Some maintenance or host commands reject combinations that have no safe meaning. In particular, storage verbs reject `--isolated` and `--default-model`, and `rkat mob host` rejects `--isolated`. Fresh prompt-first runs and `rkat help` are fail-soft only at the default workspace storage boundary. If the workspace-derived realm cannot be opened, no historical session from that realm is loaded into the fresh run, and the explicit compatibility bridge is never invoked automatically. The ordinary strict open may already have completed supported manifest initialization or an earlier domain migration before a later store refuses. The CLI warns and creates a new generated realm using durable SQLite under the same resolved state root. The workspace's configuration, auth policy, provider selection, and tool policy remain in force; only persistence-owned state is isolated in the generated realm. The warning names both realms and prints the explicit recovery command: ```bash theme={null} rkat --state-root --realm storage migrate --apply --bridge-pre-0-8-10 ``` An explicit `--realm`, `--isolated`, every resume form, and all `session` commands remain fail-closed. Historical session access therefore stays an explicit maintenance operation. The fresh-run fallback is never an in-memory session and never makes a historical session from the failed workspace realm part of the new run. ## Common commands | Command | Purpose | | - | - | | `rkat ` | Run a task directly from the terminal | | `rkat run ` | Explicit form of the default prompt command | | `rkat run --resume[=] ` | Continue a saved session | | `rkat help ` | Ask how to use Meerkat (embedded platform skill) | | `rkat session ...` | Inspect, delete, or interrupt sessions | | `rkat realm ...` | Inspect or manage realms | | `rkat config ...` | Read or update CLI/runtime config | | `rkat auth ...` | Log in, inspect auth profiles, test bindings, and refresh/logout credentials | | `rkat blob ...` | Inspect and retrieve stored blob content | | `rkat mcp ...` | Manage local/project MCP server config | | `rkat mob ...` | Pack, inspect, validate, deploy, and build mob artifacts | | `rkat workgraph ...` | Inspect WorkGraph items, ready work, snapshots, and events | | `rkat skill ...` | Manage configured skill sources and inspect available skills | | `rkat models` | List built-in models plus configured self-hosted aliases | | `rkat capabilities` | Show runtime capabilities | | `rkat doctor` | Check local setup, provider keys, and self-hosted server reachability | | `rkat storage doctor` | Read-only storage diagnosis (realm inventory, schema ledgers, split-brain twins, structural integrity) | | `rkat storage migrate` | Offline, fenced storage migration (dry-run by default) | | `rkat storage prune` | Lifecycle for registered storage backup artifacts | | `rkat init` | Create project config from the global template | ## Prompt-first usage ```bash theme={null} rkat [OPTIONS] rkat run [OPTIONS] ``` Examples: ```bash theme={null} rkat "summarize this repository" cat story.txt | rkat "summarize the story" git diff | rkat run --json "review these changes" tail -f app.log | rkat run --stdin lines "watch for incidents" rkat run -t workspace "fix the failing test" rkat run --yolo --param temperature=0.2 "take the gloves off" ``` ### Common run flags * `-m, --model ` * `-o, --output ` * `--json` * `-s, --stream` * `--no-stream` * `--no-web-search` * `--export-atif` (write an ATIF trajectory to the realm `trajectories/` directory after the turn, with one metered step per provider request: each tool-call turn, the answer, and each structured-output extraction request; default off) * `--resume[=]` * `--skill ` repeatable * `-d, --max-duration ` * `-t, --tools ` * `--stdin ` * `--keep-alive` * `--wait-for-mcp` * `--mcp-auth ` * `-v, --verbose` With `--stream` or `--verbose`, `rkat run` prints one token line per provider request (`N tokens (I in / O out, C cached, R reasoning)`, extraction requests labelled `extraction:`) and closes with `total:`, the run's own `run_usage`. Input counts are presented input, so cached input is included on every provider. Before exiting, `rkat run` waits for the session event log (`.rkat/sessions//events.jsonl` and the realm event store) to finish writing and warns on stderr if it cannot. For the full advanced set, use: ```bash theme={null} rkat run --help ``` Current CLI help labels this value `PATH_OR_ID`, but `rkat run --skill` and resume currently construct builtin skill keys. Direct local-path preload is therefore unsupported. `rkat skill add ` registers a filesystem source but does not make this flag source-aware, and ordinary CLI sessions do not currently advertise the skill-discovery tool set. Use typed RPC/SDK preload fields when a host must address a source UUID directly. ### Tool presets `rkat` uses presets instead of many enable/disable booleans. | Preset | Meaning | | - | - | | `safe` | Builtins, skills, configured MCP, delegated-work-safe tools; no shell | | `workspace` | `safe` plus shell access | | `full` | All CLI-side capabilities, including memory and mob tools | | `none` | Disables the preset's builtin, shell, and memory flags; contributes no WorkGraph or mob enablement | `--yolo` is an alias for `--tools full`. `--tools none` is not a universal tool prohibition or sandbox. Configured external MCP tools can still be loaded, and categories such as WorkGraph, mob, schedule, and comms have independent configuration/composition controls. Provider-native capabilities and image generation also have separate controls; the preset does not globally deny their use. ### Provider and structured output controls Use `--param` for common tuning and `--params-json` as the escape hatch: ```bash theme={null} rkat -m claude-sonnet-4-6 \ --param temperature=0.2 \ --param max_output_tokens=4000 \ "triage this incident" ``` Structured output is file-friendly: ```bash theme={null} rkat --schema ./triage.schema.json "extract issues from this diff" ``` `--json` is a convenience alias for `--output json`. HTML output asks the agent to make the final answer a standalone local HTML artifact: ```bash theme={null} rkat run --html "explain how prompt caching works in this repo" rkat run --browser "create a visual implementation plan for this change" ``` `--html` is a convenience alias for `--output html`. `--browser` is a compact shortcut for HTML output plus `--open-in-browser`. HTML output writes the file under the active realm's `presentation/html` directory and prints the path. The `Config` schema accepts template defaults under `[presentation.html]`: ```toml theme={null} [presentation.html] default_template = "polished" [presentation.html.templates.review] path = "html/review.md" [presentation.html.templates.dense] body = "Render this as a dense technical artifact with annotated snippets." ``` However, ordinary CLI realm composition currently drops the `presentation` section. On that path, the accepted config above does not supply a custom default or named template to the HTML resolver. This is a composition limitation, not a limitation of direct Rust consumers of a parsed `Config`. Use the builtin template or a file read directly by the run command: ```bash theme={null} rkat run --html --html-template polished "Explain this design" rkat run --html --html-template-file ./html/review.md "Review this change" ``` `--html-template-file` bypasses configured-template lookup. HTML output itself remains available. ### Streaming defaults * In a TTY, text output streams by default. * In pipes and scripts, streaming is off by default. * JSON and HTML output stay non-streaming by default so stdout remains machine-friendly unless `--stream` is explicitly requested. ### Stdin modes and Unix pipes `rkat` treats piped stdin as a first-class interface: * `auto` default: if stdin is piped, read it as one blob of context * `blob`: always read full stdin to EOF before running * `lines`: keep stdin open and process one line at a time * `off`: ignore stdin even if something is piped in Batch blob mode: ```bash theme={null} cat story.txt | rkat "summarize the story" git diff | rkat "review these changes" cat notes.txt | rkat "extract action items" | rkat "group these by owner" ``` Live line mode: ```bash theme={null} tail -f app.log | rkat run --stdin lines "watch for incidents and call them out" ``` `--line-format json` treats each incoming line as JSON instead of plain text. ## run --resume ```bash theme={null} rkat run --resume[=] [OPTIONS] ``` Examples: ```bash theme={null} rkat run --resume last "keep going" rkat run --resume '~2' "pick this thread back up" cat notes.txt | rkat run --resume "merge these notes into the plan" tail -f app.log | rkat run --resume --stdin lines "watch for new incidents" ``` `SESSION_ID` accepts: * a full UUID * a bare UUID prefix or tail handle * `:`, where `` matches the active realm * `last`, `~`, or `~0` for the most recent session * `~N` for the Nth older session (`~1` is one before the most recent) Omitting the value means `last`, so `rkat run --resume "keep going"` and `rkat run --resume last "keep going"` are equivalent. The CLI displays short handles as the final eight UUID characters. Ambiguous prefix/tail matches fail instead of selecting a session. A qualified locator does not switch realms: select the matching realm and storage root first. Relative aliases and short-handle lookup require `session-store` support. Quote tilde aliases so the shell does not expand them. Resume supports the same stdin model as `run`, plus per-turn tool overlays and provider params. Self-hosted aliases work the same way: ```bash theme={null} rkat run --resume -m gemma-4-31b "Continue with the local model" ``` ## session ```bash theme={null} rkat session list [--limit ] [--offset ] [--label ] rkat session show rkat session export-atif [--output ] rkat session delete rkat session interrupt ``` This is the human/operator session surface. CLI-internal control-plane session APIs are not exposed here. `session export-atif` reads the currently available event audit/replay projection. A slow live subscriber can receive a typed stream-truncation marker without dropping projector input, but projection is asynchronous best-effort derived state and export does not first drain it to the latest committed session boundary. Meerkat supports ordinary durable-state opens from 0.8.10 onward. For an older SQLite realm, stop every process that can access it and run the explicit current maintenance bridge: ```bash theme={null} rkat --state-root --realm storage migrate --apply --bridge-pre-0-8-10 ``` The frozen importer fails closed on unsupported pre-floor shapes. Normal realm opens remain strict. JSONL and memory realms are rejected before any database is mutated. The maintenance transaction preserves queued and nonterminal input rows without scheduling or replaying them. A later session activation follows normal recovery. ## blob ```bash theme={null} rkat blob get [--output ] [--json] ``` Without either output flag, `blob get` writes the decoded raw bytes to stdout. `--output` writes those bytes to a file. `--json` prints the complete `BlobPayload` (`blob_id`, `media_type`, and base64 `data`) instead of raw bytes. Blob IDs are realm-scoped, so use the same realm that produced the session or artifact reference. ## realm ```bash theme={null} rkat realm current rkat realm list rkat realm show rkat realm create [--backend ] rkat realm delete [--force] rkat realm prune [--isolated-only] [--older-than-hours ] [--force] ``` `realm current` prints the realm selected by the CLI scope. `realm list` and `realm show` inspect manifests in the active state root. `realm prune` defaults to a 24-hour age threshold. `--force` bypasses active-lease safety checks, so use it only after confirming that no process still owns the target realm. ## config ```bash theme={null} rkat config get [--format toml|json] [--with-generation] rkat config set [] [--json | --toml ] [--expected-generation ] rkat config patch [] [--json ] [--expected-generation ] ``` The positional argument is always a file path; inline payloads use `--json` (or `--toml` for `set`). Examples: ```bash theme={null} rkat config get --format toml rkat config set ./.rkat/config.toml rkat config patch --json '{"agent":{"model":"gpt-5.5"}}' rkat config patch --json '{"self_hosted":{"servers":{"ollama":{"transport":"openai_compatible","base_url":"http://127.0.0.1:11434","api_style":"chat_completions"}}}}' ``` ## auth ```bash theme={null} rkat auth realms # list realms defined in config rkat auth profiles # list profiles/backends/bindings rkat auth profile # inspect one profile rkat auth login [] # interactive OAuth/device authorization rkat auth login --non-interactive [--secret ] [--backend ] [--method ] rkat auth status # realm config shape + AuthMachine lease lifecycle state rkat auth test # dry-run resolve the binding rkat auth refresh # force-refresh OAuth tokens rkat auth logout [:] # clear persisted credentials; bare means global rkat auth profile-delete -y ``` Interactive OAuth/device `rkat auth login` writes nonsecret backend, auth profile, and binding declarations into the reserved **`global`** config doc at `~/.rkat/config.toml`. Secret tokens are stored separately through TokenStore. The stock CLI uses `/meerkat/credentials/global/.json`, where the platform config directory comes from `dirs::config_dir()`; files are created with mode 0600 on Unix. `--user-config-root` changes the config document location, not this TokenStore root. Custom hosts can select a keyring or another store. Interactive login therefore materializes `global` as the optional chain tail, after which a single sign-in is inherited by workspace realms without a per-realm re-login. Earlier `dev`-realm logins are migrated to `global` on the run path (one-time, idempotent, no-clobber), so an existing sign-in keeps working without re-auth. `--backend` and `--method` are accepted but ignored during interactive login; the provider determines its OAuth/device flow. In `--non-interactive` mode, `--method` selects an allowed persisted-secret mode (default `api_key`), and `--backend` is token metadata, not backend provisioning or full tuple validation. Scripted login saves a token under `global:default_` but does not create backend/auth/binding config or materialize `global` config. Using that token requires an already-configured matching `managed_store` binding; see the [scripted API-key example](/guides/auth#log-in). Alternatively, use environment-key auth. If `--secret` is omitted, scripted login reads one line from stdin. Runtime credential **reads inherit** down the realm chain. CLI auth administration differs: `--realm` on profile, status, test, and refresh commands selects an owner section within the scope's already-loaded effective config (normally the workspace/global chain). It does not by itself discover `//config.toml` or inherit a missing profile/binding for those subcommands. The selected owner's sections must already be reachable, for example through a workspace parent link at the same state root or a multi-section workspace/global document. The default owner section is `global`. `HostAuthService` and RPC/REST credential **writes are strict-owner** and reject child-addressed inherited writes. **Current CLI logout exception:** when the loaded config resolves `child:` to an inherited owner, `rkat auth logout child:` can clear that owner's stored credential, including a shared credential account used by other routes. Explicitly address the true owner instead. A bare logout binding means `global`; `--realm` does not retarget logout. Config `get` / `set` / `patch` still operate on the raw head document. See [Realm inheritance](/concepts/realm-inheritance) and the [auth guide](/guides/auth) for the full model. ## models ```bash theme={null} rkat models ``` This prints the effective runtime model registry for the active realm, including configured `self_hosted` aliases and their `server_id`. ## doctor ```bash theme={null} rkat doctor ``` For self-hosted setups, `doctor` checks the normalized `/v1/models` endpoint for each configured server and warns when configured aliases are missing from the returned model list. For Gemma 4, prefer `chat_completions` in your server config unless you have validated a specific `responses` path for your serving stack. Follow [Self-hosting models](/guides/self-hosting-models) for the full Gemma 4 examples. ## storage Storage administration verbs. All of them dispatch before realm resolution, so they run against the exact on-disk state they diagnose — including split-brain realms the normal resolver refuses. Because no runtime scope is resolved, the global `--isolated` and `--default-model` flags are rejected as usage errors on every storage verb rather than silently ignored; scope the sweep with `--realm`, `--root`, or `--state-root` instead. ```bash theme={null} rkat storage doctor [--json] [--root PATH]... ``` Read-only and safe against a live realm: takes no leases, never changes logical database content, and never creates a database or directory. SQLite may create or update the `-wal`/`-shm` sidecars needed for a coherent WAL read. Sweeps the invocation-context-local (`/.rkat/realms`) and user-global roots by default. Ordinary runtime resolution first walks to the nearest ancestor containing `.rkat`, so run the storage verb from that project root (or pass `--root`) to inspect the same local candidate; any explicit root (`--state-root` or repeatable `--root`) restricts the sweep to exactly those roots. `--realm` focuses the sweep on one realm. Reports per-root realm inventory, schema-ledger state per database, dual-root twins, dangling session→blob references, and orphaned lease/lock/backup artifacts. Exit 0 means no error-severity findings; exit 1 means errors were found. ```bash theme={null} rkat storage migrate [--apply] [--bridge-pre-0-8-10] [--json] [--root PATH]... [--adopt-root PATH] [--fence-wait-secs SECS] ``` `migrate` is dry-run by default, offline, resumable, and fail-closed. It sweeps the same roots (and honors the same `--realm` filter) as `storage doctor`. The dry run reads each realm's schema-ledger baseline read-only — no fence, no store opens, no mutation. `--apply` acquires the realm's exclusive maintenance fence (`--fence-wait-secs`, default 10, bounds the wait for in-flight store operations to drain), then per realm: stamps ledgers by opening every store through its normal constructor under the held fence and reports state-root adoption (report-only — realms are used where they lie). Exact 0.8.10 session-state conversion is owned by each backend's activation transaction, which binds the released schema, key, and source bytes before installing current store authority; `storage migrate` does not bulk-adopt documents or defer conversion to ordinary reads. A realm materialized under multiple swept roots is a fail-closed refusal unless `--apply --adopt-root ` names the swept root whose copy to keep: every copy is fenced first, the divergence report is recomputed under the held fences, and archiving is gated on that comparison being conclusive (an unreadable entry poisons it and refuses) — then every other copy is archived read-only under the registered `*.pre--` backup naming. Divergent data is preserved and reported, never merged. Per-mob databases under `mobs/` are report-only, and credential stores are never read, moved, or reported. Exit 0 = clean; exit 1 = errors or fail-closed refusals (split-brain without `--adopt-root`, unacquirable maintenance fence). `--bridge-pre-0-8-10` is an explicit `--apply`-only maintenance lane for SQLite realms. JSONL and memory realms are rejected before any database is mutated. While the realm fence is held, it runs a frozen importer before the normal store constructors. A domain is authenticated as exactly one caller-authorized source catalog; an unknown or ambiguous catalog refuses that domain instead of being inferred or stamped, and each domain owns its own transaction, so a domain that committed stays committed when a later one refuses. The same authentication answers the eligibility sentence an ordinary open prints, so a realm the bridge recovers is never described as unrecoverable and a realm it cannot recover is never handed this command. Row admission is per record, not per domain. Every runtime input row is carried forward when it decodes through the current typed contract and re-encodes without losing a field; a row that cannot be is left with its bytes exactly as found - never deleted, never blanked - and named in the output with the reason and the consequence. Rows are preserved in the current representation without being scheduled or replayed, and that includes their ingress payloads: a rescued realm keeps the operator's own prompt whatever lifecycle state its input reached. A later session activation follows normal recovery, which applies this binary's ordinary payload retirement to rows it writes itself. Ordinary store opens never enter this bridge. ```bash theme={null} rkat storage prune [--apply] [--older-than-days N] [--json] [--root PATH]... ``` `prune` owns the lifecycle of registered maintenance artifacts only — `*.pre--` migration backups and `*.corrupt-` index quarantines; nothing outside those naming patterns is ever touched. The default run lists artifacts with sizes and ages; `--apply` deletes those at least `--older-than-days` (default 30; 0 = all) days old. Age comes from the timestamp embedded in the registered name, not filesystem mtime — renames preserve mtime, so a long-idle file archived today would otherwise look instantly prunable. `--realm` restricts eligibility to one realm's artifacts. Exit 0 = clean; exit 1 = deletion failures. Not to be confused with `rkat realm prune`, which deletes whole realm directories. ## mcp `rkat mcp` is for local and project configuration only. ```bash theme={null} rkat mcp add [--transport stdio|http|sse] [--scope project|user|local] [-H KEY:VALUE...] [-e KEY=VALUE...] [--url | | -- ] rkat mcp login [--scope project|user|local] rkat mcp remove [--scope project|user|local] rkat mcp list [--scope project|user|local] [--json] rkat mcp get [--scope project|user|local] [--json] ``` Examples: ```bash theme={null} rkat mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem . rkat mcp add api --url https://mcp.example.com/api rkat mcp add --transport http remote https://mcp.example.com/api rkat mcp login remote rkat mcp list rkat mcp get filesystem --scope project ``` Omit `--scope` on `list` or `get` to search all configured scopes. HTTP OAuth is discovered when connecting, not stored in `.rkat/mcp.toml`. Interactive login requires an account selection on the server entry: its `oauth_account` (the account the login must prove; the OpenID Connect `sub` for the default strategy), or `oauth_account_selection = "discover"` (bind the account verified at the first login) or `"unverified"` (an explicit opt-in for servers that cannot prove an account; no account is verified). Set these in `mcp.toml`; `rkat mcp get` shows them. Use `rkat mcp login ` to open the browser flow ahead of time, or `rkat run "..." --mcp-auth interactive` to allow a TTY run to authenticate when an HTTP MCP server first requires it. The CLI owns the loopback callback and your browser; agents never see the authorize URL or callback data. The default `--mcp-auth stored` uses persisted tokens only, and a server without a usable credential is reported as awaiting human authorization. ## mob `rkat mob` is the artifact, typed callable run, and operational run-resource surface. Runtime semantics still live in the shared mob runtime; the CLI only binds to it. ```bash theme={null} rkat mob pack -o [--sign --signer-id ] rkat mob inspect rkat mob validate [--trust-policy ] rkat mob run [--flow ] [--param ...] [--prompt ] [--detach] [--json] [--trust-policy ] rkat mob deploy [-m|--model ] [--max-total-tokens ] [-d|--max-duration ] [--max-tool-calls ] [--trust-policy ] [--surface ] rkat mob web build -o --wasm [--trust-policy ] rkat mob run-flow --flow [--params ] [--stream|--no-stream] rkat mob flow-status rkat mob runs [--flow ] [--json] rkat mob status [--json] rkat mob logs [--after-cursor ] [--limit ] [--json] rkat mob attach [--json] rkat mob spawn-helper --agent-identity --result-label