Skip to main content
Mirrored from Meerkat Meta main at 26f5ae9da1fd 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:
  • 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.
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:
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:
Use the rpc_gateway executable installed by the quickstart for the explicit gateway path.

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

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):
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: 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:
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.
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: Two separately granted host-plane verbs address the runtime host, not a member: 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: They too must be named explicitly in a grant (see 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

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