> ## 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.

# Access control

> Optional ABAC for the console: control which users can see which agents and what they can do with them, configurable live per user. Plus scoped grants for peer gateways on the cross-mob control channel.

*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 ships an optional attribute-based access control (ABAC) layer for everything that flows through the console and observation surfaces. When it is off — the default — nothing changes. When it is on, every console caller only sees and operates exactly what their attributes grant: the agent sidebar, roster, topology, timeline, event streams, sends, and lifecycle actions are all filtered or gated per principal, invisibly. A restricted user doesn't see denied agents or disabled buttons explained; they just get a console shaped to what they're allowed.

There are no roles. Every check evaluates the caller's attributes (subject, group memberships) against the resource's attributes (agent identity, role, labels) for one action — the closest thing to a role is a rule that bundles a group with a set of actions.

## The model

* **Principal** — the authenticated console caller: the `subject` (email or token `sub`) plus the groups it belongs to. On an auth-optional console (`require_app_auth = false`), callers without a valid voluntarily supplied token are anonymous and only match rules without subject constraints. With access control and trusted-token validation configured, a valid optional token can still identify a named principal for HTTP and SSE access checks.
* **Resource** — usually an agent: its identity, role, and labels (the same labels you already use for console grouping).
* **Action** — a verb from a fixed vocabulary:

| Action | Grants |
| - | - |
| `agent.view` | See the agent anywhere: sidebar, roster, topology, timeline frames, event streams, inspection |
| `agent.send` | Send messages to the agent |
| `agent.memory.write` | Persist durable identity-scoped agent memory for future context injection |
| `agent.memory.delete` | Delete durable identity-scoped agent memory records |
| `agent.memory.read` | Read identity-scoped memory: recall/manifest RPCs and the console Memory panel. An **unscoped** grant (a rule with no resource selector) additionally reads realm-scoped records and the dream history |
| `operator.memory.read` | Read operator-scoped memory (cross-mob personal facts). Always an explicit rule — never implied by an unscoped `agent.memory.read` and never granted by the migration rewrite |
| `agent.memory.admin` | Reserved for administrative memory operations (imports, re-keying); no RPC maps to it yet |
| `mob.memory.read` | Read mob-scoped memory records in the Memory panel |
| `mob.memory.propose` | Propose records into mob scope (future propose surfaces) |
| `mob.memory.commit` | Reserved for a future direct mob-commit RPC — steward promotions ride the gating flow (`gating.decide`), not this action |
| `memory.quarantine.review` | Read the quarantine queue and quarantined record bodies. Together with an identity's memory grants it also decides that identity's quarantined records; on its own it decides nothing |
| `agent.spawn` | Create members: ensure/spawn/fork helpers, run flows |
| `agent.respawn` | Respawn the agent |
| `agent.retire` | Retire / force-cancel / delete the agent |
| `agent.reset` | Reset durable agent state |
| `gating.view` / `gating.decide` | Read and decide approval gates |
| `workgraph.view` | Read WorkGraph snapshots, items, attention, events, and facts; observe the wake stream |
| `workgraph.manage` | Mutate WorkGraph items, goals, links, and attention bindings |
| `mob.observe` | Subscribe to whole-mob raw event streams |
| `runtime.admin` | Routing tables, labels, wiring, reconcile, mob announcements |
| `topology.view` | Inspect topology state for an agent; also requires `agent.view` |
| `topology.connect` / `topology.disconnect` / `topology.reconnect` | Plan or apply the corresponding edge action; required independently on both endpoints |
| `topology.bulk` | Submit more than one explicit topology operation in a bounded transaction |
| `topology.cross_authority` | Use the same-process bilateral host coordinator for an edge spanning two authorities; never enables authority-local JSON-RPC cross-mutation |
| `topology.audit` | Read durable topology mutation attempts and actor/principal attribution; required on every endpoint returned |
| `mobpack.author` | Author mobpacks in the Flow Editor: drafts, authoring operations, validation, source rendering, export/import, authoring catalogs |
| `mobpack.deploy` | Execute a mobpack flow run on the host (`rkat mob run`); also unlocks the editor's deploy affordances |
| `access.admin` | Read and edit this configuration |

* **Rule** — matches principals (`subjects`, `groups`), actions (exact, `agent.*`, or `*`), and resources (`agents`, `roles`, `match_labels`). Resource selectors AND together; values within a list OR together. Empty selectors leave a dimension unconstrained.

Evaluation is **deny-by-default with deny-overrides**: a matching `deny` rule always wins, then a matching `allow` rule allows, otherwise the check denies. Subjects listed in `admins` bypass rules entirely. A disabled config allows everything.

WorkGraph actions are resource-less grants over the whole WorkGraph, not
per-agent permissions. Console mutations also require the independent
read-only policy gate to allow mutation.

### Spawn-lineage inheritance

Members spawned **by an agent** (via `mob_spawn_member`, `delegate`, and the other agent-facing spawn tools) inherit their spawning parent's permissions. The spawn path records a `spawned_by` label as console metadata, and agent checks evaluate the member **and its spawn ancestors**: a rule that matches the parent — by identity, role, or labels — also matches the members it spawned, transitively. Deny-overrides is preserved across the chain, so a `deny` on the parent also denies its descendants, and a member-specific `deny` beats any inherited `allow`.

This is what makes agent-fanned-out workers governable without per-worker rules: granting `agent.view` on a coordinator lets the operator watch everything that coordinator spawns, and hiding a sensitive lead also hides its helpers. Lineage resolution is cycle-safe and bounded: a check walks the member and seven ancestors, and a member whose lineage goes deeper than that is refused for everyone but an admin (a deny on an ancestor past the bound would not be seen), on every agent check and on the timeline frames it wrote. Members can always carry their own labels or rules to override the inherited outcome (subject to deny-overrides).

Lineage is **runtime-derived only**: `spawned_by` and `via_tool` come from the spawn path's own record of who performed the spawn. A `spawned_by` claim arriving through caller-controlled labels (spawn specs, roster labels) is stripped at every enforcement seam — an unverified claim could otherwise mint inherited visibility, or hide a member behind a denied parent. The lineage registry is in-memory: it repopulates as members are spawned and does not survive a runtime restart.

In a multi-runtime Console, member rows and the identity list calls (`GET /console/identities`, `meta/console/list_identities`) name each member's spawner with its runtime's prefix, as they name the member: a rule on the prefixed spawner (`b/ops-lead`) reaches the rows of the members it spawned, and a rule on the bare name (`ops-lead`) does not reach the members another runtime's agent of that name spawned.

### Side chats

A Console **side chat** (`meta/fork_member`) is decided as the agent it was forked from: its `side_chat_of` label names that agent, and agent checks evaluate the side chat, then the agent and the agent's own ancestors. Every decision on a side chat is the agent's:

* an `allow` must match the agent or one of its ancestors. A rule that matches only the side chat itself (its name, its role, or a label only the side chat carries, such as its own console flags) grants nothing, so a grant on a side chat's name never widens what its agent allows;
* a `deny` that matches the side chat, the agent or any of its ancestors refuses, as everywhere.

So a rule that matches the agent covers its side chats for every action (`agent.view`, `agent.send`, `agent.retire`, ...), a rule on another identity does not, and a deny on the agent hides them. Closing a side chat is a retire of it, so it needs `agent.retire` on the agent. Forking one needs `agent.view` **and** `agent.send` on the agent, plus `agent.spawn` on the side chat's name: a side chat runs with the agent's tools, so a viewer who may only read an agent can neither fork it nor drive its tools through a side chat. A side chat's lineage is its agent's and one link more, so an agent whose lineage already fills the walk (the agent and seven ancestors) is refused for a fork (`-32030`, `reason: "lineage_too_deep"`): no one but an admin could open its side chat.

A Console with no local runtime (one that serves its runtimes through the aggregator) records those runtimes' members before it decides a send, as every route does, so a send to a side chat is decided as its agent there too; a member whose record names a lineage the cache still does not know is refused rather than decided by its bare name.

The relation is runtime-derived, from Meerkat's durable record of the fork, so unlike the spawn registry it survives a restart, and it holds after the agent is respawned or retired. A `side_chat_of` claim in caller-controlled labels (spawn specs, roster labels, an identity's declaration) is stripped like `spawned_by`. A multi-runtime Console names the agent with its runtime's prefix, as it names the side chat, so a rule on the prefixed agent (`b/ops-lead`) reaches its side chats and one on the bare name does not.

> [!WARNING]
>
> Upgrading changes what existing rules grant: an operator holding `agent.view`/`agent.send` on a coordinator now also holds those actions on every member that coordinator spawns. This is the intended default — agent-spawned workers are part of the parent's work — but deployments with tight per-agent rules should review coordinator grants, and can add member-specific `deny` rules where inheritance is unwanted.

## Configuration

Drop a `config/access.toml` next to your other config files — its presence opts the deployment in:

```toml theme={null}
enabled = true
admins = ["root@example.com"]

[groups.ops]
description = "Operations"
members = ["alice@example.com", "bob@example.com"]

[groups.contractors]
description = "Payments contractors"
members = ["contractor@example.com"]

# Ops can see every agent...
[[rules]]
id = "ops-view-all"
groups = ["ops"]
actions = ["agent.view"]

# ...but only interact with the ops lead.
[[rules]]
id = "ops-send-lead"
groups = ["ops"]
actions = ["agent.send"]
agents = ["identity:ops-lead"]

# Contractors see only the payments org, read-only.
[[rules]]
id = "contractors-payments-readonly"
groups = ["contractors"]
actions = ["agent.view"]
[rules.match_labels]
org = "payments"

# Nobody but admins ever touches the auditor, even via wildcards.
[[rules]]
id = "protect-auditor"
effect = "deny"
actions = ["agent.*"]
agents = ["identity:auditor"]
```

Enabling requires at least one admin subject — the config layer refuses any state that would lock every administrator out.

Programmatic embedders use the builder instead:

```rust theme={null}
let runtime = UnifiedRuntime::builder()
    .definition_path("config/mob.toml")
    .access_control_file("config/access.toml")?
    .build()
    .await?;
```

```typescript theme={null}
import { Meta } from "@rkat/meta-sdk";

const meta = await Meta.builder()
  .mob("config/mob.toml")
  .accessControl("config/access.toml") // auto-discovered if the file exists
  .gateway("/path/to/rpc_gateway")
  .build();
```

Use the `rpc_gateway` executable installed by the
[quickstart](https://docs.rkat.ai/meta/quickstart.md#prepare-a-matching-sdk-and-gateway) for the explicit gateway path.

```python theme={null}
meta = await (
    Meta.builder()
    .mob("config/mob.toml")
    .access_control("config/access.toml")  # auto-discovered if the file exists
    .build()
)
```

### Approver identities

`approvers` binds the approver ids that gating requests name in `requested_approver` to the principals who may decide as them. A binding lists subjects, groups, or both; several principals may hold one id, such as a shared role.

```toml theme={null}
[approvers."person:alice"]
subjects = ["alice@example.com"]

[approvers."role:release-manager"]
groups = ["release-managers"]
```

Holders are read from the live configuration when a decision is made, so removing a subject from a binding or from a holding group revokes the identity for the next request. An approver id must not be empty or carry surrounding whitespace, must have at least one holder, and may only reference defined groups. A binding grants no permission by itself: deciding still requires `gating.decide`. Bindings apply only to authenticated console decisions; an open console (no app authentication) takes the approver id from the request and is for trusted local use only. The console re-checks `gating.decide`, the request's origin and the held approver ids against the live configuration while it records the decision, so a revocation published while a decision waits is honored.

## Live administration from the console

### Configure the owner and the connection

Load the file shown under [Configuration](#configuration) through
`UnifiedRuntime::builder().access_control_file("config/access.toml")`, or the
existing SDK `accessControl` / `access_control` builder entrypoint. Keep at least
one admin when enabling enforcement. A file-backed controller saves successful
changes to its configured TOML path before publishing the new live state; an
in-memory controller has no file persistence guarantee.

Authentication alone never grants administration. A controller whose
configuration names no admin, enabled or not, can be administered by nobody from
the console. Name the first admin in the file before starting the runtime, or in
trusted local setup with `AccessController::new` and `with_persist_path`. A
configured access file must exist and be valid: a missing path, a directory or
an invalid file stops startup. An empty path stops startup too: it no longer
switches access control off, as it did when the predecessor SDKs dropped it. To
keep access control off, omit `runtime_options.access_config_path` (or send
`null`), and in the SDKs do not call `accessControl` / `access_control`.
Conventional discovery follows the same rule.
Access control stays off only when nothing is at `config/access.toml`, which
includes a missing or dangling parent directory. Any other entry there, even a
directory or a dangling `access.toml` link, and any other lookup error (such as
a parent that is a file) goes to the same strict load, which stops startup when
it cannot open, read, parse or validate the file. The loader does not check the
file type before reading, so a FIFO at that path blocks startup instead of
stopping it with an error.

Configure trusted [console authentication](https://docs.rkat.ai/meta/guides/authentication.md) separately.
The authenticated token supplies the caller's subject; a `subject` in a preview
request selects the subject being examined, not the identity making the request.
For view-only deployments, set server-side `ConsolePolicy.read_only = true`
(or SDK runtime option `console_read_only`). The URL option
`?console_read_only=true` only changes presentation and does not protect direct
RPC calls. See [Console authentication](https://docs.rkat.ai/meta/guides/console.md#authentication).

### Review and save

Open **Access** with current administrator access. Use **Preview** to check a
subject, action and optional agent against the current policy before editing.
Changing the preview inputs or refreshing the configuration invalidates the old
observation; run Preview again. A preview does not reserve permission for a later
request.

1. Start an edit in **Overview**, **Groups** or **Rules**. The draft keeps the
   configuration, owner instance and revision from when the edit began.
2. Select **Save**. The owner rechecks the caller's current administrator access
   and the draft's complete edit token in the same serialized mutation. Another
   administrator's successful save makes an older revision stale.
3. On a conflict or owner replacement, keep the draft and inspect the refreshed
   settings. Select **Review and reapply**, then deliberately save again. That
   review step binds the intended edit to the current configuration; it does not
   itself write. Refreshing alone never silently rebases or retries the draft.

The first read after opening Access or changing connection or subject, a Refresh,
and the read after a save disable edits until they succeed; so does a failed read.
Reads the console starts in the background do not disable edits while they run:
the panel keeps the last read until one lands and then applies it at once, a
refusal included. They never rebase a draft; the owner still checks the draft's
edit token when you save.
Missing or unknown checked-save capability also disables edits while permitted
reads remain available. Losing administrator access or switching connection or
subject must not expose the previous scope's draft. Server-side read-only mode
still forbids writes, including administrator writes; authorized configuration
reads and previews remain available.

### Implement a checked-save client

Use `POST /console/rpc` with the existing console authentication transport.
Read `meta/access/status`, then the protected `meta/access/get`. Require
`conditional_mutations: "checked_v1"`, a nonempty `owner_instance` and a usable
`revision`; retain the `get` configuration and its complete token with the draft.
The owner instance identifies this controller lifetime. It is not a permission
or a durable version number, and a replacement owner can start at revision zero.

Send the complete write payload only inside `params.checked_v1`. For example,
upsert a rule using the owner instance and revision returned by the read
(`12` below is illustrative):

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": "access-save-1",
  "method": "meta/access/rules/upsert",
  "params": {
    "checked_v1": {
      "owner_instance": "<owner_instance from get>",
      "expected_revision": 12,
      "rule": {
        "id": "ops-view-all",
        "groups": ["ops"],
        "actions": ["agent.view"]
      }
    }
  }
}
```

| Existing method | Payload inside `checked_v1`, in addition to the edit token |
| - | - |
| `meta/access/set` | `config` |
| `meta/access/enable` | `enabled` |
| `meta/access/rules/upsert` | `rule` |
| `meta/access/rules/delete` | `id` |
| `meta/access/groups/set` | `name`, `group` |
| `meta/access/groups/delete` | `name` |

Successful writes return the new `revision`; refresh before starting a new edit.
Malformed, mixed or unknown checked envelopes fail without falling back to a
legacy write. Do not duplicate the payload at the top level or retry without
`checked_v1`: older handlers reject the nested-only request because the required
legacy payload is absent. Legacy top-level requests remain compatible but do
not provide the checked-save concurrency guarantee.

Use the error code together with `data.kind`, rather than matching free-form
server messages:

| Response | Client action |
| - | - |
| `-32009`, `access_revision_conflict` | Retain the draft, refresh, review and explicitly reapply. `expected_revision` and `actual_revision` describe the mismatch. |
| `-32009`, `access_owner_changed` | Read the replacement owner and explicitly review before another save. |
| `-32004`, `access_mutation_unavailable` | Keep the draft; do not fall back to an unchecked write. Refresh after availability is restored. |
| `-32602`, invalid configuration or checked request | Correct the request; do not treat it as a successful save. An invalid configuration answers `invalid_access_config` with `reason` and `detail`, which say what is wrong (below). |
| `-32000`, `access_persistence_failed` | Report that persistence failed and refresh before an explicit retry. The new live revision was not published. |
| `-32030`, `access_denied`, or the separate `read_only` error | Respect the current authorization or connection policy; changing the revision cannot authorize the write. |

An `invalid_access_config` refusal carries `reason`, a stable token, and
`detail`, the text that names the rule, action, group or approver at fault,
for example `"reason": "unknown_group"` with
`"detail": "access rule ops: unknown group \"ghosts\""`. The reasons are
`enabled_without_admins`, `empty_rule_id`, `duplicate_rule_id`,
`empty_actions`, `unknown_action`, `unknown_group`, `empty_approver_id`,
`padded_approver_id`, `approver_without_holders`, `unknown_approver_group`,
`unknown_rule` and `invalid_toml` (a configuration the owner could not write
as TOML), and the list can grow. Map the reasons you know to your own words.
Only an administrator receives this refusal, and `detail` names only entries
of the configuration that was saved, so an access editor may show it, also
for a reason it does not know.

A transport timeout is not proof of rejection. Read the current owner state
before deciding whether to retry. Apart from that `detail`, UI error notices
should remain finite and safe, without displaying arbitrary backend
diagnostic text.

### Refusal feedback and audit limits

These rules control Console access and administration. They do not grant native
tool, source or connected-account permissions. In a host-commissioned governed
Meerkat run, an operation-local tool refusal becomes safe model feedback without
entering that tool's body; permitted sibling operations can continue. Preserve
that refusal result rather than turning it into a blanket run failure or silently
retrying the denied action. Input admission and infrastructure/readiness failures
are separate typed errors. Ordinary Console access settings do not activate that
native governed path.

A preview, a saved revision and a visible timeline are different observations.
Preview evaluates current policy; a save confirms that configuration mutation;
operation audit records describe the recorded operation boundaries. None proves
that prompt text caused an action, that all source material was read, or that an
unrecorded external effect occurred. Observation permissions remain separate
from mutation permissions. Checked saves add no comprehensive
configuration-history or native-operation audit viewer.

For implementation work, the existing entrypoints are
`crates/meerkat-meta/src/access/controller.rs` (owner),
`crates/meerkat-meta/src/http_console.rs` (RPC contract),
`console/src/panels/AccessPanel.tsx` and `console/src/ConsoleApp.tsx` (draft and
save flow), and `packages/console-core/src/headless.ts` (transport).
The real-owner browser scenario runs through `console/browser-e2e.cjs` against
the existing `console_acceptance_fixture` example. Discover it with:

```sh theme={null}
node console/browser-e2e.cjs --list --scenario=real-checked-save-recovery
```

Running it requires the prebuilt fixture binary (`META_EXAMPLE_BIN_DIR`) and
the composed Console bundle; discovery alone is not an E2E result.

## What gets enforced

* `/console/experience` — agents the caller can't view vanish from the sidebar, roster, topology, identity status; per-agent affordances (`can_send_message`, `can_retire`, `can_respawn`) and runtime capabilities are intersected with the caller's grants, so the stock console UI degrades invisibly. Affordances are resolved per agent: a caller who can view and retire an agent but not message it gets a disabled composer on that agent while keeping the retire action — having no send grant anywhere does not flip the whole console to deployment read-only.
* `/console/timeline`, timeline streams, and `meta/console/query_timeline` — frames for hidden identities are filtered out.
* `meta/console/send` and `/console/send` — require `agent.send` on the target.
* `meta/schedule/list` (an agent's console schedules) - requires `agent.view` on the agent; `meta/schedule/create`, `update`, `pause`, `resume` and `delete` require `agent.send` on it, since a console schedule is a deferred send. Each fire is a console send by the schedule's sender, judged when it fires: the Console admits the sender as it admits a live request by it (sign-in, allowlist, trusted issuer, revoked sessions), and the send needs `agent.send` for the sender under the policy at that time, so withdrawing the grant stops the schedule's later sends.
* `meta/agent_memory/remember` and `meta/agent_memory/forget` — require `agent.memory.write` and `agent.memory.delete` respectively on the target identity. `meta/agent_memory/recall` and `meta/agent_memory/manifest` require **both** `agent.memory.read` and `agent.view` on the identity.
* `meta/memory/panel/*` (the console Memory panel's reads) filters every returned record by its scope: identity-scoped records need `agent.memory.read` + `agent.view` on that identity, mob-scoped records need `mob.memory.read`, operator-scoped records need `operator.memory.read`, realm-scoped records and `meta/memory/panel/dreams` need an unscoped `agent.memory.read` grant, and quarantined records (still quarantined, or tombstoned after ever being quarantined) plus `meta/memory/panel/quarantine` need `memory.quarantine.review`. The review grant only opens the quarantine surface; each queue record is still filtered by the per-scope read grants above on its scope, and each pending promotion both on its target scope and on its source record or proposal (a promotion the steward made from a proposal names that proposal, judged by its scope), whose content it would publish and which still holds that content. Once applied, the promoted content is a record of its target scope and is judged there. The other realm-level reads (`overview`, `proposals`, `injections`, `harvests` and `audit_verdicts`) also need the unscoped `agent.memory.read` grant to be called, and return only the rows the caller may read by the same rules: a store-overview scope row and a proposal by their scope, a harvest by its identity, an audit verdict by its record, and an injection-ledger row by both its record and the identity it went into (a record's detail likewise lists only the injections into identities the caller may read). Their entry check has no resource, so a deny on one identity, role or label does not refuse the call; it removes that identity's rows from the answer. Each row is judged under the name the console gives the identity, and so is the identity a record listing is filtered by: on a shared Console that is `<namespace>/<identity>`, so its rules name identities that way, and the Console records its runtimes' roles and labels before a memory read. An identity whose attributes the console does not know (memory written for a name no member holds) is withheld while a deny scoped by a role, a label or another agent applies to `agent.memory.read` or `agent.view`, since its bare name cannot be matched against such a deny. `meta/memory/panel/dreams` and `meta/memory/panel/dream_runs` summarize steward runs that span a realm's scopes and can describe quarantined content, so they list a realm's runs only to a caller who may read every memory scope the realm names (the scopes of its records, proposals and gated promotions, and its harvested identities: each identity by the identity rule above, its mobs by `mob.memory.read`, and the operator scope by `operator.memory.read`) and, while the realm holds quarantined content (a quarantined record, or a tombstoned one that was ever quarantined), also holds `memory.quarantine.review`. For any other caller those runs are left out, and `withheld_by_access` in the result is true when access withholds the dream history of at least one realm, whether or not that realm has runs (`false` otherwise), so a client can tell withheld history from none. Memory content is more sensitive than roster visibility, so plain `agent.view` alone no longer reads any of it.
* `meta/memory/quarantine/decide` (the operator quarantine decision) requires `memory.quarantine.review`, `agent.view` and `agent.memory.read` on the identity, and `agent.memory.write` to release or `agent.memory.delete` to tombstone (any other verdict needs both, then fails validation). Every effect is one the same caller could already cause through `remember`, `update` or `forget` (a released update supersedes its prior, as `update` does); the decision adds the lineage, the trust ceiling, the content binding and the audit trail. An anonymous caller is held to the same grants whenever access control is on. The capabilities list advertises it only to callers holding `memory.quarantine.review`.
* Lifecycle and runtime RPCs — mapped to `agent.retire` / `agent.respawn` / `agent.reset` / `agent.spawn` / `runtime.admin` per method.
* `meta/mob_announcements/*` on `/console/rpc` - every method, the reads included, requires `runtime.admin` with no resource: they list announcements, subscriptions and deliveries across members and mobs with no per-agent filter. A read-only console refuses the ones that change state (`announce`, `subscriptions/put`, `pause`, `resume`, `cancel` and `deliveries/reconcile`) with the `read_only` error. The capabilities list names the family only while the runtime has mob announcements.
* Topology control — query visibility requires `agent.view` and
  `topology.view` per endpoint. Connect, disconnect, and reconnect require the
  matching `topology.*` grant on both endpoint identities for planning as well
  as applying. Multi-operation requests also require `topology.bulk`; the
  runtime still enforces its finite batch ceiling. Durable mutation audit and
  actor/principal attribution require the separate `topology.audit` grant on
  every endpoint in a returned record. Cross-authority mutation is separately
  gated and is available only to a same-process host coordinating both
  runtimes; the authority-local JSON-RPC surface fails it closed.
* `/agents/{id}/events` — requires `agent.view` on that agent. `/mob/events` and `/meta/mob_events/stream` require `mob.observe` to open and additionally filter each event by `agent.view` on its source (see below).

A committed configuration change reaches open streams before their next frame. The timeline streams, `/agents/{id}/events`, `/mob/events`, `/meta/mob_events/stream` and the WorkGraph facts stream decide each frame right before they send it, under the configuration committed at that point, not the one they opened with. A stream whose own grant is withdrawn (`agent.view` on its agent, `mob.observe`, or `workgraph.view`) ends when the change is committed, even while it has nothing to send, and the client's reconnect is decided under the new configuration. A member that joins while a timeline stream is open is judged by its own labels and role.

Label- and role-based rules resolve against an attribute cache the runtime refreshes from every roster projection, so selectors keep working on surfaces that only carry an identity string.

> [!NOTE]
>
> **Migration — memory read actions.** Recall used to ride bare `agent.view`. Configs written before `agent.memory.read` existed (no `*.memory.*` action mentioned in any rule) keep working: on load, every rule matching `agent.view` is extended to also cover `agent.memory.read` — allow *and* deny rules alike — and a warning is logged recommending explicit rules. Normalization is in memory on load and reaches the TOML file on the next successful persisted admin mutation/save. Until then, an unchanged old file is normalized again on restart; after the normalized config is saved, the rewrite is self-limiting. A config that mentions any memory action anywhere is taken literally.

### `mob.observe` and per-agent filtering

`mob.observe` gates *access to the whole-mob event surfaces* — `/mob/events`, `/meta/mob_events/stream`, and `meta/mob_events/query` / `subscribe`. It does **not** override per-agent `agent.view`: events flowing through those surfaces are still filtered per agent, so a caller with `mob.observe` but not `agent.view` on agent X sees neither X's live events nor X's lifecycle entries. (Mob-level structural events with no agent attribution flow on the `mob.observe` grant alone.) To observe the entire mob, grant `mob.observe` together with `agent.view` on `*`.

### `/blobs/{id}` is a capability surface

`/blobs/{id}` serves content-addressed (`sha256:…`) blobs. Blobs are deduplicated by content hash and have no single owning agent, so they are gated by console authentication plus hash unguessability rather than per-agent ACL. A 256-bit content hash is only learnable through a surface the caller is already allowed to see (e.g. a visible agent's timeline frame), so a denied caller cannot enumerate another agent's images. Do not treat blob URLs as a per-agent confidentiality boundary; treat them as capability URLs.

## Relationship to authentication

Access control layers on top of console authentication (`require_app_auth` + the email allowlist): authentication decides who may reach the console at all, ABAC decides what each of them sees and does once inside. Identity comes from the validated bearer token (`email` or `sub` claim). With authentication optional, a valid voluntarily supplied token can still establish a named principal when trusted-token validation and access control are configured. Callers without one are anonymous and match only rules without subject constraints. For per-user deployments, require authentication rather than relying on optional identification.

## Timeline frames of members that changed or left

When the console records a member's timeline frame, it also records the member's attributes at that moment: its identity, role and labels, and the spawn ancestors its lineage names. Each timeline surface (the REST window, `meta/console/query_timeline`, and the snapshot and live frames of the timeline streams) decides `agent.view` on every frame with those recorded attributes, before the frame is shaped for the caller, so a page limit counts only frames the caller may see. The recorded attributes never leave the server.

The most restrictive rule applies:

* A frame is visible only when both the recorded attributes and the member's current attributes allow it. After a member is relabelled into a denied class, its earlier frames are denied too; after it is relabelled out of one, frames its recorded attributes deny stay denied.
* A member that has left, or that nothing has read from the roster since a restart, is decided by its recorded attributes only. A label or role deny keeps applying to its frames.
* A spawn ancestor is recorded only from the member that actually performed the spawn (the spawning session), not from a later member under the same identity. An ancestor that could not be confirmed counts as unknown, and the current attributes of a member under an ancestor's identity can only add a deny.
* A frame recorded without member attributes (written by an earlier release, or when the console could not tell which member wrote it) and an unknown ancestor are refused under any deny rule the identity alone cannot decide: a rule with a role or label selector, or one naming another agent (it could be an ancestor). Allow rules and identity-only denies decide such a frame by its identity, as before.
* The console's own frames (the `_system` and `__console__` identities) are decided by those identities. No member can run under them.

Every timeline surface applies this decision, frame by frame. When a recent-mode query scans its whole budget without a frame the caller may see, it returns an empty page with a cursor to continue from, the same shape as any page with nothing visible.

## Cross-mob control grants

Everything above governs the **console plane**: human callers reaching agents through the console, REST, and RPC surfaces. The **cross-mob control channel** is a separate plane with a separate policy, and the two never see each other's rules.

That channel is the TCP/UDS listener a gateway binds with `control_listen` / `--control-listen`. Peer gateways drive four member-plane verbs across it:

| Verb | Reaches |
| - | - |
| `wire` / `unwire` | Install or remove a trusted peer descriptor on one of your members |
| `inject` | Deliver an external-turn message into one of your members' sessions |
| `lookup_member` | Read a member's comms facts (peer id, comms name, transport pubkey, advertised address) |

Two separately granted host-plane verbs address the runtime host, not a member:

| Verb | Reaches |
| - | - |
| `host_describe` | Host identity, capabilities, and placement labels |
| `host_health` | Host health projection |

Host verbs must be explicitly named in the grant and require an installed
host-facts provider to return data; member selectors do not scope them.

Two more member-plane verbs carry keyed, reconcilable delivery, which mob
announcements use to reach members of a mob on a peer gateway:

| Verb | Reaches |
| - | - |
| `inject_keyed` | Commit a keyed delivery into one of your members' runtime inbox, once per key |
| `inject_status` | Read the status of a keyed delivery the same caller made |

They too must be named explicitly in a grant (see [Keyed inject](#keyed-inject)).

A **control grant** binds one calling gateway - identified by its Ed25519 pubkey, the same one `meta/peer_pubkey` reports - to the verbs it may use and, for member-plane operations, the members it may reach. Anything outside the grant is refused before the request reaches your mob, so an ungranted caller never learns whether the member it named exists.

### How a caller is identified

Callers sign every control request with their gateway keypair. The signature covers the request's semantic fields plus the audience (the mob id the caller dialed), so it cannot be re-pointed at another member, another verb, or another gateway. The receiving side matches the verified pubkey against its grant table.

This is independent of the response signing you may already be using: `pubkey` on a contact entry authenticates the *answers you receive*, a control grant authorizes the *requests you serve*. They compose, and either can be used alone.

### Configuring grants

```toml theme={null}
[control_grants.ops-mob]
# The calling gateway's pubkey, from its `meta/peer_pubkey`.
pubkey = "ed25519:KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio="
verbs = ["lookup_member", "wire", "unwire"]
members = ["bob"]

[control_grants.dispatcher]
pubkey = "ed25519:..."
verbs = ["inject"]
# `members` omitted (or ["*"]) grants every member, present and future.
```

`verbs = ["*"]` grants only the four member-plane verbs, never
`host_describe`, `host_health`, `inject_keyed` or `inject_status`; include those
names explicitly when intended.
An unknown verb, a missing or empty `verbs` list, an undecodable pubkey, or two
labels sharing one pubkey are all config errors - the table refuses to load
rather than granting something the operator did not write.

A caller entry accepts only `pubkey`, `verbs`, and `members`; any other key is a config error too. That is deliberate rather than pedantic: `member = ["bob"]` (singular) would otherwise leave `members` absent, and an absent `members` means *every* member - the typo would quietly widen a one-member grant to the whole mob. Put notes in `#` comments.

Refusals come back with a stable code: `unauthenticated_caller`, `invalid_caller_credential`, `invalid_caller_signature`, `caller_not_granted`, `verb_not_granted`, `member_not_granted`, `audience_mismatch`. On the calling side they surface as a distinct error from an ordinary rejection, because the remedy is different: nothing about the request is malformed and retrying will not help until the peer operator widens the grant.

> [!WARNING]
>
> **A control listener without grants authorizes nothing.** `start_control_listener`, the Rust builder's `control_listen`, and the gateway `--control-listen` path bind with an empty table and refuse every request until scoped grants are installed. An **empty** table is not the same as an explicitly open authorizer: empty refuses everyone, while `ControlAuthorizer::open()` is a lower-level compatibility choice an embedder must name deliberately.

Embedders mount a table directly:

```rust theme={null}
let mut table = ControlGrantTable::new();
table.insert(
    peer_pubkey,
    ControlGrant::new(
        "ops-mob",
        [ControlVerb::Inject],
        ControlMemberScope::members(["bob"]),
    ),
);
let advertised = runtime
    .start_control_listener_with_authorizer(
        &addr,
        Arc::new(ControlAuthorizer::with_grants(table)),
    )
    .await?;
```

Use `ControlAuthorizer::with_grants_for_audience(table, "my-mob-id")` when one caller holds grants on several of your gateways and a captured request must not be replayable across them; the mob id is the one peers use for this gateway in their contact directories.

> [!NOTE]
>
> The SDK-driven `rpc_gateway` accepts the same TOML under `runtime_options.control_grants_toml`; the conventional `meta_gateway` reads `[control_grants]` beside `[mobs]` in `config/contacts.toml`. The Rust builder accepts a `ControlGrantTable` through `control_grants`. Every configured-listener path binds the table to the runtime's mob id as the signed audience. `ControlAuthorizer::from_toml` still distinguishes an absent section from an empty section for embedders that explicitly choose compatibility behavior.

### Keyed inject

`inject_keyed` delivers one keyed continuation to a member: the serving
gateway commits it into the member's durable runtime inbox under the key,
scoped to the caller (the label of the grant that admitted it), and admits it
as one input when the member is served. An identical request under the same
key answers the original receipt; other content under it is refused
(`conflict`). `inject_status` reads such a delivery back, for the caller's
own keys only. Both need:

* a grant that names the verb explicitly, on a listener with grants (an open
  authorizer refuses them);
* a request issued within the serving gateway's freshness window of its own
  clock (`runtime_options.control_request_window_ms`, five minutes by
  default) and not before the serving process started. A delivery the
  gateway admitted has a deadline, its issue time plus the window: the
  gateway prepares the member within it, checks it again just before the
  commit, and refuses a delivery it cannot commit in time; a member
  preparation it stopped waiting for still finishes, so the next attempt
  finds the member ready. The deadline uses the serving gateway's own
  window, so gateways that deliver to each other should configure the same
  `control_request_window_ms`. Each stale refusal carries its typed cause in
  a field of the signed refusal: `outside_window`, `issued_before_start`
  (a request issued before the serving process started, as when it
  restarted while the request was on its way) or `deadline_passed`;
* an identity-first serving gateway that serves keyed injects. Every gateway
  that binds a control listener (`rpc_gateway`, `meta_gateway`, and a
  runtime the builder composes) seals what the plane serves once its runtime
  is composed, whether or not it enables mob announcements itself. Until
  then the verbs answer `keyed_inject_unavailable`: the gateway is starting.
  A gateway that is not identity-first, or whose configuration turns serving
  off (`runtime_options.serve_keyed_inject: false` on `rpc_gateway`),
  answers `not_served` with its typed cause, `not_identity_first` or
  `runtime_disabled`, in a field of the signed refusal. `meta_gateway` has
  no such switch: its listener serves keyed injects whenever its runtime is
  identity-first (its default), with the default window,
  `DEFAULT_KEYED_INJECT_WINDOW_MS` (five minutes), so the grants that name
  the verbs are what decide who may call them.

Every answer is signed with the serving gateway's key, and the caller treats
only an answer its pinned key verifies as evidence. A signed refusal says
that nothing was committed for that attempt, and the caller's delivery waits
with a typed reason: a member that is unknown or not served there (placed
on a host, or bound to an external runtime), a governed runtime, a member
with no live incarnation, a stale request or a gateway that does not serve
keyed injects are states that can change, so none of them ends a
subscription. Only a signed `member_departed` ends the caller's
subscriptions for that member. An unsigned answer, such as a `decode` error
from a gateway that predates these verbs, is never evidence: the outcome
stays unknown, and the caller reads the delivery's status before it submits
again.

Three limits worth stating plainly:

* A grant is checked per request, so a caller granted `inject` on a member may inject repeatedly. Grants bound *who and what*, not *how often*.
* The channel has no replay cache: a captured request stays valid at its audience until the grant is withdrawn. Each request carries a client-minted nonce inside the signed material, so a future replay guard has something to key on, but none is enforced today.
* `wire` is a trust-granting verb. A caller granted `wire` on a member may install a trusted peer descriptor of its choosing on that member - including one naming a third party's key. Grant it only to peers you would let choose who your member talks to; `inject` and `lookup_member` are the narrower verbs.


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