> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rkat.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Configuration

> Configuration reference for Meta: module definitions, runtime options, auth policies, and session stores.

*Mirrored from Meerkat Meta main at [26f5ae9da1fd](https://github.com/lukacf/meerkat/commit/26f5ae9da1fd3caac378f1cd847ce50f9ec5ac92) on 2026-11-03; Meta version 0.9.9 at that commit.*

Meta is configured through Rust types passed at startup. This page documents every configuration surface.

## MetaConfig

Top-level configuration for the module runtime.

```rust theme={null}
MetaConfig {
    modules: Vec<ModuleConfig>,
    discovery: DiscoverySpec,
    pre_spawn: Vec<PreSpawnData>,
}
```

| Field | Type | Description |
| - | - | - |
| `modules` | `Vec<ModuleConfig>` | Module definitions to load |
| `discovery` | `DiscoverySpec` | Namespace and expected module list |
| `pre_spawn` | `Vec<PreSpawnData>` | Environment variables to inject before spawning |

***

## ModuleConfig

```rust theme={null}
ModuleConfig {
    id: String,
    command: String,
    args: Vec<String>,
    restart_policy: RestartPolicy,
}
```

| Field | Type | Default | Description |
| - | - | - | - |
| `id` | `String` | -- | Unique module identifier |
| `command` | `String` | -- | Executable path or name |
| `args` | `Vec<String>` | -- (required) | Command-line arguments |
| `restart_policy` | `RestartPolicy` | -- (required) | Process restart behavior |

Raw Rust construction and serde deserialization of `ModuleConfig` require all
four fields. The [trust-manifest loader](#trust-manifest-meta-toml) supplies
`[]` for omitted `args` and `OnFailure` for omitted `restart_policy`; those
adapter defaults are not defaults on `ModuleConfig` itself.

### RestartPolicy

```rust theme={null}
enum RestartPolicy {
    Never,
    OnFailure,
    Always,
}
```

***

## DiscoverySpec

```rust theme={null}
DiscoverySpec {
    namespace: String,
    modules: Vec<String>,
}
```

| Field | Type | Description |
| - | - | - |
| `namespace` | `String` | Logical namespace for module grouping |
| `modules` | `Vec<String>` | Module IDs expected in this namespace |

***

## PreSpawnData

```rust theme={null}
PreSpawnData {
    module_id: String,
    env: Vec<(String, String)>,
}
```

| Field | Type | Description |
| - | - | - |
| `module_id` | `String` | Target module ID |
| `env` | `Vec<(String, String)>` | Environment variable key-value pairs |

***

## SDK and Gateway runtime options

The stock SDK gateway (`rpc_gateway`) consumes the `runtime_options` object of `meta/init`. The Python and TypeScript builders emit it from their setters; where a row names a setter, that is the only way the SDK reaches the key, and a key with no setter on an SDK can be sent only from a hand-built `meta/init` payload. `meta_gateway` refuses a `runtime_options` object rather than dropping it.

| Field | Type | Gateway behavior |
| - | - | - |
| `memory_config` | Object | Supports `{ "backend": "local_json" }` with optional `health_check_endpoint`, deriving `state_path` as `<persistent_state>/memory-ledger-state.json`; no other file name is ever read. Any other `backend` value, the predecessor's included, is refused with -32602 naming `local_json`. A predecessor (pre-0.9) ledger file under `persistent_state` refuses init, with or without `memory_config`, until it is adopted with `meta_gateway storage-adopt-predecessor --state-dir <persistent_state> --apply`. |
| `identity_bootstrap_mode` | Object | Controls the roster pass of identity-first gateways. Omitted preserves the classic gateway when no roster is configured; with a roster, omission reports the `eager_materialize` mode. Any explicit value declares identity-first intent and requires a roster provider, including `{ "mode": "eager_materialize" }`. Under every mode the pass makes each roster identity a member of the mob (seated, bound to its session, nothing materialized; an identity that owes a kickoff, an `autonomous_host` member or one with an `initial_message`, is spawned live instead) and reports ready once each is; what is materialized at start is `member_restore`'s to say. A placed or externally bound identity is created through its host instead: at the pass under `eager_materialize`, and at its first use under the lazy modes, where it reads `dormant` until then. `eager_materialize` and `{ "mode": "lazy_materialize" }` otherwise behave alike, and the names are kept for configuration compatibility. `{ "mode": "lazy_with_background_warm", "concurrency": 2 }` also asks the mob, once ready, to materialize each roster identity in a tracked background task, at most `concurrency` at a time; concurrency must be `1..=16`. Omit the key for the warm to run at the member restore's bound, `META_IDENTITY_RESTORE_CONCURRENCY` (16 by default); an explicit `"concurrency": null` is refused with `-32602`. The warm runs off the ready path: ready does not wait for it. |
| `member_restore` | String | How the mob materializes its seated members when it starts, under every `identity_bootstrap_mode`: `"on_demand"` (the default when absent) materializes only the members that must be live (members that hold work, a kickoff owed, a network transport endpoint, a host requirement) and every other member on its first delivery; `"on_demand_verified"` adds one read-only check of every other member before ready, so a member whose session cannot be resumed reads broken at ready without being materialized (it checks nothing else: a refusal at a member's build, such as a `member_auth_binding` build check, still comes at the member's first wake); `"eager"` materializes every member at start. Each seated member is listed, addressable and reported `active` from ready under every value, and a read never materializes a member. Any other value refuses init with `-32602`. A gateway older than this option refuses the key as an unsupported runtime option. Python `.member_restore("eager")`, TypeScript `.memberRestore("eager")`; the Rust builder's `member_restore(MemberRestore::Eager)`. |
| `agent_memory` | Boolean or object | Enables identity-first agent memory injection for roster-backed agents. `true` uses contextual recall defaults with the bundled per-realm SQLite store under `<persistent_state>/agent-memory`. The object form controls recall, injection, trust, Distiller, Steward, and provisional operator scope. `store: "markdown"` is import-only and refused with -32014. `selector` is retired: absence or a string that trims to `"off"` or empty is accepted as disabled compatibility (including whitespace-only strings); remove the key or use `"off"` for new configuration. Every activation-shaped value is refused with -32602, and recall is always deterministic lexical recall. `hygienist` is parked: only absence, `false`, or `{enabled:false}` is accepted for migration compatibility; every activation-shaped value is refused with -32602. The internal Hygienist engine remains in the Rust crate, but the public gateway has no activation seam and this release makes no provider-proof claim. `operator_scope: "provisional"` is preserved: the SDK gateway installs the console-auth-principal resolver and composes operator scope only for authenticated principals it has observed addressing an identity. Library embedders may install a different `OperatorResolver`. `TrustTier::Operator`, operator-scope schema, and re-derivation metadata remain policy-bearing contracts. |
| `routing_config_path` | String | Loads JSON or TOML route config (`routes = [...]` / `[[routes]]`) and installs the routes into the unified runtime before serving RPCs. |
| `gating_config_path` | String | Loads JSON or TOML action policies from an `actions` object. For matching `meta/gating/evaluate` requests through the SDK/stdio dispatcher, the configured `risk_tier` fills an omitted tier or overrides any caller-supplied tier; a conflicting supplied string tier logs a warning. Unconfigured actions retain ordinary request behavior and still require a caller-supplied `risk_tier`. This rewriting is specific to `rpc_gateway`'s stdio dispatcher, not a guarantee for unrelated HTTP/library entrypoints. An optional `escalation` object (`default` and `routes`, approver id to approver id) sets where escalated gates go; without a matching route, escalation is refused. A non-string value, or a path that cannot be opened or read or whose contents are rejected, refuses initialization; absent or null leaves gating off. An empty string is refused too: it no longer switches gating off, as it did when the predecessor SDKs dropped it. To keep gating off, omit the key or send null; in the SDKs, do not call `.gating(...)` and keep nothing at `config/gating.toml` under the working directory, which the builders load when present. |
| `access_config_path` | String | Loads the Console [access control](https://docs.rkat.ai/meta/concepts/access-control.md) TOML; admin edits from the Console persist back to the same file. A non-string value, or a path that is missing, a directory, unreadable or invalid, refuses initialization; absent or null leaves access control off. An empty string is refused too: it no longer switches access control off, as it did when the predecessor SDKs dropped it. To keep access control off, omit the key or send null; in the SDKs, do not call `.accessControl(...)` / `.access_control(...)` and keep nothing at `config/access.toml` under the working directory, which the builders load when present. |
| `auth_config` | Object | Supports the shared JWT (`provider = "jwt"` / `shared_secret`) and OIDC (`provider = "oidc"`) configuration and wires the reference HTTP console/SSE routes to the configured trusted issuer, audience, and email allowlist. See [Authentication](https://docs.rkat.ai/meta/guides/authentication.md). `rpc_gateway` consumes this under `runtime_options`; `meta_gateway` uses the same shape as a top-level init parameter. |
| `console_config_path` | String | Loads a TOML `ConsoleUiConfig` and projects it through `/console/experience` as `console_config`. Python `.console_config(path)`, TypeScript `.consoleConfig(path)`. |
| `console_require_app_auth` | Boolean | Overrides `ConsolePolicy.require_app_auth`. The default is fail-closed: without `auth_config` the gateway's decision state trusts no signing key, so a console that still requires app auth refuses every request with 401, and the gateway logs a startup warning naming this. `false` is the explicit opt-out for a local or host-protected console. Python `.console_auth_required(False)`, TypeScript `.consoleAuthRequired(false)`; the Rust library twin is `RuntimeDecisionState::local_console`. |
| `console_read_only` | Boolean | Sets `ConsolePolicy.read_only` for SDK `rpc_gateway` launches; mutating console RPCs are denied and omitted from capabilities while read/stream routes stay available. Python `.console_read_only()`, TypeScript `.consoleReadOnly()`. |
| `console_fetch_timeout_ms` | Positive integer | Sets `RuntimeDecisionState.console.fetch_timeout_ms`, which the bundled console uses for JSON fetches after the initial 60s default. |
| `http_listen` | `HOST:PORT` string | Bind address of the gateway HTTP listener (console, JSON-RPC, blobs, SSE, live). Default `127.0.0.1:0`: loopback, ephemeral port, the only bind every earlier release had. IP literals only, no hostnames. A non-loopback address is refused at init (`-32602`, before any bootstrap) unless `auth_config` enforces console app auth or `allow_remote` is `true`; every non-loopback bind that passes logs one WARN line. The listener is bound right behind that gate, still before any bootstrap, so a port another process holds is a `-32603` init error naming the address with no runtime built and no schedule executor lease taken behind it. The init result's `http_base_url` preserves the bound port and maps wildcard binds to same-family loopback: `0.0.0.0:PORT` becomes `http://127.0.0.1:PORT`, and `[::]:PORT` becomes `http://[::1]:PORT`; concrete bound addresses are unchanged. Python `.http_listen("0.0.0.0:8080")`, TypeScript `.httpListen("0.0.0.0:8080")`. A top-level `http_listen` (the `meta_gateway` init-param shape) is refused with `-32602` naming `runtime_options.http_listen`. See [Deployment](https://docs.rkat.ai/meta/guides/deployment.md). |
| `http_public_base_url` | Absolute `http(s)://` URL | The base URL clients reach the gateway at through a proxy. Reported back in the init result as `http_public_base_url` (trailing `/` removed; `null` when undeclared); never used to bind. Python `.http_public_base_url(url)`, read back as `runtime.rust_http_public_base_url`; TypeScript `.httpPublicBaseUrl(url)`, read back as `runtime.rustHttpPublicBaseUrl`. |
| `allow_remote` | Boolean | Explicit acknowledgement that `http_listen` exposes the listener beyond this host; the same word and rule as `--allow-remote` on `rkat-rpc --tcp` and `rkat mob host --listen-tcp`. An exposure opt-in, not an auth mechanism: pass it only with an authenticating proxy in front of the listener. Python `.allow_remote()`, TypeScript `.allowRemote()`. |
| `mob_announcements` | Object `{enabled, batch?, retention_ms?, member_limits?, member_tools_in_child_mobs?, child_member_limits?, on_unhandled?, handled_unverified_threshold?, renotify_after_ms?, health_audience?, declarations?, declarations_path?}` | Mob announcements. `enabled: true` needs a persistent, identity-first launch. `batch` sets `max_count` (default 100), `max_bytes` (65536), `max_delay_ms` (60000) and `min_wake_interval_ms` (60000); `retention_ms` defaults to seven days and is at least 3600000 (one hour). `member_limits` bounds the member tools per member: `announcements_per_minute` (30), `announcement_bytes_per_minute` (65536), `subscriptions` (8) and `topics_per_subscription` (16). `member_tools_in_child_mobs` (default false) is the ceiling of the member tools in mobs members create: with it the `mob_announcements` bundle is offered to child mobs, and a child mob profile gets the member tools only by naming `mob_announcements` in its own `tools.bundles` (Meerkat's narrowing and quotas still apply). Without it a child mob create whose profile names the bundle is refused, and a member that opted in under a ceiling since lowered is built without the tools. The host can subscribe members of child mobs either way. `child_member_limits` bounds live subscriptions for members of mobs hosted here: `subscriptions` (1024) on the gateway and `per_mob` (256) naming any one mob. `on_unhandled` (`"report"`, the default, `"redeliver"` or `"hold"`) applies to a plain announcement whose turn ended unhandled. `"report"` only records it. `"redeliver"` owes it again with a notice, at most twice. `"hold"` keeps it for an operator. A source's items follow its `delivery.on_unhandled`; see [Delivery handling](https://docs.rkat.ai/meta/api/rpc.md#delivery-handling). `handled_unverified_threshold` (default 3, at least 1): the member escalates as `handled_unverified` once this many of its turns end `handled_unverified` within a day. `renotify_after_ms` (default one day, at least 60000) is how long an open escalation waits before it is notified again. `health_audience` (default none) holds audience grants, as in a source's `audience`. Covered members receive member escalations on the `mob_announcements.health` topic; see [Mob announcement health](https://docs.rkat.ai/meta/api/rpc.md#mob-announcement-health). `declarations` (inline) or `declarations_path` (a packaged JSON file, read at init and at each `declarations/apply`), never both, declare sources and subscriptions; see [Mob announcement declarations](#mob-announcement-declarations). Enables the mob announcement methods and the `mob_announcements` tool bundle a profile opts into with `tools.rust_bundles`; see [Mob announcements](https://docs.rkat.ai/meta/api/rpc.md#mob-announcements-optional). `meta_gateway` takes the same object as its top-level `mob_announcements` init param, and a Rust host as `UnifiedRuntimeBuilder::mob_announcements`; see [Mob announcements](#mob-announcements). |
| `member_comms_address` | Socket-address string | Binds each local member to a signed TCP endpoint that remote peers can dial. Wildcard addresses are refused because they cannot be advertised as a concrete route. |
| `contacts_toml` | TOML string | Loads the contact directory used to resolve remote mob ids to TCP/UDS control endpoints and pinned gateway public keys. |
| `control_grants_toml` | TOML string | Loads the closed `[control_grants]` table for `--control-listen`. Each caller public key is scoped to named control verbs and members, and the gateway binds every request signature to the local mob id as its audience. Omitting the field installs an empty deny-all table; an explicit value without `[control_grants]`, or with an unknown/invalid field, fails initialization. |
| `control_request_window_ms` | Integer, 1000 to 3600000 | How far a signed `inject_keyed` or `inject_status` request's issue time may be from this gateway's clock, in either direction, before the control listener refuses it as `stale_request`. Default 300000 (five minutes). See [Keyed inject](https://docs.rkat.ai/meta/concepts/access-control.md#keyed-inject). |
| `serve_keyed_inject` | Boolean | Whether the control listener serves peers' `inject_keyed` and `inject_status` requests. Default true. With false, both verbs answer `not_served` with the cause `runtime_disabled`, and peers keep their batches for this gateway's members waiting until it serves again. See [Keyed inject](https://docs.rkat.ai/meta/concepts/access-control.md#keyed-inject). |
| `max_sessions` | Positive integer | Session-capacity limit, default `16` in the stock gateway. TypeScript `.maxSessions(n)`; Python requires raw init input. |
| `implicit_delegate_idle_retire_secs` | Nonnegative integer or `null` | Default idle timeout in seconds for eligible delegates, default `300`. `null` disables the runtime default, not explicit per-member numeric overrides. Python `.implicit_delegate_idle_retirement(seconds)` / TypeScript `.implicitDelegateIdleRetirement(seconds)` (use `None` / `null` to disable the default). See [scope and overrides](#implicit-delegate-idle-retirement). |
| `implicit_delegate_idle_sweep_interval_ms` | Positive integer | Delegate retirement sweep interval, default `10000` ms; the runtime clamps it to an effective minimum of `1000` ms. Raw init input only; neither SDK has a setter. |
| `event_log` | Object | Declares the operational event log. `storage = "memory"` (preferred; `"in_memory"` is an accepted compatibility alias, with optional `batch_size`, `flush_interval_ms`) keeps a bounded queryable in-process store so `meta/query_events` returns events instead of `no_event_log_configured`; `storage = "null"` declares that events are dropped. These are declared-ephemeral choices; omitting the key means no ingestion at all, and any other storage kind fails initialization. Durable event-log backends are embedder-only (`UnifiedRuntimeBuilder` accepts any `EventLogStore`). |
| `runtime_store` | Object | Declares the runtime store's durability (session resume, archive, retire). With top-level `persistent_state` configured, the runtime store is persistent SQLite at `runtime.sqlite`, where sessions live: a declared in-memory store is refused (`-32014`), and a failed SQLite open is a startup error (`-32014`), never a silent in-memory fallback. Without `persistent_state`, the gateway uses `InMemoryRuntimeStore`, which `{ "storage": "memory" }` declares explicitly (`"in_memory"` is an accepted compatibility alias); `null` is not a supported storage kind here. In-memory runtime-store state does not survive a gateway restart. |
| `declare_spec_update` | Object | One-shot canonical mob-definition compare-and-swap. `{ "expected_revision": N }` applies the `mob_config` supplied in the same `meta/init` request only when the durable definition is still at `N`. The launch must be authoritative and use persistent mob storage. Exact replay converges; stale revision or successor-content disagreement refuses initialization with typed storage error `-32014`. |
| `mob_composition` | Object `{authority, candidate_definition?}` | Who speaks for the persistent mob storage's recorded composition (the composition pin). `authority` is `"authoritative"` by default: the launch records the definition it supplies when none is recorded, and a supplied definition that differs from the recorded one is refused with `-32019` (`divergent`); declare such a change with `declare_spec_update`, which needs an authoritative launch. `"candidate"` is for the candidate or certification boot of a candidate-then-promote pipeline on one state directory: the launch neither creates the pin nor is refused by one, but a candidate whose supplied definition differs from the stored one is refused (`-32019`, `candidate_divergent`) unless `candidate_definition` is `"stored"`, which boots the stored definition instead, logs the diverged fields at WARN and reports them in a degraded `mob_composition` storage-health slot. `candidate_definition` is candidate-only: an authoritative launch that sets it is refused when the options are parsed, and so is any other value. A candidate's reads still answer, and it runs no schedule firing host (its schedule tools refuse every change), delivers no mob announcement batch and keeps no source runner schedule (an announcement or source change is refused, over RPC with `-32047` `candidate_launch`), starts no stored detached callback job, runs no orchestrator resume turn, arms no runtime delivery owner, recovers no durable monitor and settles no Console send a previous process left unsettled at startup (a `candidate launch census` WARN counts them; the authoritative launch settles them). Every session its runtime store holds registers with its run starts held, and a candidate never releases them, so a run the store left open does not resume and an input queued for a member does not run, in the mob and in the mobs its members created; it binds no WorkGraph resume gate either. It creates no session: `meta/spawn_member`, `meta/ensure_member`, `meta/spawn_helper`, `meta/fork_helper` and `meta/attach_existing_session` are refused with `-32047` `candidate_launch`, so no new session's turn can reach a held member. A candidate changes no stored member: its roster pass retires and respawns none (a member its roster no longer lists, or lists under another profile, stays as stored, and the pass logs at WARN, as `candidate launch roster census`, what the authoritative launch would retire or move; a live member Meta 0.9.3 spawned without the durable-identity marker is not respawned onto its session, and its identity is reported deferred `resume_member`, its member detail showing it as Core holds it, seated and not materialized), a wake that would create a member, move it to another role, start a fresh session in place of a stored one that is gone, or spawn a member onto a stored session Core still holds (a dormant or retired identity, or a first-boot `Resume` row; Core would journal that member in the shared mob store) is refused and the identity is reported deferred to the authoritative launch, not broken (see the bootstrap status below; the members Core restored live at start stay as they are), it starts no implicit delegate idle-retire sweep, and `meta/retire`, `meta/retire_member`, `meta/respawn`, `meta/respawn_member`, `meta/reset`, `meta/reset_all`, `meta/delete_identity` and `meta/reconcile_identity` are refused with `-32047` `candidate_launch`, as are `mob/adopt_member_identity_declaration` and `mob/apply_member_tool_declaration`, which write durable member intent (the declaration read `mob/member_tool_declaration` still answers). It rewires no member either: the managed peer-edge reconcile and the edge reconcile apply nothing and write no topology journal (each logs a `candidate launch peer-edge census` of what it would wire and unwire), `meta/reconcile_edges` answers a dry run (the same report, naming what would be wired, unwired and pruned, with `"applied": false`), and `meta/rediscover`, `meta/cross_mob/wire`, `meta/cross_mob/unwire`, `meta/cross_mob/wire_local`, `meta/cross_mob/unwire_local` and `meta/topology/apply` are refused with `-32047` `candidate_launch`, as is `meta/gating/decide`: a pending gate's decision belongs to the authoritative launch. It admits no member input either: `meta/send`, `meta/send_message`, `meta/interact`, `meta/dispatch`, `meta/delivery/send`, `meta/cross_mob/send`, `meta/run_flow`, `meta/reload_member`, `meta/identity/warm`, `meta/stop_member_run`, `meta/force_cancel_member`, `meta/compact_member`, `meta/bound_member_transcript`, `meta/prune_member_system_rows`, `meta/member_activate_instruction`, `meta/live/open`, the Console's sends and voice open and its schedule writes (`meta/schedule/create`, `update`, `pause`, `resume` and `delete`) are refused the same way (`POST /console/send` with HTTP `409` `candidate_launch`), and its cross-mob control listener answers a peer gateway's `wire`, `unwire` and `inject` with the error code `candidate_launch` (it still serves `lookup_member`, the host verbs and the keyed inject verbs). It makes no operator change either: the WorkGraph writes, `meta/call_tool`, `meta/routing/routes/add` and `meta/routing/routes/delete`, `meta/memory/index`, `meta/memory/quarantine/decide`, the agent memory writes (`meta/agent_memory/remember`, `meta/agent_memory/update` and `meta/agent_memory/forget`), the label sets and deletes, the Console's access policy writes, `meta/console/revoke_sessions`, `meta/console/recover_send`, `meta/blob/upload`, `meta/cancel_flow`, `jobs/cancel`, `jobs/retry`, `monitors/start`, the live channel verbs, a `meta/mobpacks/deploy` with `execute`, a `meta/mobpacks/validate` with `rkat_validate` (also from the Flow Editor), a Flow Editor draft write (`meta/mobpacks/create`, `meta/mobpacks/save`, `meta/mobpacks/delete`, `meta/mobpacks/undo` and `meta/mobpacks/redo`) except to a store file the caller names (`store_path`) outside the state directory and the gateway home that is not the store `META_FLOW_EDITOR_DRAFT_STORE` names, a `meta/session_store/bigquery` row insert, a `meta/gating/evaluate` of an R3 action that names an approval route (`approval_recipient` and `approval_channel`), which sends the approval request through the delivery module, every call passed through to a loaded module (a method outside `meta/` that no gateway method serves) and `meta/reconcile`, which starts configured modules, are refused with `-32047` `candidate_launch`, and `POST /console/mcp-apps/call-tool` and `POST /console/send/recover` with HTTP `409` `candidate_launch`; their reads, deploy plans and structural validations still answer. Its Console writes nothing to the session fence record either: a principal whose token has no `exp` is listed in its own process only. Three limits are the operator's to keep. A peer's comms message to a member is admitted inside Meerkat, not by the gateway, so a candidate cannot refuse it: keep a candidate off production comms addresses, where no production peer reaches its members. A candidate holds only the sessions of its own runtime store: a member placed on a remote runtime host is not held, and the candidate's Shutdown reaches that host, so a candidate must not run over a composition with members placed on remote hosts. And after an unclean stop of the authoritative launch (a crash, a kill or a power loss), start the authoritative launch once before any candidate: a candidate's restore may complete Meerkat's own recovery of an operation the stop interrupted (a spawn, a respawn, a session rebinding or a host bind), which records that repair in the shared mob store. A candidate over a mob the authoritative launch left stopped (its graceful shutdown stops the mob before it shuts it down) lifts it, as the authoritative launch's own next start would, and records the lift there too. Its graceful shutdown, and the teardown of a startup that fails once its mob is built, hand the store back as they found it: the mob Shutdowns (the root mob's and its child mobs') keep every member's inputs and the runs its sessions left open exactly as the store holds them, with no requeue mark (Meerkat's `QueuedInputsOnShutdown::Keep`), and neither stops the mob first, nor does an in-flight drain (a candidate holds every member, so nothing is in flight): a Stop would journal `MobStopped` in the shared mob store and fail pending spawns, cancel pending peer deliveries and close kickoff origins, so only the keep Shutdowns run. A kill leaves them as the candidate's registrations found them too. It leaves the `storage-migrate-native` first-boot manifest as it found it: it carries out no row, and a row it finds done leaves only its own pending rows. It still accepts peers' keyed injects into its inbox, where they wait for the authoritative launch. |
| `fork_instruction_policy` | String | What a fork of a member with activated host instructions does with them. `"require_explicit_activation"` (the default when absent, Meerkat's default) leaves the activation to the host: a host-driven fork (the console fork, `UnifiedRuntime::fork_member`) is committed and the host activates each inherited key before the child's first turn, while a fork that starts its own first turn (`fork_off`, a delegation fork, a council participant) is refused with a typed `fork_instruction_refused` before anything is committed. `"reactivate_inherited"` re-activates, in the child and before it is committed, every key that was effective in the source at the fork. `"refuse"` refuses any fork whose child would inherit an activation. Applied to the gateway's mob on create and resume. Any other value refuses initialization. Python `.fork_instruction_policy(policy)`, TypeScript `.forkInstructionPolicy(policy)`; Rust embedders set it with `UnifiedRuntimeBuilder::fork_instruction_policy` (see [the Rust SDK](https://docs.rkat.ai/meta/sdks/rust.md#fork-instruction-policy)). `meta_gateway` takes no `runtime_options` and runs Meerkat's default. See [Fork instruction policy](#fork-instruction-policy) for what a fork copies, what each value does and what `reactivate_inherited` trusts. |
| `compaction` | Object | Declares the host-level session-compaction policy for every agent the gateway builds: `auto_compact_threshold` (tokens, non-zero), `recent_turn_budget`, `max_summary_tokens`, `min_turns_between_compactions`, `history_retained_rewrites` (non-zero; how many transcript rewrites keep their bodies, default 64 in Meta, where Meerkat's own default is 4). A declared 4, Meerkat's default, is treated as undeclared and becomes 64; a host that wants a smaller window sets any value other than 4. A compactor is always installed; this declares *when it fires*. **Omitting `auto_compact_threshold` inherits Meerkat's model-aware default - `context_window * 4 / 5`, i.e. \~840,000 tokens on a million-token model such as `gpt-5.6-sol`, which in practice never fires and resends the full transcript every turn.** Declaring the key pins it. A mob profile's own `auto_compact_threshold` still wins per member. Unknown keys and a zero threshold fail initialization. |
| `meerkat_config_path` | String path | The Meerkat host `config.toml` every agent the gateway builds starts from (the live/realtime door included), loaded once at init, merged over `Config::default()` with Meerkat's own file semantics and validated with Meerkat's own `Config::validate` against the canonical catalog. This and `native_realm` (never both) are the doors for the `[self_hosted]` and `[realm]` tables, which Meerkat keeps in the host config rather than in `mob.toml`; a host-config `[models]` table reaches the factory too, but does not satisfy the [init model check](#the-init-model-check), which reads the definition's own `[models.<id>]`. The gateway's own overlays (`member_comms_address`, `compaction`) are layered on top. A missing, malformed or non-validating file (a `[self_hosted.models.<id>]` alias naming an undeclared server, for one) refuses init with `-32602` naming the option and the path, never a silent `Config::default()`. Omitted, agents build from Meerkat's default config as before. Python `.meerkat_config(path)`, TypeScript `.meerkatConfig(path)`. `meta_gateway` takes the same file as its top-level `meerkat_config_path` init param and adopts `<workspace>/.rkat/config.toml` when it exists, logging one INFO line naming the adopted file either way; a top-level `meerkat_config_path` sent to `rpc_gateway` is refused with `-32602` naming `runtime_options.meerkat_config_path`. See [Self-hosted models and the Meerkat host config](#self-hosted-models-and-the-meerkat-host-config). |
| `provider_credentials_root` | Absolute directory path | The provider credentials directory every agent the gateway builds resolves and persists provider credentials in, in place of the process default `<config dir>/meerkat/credentials`, which every process of the OS user shares. Opened once at init, creating the directory when missing. A non-string, empty or relative value refuses init with `-32602`, and a root that cannot be opened refuses init with `-32014`, never a fall back to the default. Refused with `-32602` under a native host composition, whose `GatewayHostComposition::provider_auth_persistence` names the store instead. Omitted, agents keep the process default as before. Python `.provider_credentials_root(path)`, TypeScript `.providerCredentialsRoot(path)`. `meta_gateway` takes the same path as its top-level `provider_credentials_root` init param; a top-level `provider_credentials_root` sent to `rpc_gateway` is refused with `-32602` naming `runtime_options.provider_credentials_root`. See [Provider credentials](#provider-credentials). |
| `native_realm` | Object `{realm_id, context_root, state_root, user_config_root}` | A realm a native host keeps, whose whole effective configuration every agent the gateway builds starts from, in place of `meerkat_config_path`: the two are refused together (`native_realm_config_conflict`). `realm_id` is the realm's id under `state_root`, the realms root it lives under; `user_config_root` is the home-like root whose `.rkat/config.toml` is the `global` realm's document; `context_root` is the native host's context root, which selects no document. Every field is required, each root is an absolute path, and no other field is accepted (`native_realm_invalid`). Read once at init with Meerkat's own native reader (`StorageLayout`, `FilesystemRealmConfigSource`, `EffectiveConfigReader`, the environment overrides, then `Config::validate`), copying and writing nothing. A realm the state root does not hold refuses init with `-32014` (`native_realm_unknown`), and so does one whose storage cannot be resolved (`native_realm_unreadable`); documents that cannot be read or do not validate refuse it with `-32602` (`native_realm_config_invalid`), as a bad `meerkat_config_path` file does. None falls back to the default configuration. Requires `member_auth_binding` (`member_auth_binding_required`). Refused at the top level of the init params. Python `.native_realm(realm_id, *, context_root, state_root, user_config_root)`, TypeScript `.nativeRealm({realmId, contextRoot, stateRoot, userConfigRoot})`. See [Native realm and member auth binding](#native-realm-and-member-auth-binding). |
| `member_auth_binding` | Object `{realm, binding, profile?, origin?}` | The configured auth binding every member the gateway builds uses, exactly, or its build is refused: Meerkat's `AuthBindingRef`. `origin`, when present, must be `"configured"` (`member_auth_binding_origin`); a malformed value is `member_auth_binding_invalid`. Checked at init against the members' configuration (the native realm's, or the `meerkat_config_path` file's): a binding it does not define is `member_auth_binding_unknown`, a definition profile whose model or provider the binding does not serve is `member_auth_binding_mismatch`, and an automatic `[spawn_policy]`, an enabled model fallback target that names another binding or none, image generation, or the agent memory engines are `member_auth_binding_uncarried`. A spawn that names another binding is refused, and so are an agent's `mob_create` of a child mob that would make provider calls off the binding, `mob/adopt_member_identity_declaration` and `mob/apply_member_tool_declaration`. Every init refusal answers `-32602` before any agent factory is built and never falls back to a default binding. Refused at the top level of the init params. Python `.member_auth_binding(AuthBindingRef(...))`, TypeScript `.memberAuthBinding({...})`. See [Native realm and member auth binding](#native-realm-and-member-auth-binding). |
| `workgraph` | Boolean or directory string | WorkGraph construction switch, default `true`. `false` disables the store, the member `workgraph_*` tools, the attention overlays and the `meta/workgraph/*` RPCs; a non-empty string is an explicit directory for the durable store (`workgraph.sqlite3` is created inside it); any other value fails initialization. Python `.workgraph(enabled)` and TypeScript `.workgraph(enabled)` take `true`, `false` or the directory string; an unused setter sends nothing and the gateway default applies. Store placement per launch mode, the auto-issued namespace grant and the memory-backed fallback are in [WorkGraph](#workgraph). |

Unsupported nested fields fail initialization with `-32602` instead of being silently ignored.

The gateway's `--persistent` flag selects a long-lived stdio/process protocol;
it does not itself configure durable storage. The separate top-level
`persistent_state` path selects persistent storage composition.

### Implicit delegate idle retirement

Idle retirement applies to members of implicit delegation mobs and to
explicitly opted-in primary-mob members, not unconditionally to the durable
identity roster. A primary-mob member becomes eligible through a per-delegate
override or the `implicit_delegate_idle_retire_secs` member label.

Per-member overrides take precedence: they can disable retirement, select a
numeric timeout, or use the runtime default. Without such an override, the
member label accepts a numeric seconds string or `"disabled"`; otherwise the
runtime default applies. Setting the runtime default to `null` therefore does
not disable a member's explicit numeric timeout. See
[roster and member lifecycle](https://docs.rkat.ai/meta/concepts/roster.md) for the identity/worker boundary.

A member is idle while its session runs no turn. Its window starts when the
sweep first finds it idle under its current session, so a member re-seated or
woken under a new session starts a fresh window, and the sweep reads the
member's session again right before it retires the member. A member the
previous pass did not find idle starts a fresh window too. When the
member's runtime reports its runs, so does a member that ran a turn
since the sweep last found it idle, even a turn the sweep never saw
(its session's last finished run changed); a session that answers no
execution snapshot reads idle. A member every pass finds idle, under
the same session and with no new run, keeps its window however long the
passes take. After a restart, a member whose session no live runtime serves yet
counts as idle while no kickoff of it is in flight, and it is retired only when
it owes no work: no input of its session short of a terminal state either. The
sweep reads that from the store, together with whether this process holds the
member's session at all, only once the member's window has passed; until then
it reads nothing from the store for the member. A store read that fails, or
does not answer within a minute, keeps the member for a later window and is
logged at WARN. The sweep never retires a member whose session this process
does not hold, such as one placed on another host, or one that still owes
work: such a member starts a fresh window.

Agents set a per-member override with the `idle_retire_secs` argument of the
`delegate`, `fork_off` and `mob_spawn_member` tools. A non-negative integer is
that many idle seconds. JSON `null` is the same as omitting the argument, like
every other optional tool argument:

| Tool | Omitted or `null` | Integer |
| - | - | - |
| `delegate` | The runtime default | That many seconds |
| `fork_off` | Opted in on the runtime default | That many seconds |
| `mob_spawn_member` | Not opted in; the member is kept | Opted in with that many seconds |

Any other value is refused as invalid arguments. Agents cannot disable
retirement for the members they spawn; a host that wants helpers kept sets the
runtime default to `null` (above). A `disabled` override persisted by an
earlier Meta, when `idle_retire_secs: null` meant disable, still loads and
still keeps its member.

### Model fallback

Automatic model fallback is **off by default**. An enabled policy requires an
explicit, non-empty chain; an empty chain never walks the model catalog.
Meerkat owns retry eligibility, target admission, credentials, and committed
model routing. Meta forwards that policy without adding its own retry loop.

The host file selected by `meerkat_config_path` accepts `[model_fallback]`.
Rust embedders can pass the same `meerkat::Config` to
`UnifiedRuntimeBuilder::meerkat_config`. In `mob.toml`, use
`[runtime.model_fallback]` for a mob default or
`[profiles.<name>.model_fallback]` on an inline profile:

```toml theme={null}
[runtime.model_fallback]
enabled = true
chain = [{ model = "gpt-5.5", provider = "openai" }]

[runtime.model_fallback.policy]
cross_provider = false
min_context_headroom = 0.10
require_tool_parity = true
trigger_after_attempts = 3
triggers = ["provider_unavailable", "capacity"]

# A profile can explicitly disable an inherited policy.
[profiles.coordinator.model_fallback]
enabled = false
```

The first present **whole table** wins: profile, then mob runtime, then host
configuration. Omission inherits; an explicit `enabled = false` wins over an
enabled parent. Tables are not merged field by field, so a profile does not
silently borrow its parent's chain. A realm-profile reference cannot carry a
sibling fallback override; put it on the referenced profile or the mob runtime.
Unknown fallback keys, `use_catalog_default_chain`, invalid policy values, and
`enabled = true` without a chain are errors. There is no separate
`runtime_options.model_fallback` gateway option.

Cross-provider fallback requires explicit `cross_provider = true` and a usable
authorized credential binding; omission does not authorize switching accounts.
Targets must pass Meerkat's context, output-budget, tool and modality admission.
Transport failures and empty output are not default fallback triggers.
New fallback provenance can cause an unsafe restored route to be held with a
typed error. Historical sessions without that provenance are **not** inferred
to be fallback sessions, automatically reverted, or migrated. Existing explicit
operator model/provider resume masks retain their authority.

The host config setter affects builder-created member session services, not an
already-built `.mob_spec()` service. Native background memory engines currently
use their own default config (also fallback-off); this table does not configure
those engines. This release does not add fallback overlays, automatic reversion,
or a routing-repair CLI.

### One-shot mob definition update

Python hosts use the supported builder ceremony on the single activation
authorized to change the durable definition:

```python theme={null}
runtime = await (
    Meta.builder()
    .mob("config/mob.toml")  # complete replacement definition
    .persistent_state("/var/lib/example")
    .declare_spec_update(expected_revision=1)
    .gateway("/path/to/rpc_gateway")
    .build()
)
```

TypeScript hosts use the same ceremony with `declareSpecUpdate()`:

```typescript theme={null}
const runtime = await Meta.builder()
  .mob("config/mob.toml") // complete replacement definition
  .persistentState("/var/lib/meta-app")
  .declareSpecUpdate(1)
  .gateway("/path/to/rpc_gateway")
  .build();
```

The builder sends the replacement TOML as top-level `mob_config` and emits:

```json theme={null}
{
  "runtime_options": {
    "mob_composition": {"authority": "authoritative"},
    "declare_spec_update": {"expected_revision": 1}
  }
}
```

The gateway delegates the mutation to Meerkat's canonical definition CAS. It
does not append raw events, rewrite a projection independently, or treat the
creation manifest as mutable authority. Remove `declare_spec_update(...)`
(TypeScript `declareSpecUpdate(...)`) from later ordinary restarts. A stale
revision or different successor definition raises `StorageResolutionError` in
both SDKs; an exact retry returns the already
committed successor revision and converges its checked projection.

This is a cold-activation ceremony, not a hot reload. Fully shut down the prior
runtime before issuing the update: an already-running `MobHandle` keeps the
definition it was constructed with. The CAS and verified-resume fence serialize
the update with the next bootstrap, but they do not reconfigure an older live
process.

> [!WARNING]
>
> `runtime_options.scheduling_files` was removed in contract 0.5.0 and is an
> unsupported field. Move definitions to the durable schedule store through
> schedule tools or `ScheduleService`.

## Self-hosted models and the Meerkat host config

Meerkat keeps three tables out of the mob definition and in the host
`config.toml`: `[self_hosted]` (serving endpoints and model aliases), `[realm]`
(backend, auth and binding profiles, including the credential binding every
`provider = "self_hosted"` member resolves through) and `[models]` (custom
model rows). A `mob.toml` profile refers to them by name only:

```toml mob.toml theme={null}
[profiles.worker]
model = "gemma-4-31b"
provider = "self_hosted"
self_hosted_server_id = "local"
```

```toml config.toml theme={null}
[self_hosted]
default_model = "gemma-4-31b"

[self_hosted.servers.local]
transport = "openai_compatible"
base_url = "http://127.0.0.1:11434"
api_style = "chat_completions"

[self_hosted.models.gemma-4-31b]
server = "local"
remote_model = "gemma4:31b"

[realm.global]
default_binding = "local"

[realm.global.backend.local]
provider = "self_hosted"
backend_kind = "self_hosted"
server = "local"

[realm.global.auth.local_auth]
provider = "self_hosted"
auth_method = "none"
source = { kind = "platform_default" }

[realm.global.binding.local]
backend_profile = "local"
auth_profile = "local_auth"
```

The alias shape, the realm binding rules and the Gemma 4 worked example are
Meerkat's contract, documented in the Meerkat
[self-hosting guide](https://github.com/lukacf/meerkat/blob/main/docs/guides/self-hosting-models.mdx);
Meta adds nothing to them. Note that `[models.<id>] provider = "self_hosted"`
is refused by Meerkat's model registry: self-hosted aliases live under
`[self_hosted.models]` only.

**`[self_hosted]` and `[realm]` belong in the host config, never in `mob.toml`.**
`MobDefinition::from_toml` deserializes without `deny_unknown_fields`, so a
top-level `[self_hosted]` or `[realm]` table written into `mob.toml` used to
vanish without a diagnostic and resurface two layers later as a member that
could not build. Both binaries now refuse it at init, on the raw TOML text,
with one message:

```text theme={null}
mob.toml declares a top-level [self_hosted] table, which is meerkat host configuration
that the mob definition parser ignores; move it into the meerkat config.toml and point the
gateway at that file: runtime_options.meerkat_config_path on rpc_gateway, or the
meerkat_config_path init param (default <workspace>/.rkat/config.toml) on meta_gateway
```

Both binaries answer it as `-32602` on the `meta/init` request id
(`meta_gateway` when it loads `config/mob.toml`).

`[models.<id>]` is different from the two tables above. A profile's `model`
must be catalogued, provider-annotated, or defined under the **mob
definition's own** `[models.<id>]` to pass the model check: the check
(`meerkat_mob::validate_definition`, run at init on `rpc_gateway` and by
Meerkat at create time on both binaries) reads only the definition's tables.
So a custom model a profile names by id belongs in `mob.toml`. A `[models]`
table in the host config still reaches the factory through
`meerkat_config_path` (a provider-annotated profile can use such a row), but
it does not satisfy the check, and a profile whose model exists only there and
carries no `provider` is refused at init as `UnknownModel`.

### How the host config reaches each surface

| Surface | Ingress | When nothing is declared |
| - | - | - |
| `rpc_gateway` | `runtime_options.meerkat_config_path`; Python `.meerkat_config(path)`, TypeScript `.meerkatConfig(path)`. Or, never with it, a native realm's effective configuration (`runtime_options.native_realm`; see [Native realm and member auth binding](#native-realm-and-member-auth-binding)) | `Config::default()`, as in every earlier release |
| `meta_gateway` | Top-level `meerkat_config_path` init param (a relative path resolves against the launch directory); the file's content joins the resume fingerprint, so a changed host config does not resume a runtime built without it | `<workspace>/.rkat/config.toml` when that file exists (`ConventionalPaths::with_meerkat_config_from_workspace`), else `Config::default()` |
| Rust library | `UnifiedRuntimeBuilder::meerkat_config(config)`; `gateway_composition::load_gateway_host_config(path)` is the loader both binaries use and `refuse_host_config_tables_in_mob_toml(text)` the `mob.toml` check | `Config::default()` |

The loader reads exactly the named file and merges it over `Config::default()`
with `Config::merge_toml_str`, the step Meerkat applies to a discovered
`.rkat/config.toml`: no directory walk and no home-directory fallback, so what
the gateway loads is what the operator named. Adoption is never silent:
`meta_gateway` logs one INFO line naming the adopted file and whether the
init param or the workspace convention produced it. A file that cannot be read or
does not parse or does not validate refuses init (`rpc_gateway`: `-32602`,
`runtime_options.meerkat_config_path: failed to read meerkat config <path>: ...`,
`meerkat config <path> is invalid: ...` or
`meerkat config <path> does not validate: ...`; `meta_gateway`: `-32602` on
the request id, `init params: meerkat_config_path: ...`), because an operator
who pointed the gateway at a file expects that file in force. Validation is
Meerkat's own `Config::validate` against the canonical catalog, the step
`rkat` and `rkat-rpc` run after every load: a `[self_hosted.models.<id>]`
alias whose `server` is not declared, a `[models.<id>]` row colliding with a
catalog id, or a zero compaction threshold is refused at init instead of
killing the first member build. The gateway's own
declarations stay layered on top: `member_comms_address` and `compaction` on
`rpc_gateway`, `compaction` on `meta_gateway`, and a profile's own
`auto_compact_threshold` still wins per member.

### The init model check

`rpc_gateway` checks every inline profile's `model` before the mob exists, by
Meerkat's own rule (`meerkat_mob::validate_definition`, narrowed to its
`UnknownModel` diagnostic): a model is acceptable when it is catalogued, or
defined under this definition's `[models.<id>]`, or provider-annotated
(`provider = "self_hosted"` with a `self_hosted_server_id`, for one). This is
the rule `rkat mob validate` documents and `MobBuilder::create` applies at
bootstrap. A refused model is `-32602` with the profile named and, when a
catalog id is close, a `Did you mean one of: ...?` hint (the common cause is a
dated id such as `claude-sonnet-4-5-20250514` for `claude-sonnet-4-5`). Only
that one diagnostic is adopted at init on purpose: the rest of the set
(missing skill refs, wiring, flows) is Meerkat's to refuse at create time.
`meta_gateway` has no pre-check; Meerkat runs the same validation when it
creates the mob.

**Provider-annotated profiles bypass the refusal.** By Meerkat's rule a
profile that sets `provider = "..."` is resolvable whatever its `model` string
says, so a dated or misspelled id on such a profile (every profile in the downstream app
carries `provider = "openai"`) passes init and surfaces at the first LLM call.
`rpc_gateway` does not add a second refusal authority for that case; it logs
one WARN per such profile at init naming the profile, the model and the closest
catalog ids (`profiles.<p> model '<m>' is provider-annotated ... a typo surfaces
at the first LLM call. Did you mean one of: ...?`). A `provider = "self_hosted"`
alias is checked against the host config's `[self_hosted.models]` instead and
warned about only when it is not registered there. Catalogued and
`[models.<id>]`-defined models warn about nothing.

Earlier releases compared the model string against the static catalog alone,
so a `[models.<id>]` custom model and every self-hosted alias were refused at
init on `rpc_gateway` while `meta_gateway` accepted them and then failed at
the first member build with "self-hosted model '..' is not registered in
config"; both binaries now agree, and with `meerkat_config_path` the member
builds.

## Provider credentials

An agent factory resolves a provider binding's persisted credentials (API
keys and the OAuth tokens a login saved) from a credential store, and writes
refreshed tokens and logouts back to it. By default that store is Meerkat's
process default, the file store at `<config dir>/meerkat/credentials`, where
`<config dir>` is the OS user's configuration directory (`$XDG_CONFIG_HOME`,
else `~/.config`, on Linux; `~/Library/Application Support` on macOS). A
credential lives at `<root>/<realm>/<binding>.json`, and refresh locks under
`<root>/refresh-locks`.

Everything that shares a store shares provider logins: every runtime in one
process, and every process of one OS user, unless they name their own. Two
runtimes whose agents use the same realm and binding ids read and write the
same credential file, so one user's login replaces the other's and one
logout removes it for both. A single-user host needs nothing more. A host
serving several users names a store per user:

| Surface | Door |
| - | - |
| `rpc_gateway` (the Python and TypeScript SDKs) | `runtime_options.provider_credentials_root`; Python `.provider_credentials_root(path)`, TypeScript `.providerCredentialsRoot(path)` |
| `meta_gateway` | The top-level `provider_credentials_root` init param |
| A Rust host on `UnifiedRuntimeBuilder` | `.provider_auth_persistence(store)`; see [the Rust SDK](https://docs.rkat.ai/meta/sdks/rust.md#provider-credential-store) |
| A host composing `rpc_gateway` natively | `GatewayHostComposition::provider_auth_persistence` |

The root is the credentials directory itself, an absolute path. The gateway
opens it once at init, creating it when missing, and every agent factory of
the launch holds that one store, with one refresh authority: the members and
the agents they create (helpers, forks, child mobs and councils), live
sessions, the agent memory engines, and the host auth and realm MCP
administration a native host composes. An empty or relative root is refused
with `-32602`, and a root the gateway cannot open refuses init with
`-32014`: a launch that names a root never falls back to the process default.
Launches that name the same root share its credentials, and its file lock
serializes their token refreshes. `meta_gateway` adds the root to its resume
key, so a launch never resumes a runtime that keeps its credentials
elsewhere.

Pass the same root on every reopen of a user's state. Nothing records which
root a state directory was launched with: a launch that names another root,
or none, runs that user's agents on that other store, where their logins are
missing.

A host that kept its users apart with a per-process `XDG_CONFIG_HOME=<C>`
passes `<C>/meerkat/credentials` to keep their existing logins.
`XDG_CONFIG_HOME` moves the default store on Linux only: on macOS the
default is always under `~/Library/Application Support`.

A host deploy cannot carry a named store: `meta/mobpacks/deploy` with
`execute: true` runs `rkat mob run` in a process of its own, which resolves
provider credentials in the process default store. A runtime whose agents
hold a named store therefore refuses such a deploy, on its RPC and on its
Flow Editor, with `-32004` and `data.kind =
"provider_credentials_not_carried"`, whatever grant or opt-in the caller has.
A launch whose members build from a native realm or with a member auth
binding refuses it the same way, since that process would carry neither.
Deploy plans still answer.

### GitHub Copilot with a service-account token

From Meerkat 0.9.9, Meerkat's Copilot routes can authenticate with a GitHub
token the operator supplies instead of a device sign-in (`auth_method =
"github_copilot_token"`; see "GitHub Copilot with a service-account token" in
the Meerkat
[auth guide](https://github.com/lukacf/meerkat/blob/main/docs/guides/auth.mdx)).
Meta adds nothing to it. The routes are `[realm]` tables, so they go in the
host config (`meerkat_config_path`, or a native realm's documents), and every
member resolves them like any other binding. With the guide's
`source = { kind = "env", ... }`, the token comes from the gateway process's
environment: `COPILOT_GITHUB_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`.
With `source = { kind = "managed_store" }`, it comes from the credential store
above, where `rkat auth login copilot --non-interactive` stores it when the
gateway uses the process default store. Name the route for every member with
`member_auth_binding`, for example
`{"realm": "global", "binding": "copilot_anthropic"}`.

## Native realm and member auth binding

A host whose provider setup lives in a native realm (a realm a native
Meerkat host keeps, with its bindings and their credentials) launches the
SDK gateway's members from that realm's whole configuration, with the
binding it selected:

```json theme={null}
{
  "runtime_options": {
    "native_realm": {
      "realm_id": "team",
      "context_root": "/home/user/projects/app",
      "state_root": "/home/user/.rkat/realms",
      "user_config_root": "/home/user"
    },
    "member_auth_binding": { "realm": "team", "binding": "work", "profile": "default" },
    "provider_credentials_root": "/srv/app/users/u1/credentials"
  }
}
```

Both options belong in `runtime_options`; sent at the top level of the
`meta/init` params, either is refused with `-32602` naming its home.

**The members' configuration.** At init the gateway resolves the realm with
Meerkat's `StorageLayout`, checks with Meerkat's own realm probe that the
realm exists there, maps the realm and its chain to their documents with
`FilesystemRealmConfigSource` (`<state_root>/<realm_id>/config.toml`, its
parents', and the `global` document `<user_config_root>/.rkat/config.toml`),
composes them with `EffectiveConfigReader`, applies Meerkat's environment
overrides and validates the result with `Config::validate`, as a native host
does. The explicit `state_root` wins over every probe, so `context_root`
selects no document: it is passed as Meerkat's invocation context. Nothing
is copied, merged with another source, or written, and no path is derived
beyond what Meerkat derives from the four inputs. The reserved `global`
realm keeps no manifest; naming it reads the user document.

That configuration is the members' whole configuration, not only its
`[realm]`, `[self_hosted]` and `[models]` tables: `[comms]`, hooks, skills
repositories, `[model_fallback]`, `[limits]`, `[agent]`, `[confinement]` and
`[governance]` apply to every member as well. Only the gateway's own
overlays (`member_comms_address`, `compaction`) are layered on top, as for
`meerkat_config_path`; a realm document that pins comms to a fixed address,
for example, sends every member there unless `member_comms_address`
overrides it.

**The members' binding.** `member_auth_binding` is Meerkat's typed
`AuthBindingRef`. Meerkat's own explicit resolution checks it at init: its
realm must be in the members' configuration and the binding must be defined
on that realm's chain, where it resolves at the realm that owns it. Every
profile of the definition must then resolve to the provider the binding's
backend serves: an inline profile, and a realm-referenced one
(`realm_profile = "..."`), which init resolves from the mob storage's realm
profiles, where Meerkat resolves it at each build (one the store does not
hold is left to that build, which refuses it). The provider is the profile's
own `provider`, else the one the members' model registry (with the
definition's `[models.<id>]`) owns the model under. A model registered under
another provider is refused, and so, under a self-hosted binding, is a model
the configuration does not register with a server or one served by a server
other than the one the binding's backend declares.

Every member the gateway builds then uses exactly this binding, or its build
is refused: the members of the identity roster, helpers and forks
(`meta/spawn_helper`, `meta/fork_helper`), later identity-first spawns,
`meta/spawn_member` and `meta/attach_existing_session` members, members
agents create with their mob tools, respawns and reset successors, members
rebuilt on their own sessions after a restart (the launch's binding takes the
place of the one the session recorded), and the members of the child mobs
agents create. A spawn that names the same binding is accepted; one that
names another binding (another realm, binding or auth profile) is refused
with Meerkat's wiring error, even for a member placed on another member
host, which is otherwise built there with that host's configuration.
Wherever the gateway compares a binding with the launch's (a spawn, a
fallback target, a declaration, an attach, a member build), the launch's
binding counts as named or stamped with the realm that owns it, the form
Meerkat records it in (a binding defined on a parent realm or on `global` is
recorded under that realm).

Meerkat rebuilds some members from their own records without asking the
gateway: a durable fork (an agent's `fork_off` child, a live delegation
fork, a council participant) repeats its source's build from the records
its source's session keeps, and keeps doing so on every later rebuild. Such
a member builds with the binding its session recorded. A durable fork this
launch seats records the binding its source's session records, which is the
launch's once the source has been built under the launch (a member that
forks itself has been); one seated before the launch named the binding,
which records no binding or another one, is refused at its rebuild
(`member_auth_binding_uncarried`) and reads `broken`: re-create it under the
launch. A `meta/fork_helper` fork is not one of these: like a
`meta/spawn_helper` helper, it is a new member the gateway builds with the
launch's binding, which only reads its source's history, whether or not the
launch has woken the source.

The gateway refuses every path that would make a provider call off the
binding, rather than let it happen:

* a definition whose `[spawn_policy]` creates members automatically: Meerkat
  builds those members without the gateway's spawn customizers;
* an enabled model fallback chain with a target that names no
  `auth_binding` (Meerkat would select a default binding for it) or another
  binding, in the members' configuration, the definition's
  `[runtime.model_fallback]`, an inline profile's `model_fallback` or a
  realm-referenced profile's. Name the launch's binding on every target, or
  disable the fallback;
* image generation (`tools.image_generation = true` in an inline or a
  realm-referenced profile): Meerkat builds an image generation executor for
  each image provider other than the member's own by default binding
  selection. A spawn whose profile override enables it, or that explicitly
  enables image generation, is refused too;
* the agent memory engines (`agent_memory.distiller`, `agent_memory.steward`
  enabled): they build their model clients from the default configuration
  with no binding;
* the same in a child mob an agent creates: `mob_create` with an automatic
  spawn policy, a fallback target off the binding or image generation, in an
  inline profile or a realm profile one references, and a `tooling` profile,
  inline or a realm profile, that would make such calls; an agent's realm
  profile write (`mob_profile_create`, `mob_profile_update`) of such a
  profile is refused too;
* identity declarations an earlier launch adopted, which the launch's mob
  storage holds: Meerkat materializes a declared identity from its sealed
  declaration without the gateway's spawn customizers, so init refuses
  declarations sealed with no binding or another binding, naming them;
* a durable child mob an agent created before the launch named the binding:
  a restore rebuilds it with the definition it was created with, so init
  reads every stored child definition and refuses one with an automatic
  spawn policy, a fallback target off the binding or image generation,
  naming it. A child whose destroy is pending is skipped (Meerkat only
  finishes destroying it); a child Meerkat keeps from running for another
  reason (its owning session archived or not observable) is still refused,
  fail closed, and one launch without the binding settles it;
* every member build that would still run off the binding or make such
  calls, checked where the gateway's session services build a member, and
  again after an SDK session builder's `callback/build_agent` answer is
  applied (a `resume_session_id` answer seats the build on another stored
  session; the resume itself is not refused). Refused are: a member build
  on another binding or none (none is Meerkat's default binding selection:
  a fork seated before the launch, above); Meerkat's fallback web search,
  enabled by the build or, on a member rebuilt on its session, by the
  tooling that session recorded, where the provider's own web search does
  not serve it (a model without native search, judged as Meerkat's factory
  judges it on the model the build runs on and the `[models]` of the
  member's own mob definition; a copilot backend; or tool governance that
  disables the provider's native tools: a dispatch admission, an
  application tool policy, a restricted tool access policy, or a host's
  member registration hook, for whose decision Meerkat prepares every
  member the hook registers, governed whatever its application tool policy);
  image generation; and a fallback target off the binding. The provider's
  own web search runs on the member's binding and is not refused. The build
  is refused, and a member Meerkat builds that way reads `broken` with the
  reason, at ready or at its first wake (below);
* `mob/adopt_member_identity_declaration` and
  `mob/apply_member_tool_declaration`, on the stdio gateway and on
  `POST /console/rpc`: Meerkat materializes a declared identity from its
  sealed declaration without the gateway's spawn customizers, so a launch
  with `member_auth_binding` refuses both (`-32602`,
  `member_auth_binding_uncarried`) before anything is written;
* `meta/attach_existing_session`, or `meta/ensure_member` with a
  `resume_session_id`, of a session that already has a live actor in the
  gateway's process, on the stdio gateway and on `POST /console/rpc`:
  Meerkat reuses that actor as it is, with the binding it runs, so unless the
  session records the launch's binding, in either form, the attach is
  refused (`-32602`, `member_auth_binding_conflict`, with `session_id`)
  before the spawn. A session whose binding cannot be read is refused too,
  with `retryable: true` in `data`: the same call can succeed once the read
  does. A session without a live actor is rebuilt with the launch's binding.

A roster identity Meta seats, and the fresh life a reset or delete moves an
identity to, builds nothing at the roster pass or at the reset: Meerkat
seats only the member's roster facts and builds the member at its first
wake (creating a fresh seat's session then), through the gateway's spawn
customizers, which give that build the launch's binding, and the build
check above judges it there. A member whose build would run off the
binding is refused at that wake, for good: it reads `broken` with the
reason, and the wake answers the refusal. The roster pass, the reset and
the delete themselves answer as they do without a binding. The exception
is an identity that owes a kickoff (`runtime_mode` `autonomous_host`, or
an `initial_message`): Meerkat spawns it live at the roster pass, or at
the reset when it does not hold the identity, so the build check judges
that spawn there. Its refusal is final too: the seat answers it as the
identity's refusal and is not retried on its own, and a refused reset
keeps the identity's current state and leaves nothing to carry out after
a restart. An archived session's revival and a declared role migration,
which the roster pass carries out on the identity's own session, are
builds as well, judged and refused the same way.

Under the default `member_restore` (`"on_demand"`) a seated member is built
at its first wake, not at start, so a refusal at its build (the build check
above, judged against that build's own model registry, or a durable fork
recorded off the binding) comes at that wake, which answers it for good;
until then the member reads `active` like any seated member.
`"on_demand_verified"` checks only that each session can be resumed, so it
does not catch these refusals. A launch that changes its binding and wants
them at ready restores with `member_restore: "eager"`.

An agent meets these refusals typed: its mob tool answers a policy denial
whose code is `member_auth_binding_conflict` (`mob_spawn_member` naming
another `auth_binding`) or `member_auth_binding_uncarried` (`mob_create`, a
`tooling` profile of `mob_spawn_member` or `delegate`, inline or a realm
profile, or a `mob_profile_create` or `mob_profile_update`, that would make
provider calls off the binding), before the tool runs.

A candidate launch that boots the stored definition
(`mob_composition.candidate_definition: "stored"`) is checked against the
definition it boots as well.

**Credential scope.** The options name the route, not the credentials'
store. With `provider_credentials_root`, the binding's credentials are read
and refreshed in that directory, with nothing copied: `<root>/<realm>/<binding>.json`,
`<root>/<realm>/<binding>@<profile>.json` for a binding's auth profile, or
`<root>/<realm>/account~<account>.json` for a binding with a credential
account, under one refresh lock. `<realm>` is the realm that owns the
binding, which can be a parent of the realm the option names. Without
`provider_credentials_root`, the process default store of the gateway's user
applies: launch the gateway with the native owner's `HOME` (and, on Linux,
`XDG_CONFIG_HOME`) to share the native owner's store.

**Generations.** The options are read once, at init. A running gateway is
never rebound: a changed realm document or binding takes effect in the next
launch, which rebuilds each member with its own binding when it builds it
(at start under `member_restore: "eager"`, otherwise at the member's first
wake), except the durable forks Meerkat rebuilds from their own records
(above). Pass the same options on every launch of the same state. A member
whose stored profile override carries a fallback target bound to an earlier
launch's binding, and a durable fork seated under an earlier launch's
binding, are refused at their rebuild or respawn under a new binding and
come up broken: re-create such a member.

**Refusals.** Each answers `meta/init` (or its `failed` settlement) before any
agent factory is built, with `error.data.kind`. A realm the state root cannot
resolve, and launch state init cannot read to check it against the binding,
are storage refusals (`-32014`); a realm document that does not read,
compose or validate is invalid params (`-32602`), as a `meerkat_config_path`
file is.

| `kind` | Code | When | `data` |
| - | - | - | - |
| `native_realm_config_conflict` | `-32602` | `native_realm` beside `meerkat_config_path` | |
| `member_auth_binding_required` | `-32602` | `native_realm` without `member_auth_binding` | |
| `native_realm_invalid` | `-32602` | `native_realm` is not a closed object of a valid realm id and three absolute roots | `reason` |
| `native_realm_unknown` | `-32014` | the state root holds no realm of that id | `realm_id`, `state_root` |
| `native_realm_unreadable` | `-32014` | the realm's storage cannot be resolved at the state root (its manifest cannot be read, say), or, with `member_auth_binding` (with or without a native realm), the launch's state init checks cannot be read (the mob storage's realm profiles or identity declarations, a stored child mob) | `realm_id` and `reason` for the realm's storage; `reason` only for a state read |
| `native_realm_config_invalid` | `-32602` | the realm's documents cannot be read, parsed or composed, or the result does not validate | `realm_id`, `reason` |
| `member_auth_binding_invalid` | `-32602` | `member_auth_binding` is not a closed object of valid ids | `reason` |
| `member_auth_binding_origin` | `-32602` | `origin` other than `"configured"` | `origin` |
| `member_auth_binding_unknown` | `-32602` | the binding does not resolve in the members' configuration | `realm`, `binding`, `auth_profile` (the binding's, when it names one), `reason` |
| `member_auth_binding_mismatch` | `-32602` | a definition profile's model or provider, inline or of the realm profile it references, is not one the binding serves | `realm`, `binding`, `auth_profile` (when named), `binding_provider`, `mob_profile` and `model` (the definition profile, when one is at fault), `reason` |
| `member_auth_binding_uncarried` | `-32602` | a path that would make provider calls off the binding: an automatic spawn policy, a fallback target off the binding, image generation, the agent memory engines, adopted identity declarations sealed off the binding, a stored child mob off the binding (one whose destroy is pending is skipped: Meerkat only finishes destroying it), or (after init) an identity declaration mutation | `reason` |
| `member_auth_binding_conflict` | `-32602` | (after init) `meta/attach_existing_session`, or `meta/ensure_member` with `resume_session_id`, of a session whose live actor runs another binding, records none, or cannot be read | `session_id`, `reason`, and `retryable: true` (advisory) when a read failed |

A launch with either option refuses an executing `meta/mobpacks/deploy`
with `provider_credentials_not_carried` (see
[Provider credentials](#provider-credentials)). Live channels keep their own
binding options (`openai_live`, `console_voice`).

A member build that the gateway refuses (a fork seated before the launch, or
a member rebuilt on a session that recorded the fallback web search, say) is
not an init refusal: it fails the build with Meerkat's
`SessionError::FailedWithData`, whose message is
`member_auth_binding_uncarried: ...` with the reason and whose data carries
`kind` and `reason`. A caller meets it as the error of the method that built
the member: the error message contains `member_auth_binding_uncarried:` and
the reason (behind Meerkat's `session error: ` prefix), and
`meta/spawn_member`, which answers `-32602` (`Invalid params: ...`), also
carries `error.data = {"kind": "member_auth_binding_uncarried", "reason":
...}` when the refusal reaches it as that typed error. A member Meerkat
builds reads `broken` with it, at ready or at its first wake (above).

**Known limitations.** These are refused, never run off the binding:

* Meerkat's auxiliary tool executors (image generation, the fallback web
  search) resolve their credentials by default selection, never with the
  member's binding, so whatever would enable them under a member auth
  binding is refused (`member_auth_binding_uncarried`): image generation at
  init, at the spawn and the agent tool, and at the member's build; the
  fallback web search at the member's build, where the provider's own web
  search, which runs on the member's binding, passes.
* Meerkat rebuilds a durable fork from its own records, with the binding its
  session recorded, so a durable fork seated before a launch named its
  member auth binding is refused at its rebuild under that launch
  (`member_auth_binding_uncarried`) and has to be re-created. A new durable
  fork copies its source's records the same way: after a launch changes the
  binding, a source this launch has not built yet (a seated member nothing
  has woken, under the default `member_restore`) still records the earlier
  binding, and a durable fork of it is refused. Wake the source first (a
  delivery, or `meta/identity/warm` for an identity), or restore with
  `member_restore: "eager"`. A `meta/fork_helper` fork is built with the
  launch's binding either way.
* The agent memory engines build their model clients from the default
  configuration, so a launch with a member auth binding refuses them at init
  (`member_auth_binding_uncarried`). A later release can build them from the
  members' configuration and binding.

## Member role migrations

`role_migrations` is a **top-level** `meta/init` param, a sibling of
`runtime_options` rather than a field inside it. On `rpc_gateway`,
`runtime_options` is a closed allowlist and unknown keys are a hard error, so
nesting the declaration there fails initialization with
`unsupported runtime_options fields: role_migrations`.

A durable member whose role changed refuses to resume: Meerkat returns
`MobError::MemberRoleMigrationRequired` rather than silently restamping the
member's durable role, comms name and binding together. The host clears that
refusal by declaring, for one boot, which exact identity is migrating and which
role it is migrating from.

```json theme={null}
{
  "role_migrations": [
    { "identity": "domain:automation", "from_role": "domain" }
  ]
}
```

Python hosts declare the same thing through the builder:

```python theme={null}
from meerkat_meta import Meta, RoleMigrationDeclaration

runtime = await (
    Meta.builder()
    .gateway("./target/debug/rpc_gateway")
    .roster(roster_provider)
    .role_migrations(
        [
            RoleMigrationDeclaration(
                identity="domain:automation", from_role="domain"
            )
        ]
    )
    .build()
)
```

`.role_migrations([...])` accepts `RoleMigrationDeclaration` dataclasses or
plain dicts and validates both; a conflicting pair raises `ValueError`; leaving
the setter unused emits no `role_migrations` key at all. The TypeScript builder
exposes no equivalent setter.

| Property | Behavior |
| - | - |
| Lifetime | Boot-scoped. The list is installed on the session bridge, which lives for exactly one boot, and is never written to any store. Dropping it from the next boot payload is how the authority goes away. |
| Matching | Exact `AgentIdentity` match. No prefix, suffix, or case variant of a declared identity inherits authority. |
| Authority | Meta only carries the declared predecessor role into the resume request. Meerkat re-verifies it against durable state and refuses with `MobError::MemberRoleMigrationRejected` on mismatch; Meta never infers a declaration and never retries a resume Meerkat refused. |
| Leftovers | Once the durable and requested roles agree, Meerkat returns before reading the declaration, so a declaration left in place after the migration landed is inert. An identical repeated declaration is therefore accepted on purpose. |
| Bad payloads | A payload that does not deserialize refuses the boot rather than arming nothing. One identity declared twice with conflicting predecessor roles is refused too, because nothing but map order could pick which predecessor becomes authority. |

The two gateway binaries, whose names differ by one word, refuse bad payloads
differently and install accepted ones under different conditions.

| Binary | Refusals | Install condition |
| - | - | - |
| `rpc_gateway` (untyped init params; what SDK hosts run) | Parses and conflict-checks at init scope, answering both the malformed and the conflicting case with `-32602` on the request id | Inside the roster-provider branch |
| `meta_gateway` (typed `InitParams`) | Malformed params fail typed deserialization and answer `-32602` with a null `id`; the conflict check sits inside the identity-first block and fails the boot out as `-32603`, also with a null `id`. Under `identity_first: false` that block never runs, so a self-contradicting payload is not refused at all; `identity_first` defaults to true | Inside the identity-first block |

Declarations that reach neither identity plane arm nothing, because there is no
identity plane to migrate on. See
[roster and member lifecycle](https://docs.rkat.ai/meta/concepts/roster.md#role-migration) for the operator
view of the refusal.

## Fork instruction policy

`runtime_options.fork_instruction_policy` decides what happens to a member's
activated instructions when that member is forked. This section explains what
a fork copies, what each policy does, what `reactivate_inherited` writes and
trusts, and how to set the policy.

### Activated instructions

A host changes a member's standing instructions with
[`meta/member_activate_instruction`](https://docs.rkat.ai/meta/api/rpc): each new activation appends one
keyed, immutable activation row to the member's session, for example the body
of a skill your application keeps under namespace `app.skills` and key
`calendar`. For each namespace and key, the newest row that the member's own
session wrote is the instruction in effect. Meerkat treats every namespace
alike, so a skill, a persona, a standing rule and any other key are all
activation rows, and the policy applies to every one of them.

Instructions a member gets from its build (its profile, the skills its profile
preloads included, and its system prompt) are not activation rows. The policy
does not concern them.

### What a fork copies

A durable fork copies the source member's committed transcript, up to the fork
boundary, into a new session and seats that session as a new member. The
durable forks are the agent `fork_off` tool, `UnifiedRuntime::fork_member`, a
live delegation's fork of the member, and the participants of a temporary
council. (`meta/fork_helper` is not a durable fork, and this policy does not
apply to it. It renders the source's transcript as text for a fresh member. A
helper that runs as an autonomous host gets that text as its first prompt, and
with the default full-history context it includes the source's system rows, so
the bodies of the source's activated instructions reach that helper as plain
context under every policy, `refuse` included.)

| Copied into the child | Not copied |
| - | - |
| The conversation up to the fork boundary: messages, tool calls and results, and system rows. | The source's member identity, comms name, runtime binding and roster entry. The child is a new member. |
| Every activation row in that prefix, as history only (see below). | The source's activations as instructions in effect. |
| The session configuration: output schema, hook overrides, budget limits, timeouts, application context, additional instructions and callback tool definitions. | Mob operator authority, which belongs to the source's identity, not to a branch of its transcript; the shell environment; MCP attachments. |
| For `fork_off`, `UnifiedRuntime::fork_member`, the bounded and detached member forks of `MobHandle` and local council participants: the source's application labels, except the standard member labels that name the source, and its per-spawn tool overlay. | For a live delegation's fork and a council participant on a member host: those build inputs. They are built from their own spec. |

`fork_off` also gives the child the source's role profile, its profile and
model overrides, and the forking member's own tool access policy. The forking
member needs spawn admission for that role: manage scope over the mob, or
spawn scope for the profile.

Inherited activation rows stay in the child's transcript with their full text,
rendered for the child as `[meerkat-inherited-instruction-v1]` with
`status=historical_only_not_active_in_child`. The child can read what its
source was told, but none of it is in effect. A child runs no model turn while
an inherited key has no activation of its own: its first model request fails
with `forked session must explicitly activate inherited instruction keys`,
followed by the keys. The policy decides who writes that activation, or
refuses the fork instead.

### The three policies

| Policy | Host-driven fork (`UnifiedRuntime::fork_member`) | Fork that starts its own first turn (`fork_off`, a live delegation's fork, council participants, the bounded and detached member forks of `MobHandle`) |
| - | - | - |
| `require_explicit_activation` (default) | Committed. The host activates each inherited key on the child before its first turn. | Refused before anything is committed (`fork_instruction_refused`, cause `policy_refuses`), because nobody could activate the child before the turn the fork starts. |
| `reactivate_inherited` | Committed with one re-activation row per inherited key; the child needs no activation from the host. | Committed with the re-activation rows, and the child's first turn runs. |
| `refuse` | Refused before anything is committed. | Refused before anything is committed. |

The Console's side chat fork (`meta/fork_member`) is a host-driven fork whose
host is the Console. Under `require_explicit_activation` it activates in the
side chat, as the host, each inherited key whose head the source activated
itself, with that head's revision and body, superseding the copied row; any
other inherited key stays inactive, the side chat refuses its first turn
naming it, and the fork's result lists it (see
[`meta/fork_member`](https://docs.rkat.ai/meta/api/rpc)).

A source with no activation rows in the copied prefix forks the same way
under every policy. The default is the least-privilege choice: no durable
fork's child runs an instruction that nobody activated for that child.

Under `require_explicit_activation`, a Rust host activates an inherited key on
the child with `MobHandle::activate_member_instruction(child, request)`
(`runtime.mob_handle()`). Read the key first with
`MobHandle::read_member_instruction_activations` and a query that names its
`namespace` and `key`: for an inherited key, `key_state` reports
`requires_explicit_child_activation: true`, `next_expectation`
`{"kind": "absent"}` and `next_supersedes` set to the inherited row's
`activation_id`. The host may activate the same revision and body, or a
different one.

### What `reactivate_inherited` writes

Before the child is committed, the fork appends one activation row per
inherited key, in namespace and key order:

* **Keys:** every key with an activation row in the copied prefix, in every
  namespace. There is no namespace filter.
* **Revision and body:** the key's head at the fork boundary, which is its
  last copied row. The new row carries that row's `revision` (`namespace`,
  `key`, `revision_id`, `content_sha256`) and its whole body, unchanged. An
  activation the source wrote after the fork boundary is not in the prefix
  and is not copied.
* **Origin:** the child's own session. The key is then in effect for the child
  exactly as if the host had activated it there. The row `supersedes` the
  copied head's `activation_id`.
* **Identity:** `activation_id` is `fork-reactivation:` followed by the first
  32 hex digits of SHA-256 over the child session id, a zero byte and the
  head's activation id. It depends only on the child and the head, so the same
  fork writes the same rows. The prefix is for operators; Meerkat recognizes
  these rows by reproducing them, not by their ids.
* **Timestamp:** the last copied message's timestamp.
* **Rules:** the rows pass the ordinary activation rules: the body digest, 256
  KiB per body, and the 1 MiB budget of live activation bodies in the
  session's transcript, copied rows included. Each re-activation adds its body
  again until the child's first compaction retires the copied rows it
  supersedes, so a chain of forks of a member with large bodies that does not
  compact between forks can reach that budget and is refused with
  `lineage_body_budget_exceeded`.
* **Only what the source activated itself:** each head must be an activation the
  source session wrote itself. A head the source only inherited from an
  earlier fork, and never activated, refuses the fork with
  `not_effective_in_source`.
* **Only a body the source still holds:** a compaction retires the body of
  every activation row that a later row of its key superseded. A fork whose
  prefix ends between such a row and the row that superseded it has no body
  to carry and is refused with `head_body_retired`. A fork at the tail
  copies, with its body, each key's newest row the source activated itself:
  a compaction never retires it.

### What `reactivate_inherited` trusts

With `reactivate_inherited`, the child claims its source's activations as its
own:

* The copied body is the one in effect in the source at the fork boundary.
  Meerkat does not ask your application whether that revision is still
  current, still allowed, or meant for this child. A skill revision you have
  since withdrawn, replaced or narrowed keeps running in a child forked from a
  transcript that still carried it.
* Once written, the row is the child's instruction for that key, like one your
  host activated on the child. An activation is never removed: to change what
  the child runs, activate a new revision of the key on the child, which
  supersedes the re-activation row.
* A fork of the child copies those rows again, so one setting carries an
  instruction down a chain of forks.
* The policy is one decision for every member, every key and every fork of the
  gateway's mob. Choose `reactivate_inherited` only when every instruction your
  members may carry is safe to run, unchanged, in any fork of them.
  `require_explicit_activation` keeps that decision with the host for each
  child (and refuses forks that leave it no chance), and `refuse` rules out
  durable forks of activated members (an autonomous-host `meta/fork_helper`
  still sees their text).

### Refusals

A refused fork commits nothing: no child session, no member and no
transcript. Meerkat classifies the refusal as `FORK_INSTRUCTION_REFUSED`
(`-32037` on Meerkat's own JSON-RPC, HTTP 422, CLI exit 57). The Console's
side chat fork (`meta/fork_member`) answers it as `-32037`; no other method of
this gateway forks a member, so elsewhere you meet the refusal as the agent's
`fork_off` tool error, a council exit reason, or
`MobError::ForkInstructionRefused { source_member_id, refusal }` from the Rust
API. Its structured data:

```json theme={null}
{
  "kind": "fork_instruction_refused",
  "source_member_id": "assistant",
  "fork_instruction_refusal": {
    "cause": { "kind": "policy_refuses" },
    "keys": [["app.skills", "calendar"]]
  }
}
```

| `cause.kind` | Meaning |
| - | - |
| `policy_refuses` | The policy refuses a child that inherits activation rows: `refuse`, or `require_explicit_activation` for a fork that starts its own first turn. |
| `not_effective_in_source` | `reactivate_inherited`, but a copied key's head was never activated by the source itself. |
| `head_body_retired` | `reactivate_inherited`, but a compaction of the source retired the body of a copied key's head: the fork's prefix ends before the later row of that key that superseded it. A fork at a later boundary copies that row, with its body. |
| `lineage_body_budget_exceeded` (with `max_bytes`) | Re-activating would exceed the session's budget of live activation bodies. |
| `activation` (with `code`) | The activation rules refused a re-activation row. |
| `member_host_unsupported` | `reactivate_inherited`, but the member host that holds a placed source is older than 0.9.4 and cannot apply the policy. Upgrade member hosts before their controller. |

The message names the keys, for example:

```text theme={null}
fork of member 'assistant' refused: fork refused: the child would inherit activated instruction keys [app.skills/calendar] and the fork instruction policy refuses them; reactivate_inherited carries them into the child
```

An agent that called `fork_off` gets a tool error with that message after
`tool 'fork_off' failed:` and the structured data above. A temporary council
ends with the exit reason `participant_fork_instruction_refused`, naming the
participant's order and the refusal; nothing was created for that
participant, so its cleanup has nothing to revoke.

### Setting the policy

The policy belongs to the host. It is never a tool argument or part of a mob
definition, and it cannot be chosen per fork: every fork of the gateway's mob
uses it. The gateway applies it to its mob on every create and resume. It is
not stored with the mob, so set it on every boot. A fork applies the policy
the gateway runs at the moment of the fork; changing the policy later does not
touch children that already exist.

| Surface | Setting |
| - | - |
| `meta/init` | `runtime_options.fork_instruction_policy`: `"require_explicit_activation"` (the default when absent), `"reactivate_inherited"` or `"refuse"`. Any other value refuses initialization. |
| Python | `Meta.builder().fork_instruction_policy("reactivate_inherited")`. Another value raises `ValueError`. |
| TypeScript | `.forkInstructionPolicy("reactivate_inherited")` (type `ForkInstructionPolicy`). Another value throws `RangeError`. |
| Rust | `UnifiedRuntimeBuilder::fork_instruction_policy(ForkInstructionPolicy::ReactivateInherited)`, or `MobBootstrapSpec::with_fork_instruction_policy` on a spec you build. |
| `meta_gateway` | Takes no `runtime_options` and always runs the default. |

Meerkat's own JSON-RPC server forks sessions with `session/fork_at` and
`session/fork_replace`; those calls take the policy per call as
`inherited_instructions`. They are not this gateway's member forks.

### Example: a member with an activated skill

The host activates a skill on member `assistant`:

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "meta/member_activate_instruction",
  "params": {
    "identity": "assistant",
    "activation": {
      "revision": {
        "namespace": "app.skills",
        "key": "calendar",
        "revision_id": "calendar-v3",
        "content_sha256": "sha256:<hex of the body>"
      },
      "activation_id": "calendar-v3-assistant",
      "expectation": { "kind": "absent" },
      "body": "Use the calendar tools to answer scheduling questions."
    }
  }
}
```

With the default policy, `assistant` calling `fork_off` now gets the
`fork_instruction_refused` tool error above, and nothing is forked. A host
that wants forks to keep the skill opts in at boot:

```python theme={null}
from meerkat_meta import Meta

runtime = await (
    Meta.builder()
    .mob("config/mob.toml")
    .fork_instruction_policy("reactivate_inherited")
    .build()
)
```

The `fork_off` child then starts with its own activation of
`app.skills/calendar`, with the body `assistant` had at the fork boundary, and
its first turn runs.

## Child mob tool policy and bundles

A child mob is a mob a member creates itself: the agent `mob_create` tool, and
the implicit mob `delegate` helpers run in. Mobs the host creates, same-mob
`fork_off` and `mob_spawn_member`, and temporary councils are not child mobs;
their members keep their own bindings.

### `child_application_tool_policy`

Once the host serves application tool policies (the `application_tool_policies`
init parameter, or `with_tool_consequence_policy_registry` in Rust), every
child mob member needs an application tool policy the host chose. Without one
a governed member could create a child mob whose members run outside the
host's policy, so Meerkat refuses `mob_create` and `delegate` until one is
configured. There is no implicit default.

The refusal is a typed tool error, not a run failure: the member's turn goes
on and the model sees `policy_denied` with code `child_tool_policy_required`
and a message that says why (the host runs a tool-policy registry and no child
application tool policy is configured) and names the three fixes below.

The value is an `ApplicationToolPolicyBinding` object, a top-level
`meta/init` parameter next to `application_tool_policies`:

```json theme={null}
{
  "application_tool_policies": ["{\"schema_version\":1,...}\n"],
  "child_application_tool_policy": {
    "kind": "provider",
    "provider_id": "example",
    "policy_id": "example-tools"
  }
}
```

| Value | Effect |
| - | - |
| `{"kind": "provider", "provider_id": ..., "policy_id": ...}` | Child members are built under that served policy. The policy's grants are per member identity, so it must grant the identities child members use (a delegate helper is the `member_id` the delegating member names). |
| `{"kind": "unmanaged"}` | The explicit opt-out: child members run without the host's tool policy, as they did before 0.8.46. |
| absent | No child policy. With application tool policies served, `mob_create` and `delegate` are refused; without them nothing changes. |

`{"kind": "inherit"}` is refused: a child mob has no member to inherit from
when it is created. The gateway checks the value at init and refuses the boot
with `-32602` when it is malformed, `inherit`, or a provider binding whose
provider or policy no `application_tool_policies` entry carries, so a binding
that could never resolve does not surface later as a refused `delegate`.

* Python: `Meta.builder().child_application_tool_policy({"kind": "provider", "provider_id": "example", "policy_id": "example-tools"})`.
* Rust: `MobBootstrapSpec::with_child_application_tool_policy(binding)` or
  `UnifiedRuntimeBuilder::child_application_tool_policy(binding)`. A
  conflicting binding from the two refuses the build with
  `ConflictingConfiguration`.

The binding is the host's choice and is never caller-settable; it persists
with each child member, so a restored child keeps the policy it was built
with.

### Child-available tool bundles

Host Rust tool bundles are host-only by default: agent-created child mobs do
not receive them. Register a bundle as child-available to supply it to child
members:

```rust theme={null}
use meerkat_meta::ChildToolBundleAvailability;

let spec = spec
    .register_tool_bundle("team", team_tools.clone())
    .register_tool_bundle_with_availability(
        "calendar",
        calendar_tools.clone(),
        ChildToolBundleAvailability::ChildAvailable,
    );
```

`UnifiedRuntimeBuilder::register_tool_bundle_with_availability` takes the same
arguments. When a child mob is created, every child-available bundle id is
supplied to each inline profile of its definition and persisted with it;
callers never name bundles, and naming `rust_bundles` in an agent's profile
input is refused. The child profile's `deny` list still narrows the supplied
bundle tools, and the child application tool policy governs them like any
other tool. A bundle the host later stops registering is neither dropped nor
granted: resuming a child member that was built with it refuses with
`MobError::ToolBundleUnavailable { bundle }`.

## Mob profile tools (`[profiles.*.tools]`)

The mob definition (`.mob("config/mob.toml")`, or the `mob_config` string of
`meta/init`) is Meerkat's `MobDefinition`. Each
`[profiles.<name>.tools]` table is meerkat-mob's `ToolConfig`, and every
boolean in it defaults to `false`. Raw `ToolConfig` serde deserialization
ignores unknown keys, but `MobDefinition::from_toml` checks profile/tool keys
and warns about ordinary ignored unknowns; `MobDefinition::parse_toml` exposes
typed diagnostics. A typo such as `comm = true` still does not enable `comms`.
These TOML diagnostics do not make all unknown keys fatal and do not apply to
bare JSON/serde deserialization.

Dropping a key the parse warns about and ignores is safe on a persistent store.
The key never reaches the typed definition, so it is not part of the
composition Meta records when it creates the store or compares on resume.
Removing such a key (for example `mob_tasks` under `[profiles.<name>.tools]`,
which configs written for earlier releases carry), or adding one, never makes
the store refuse as `divergent`; a divergence refusal names only keys the
definition accepts.

```toml theme={null}
[profiles.assistant.tools]
comms = true
builtins = true
workgraph = true
schedule = true
```

| Key | Default | Effect |
| - | - | - |
| `comms` | `false` | **Must be `true` for every profile a member spawns from.** A member's durable identity, roster occupancy check, wiring, peer messaging and supervisor bridge are all keyed on its comms name, and the peer-messaging tools are composed whenever a member has one, so there is no honest member without comms: Meerkat refuses `comms = false`, and an omitted key, at build time with `MobError::WiringError`: `profile '<name>' has tools.comms=false; mob meerkats require comms=true`. On the worker plane `meta/ensure_member` answers `-32602` with `ensure_member failed: wiring error: ...` and creates no roster entry; on the identity plane the identity reconciles to outcome `broken` (`EmbodimentFailed`, the message as its detail), `meta/status_identity_bootstrap` carries it as `error`, and the continuity repair task retries what is a static configuration error. To keep a member from messaging peers, leave `comms = true` and set `read_only = true` (`send_message` is denied, `peers` stays permitted) or list the tools in `deny` (or deny them through a per-spawn tool access policy). Mobpack validation does not flag this; it fails at spawn. |
| `builtins` | `false` | Meerkat's built-in tools (task, blob, patch, datetime, web search and similar). When `workgraph` is `false` and `builtins` is `true`, the `task-workflow` skill is preloaded. |
| `shell` | `false` | The shell execution tool. Never read-only: nothing classifies a command line, so `read_only = true` denies it outright. |
| `memory` | `false` | Memory and semantic-search tools. |
| `workgraph` | `false` | The realm-scoped `workgraph_*` tools; preloads the `workgraph-workflow` skill. The namespace grant the tools need is issued by the gateway, not the host (see [WorkGraph](#workgraph)). |
| `mob` | `false` | Mob management tools (spawn, retire, wire, unwire, list). Without a host-supplied authority context the member is granted spawn authority over every profile in the definition. |
| `schedule` | `false` | Durable schedule tools (create, list, update, pause, resume, delete). |
| `image_generation` | `false` | Image generation tools; the profile's `image_generation_provider` names the backend. |
| `read_only` | `false` | An enforcement declaration, not prompt guidance: a tool call executes only when its owning dispatcher declares it read-only, and the declaration wins over any per-spawn tool access policy. The category booleans above still decide what the model sees; this decides what may run. Provider-native tools, MCP tools and `shell` are outside the guarantee. |
| `deny` | `[]` | Tool names a member built from this profile may not run. An enforcement declaration, like `read_only`: a denied tool is not offered to the model (every build, a resume included, removes it from the member's tool list) and every call to it is still refused with an `access_denied` tool error while the turn continues. Each name must be a tool the build composed or a name in a known vocabulary: Meerkat's built-in tool families, the mob operator and agent mob tools, the exposed `tool_names` of the profile's `mcp_servers`, and tool bundles. A known name the build does not mount is inert; a declared MCP server that maps no `tool_names` leaves its names to the call-time refusal. Any other name (a typo, a stale tool) fails the member build with `DeclaredToolUnknown`, the same way a `comms = false` profile fails. A call runs only when `read_only`, `deny` and any per-spawn tool access policy all allow it. |
| `mcp` | `[]` | MCP server names this profile may use from the mob-wide default external-tools provider; an empty list means the full host surface. |
| `mcp_servers` | `[]` | Declarative MCP server configs durable on the profile, so a revived member recomposes the same MCP tools that built it. A member is built only after each of these servers has connected or failed: its authoritative tool surface is captured after them. Its construction waits for each server up to its connect timeout (`connect_timeout_secs`) plus at most 0.25 s (plus the TERM grace for a server that ignores SIGTERM), and its first turn starts after that. A member is constructed when the mob builds it. At the gateway's init the mob builds only the members `member_restore` names (under the default `on_demand`, the members that hold work, owe a kickoff, have a network transport endpoint or a host requirement; under `eager`, every member), `META_IDENTITY_RESTORE_CONCURRENCY` at a time (16 by default, at most 16; 1 serializes them), so servers that never answer add about that timeout times ceil(built members / concurrency) to the init; every other member is constructed at its first wake. A failed server leaves the member built without that server's tools, and the member's session holds a typed setup-failure notice for it. |
| `rust_bundles` | `[]` | Names of Rust tool bundles the host registers with `MobBootstrapSpec::register_tool_bundle` or `UnifiedRuntimeBuilder::register_tool_bundle`. They are wired on every spawn, resume (including revived members) and respawn. Dispatchers are not serializable; register them again on every boot. A name that is not registered refuses the member build. Agent-created child mobs receive only the bundles registered as child-available; see [Child mob tool policy and bundles](#child-mob-tool-policy-and-bundles). |

The `mob-communication` skill is preloaded for every member regardless of these
flags. Agent-side spawns that inherit the parent's tool filter open every
category, including `comms`, before the inherited allow-list is applied, so the
`comms` refusal is a host-spawn (`meta/ensure_member`) behaviour; an agent
spawn of a `comms = false` profile silently runs with comms on.

## WorkGraph

`rpc_gateway` constructs a realm-scoped `WorkGraphService` on every launch
unless `runtime_options.workgraph` is `false` (the default is `true`, so a
launch that never mentions the key has one). The service backs the member
`workgraph_*` tools, the mob-executor attention overlays and the
`meta/workgraph/*` RPCs.

| Launch | `workgraph: true` (default) | `workgraph: "<dir>"` |
| - | - | - |
| With `persistent_state` | Durable `SqliteWorkGraphStore` at `<persistent_state>/workgraph.sqlite3`, the `workgraph` storage slot. If the store fails to open, the slot reports `degraded` and WorkGraph is disabled for that launch; nothing falls back to memory. | Durable store at `<dir>/workgraph.sqlite3`, created if needed, same open-failure posture. |
| Without `persistent_state` | Memory-backed `MemoryWorkGraphStore`, reported as a declared-ephemeral slot: goals, work items and attention bindings do not survive a gateway restart. | Durable store at `<dir>/workgraph.sqlite3`; the directory form exists for exactly this case. |

`meta_gateway` has no switch: its persistent mode attaches the durable store
from the workspace state dir (same degraded posture on open failure) and its
default ephemeral launch is memory-backed.

**The namespace grant is issued by the gateway, not the host.** Meerkat
refuses to build an agent that has WorkGraph tools and no host-issued
`WorkGraphNamespaceGrant`. Whenever Meta installs the tools it takes the
grant from the service it just built, so the grant names the same scope the
tools are pinned to: realm `mob.<mob definition id>` (Meerkat's canonical mob
realm, the one every other realm-scoped store for the mob uses; if it cannot
be formed the raw id is used and Meerkat's own validation names both realms)
and namespace `default`. Every `workgraph_*` tool call has its `realm_id` and
`namespace` arguments overwritten with that scope and `all_namespaces`
removed, so a member cannot wander into another mob's graph. The meerkat
WorkGraph guide's "embedding hosts must issue the grant in the agent build" is
a Rust-embedder instruction; SDK hosts have nothing to issue. The tools stay
per-profile: a member sees them only when its profile sets
`tools.workgraph = true`, while the `meta/workgraph/*` RPCs are available
whenever the service exists.

## Mob announcements

Mob announcements are composed the same way on every surface that offers
them. The options are checked once, before anything is built; the
`mob_announcements` member tool bundle is registered for the host's own
profiles, before the runtime is built, and offered to child mob profiles
only under `member_tools_in_child_mobs`; and the owner attaches once the
identity runtime is installed, before the control listener's keyed inject
plane is sealed. Each surface refuses what it cannot compose.

| Surface | How it is enabled | Refused when |
| - | - | - |
| `rpc_gateway` | `runtime_options.mob_announcements` (see [SDK and Gateway runtime options](#sdk-and-gateway-runtime-options)) | The launch is not persistent and identity-first, or an option is malformed or out of range: init answers `-32602` |
| `meta_gateway` | Top-level `mob_announcements` init param: the same object, through the same parser | `identity_first: false`, `persistent_sessions` not `true`, or an option is malformed or out of range: the init is refused |
| Rust library | `UnifiedRuntimeBuilder::mob_announcements(AnnouncementConfig)` | Neither `persistent_state()` (it holds the announcement database) nor a host store realm (a `storage_provider()` that is a `HostStoreMetaStorageProvider`, whose realm keeps the tables), a runtime that is not identity-first, a configuration `AnnouncementConfig::validate` refuses, or a Rust tool bundle already registered under the name `mob_announcements`: `build()` answers `ConflictingConfiguration` |

`member_tools_in_child_mobs: true` sets the same ceiling on every surface: a
child mob profile gets the member tools only by opting in itself, with
`tools.bundles = ["mob_announcements"]`.

An SDK host sets `runtime_options.mob_announcements` with the builder:
`Meta.builder().mob_announcements({"enabled": True, ...})` in Python and
`Meta.builder().mobAnnouncements({ enabled: true, ... })` in TypeScript send
the object unchanged, and `mob_announcements()` or `mobAnnouncements()`
alone sends `{"enabled": true}`.

### Mob announcement source runners

A [source](https://docs.rkat.ai/meta/api/rpc.md#mob-announcement-sources) may name the collector that
advances it: `"runner": {"host_runnable": "<name>", "every_seconds": <n>}`.
The host owns the runnable's code: it registers the name in
`runtime_options.host_runnables` and handles each fire (`host_runnables`
and `on_schedule_fire` in the Python SDK). Meta schedules the runnable for
every live source that names it, through the gateway's schedule service,
first at the declaration and then every `every_seconds` (60 to 2,592,000).
A changed cadence restarts the interval at the change. Each schedule Meta
makes for a runner is labelled `mob_announcements.source_runner`, and Meta
never changes or ends a schedule without that label. `sources/put` refuses a
runner that names a blank runnable or the memory steward's (`-32602`).
A runner on a gateway that schedules none, such as the console gateway or
one without a schedule service, never fires there: such a source collects
only when the host advances it itself.

A peer gateway refuses this gateway's keyed injects issued further from its
clock than the window the owner assumes, which bounds when a batch for a
member on that peer may expire. `rpc_gateway` takes it from
`runtime_options.control_request_window_ms`. `meta_gateway`'s control
listener serves keyed injects with the default window,
`DEFAULT_KEYED_INJECT_WINDOW_MS` (five minutes), and its owner assumes the
same. A runtime the builder composes takes the window its keyed inject plane
serves with (`UnifiedRuntimeBuilder::keyed_inject_plane`, by default
`DEFAULT_KEYED_INJECT_WINDOW_MS`) in place of the configuration's
`peer_request_window_ms`, which applies only when the plane does not serve.
The runtime's shutdown stops the delivery dispatcher, whichever surface
composed it.

### Mob announcement declarations

`runtime_options.mob_announcements` may declare sources and subscriptions:
inline, as `"declarations": {"revision": <n>, "sources": [...],
"subscriptions": [...]}`, or as `"declarations_path": "<file>"`, a packaged
JSON file holding the same object, which the host supplies and Meta reads at
init and again at each `declarations/apply`; it is never a member input. A
gateway takes one of the two: naming both refuses the configuration, and so
does a sidecar that cannot be read or parsed. At startup Meta checks the
declarations against the gateway's mob, and declarations the static checks
refuse fail init as a refused announcement configuration does; it then
reconciles them before its dispatcher starts, waiting at most 30 seconds.
The options' declarations take effect when nothing is applied yet or they
name a higher revision than the applied one. `meta_gateway` takes the same
keys in its `mob_announcements` init param. The schema and the reconcile
rules are in [Mob announcement
declarations](https://docs.rkat.ai/meta/api/rpc.md#mob-announcement-declarations).

## Storage layout and durability

`MetaStorageLayout` is the single path authority for storage roots and the
canonical top-level database locators; no gateway or builder surface derives
state-dir file names or resolves ambient roots (`$XDG_STATE_HOME`, `$HOME`)
on its own, and a CI gate (`tests/storage_gate.rs`) keeps it that way.
Feature code owns *relative* names beneath the layout's roots (the blob
directory's internal sharding, per-realm agent-memory files).

**Canonical spellings** (decided once; stores shared with Meerkat keep
Meerkat's names, Meta-owned files converge on `*.sqlite3`):

| Slot | Canonical name | Legacy spellings |
| - | - | - |
| sessions (0.9.3 state: read only by the migration verbs and the storage doctor; 0.9.4 creates none) | `sessions.sqlite3` | `sessions.db`, `sessions.sqlite` |
| runtime | `runtime.sqlite` | — |
| schedule | `schedule.sqlite` | — |
| workgraph | `workgraph.sqlite3` | — |
| continuity | `continuity.sqlite3` | `continuity.db`, `identity_continuity.sqlite` |
| metadata | `meta_metadata.sqlite3` | `meta_metadata.sqlite` |
| console | `meta_console.sqlite3` | `meta_console.sqlite` |
| agent-memory root | `agent-memory/` | `agent-memory-sqlite/` |
| canonical Meerkat mob storage | `event_log.sqlite3` | — |
| blob root | `blobs/` | — |

0.9.4 keeps every session in Meerkat Core's store in `runtime.sqlite`. It
creates and opens no session database, and keeps no session rows in the
continuity database. An unretired sessions database under any of its
spellings, or a continuity database under any of its spellings that still
holds session rows, is 0.9.3 state: startup refuses it
(`NativeStorageMigrationRequired`, `-32014`) until
`meta_gateway storage-migrate-native` migrates it in place (see
[Upgrading to 0.9.4](https://docs.rkat.ai/meta/guides/upgrading-to-0-9-4.md)).

On normal persistent gateway launches, `event_log.sqlite3` holds Meerkat's
canonical mob definition, events, and adopted-member state. It is live storage,
not a reserved file, and is distinct from the optional operational query
projection configured by `runtime_options.event_log`. The SDK gateway's
explicit `runtime_options.mob_storage: { "storage": "memory" }` declaration
keeps mob storage in memory, except under `persistent_state` while a roster
provider is registered: the roster's identity bindings live in the mob
storage, so that launch is refused (`-32014`). The Rust builder refuses the
same composition (`mob_storage()` without a persistent path, together with
`persistent_state()` and `roster_provider()`) with
`ConflictingConfiguration`; without `mob_storage()`, `persistent_state()`
keeps mob storage in `event_log.sqlite3`.

**Canonical-name-first probing.** Opening a slot resolves the canonical name
first, then probes the known legacy spellings in the same directory (for the
slots 0.9.4 opens, the continuity database included, which keeps the identity
records; the sessions spellings are read only by the migration verbs and the
storage doctor). Exactly
one spelling → it is used *where it lies* (no rename at open). Two spellings
of the same store → the gateway refuses to start (error `-32014`, "file-name
twins") and points at `meta/storage/doctor`; physical renames happen only
through the storage migrate verb, under the maintenance fence.

**Durability classes and the declaration requirement.** Every composed slot
resolves to a durability class (`durable` / `scratch`) and a resolution
(`persistent` / `declared_ephemeral` / `non_persistent`). A durable-class
slot may run non-persistent only as an *explicit declaration*
(`runtime_options.event_log`, the Rust builder's `ephemeral_blobs(true)`, and,
without `persistent_state`, `runtime_options.runtime_store` and the builder's
`ephemeral_runtime_store(true)`), never as a silent fallback. Under
`persistent_state` an in-memory runtime store is refused (`-32014`, or
`ConflictingConfiguration` from the builder). Undeclared gaps are startup
errors that SDKs surface as the typed `StorageResolutionError` (`-32014`). The sanctioned boot-without
degradations (schedule or workgraph store open failure) disable the feature
and appear as `degraded` slots.

**Storage census surfaces.** The resolved picture rides the `storage` object
on `meta/status`, `meta/capabilities`, and `meta/storage/doctor`:
`blob_durability` / `blob_store_persistent` (H1), `session_store_incremental`
(H2), and the per-slot `slots` array (`domain`, `class`, `resolution`,
`backend`, `degraded`, optional `detail`).

**Incremental session persistence.** `session_store_incremental: false` means
the session store writes the whole session document at every turn boundary.
Rust embedders can opt in to strict mode with
`UnifiedRuntime::builder().require_incremental_session_persistence(true)`.
`build()` then refuses with `SessionStoreNotIncremental` instead of starting
on that fallback.

> [!NOTE]
>
> **Changelog policy.** Any storage file rename or table move gets the
> binary-rename treatment: an explicit, loud changelog entry naming the old
> and new spellings — operators read these files directly.

### Identity bootstrap

> [tab] Rust
>
> ```rust theme={null}
> use meerkat_meta::{IdentityBootstrapMode, UnifiedRuntime};
>
> let runtime = UnifiedRuntime::builder()
>     .definition(definition)
>     .persistent_state("state")
>     .roster_provider(roster_provider)
>     .identity_bootstrap_mode(
>         IdentityBootstrapMode::LazyWithBackgroundWarm { concurrency: Some(2) },
>     )
>     .build()
>     .await?;
>
> let status = runtime
>     .identity_runtime()
>     .expect("identity-first runtime")
>     .identity_bootstrap_status();
> ```

> [tab] Python
>
> ```python theme={null}
> from meerkat_meta import IdentityBootstrapMode, Meta
>
> runtime = await (
>     Meta.builder()
>     .gateway("./target/debug/rpc_gateway")
>     .mob("config/mob.toml")
>     .persistent_state("state/meta")
>     .roster(roster_provider)
>     .identity_bootstrap_mode(
>         IdentityBootstrapMode.lazy_with_background_warm(concurrency=2)
>     )
>     .build()
> )
>
> status = await runtime.wait_identity_bootstrap(
>     target="startup_ready",
>     timeout=120,
> )
> if not status.ready or status.startup_ready is not True:
>     raise RuntimeError(status)
> ```

Calling a Rust or Python `identity_bootstrap_mode` setter is an explicit
identity-first configuration and therefore requires a roster provider for all
three modes. Leave the setter unused to retain a classic non-identity gateway;
a configured roster with no mode reports the `eager_materialize` mode.

Under every mode, the roster pass makes each roster identity a member of the
mob without materializing it: an identity the mob already holds needs
nothing, and one it does not hold is seated, bound to a fresh session (or to
the session a first-boot row names), in batched commits. The mob materializes
a member when work reaches it, from the host or from a peer, and at start
only as `member_restore` says. Every seated member reads `active` from ready,
materialized or not.

A retire of an identity member (`meta/retire`, or `meta/retire_member` with
the member's id) releases it: the member stays seated on its session at its
generation, its delivery scopes stay valid, and it wakes again on its
identity, with the same incarnation, when work next reaches it. Core
releases only a quiescent member: while the member is being woken or has
work in flight, the retire is refused, retryable (`-32052`), and nothing is
interrupted. An incarnation ends with a reset or a delete, which Core
carries out as one rotation onto a fresh session, with a respawn, which
wakes the member on its session at a new generation, or when Core retires
the member: for a roster that no longer lists the identity, for a
`meta/compact_member` rebuild, and for a `meta/reload_member` over a session
bridge without Core's registration reload, the last two waking it again on
its session at a new generation.

A placed or externally bound identity has no seat: its host creates its
member. Under `eager_materialize` the pass creates it through its host, so a
host that is not bound yet makes it read `broken` at ready. Under the lazy
modes the pass leaves one the mob does not hold for its first use, as these
modes always did: it reads `dormant` (and the bootstrap reads not ready)
until its first delivery, or the background warm, creates it through its
host.

`lazy_with_background_warm` adds a warm after ready: a tracked background
task asks the mob to materialize every roster identity, at most the mode's
`concurrency` at once, or `META_IDENTITY_RESTORE_CONCURRENCY` (16 by
default) when the mode sets none, and leaves the bootstrap snapshot of every
seated identity as the pass published it. A roster refresh stops the warm
without waiting for the materializations in flight, which the mob finishes
on its own; a shutdown waits for them.

`meta/status_identity_bootstrap` returns a non-blocking typed snapshot.
`meta/wait_identity_bootstrap` accepts `target = "materialized"` or
`"startup_ready"`; terminal broken identities return with `ready = false`
rather than leaving the barrier pending indefinitely. Provider or restore
failures that apply to the whole pass are reported in the snapshot's optional
`error` field, and so is a read of Core's member history or of its broken
members that failed during the pass: the snapshot is then not ready, since it
could not tell which identities Core holds. An explicit materialization or
`meta/identity/warm` that Core refuses with a refusal that clears on its own
(`-32052`, a pending first-boot row, an owed held completion, a full
admission backlog) leaves the identity's entry as it was, unless Core holds
its member broken; any other refusal marks the entry broken.

A candidate launch (`runtime_options.mob_composition.authority =
"candidate"`) does not break an identity whose wake would change the stored
roster or its journal: an identity the store holds no member for, a member
its roster moves to another role, one whose stored session the runtime store
does not hold (a seat's session its first wake has not created yet, or one
that is gone), a pending fresh first-boot row, or one Core holds a stored
session but no live member for (a dormant or retired identity, or a
first-boot `Resume` row), whose wake would spawn a member Core journals in
the shared mob store, or
one whose live member Meta 0.9.3 spawned without the durable-identity
marker, which the roster pass respawns onto its session. The
snapshot leaves such an identity out of `identities` and lists it in
`deferred` as `{"identity": ..., "reason": ...}`, with one reason, by
precedence `first_boot_fresh`, `create_member`, `move_role`,
`fresh_session`, `resume_member`, counted in `counts.deferred`. A deferred
identity counts neither as broken nor against `ready`, the pass completes,
and a `candidate launch roster census` WARN names the deferred identities.
The members Core restored live at start stay as they are. The
authoritative launch makes the change. Any other failure (a store error, a
lost comms identity, a refusal from Core or the host) still breaks the
identity. Both fields are absent on every other launch.

During the roster pass each identity's entry also carries typed `restore`
progress: `{"stage": "registered"}`, `"resuming"` (a held pass is creating
the member), `"minted"` (the identity was seated, or a held pass created it,
on a fresh session), `"resumed"` (it is bound to its own committed session),
or
`{"stage": "refused", "code": <code>, "kind": "<kind>"}` with the refusal's
error code (`kind` is absent when the refusal has none). Neither `minted` nor
`resumed` says the member was materialized. A seated member is `active` (and
a refused one `broken` or `dormant`, as its refusal says) as soon as its own
step settles, before the whole pass ends. In process,
`IdentityRuntime::watch_identity_bootstrap_status` streams the snapshot. On
the stdio gateway `meta/init` returns after the roster pass, which
materializes nothing, so it does not wait on the members' restores.

No background pass retries a refused identity. A refused seat answers typed
and the identity's next roster pass or delivery tries again; a member Core
cannot restore when work reaches it is refused to that delivery typed, and
reads `broken` once Core holds it Broken. The agent-memory member-event
observer keeps a last-resort fallback wake for a subscribe that fails or a
stream that closes as soon as it opens: 1 s, doubling to 30 s, reset by the
first stream that stays open.

The stock persistent gateway also advertises a private shutdown handshake and
its bounded `stdio_shutdown_horizon_ms` in the `meta/init` result. SDK hosts
keep stdin and provider callbacks open until that handshake completes, then
close the process. Honor the advertised `stdio_shutdown_horizon_ms` rather than
hard-coding it. The SDKs report the value they accepted as
`runtime.gateway_shutdown_horizon_ms` (Python) and
`runtime.gatewayShutdownHorizonMs` (TypeScript), `None` or `null` when the
gateway advertised no valid horizon, so a process that supervises the application can
wait for it. The current stock 432-second horizon covers an already-admitted
provider callback, runtime event and mob drains, the mob's terminal shutdown
report, the durable child-mob stand-down, the member MCP settlement (in
the runtime shutdown and again at the gateway's executor-exit boundary), and
bounded response-delivery/reaping margin. At the executor-exit boundary the
gateway answers only after every member MCP server's close returned its
native outcome. That wait outlasts its share of the horizon only if Meerkat
breaks its own close bound, and a host that enforces the horizon may then end
the process before the answer. Provider operations
must resolve or reject within their public 120-second contract. The Python host
allows 125 seconds for event-loop completion before cancelling that callback;
the TypeScript host exposes the same cancellation as an optional
`ProviderCallbackContext` and suppresses late callback responses; the gateway
enforces a final 130-second hard wire deadline. Provider rejection,
timeout, or cancellation is pre-commit: an implementation must not commit a
replacement grant or other external authority after cancellation is observed.
Older and custom gateways that do not advertise the handshake retain the EOF
shutdown protocol.
If the gateway exhausts its runtime deadline, it reports incomplete cleanup;
the SDK still reaps the bounded child process and then surfaces the failure.

#### Letting in-flight turns finish on shutdown

By default a graceful shutdown does not wait for the turns members are
running. The mob's stop leaves each member's current run running, and the
mob's terminal shutdown then settles it (meerkat 0.9.5):

* A durable member, one whose session lives on a persistent session service
  and so commits each round, keeps its run: the run is suspended, not
  cancelled, and continues from its last committed round at the member's
  next start. Its eligible queued prompts are kept for that start too, for up
  to 15 minutes.
* Any other member's run is cancelled, and its queued inputs end with it.

An application that would rather let turns finish sets a drain window at
init: the top-level `meta/init` parameter `shutdown_in_flight_drain_ms`
(Python `MetaBuilder.shutdown_in_flight_drain_ms(ms)`, TypeScript
`MetaBuilder.shutdownInFlightDrainMs(ms)`). It is an integer of milliseconds.
Absent or 0 drains nothing, and the most is 300000 (5 minutes); init refuses
any other value.

The drain runs only for the SDK's shutdown handshake and for SIGTERM, the
stop signal supervisors send. SIGINT, a closed stdin and a closed host shut
down at once, as before. With a window, once the dispatch loop has ended and
before the rest of the shutdown, the gateway:

1. asks a host composition to stop admitting new work (see below);
2. stops the schedule host from claiming occurrences: an occurrence that
   falls due during the window stays due, and the restarted gateway handles
   it under its misfire policy. A `skip` occurrence overdue past its grace is
   skipped, and a long window plus a slow restart can push a
   `catch_up_within` occurrence past its bound;
3. stops the HTTP listener from accepting connections (requests and streams
   already open continue);
4. closes cross-mob control, identity foreground operations and runtime
   delivery;
5. stops the mob with every member's current run left running, and waits
   until each of those runs ends or the window passes.

The shutdown then continues as it does without a drain, and a run still going
when the window ends is settled by it as above: kept for its next start on a
durable member, cancelled on any other.

The advertised `stdio_shutdown_horizon_ms` grows by the window plus 10
seconds for the drain's own stop: 432000 + 10000 + the window. The SDKs wait
that much longer when they close. A supervisor that stops the gateway with
SIGTERM must allow at least that horizon, plus the time to reap the process,
before it kills it. Under Meta Forge, an application's stop budgets follow the
horizon it reports at readiness. A stop before its first readiness report, or
before the first report after the window grows, uses the default horizon; a
turn cut off then recovers at the next start.

What a drained turn can still do:

* Mob-mutating tool calls (spawn, delegate, fork, wiring) are refused, typed,
  and the model sees the error.
* A message to a peer is held, and ends `Abandoned(Stopped)` at the shutdown.
  A turn that waits on another member's new run cannot finish, so it is still
  running when the window ends.
* `mob_announce` commits, and subscribers receive the announcement after the
  restart.
* WorkGraph writes commit, and their wakes stay pending.
* Identity foreground operations are refused, typed. A caller already
  waiting on a turn (`meta/wait_for_turn`) keeps waiting and gets the turn's
  outcome.
* A schedule the turn creates is stored, and fires after the restart.
* Detached jobs and monitors keep running, with their notifications pending
  in the inbox.
* Host callbacks are answered, under the handshake and under SIGTERM. An SDK
  host hit by the same signal may be stopping itself, so SDK-hosted gateways
  should stop through the handshake.

Durable child mobs are not drained: they are stood down later, as without a
drain, and their in-flight turns are settled there the same way. A connected
voice or live session does not lengthen the drain: its member is reported
`live_channel_open` and the shutdown handles it as before. An autonomous
member whose current run is its long-lived kickoff loop holds the drain for
the whole window, and the shutdown then settles it as above. A held member
add or a roster pass still settling when the window ends cuts the drain
before its stop (`identity_foreground_busy`), and the shutdown settles it
inside its own budget.

The `meta/shutdown` answer carries the drain's report as
`result.in_flight_drain`, and the gateway also logs it at INFO
(`in-flight drain finished`), which is where a SIGTERM stop reports it.
Without a window the answer has no such field.

| `outcome` | Meaning |
| - | - |
| `drained` | The mob stopped and the drain waited. `ended` lists each member whose run ended in the window, with its terminal (`completed`, `failed`, `cancelled` and the other turn terminals, or `null` when the member's runtime registration ended with the run). `still_running_at_bound`, `live_channel_open`, `not_local` (placed or remote members) and `reload_required` list the members the drain did not see end. `starts_not_held` lists members whose run starts could not be held. A run outcome this gateway does not know is listed in `unclassified`, with its serialized form. |
| `skipped` | No drain ran. `reason` is the dispatch end (`signal`, with `signal: "SIGINT"`; `stdin_closed`; `host_closed`; `caller_cancelled`), or `host_admission_open` when the host could not stop admitting. |
| `cut` | `reason: "identity_foreground_busy"`: the window ended while identity operations were settling, before the drain's stop. |
| `stop_failed`, `drain_failed` | The drain's stop, or its wait, failed (`error`). The shutdown proceeds as without a drain. |

Every report carries `window_ms`; every report but `skipped` also carries
`waited_ms`.

A host composition (`run_persistent_with_host`) that admits work of its own
(source submissions, member adds, recovery steps) implements
`GatewayHostComposition::stop_admitting`, which the gateway calls once, before
the drain. The host keeps serving work already entered, and keeps the
credentials and custody in-flight turns use: `begin_close` still follows the
drain. An error skips the drain (`host_admission_open`). A host on the default,
which keeps admitting, can still submit work during the window: the mob's stop
holds it, and it ends `Abandoned(Stopped)` at the shutdown.

Rust hosts that dispatch JSON-RPC from an `Arc<UnifiedRuntime>` should call
`handle_unified_rpc_json_arc` (or the corresponding `with_live_arc` entrypoint).
That transfers runtime ownership to supervised identity mutations, so a
disconnected request cannot cancel a cross-mob operation between its commit
and rollback points. The borrowed dispatcher fails identity-owned cross-mob
mutations closed because it cannot provide that ownership guarantee.

### `meta_gateway` identity roster

`identity_roster` is a top-level `meta/init` param of `meta_gateway`
(typed `InitParams`), the standalone counterpart of the SDK roster provider
that `rpc_gateway` reaches through `.roster(...)` callbacks; `rpc_gateway` has
no `identity_roster` key. It is an array of `DurableAgentSpec` objects and seeds the
desired identity roster for `identity_first: true` (the default):

```json theme={null}
{
  "identity_roster": [
    { "identity": "personal:alice", "profile": "alpha" },
    {
      "identity": "domain:security",
      "profile": "security",
      "display_name": "Security",
      "labels": { "console_group": "Internal" },
      "runtime_mode_override": "turn_driven"
    }
  ]
}
```

| Field | Required | Meaning |
| - | - | - |
| `identity` | yes | `AgentIdentity` (`<kind>:<name>`), the durable identity key. |
| `profile` | yes | A `[profiles.<name>]` of the mob definition. |
| `addressability` | no | `addressable` (default; accepts `send`) or `internal_only` (`dispatch` only). |
| `display_name`, `labels`, `context`, `additional_instructions` | no | Console name, string labels, opaque JSON app context, extra prompt instructions. |
| `initial_message`, `runtime_mode_override` | no | The kickoff turn and the per-identity `autonomous_host` / `turn_driven` override, as on the SDK `DurableAgentSpec`. |
| `backend`, `binding`, `placement` | no | Backend kind, runtime binding and exact Meerkat host placement; a placement never degrades to local. |

At boot the seed becomes the `MutableRosterProvider` and `restore_flow` runs
over it: the roster is validated, Core's member history is read for every
listed identity, and each identity is created or resumed. Omitting the
param is an empty seed, so the gateway starts with no identities and
`meta/ensure_member` adds them at runtime (it upserts the same provider). The
seed is boot-scoped like `role_migrations`: it is never persisted, and an
identity missing from the next boot's seed is not restored on that boot (Core
keeps its session). Under `identity_first: false` the param is
parsed and ignored, not refused.

## ConsoleUiConfig

View-level configuration for the stock console. `meta_gateway` discovers `config/console.toml` in the conventional workspace layout; `rpc_gateway` accepts the same file via `runtime_options.console_config_path`, and the TypeScript builder exposes `.consoleConfig("config/console.toml")`.

The config is normalized and projected through `GET /console/experience` as `console_config`.

```toml theme={null}
title = "Ops"

[brand]
label = "Ops Console"
logo_url = "/assets/ops.svg"
logo_alt = "Ops"

[appearance]
default_theme = "dark"
default_variant = "graphite"

[environment]
label = "prod"

[layout]
initial_preset = "two_columns"
initial_control = "roster"
initial_agent = "identity:ops-lead"
sidebar_collapsed = false

[rail]
visible = true
collapsed = false
active_preset_id = "critical"
empty_text = "No meaningful signals yet."

[[rail.filter_presets]]
id = "critical"
label = "Critical"
watched_only = true
alert_levels = ["critical"]

[sidebar]
visible_controls = ["topology", "roster", "logs", "health"]
hidden_controls = []

[[sidebar.buttons]]
id = "ops-board"
label = "Ops Board"
href = "https://example.test/ops"
target = "_blank"
icon_name = "external-link"

[agent_list]
group_by = ["labels.console_group", "labels.group", "role"]
subgroup_by = ["labels.org", "labels.realm"]
section_order = ["Personal", "Initiatives", "Internal"]
fallback_group = "Agents"
fallback_subgroup = "Other"
default_pinned_agent_ids = ["identity:ops-lead"]
collapse_single_subgroup = true

[[agent_list.badges]]
id = "org"
label = "Org"
field = "labels.org"
tone = "info"

[[agent_list.sections]]
name = "Initiatives"
collapsed = false
empty_title = "No initiatives"
empty_text = "Create one in Linear."

[[attention.sources]]
id = "host-items"
label = "Host"
url = "/host/attention"
decide_href = "/host/decide?decision={decision_id}"

[actions]
inspect_label = "Profile"
chat_label = "Open chat"
send_label = "Send to agent"
respawn_label = "Respawn"
retire_label = "Retire"
reset_label = "Reset"
show_reset = false

[realms.ops]
title = "Ops"

[realms.ops.agent_list]
subgroup_by = ["labels.org"]
```

| Section | Description |
| - | - |
| `brand` | Label and logo metadata. |
| `appearance` | Default theme and variant. |
| `environment` | Short environment label shown in the topbar. |
| `layout` | Initial preset/control/agent and sidebar collapsed default. |
| `rail` | Signals rail visibility, collapsed state, empty text, and filter presets. |
| `sidebar` | Stock control visibility and custom buttons. |
| `agent_list` | Grouping selectors, section order, default pins, badges, and empty states. |
| `actions` | Operator-facing action labels and visibility toggles. |
| `attention` | Host-provided attention items for **Needs you** (see below). |
| `realms.<id>` | Partial overlay applied when the gateway is launched for a realm. |

Agent grouping selectors and badge fields support `labels.<key>`, `label:<key>`, raw label keys, and direct fields such as `group`, `subgroup`, `role`, `kind`, `identity`, `member_id`, and `agent_id`.

### Host-provided attention items

`[[attention.sources]]` names same-origin JSON endpoints that list things waiting for the operator which the host, not Meta gating, owns. Each source has an `id`, a short `label` (shown on its cards and when it cannot be read) and a `url`. All three keys are required: a missing key fails the console configuration. An empty `id` or `label`, or a `url` that breaks the rules below, drops that source. The `url` follows the operator panel path rules: one leading `/`, no scheme or host, no whitespace, control characters or backslashes, and no `.` or `..` segment. A source that breaks a rule is dropped. A realm overlay's `attention` list replaces the default list.

The console reads every source with the operator's session (`credentials: "same-origin"`, redirects refused) when it loads, every 30 seconds while visible, and when the page becomes visible again. A source answers:

```json theme={null}
{"items": [{"id": "a1", "title": "Confirm a setting",
            "details": [{"label": "For", "value": "plugin:example"}],
            "href": "/host/items/a1", "tone": "attention"}]}
```

* `id`, `title` and `href` are required. `href` must be a same-origin path like `url`; an item with any other `href` is dropped.
* `details` is optional, at most 12 label/value pairs. `tone` is optional: `attention`, `info`, `warning` or `critical`.
* Text is trimmed and cut at 300 characters, and the console keeps at most 50 items per source.
* A source that does not answer within 10 seconds, or whose answer is larger than 256 KiB, fails alone. A slow source delays the other sources' items by at most those 10 seconds.

**Needs you** counts the sources' items with the pending gating approvals. The inbox lists them after the approvals as cards with one **Open** link (named after the item's title), which loads `href` top-level; a single pending approval is still opened directly. A console without a gating surface (an aggregate console) shows only the host items there. The console never approves, rejects or changes an item: the host page decides. A source that fails (network error, non-2xx answer, malformed body) is named in the inbox as `Could not read: <label>` and hides nothing else. Gating approvals do not depend on any source.

A source may also name `decide_href`, a same-origin path where the host decides an approval that this console session cannot (a read-only session). On such a session each pending approval card, in the **Approvals** inbox and in an agent's chat, shows one **Decide in** link per source that names `decide_href`, named after the source label and loaded top-level, instead of the disabled decision buttons. A settled or expired card shows no link. `{decision_id}` in `decide_href` is replaced by the request's id, URL-encoded once, so `decide_href = "/host/decide?decision={decision_id}"` opens that request on the host page; without the placeholder the link is the host's page in general. A `decide_href` that is not a same-origin path, before or after the id is filled in, is dropped and the source's items stay. A read-only session with no usable link keeps the disabled buttons.

> [!NOTE]
>
> `ConsoleUiConfig` is intentionally view-level. It controls presentation, ordering, labels, default pins, and links; it does not decide runtime authorization, routing, or whether an agent exists.

## AuthPolicy

```rust theme={null}
AuthPolicy {
    default_provider: AuthProvider,
    email_allowlist: Vec<String>,
}
```

| Field | Type | Default | Description |
| - | - | - | - |
| `default_provider` | `AuthProvider` | `GoogleOAuth` | Expected identity provider |
| `email_allowlist` | `Vec<String>` | `[]` | Allowed email addresses and service identities |

### AuthProvider

```rust theme={null}
enum AuthProvider {
    GoogleOAuth,
    GitHubOAuth,
    GenericOidc,
    ServiceIdentity,
    TestProvider,
}
```

***

## ConsolePolicy

```rust theme={null}
ConsolePolicy {
    require_app_auth: bool,
    read_only: bool,
    fetch_timeout_ms: Option<u64>,
    ui: ConsoleUiConfig,
}
```

| Field | Type | Default | Description |
| - | - | - | - |
| `require_app_auth` | `bool` | `true` | Whether console routes require JWT authentication |
| `read_only` | `bool` | `false` | Whether the console is view-only; when enabled, send/upload/lifecycle/gating-write RPCs return a read-only error |
| `fetch_timeout_ms` | `Option<u64>` | `None` | Optional bundled-console JSON fetch timeout override, projected through `/console/experience` |
| `ui` | `ConsoleUiConfig` | `ConsoleUiConfig::default()` | View-level [console configuration](#consoleuiconfig), projected through `/console/experience` as `console_config` |

For standalone `meta_gateway` launches, pass `console_read_only: true` in the
`meta/init` params or set `META_CONSOLE_READ_ONLY=true`. This setting is
part of the gateway resume fingerprint, so toggling it creates or resumes the
matching runtime instead of reusing a runtime launched with the opposite policy.

`meta_gateway` takes the HTTP exposure knobs as top-level `meta/init`
params rather than `runtime_options`: `http_listen` (`HOST:PORT`, default
`127.0.0.1:0`; also `--http-listen` or `META_HTTP_LISTEN_ADDR`, the init
param winning), `allow_remote` (also `--allow-remote` or
`META_HTTP_ALLOW_REMOTE=1`) and `http_public_base_url`. Its protected console
surfaces are open by default when top-level `auth_config` is absent. Supplying
`auth_config` installs the shared JWT/OIDC configuration and requires app auth
on those served surfaces. Unlike `rpc_gateway`'s nested
`runtime_options.auth_config`, this is a top-level parameter. So is
`provider_credentials_root`, the provider credentials directory of every
agent the gateway builds (see [Provider credentials](#provider-credentials)):
an empty or relative root is refused when the params are parsed, with
`-32602` and a null `id`, a root that cannot be opened refuses init with
`-32014` on the request id, and the root joins the resume key.

The standalone gateway's pre-bootstrap exposure gate currently checks the open
default decision state, before that auth overlay is applied. A non-loopback
`http_listen` therefore still requires `allow_remote`, even with configured
auth. The following init-param refusals on this binary (`http_listen` parse,
the exposure gate, `http_public_base_url`, `meerkat_config_path`, `compaction`,
a `[self_hosted]` table in `mob.toml`) answer `-32602` on the request id, and
a listener bind failure `-32603` on the request id. The listen address and the
advertised base both join the resume fingerprint like `--control-listen`, so a
relaunch that changes either creates a runtime with the declared values instead
of reporting the previous launch's. On `rpc_gateway`, top-level `http_listen`,
`allow_remote`, `http_public_base_url`, `meerkat_config_path` and
`provider_credentials_root` are refused with `-32602` naming their
`runtime_options.*` home. See
[Deployment](https://docs.rkat.ai/meta/guides/deployment.md).

Both gateways also take `--allow-flow-editor-host-deploy` (on `rpc_gateway`
with `--persistent`). Without an enforced access controller, the Flow Editor
on the gateway's listener refuses deploys with `execute: true` and validation
with `rkat_validate: true`, because both run `rkat` on the host; the flag
allows them for every caller the route admits. It is launch-only, and on
`meta_gateway` it joins the resume fingerprint. Rust hosts use
`allow_flow_editor_host_deploy(true)` on `UnifiedRuntimeBuilder` or
`UnifiedRuntime`. See [Flow Editor](https://docs.rkat.ai/meta/guides/flow-editor.md).

### Console auth by surface

`ConsolePolicy::default().require_app_auth` is `true`, and the SDK gateway's
decision state without `runtime_options.auth_config` trusts no signing key, so
its protected console APIs are closed to every caller (each request answers 401
`missing_credentials` or a token failure). The standalone gateway instead uses
an open default until top-level `auth_config` is supplied:

| Surface | Open-console selection |
| - | - |
| Raw `meta/init` | `runtime_options.console_require_app_auth: false` |
| Python | `Meta.builder().console_auth_required(False)` |
| TypeScript | `Meta.builder().consoleAuthRequired(false)` |
| Rust library | `RuntimeDecisionState::local_console(ConsolePolicy { require_app_auth: false, ..ConsolePolicy::default() }, None)` |
| `meta_gateway` | Open when top-level `auth_config` is absent; supplying the shared JWT/OIDC shape requires app auth. A non-loopback `http_listen` still requires `allow_remote` because its earlier exposure gate does not consume the later auth overlay |

An open console is a deployment decision. Use it only behind a loopback bind
or a reverse proxy that authenticates; for anything shared, configure
`auth_config` (Python `.auth(...)`, TypeScript `.auth(...)`) instead.

***

## RuntimeOpsPolicy

```rust theme={null}
RuntimeOpsPolicy {
    replica_count: u16,
    metrics: MetricsPolicy,
}
```

| Field | Type | Default | Description |
| - | - | - | - |
| `replica_count` | `u16` | `1` | Number of runtime replicas (must be 1 in v0.1) |
| `metrics` | `MetricsPolicy` | `{ enforce_slo_targets: false }` | Metrics enforcement settings |

> [!WARNING]
>
> v0.1 enforces single-replica deployments. Setting `replica_count` to any value other than `1` produces `DecisionPolicyError::ReplicaCountMustBeOne`.

***

## LocalJsonMemoryBackendConfig

```rust theme={null}
LocalJsonMemoryBackendConfig {
    state_path: String,
    health_check_endpoint: Option<String>,
}
```

| Field | Type | Description |
| - | - | - |
| `state_path` | `String` | Local JSON file holding the operational assertion ledger (`meta/memory/*`). Runtime-owned: read once at bootstrap into memory, and rewritten wholesale on every `meta/memory/index`. The whole state is written to the sibling `state_path.with_extension("tmp")` (replacing the final extension: `memory-ledger-state.json` stages through `memory-ledger-state.tmp`), then renamed over the original file. Not a shared append log; see below. |
| `health_check_endpoint` | `Option<String>` | Optional HTTP base URL probed with `GET /v1/health` at the bootstrap load and before every persist; a failed probe refuses that load or persist. No ledger data is ever sent to it |

For SDK builds that boot through `rpc_gateway`, `state_path` is derived from `.persistent_state(path)` as `<path>/memory-ledger-state.json`; callers provide only `backend: "local_json"` (and optionally `health_check_endpoint`). `MemoryBackendConfig` has no other variant: the predecessor's backend shape was removed in 0.9 with no alias, so serialized configuration that still names its `kind` fails to decode and must be rewritten as `kind = "local_json"`. The ledger file itself holds only assertions and conflict signals, with no backend field, so adoption carries it over unchanged. Real external memory hub integration is the wire-boundary provider described in `docs/design/memory-hub-roadmap.md`.

**Ownership of the assertion ledger.** The file is a snapshot of runtime
state, not a log that other writers may append to. `MetaRuntimeHandle` loads
it exactly once at bootstrap and keeps assertions and conflicts in memory as
the authority; every `meta/memory/index` (host calls, and the Steward's
conflict signals when `agent_memory.steward` is enabled) serializes the entire
in-memory state and atomically replaces the file, with no re-read, lock or
modification check. A direct edit is therefore invisible until the next boot
and overwritten by the next flush; single-writer is a convention Meta does
not enforce with a lock. Retention is capped at 4,096 assertions, oldest
evicted first, at index time and at load. At load, a structurally malformed
file (an entry missing `assertion_id`, `entity`, `topic`, `store`, `fact` or
`indexed_at_ms`) refuses the boot with `MetaRuntimeError::MemoryBackend`,
while a well-formed entry whose entity, topic or store is not canonical, or
whose fact is empty, is dropped without a diagnostic. Append through
`meta/memory/index`, Python `MobHandle.memory_index(...)`, TypeScript
`handle.memoryIndex(...)`, or Rust `MetaRuntimeHandle::memory_index` /
`UnifiedRuntime::memory_index`; there is no agent-facing tool, so a host that
wants members to write exposes one over the handle. This ledger is distinct
from the `agent_memory` injection and turn-surface ledgers below.

## Agent memory injection

`agent_memory` is separate from `meta/memory/*`. It is hot identity-scoped memory plus prompt injection: during create/resume/materialize and eligible identity-first turns, Meta asks an `AgentMemoryProvider` for memories keyed by `AgentIdentity` and injects a bounded, quoted observation summary.

Generic SDK/work delivery to `autonomous_host` still skips ambient per-turn
recall; use `runtime_mode = "turn_driven"` when those turns need it. Non-steer
local canonical console-human input follows a separate preparation path and
can carry configured typed injected context for `autonomous_host` too. This
exception does not cover arbitrary external bindings, and steer input bypasses
injection in either mode.

The bundled gateway provider is one SQLite database per realm under `<persistent_state>/agent-memory` (`store: "sqlite"`, the only live gateway store). The earlier per-identity markdown files are import-only: `store: "markdown"` is refused at init with a migration verdict, and pointing SQLite at the same directory imports a realm's un-imported markdown files when that realm is first accessed and its SQLite connection is opened, preserving ids, tag content and timestamps and renaming each original `<file>.imported` rather than deleting it (tags are collated on insert, so tag ORDER is not preserved - assert on the set, not the sequence). The path is server-owned and cannot be overridden from SDK init input. An external memory hub remains an optional deeper knowledge backend for graph/document/truth/provenance use cases and is not required for this hot path.

With the SQLite store, build-time injection also composes a metadata index (record ids, kinds, titles, descriptions, human-phrased age) over the identity's readable scopes, budgeted at \~8 KiB, ahead of the selected record bodies. Every injected record is logged to an injection ledger inside the realm database and counted in per-record usage statistics; explicit `meta/agent_memory/recall` reads are counted separately from ambient injection.

Inbound anti-spoofing is on by default (`defang_inbound: true`): every non-steer identity-first send is scanned for reserved memory-envelope markers (the injection header pattern, `<meta_memory_observation>` tags, `[mem-token:` prefixes) and matches are visibly neutralized before delivery, so a peer message or echoed web content cannot impersonate remembered context. A warning with a hit count is logged whenever defanging fires. Steer sends bypass injection and defanging entirely.

Rust builders can use `.persistent_agent_memory_stack(config, engines)` with `.persistent_state(path)` to install the full SQLite stack (store plus taint firewall plus the enabled judgment-plane engines). Callers that need a different authority use `.agent_memory(provider, config)`. The former `.persistent_agent_memory(...)` markdown constructor is removed; legacy markdown is accepted only by the one-shot SQLite importer.

Gateway callers can write records with `meta/agent_memory/remember`, read them with `meta/agent_memory/recall`, and delete individual records with `meta/agent_memory/forget`. TypeScript callers can use `handle.rememberAgentMemory(identity, { title, body, tags, realm })`, `handle.recallAgentMemory(identity, { realm, selection, queryText, queryTerms, maxEntries })`, and `handle.forgetAgentMemory(identity, memoryId, { realm })`; Python callers can use `handle.remember_agent_memory(identity, title=..., body=..., tags=..., realm=...)`, `handle.recall_agent_memory(identity, realm=..., selection=..., query_text=..., query_terms=..., max_entries=...)`, and `handle.forget_agent_memory(identity, memory_id, realm=...)`. Writes and deletes are identity-scoped and require the identity to be registered in the identity runtime. Writes are eligible for turn context on the next identity-first send, subject to the runtime-mode and recall policies, plus build context on materialization/resume/respawn/reset for that identity. A successful write means the configured hot provider accepted the record; it does not mean optional external memory hub extraction/enrichment has completed. Bundled SQLite writes enforce the staged-memory validator's record limits. The retired markdown parser is used only while importing existing files.

When `selection = "always"`, the latest memories for the identity are injected. When `selection = "contextual"` (the default), build-time injection sends synthesized identity/profile/label query text and deterministic terms; per-turn injection sends the raw current identity-first message plus normalized terms. Common stopwords are ignored by the bundled provider and records below the relevance threshold are not injected. Meta does not send app context or customizer-added instructions to the memory provider. Semantic/vector retrieval remains a provider concern rather than a Meta runtime authority.

Per-turn injection is governed by `per_turn_injection`. When omitted, the gateway derives the effective policy from the selected memory backend's library default: `"budgeted"` for the bundled SQLite store and `"off"` for the legacy, import-only markdown kind. Set `"off"` explicitly to disable ambient push. Selected memories travel as typed injected context immediately before the conversational input, rather than being fused into the user message, and Meerkat excludes that role from semantic-memory indexing. Budgeted injection applies an injection ladder: at most \~4 KiB rendered per record, 20 KiB per assembly, and 60 KiB cumulative per session key between compaction resets, with records offered at most once in that interval (cross-turn dedup). The turn-surface ledger records that preparation-time offer; the transcript's typed injected-context append records delivery at activation. Build-time injection always applies the per-record and per-assembly caps.

The `rpc_gateway` memory stack forwards session `CompactionCompleted` events
through `CompactionResetSink` to clear both cumulative byte accounting and
cross-turn dedup for that session. After that reset, records compacted out of
context may be offered again in the same session; neither limit is a
lifetime-per-session guarantee.

Native/custom compositions need equivalent reset wiring from
`CompactionResetSink` to `AgentMemoryRuntimeInjector::on_session_compacted`.
The current standard Rust `.persistent_agent_memory_stack(...)` builder does
not install this reset sink, so using that builder alone does not reset session
accounting on compaction.

Automatic build/turn injection uses `recall_timeout_ms` (default `500`, max `30000`) and `recall_failure_policy` (default `"skip"`, or `"fail"`). With the default skip policy, provider errors and timeouts omit the memory block and allow delivery to continue. Explicit `meta/agent_memory/recall` requests still return provider errors.

Agent memory is identity-scoped and is not automatically cleared by session respawn or identity reset. Apps that require privacy reset semantics can call `meta/agent_memory/forget` for specific records or use an `AgentMemoryProvider` whose lifecycle policy clears memory alongside the app's identity lifecycle. `forget` removes records from future recall and injection; it does not erase text already delivered into an active model context, so reset or respawn the identity after deletion when immediate context revocation matters.

***

## BigQueryNaming

```rust theme={null}
BigQueryNaming {
    dataset: String,
    table: String,
}
```

| Field | Type | Validation |
| - | - | - |
| `dataset` | `String` | Non-empty, alphanumeric + underscore + hyphen only |
| `table` | `String` | Non-empty, alphanumeric + underscore + hyphen only |

***

## ReleaseMetadata

```rust theme={null}
ReleaseMetadata {
    targets: Vec<String>,
    support_matrix: String,
}
```

| Field | Type | Description |
| - | - | - |
| `targets` | `Vec<String>` | Release targets (must include: `crates.io`, `npm`, `pypi`, `github-releases`) |
| `support_matrix` | `String` | Must be `"same-as-meerkat"` |

`build_runtime_decision_state` parses and validates this document on every
build, with or without console auth; nothing reads it at serve time, so the
check is release governance, not runtime policy. `RuntimeDecisionState::local_console`
fills the canonical value. A malformed document is a `DecisionPolicyError::TomlParse`
whose message names the release metadata JSON.

***

## Trust manifest (meta.toml)

```toml theme={null}
[[modules]]
id = "router"
command = "meerkat-router"
args = ["--port", "9001"]
restart_policy = "on_failure"

[[modules]]
id = "delivery"
command = "meerkat-delivery"
```

| Field | Type | Required | Default |
| - | - | - | - |
| `id` | `string` | yes | -- |
| `command` | `string` | yes | -- |
| `args` | `string[]` | no | `[]` |
| `restart_policy` | `string` | no | `"on_failure"` |

## Constants

| Constant | Value | Description |
| - | - | - |
| `META_CONTRACT_VERSION` | `"0.5.0"` | JSON-RPC contract version |
| `DEFAULT_KEEP_ALIVE_INTERVAL` | `15s` | SSE keep-alive interval |
| `GATING_AUDIT_MAX_RETAINED` | implementation-defined | Max gating audit entries |
| `GATING_PENDING_MAX_RETAINED` | implementation-defined | Max pending gating entries |

## See also

* [Modules](https://docs.rkat.ai/meta/concepts/modules.md) -- module configuration in context
* [Authentication](https://docs.rkat.ai/meta/guides/authentication.md) -- auth policy usage
* [Sessions](https://docs.rkat.ai/meta/concepts/sessions.md) -- session store configuration


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.