Skip to main content
Mobs are Meerkat’s multi-agent runtime. There is no separate sub-agent substrate: delegation, helper agents, flows, and reusable profiles all compile to mob members managed by meerkat-mob.

Core Model

MobActor serializes mob commands, persists mob state, and projects public status from MobMachine authority. Member sessions are still ordinary Meerkat sessions, so they inherit provider, tool, auth, memory, and live-channel behavior from the session runtime.

Identity

Mob member identity has two layers: Use AgentIdentity for facts that survive respawn. Use AgentRuntimeId only for per-binding runtime facts. Since 0.9.4 resume recovers each identity’s highest generation from the whole event log, every reset epoch included, before it replays the events after the last MobReset, and every spawn takes its generation from the MobMachine. A member re-spawned after a reset and a restart therefore never reuses its pre-reset runtime id or member address. A store written before 0.9.4 whose last epoch already reused a generation still resumes, and that identity’s next spawn goes above the recovered high-water.

Member binding history

Since 0.9.4 a Rust host can read which member incarnation held which session. MobHandle::member_binding_history() folds the mob’s durable member journal and Core’s identity intent rows, read in one store transaction, into a MemberBindingHistory. MobReadHandle (read_handle()) has the same reads. It is not mob/member_history, which pages a member’s transcript. The history value answers the same questions (binding_for_session, last_binding, durable_identity_members, identity_management), and durable_identity_bindings lists every durable identity’s last binding since the latest mob reset, live or retired. Each MemberBinding carries its identity, generation, session (none for a peer-only member), role, continuity intent and a MemberBindingState: Live, Moved (a binding recovery moved the membership to another session) or Retired (retired, or ended by a mob reset). MemberIdentityManagement is CommandDriven (lifecycle commands alone drive the identity), IntentManaged (Core’s identity reconciler drives it, so lifecycle commands from another caller would make a second driver), IntentUnreadable (an intent row this build cannot read; do not drive the identity) or, since 0.9.9, CreatedEntry (a created identity entry’s row: MobMachine drives the identity, and neither the identity reconciler nor a host’s identity-first verbs act on it; see Identity entries). The reads are store reads: they never wait behind the mob actor, need ControlScope::List (MobError::ScopeDenied otherwise), and a failed read is an error, never an empty history. A handle and its clones keep the fold between reads, so a read reads only the journal events appended since the previous one (with the identity intent rows, in one store transaction) instead of reading or decoding the whole journal: it costs those events plus the size of the history it answers (its members and sessions). A journal that a destroy cleared is read whole again. A storage composition without the built-in identity and member store refuses them with MobStoreError::IdentityMemberAtomicPersistenceUnavailable. Pruning never removes the events the history reads. Destroying the mob clears its journal, so a mob created again under the same id starts a new history. With no running mob, MobStorage::read_member_binding_history(path) (native builds) reads the same history from a persistent store. It opens the file read-only and never creates or migrates it. A missing file is MemberBindingHistoryReadError::StoreMissing, and a file that is not a mob store this build reads is ForeignSchema; a store that holds no mob answers an empty history. A binding can name a session no store holds yet. A seat, or a rotation’s next life, builds nothing: the member’s first wake creates its session under the id the seat named, so until then the session is unborn, not lost. The history’s session_unborn(session_id) answers it, read from the journal rows the mob’s own restart restores the fact from, and MobHandle::session_unborn(session_id) answers it on a running mob. A host that checks a stopped store for bindings whose sessions are missing leaves unborn sessions out. A member retired before its first wake leaves its session unborn. MobHandle::member_history of a member whose session is unborn answers an empty page and does not wake it. Core’s reads by session id (RPC session/history, REST GET /sessions/{id}/history) answer not found for an unborn session, since no store holds it; a host that serves its own reads of member sessions by id checks session_unborn first. session_bindings_since_reset() lists the latest binding of each session named since the latest mob reset, so a host can tell whether an identity had a created session before its last binding: a member reset seats the next life on a new, unborn session, while the session of the life before it stays in that list. A seated member that is not materialized in this process (dormant, or waking while its wake runs) is not served yet: a dormant member has no session actor, and a waking one may or may not have one before its wake ends. A subscription to its agent events opens anyway, without waking it and with no command to the mob while it waits: MobHandle::subscribe_agent_events, subscribe_agent_events_from, subscribe_agent_events_observation and subscribe_all_agent_events attach the member’s stream at its wake, from the woken actor’s earliest retained event, so the run its next delivery starts is read from its first event (the subscription’s attached_epoch is set at the attach, to the sequence space the woken actor’s stream names). The mob event router (subscribe_mob_events) carries such a member from its wake, and a member released and woken again from its new actor. A member the machine holds live whose session actor is gone is refused MobError::MemberSessionNotLive. MobHandle::member_unmaterialized(identity) reads the residency fact from the published machine state, with no command and without waking the member, and stays true until the wake ends. The answer can be stale by the time the host acts on it, and the read is never a write path: only the machine’s own transitions move a member’s residency, and a host that needs a member live wakes it with a delivery or ensure_member_live. A wake that finds a member’s bound session gone from the store restores it on a listed successor session (not archived, bound to no member, naming the same mob, member and role) when one exists. The successor’s key gives the member a new endpoint, and the wake removes the old one from each live or placed peer’s trust as it reinstalls the edge, as a retirement does.

Identity entries

Since 0.9.9 a Rust host can tell its own durable identities from worker members. An identity entry is a MobMachine fact (identity_entry_origins): the entry’s member is a member like any other, and the entry adds custody of its name.
  • A spawn or a dormant seat whose SpawnMemberSpec::entry_custody is set (with_entry_custody(true)) seats an entry: MobMachine records the member an entry in the step that seats it, and the member’s MemberSpawned row records the entry, so a restart restores it from the journal alone.
  • No spawn or seat without entry custody takes an entry’s name: it is refused MobError::IdentityMember (structured kind identity_member) before anything is created. A spawn with entry custody of a seated member’s name is a duplicate (MemberAlreadyExists), and so is a seat with entry custody of a worker’s name.
  • A worker reconcile (reconcile, ensure_member) never spawns or retires an entry: an entry is neither desired nor stale for it. It refuses a spec that carries entry custody with MobError::WiringError before anything is created; a host declares an entry with spawn_spec or seat_dormant_members.
  • The agent lane (the handle the member tools act through) does not act on an entry as a member: its retire, respawn, rotation, release, reload, cancellation (cancel_all_work included), run-start release and wiring commands addressed to an entry are refused MobError::IdentityMember before anything runs, even beside a host retirement of the same member in flight, and its requests carry no entry custody. It cannot adopt a legacy entry (MobError::ScopeDenied). CommandAuthority::principal_for_agent applies the same rules on top of a principal’s grants, for a surface whose tools an agent calls: the agent mob tools of meerkat-mob-mcp (mob_retire_member, mob_wire, mob_unwire and the others) use it. The host acts on its own entries.
  • External work does not reach an entry, nor a member whose name is reserved unreadable: a member send from outside the mob (MemberHandle::send, a submit of WorkOrigin::External) addressed to one is refused MobError::IdentityMember before anything is submitted, whatever its profile’s external_addressable says (0.9.11). The host delivers to its entries through its own door, as internal work (submit_work_with_mode with WorkOrigin::Internal), which MobMachine admits as for any member.
  • Core’s mob doors on the wire (the mob/* methods of rkat-rpc, the public MCP mob tools, the web runtime’s mob exports) keep the same split (0.9.11):
    • An external-work door refuses an entry, and a member whose name is reserved unreadable, identity_member for every caller, the owner included: mob/member_send with or without idempotency_key, mob/ingress_interaction, mob/submit_work with the default external origin, meerkat_mob_member_send and the web runtime’s mob_member_send. A keyed send (MobMcpState::mob_member_send_keyed) answers exactly as the unkeyed send: before its first commit it asks MobMachine the same external admission (MobHandle::admit_external_work), and a refusal (identity_member, NotExternallyAddressable for a member whose runtime admits no external work) commits nothing. Its delivery asks again: a keyed send committed before its member became an entry, or before its name became reserved unreadable, settles refused (reason authority_denied), final, and nothing is admitted. MobMachine records a runtime’s external addressability when it seats that runtime, so a member stops admitting external work only as a new incarnation (a respawn or a rotation, onto a profile that admits none included); a keyed send committed to the earlier incarnation settles stranded, final, as for any replaced incarnation.
    • A host door acts on a member’s session as its host: mob/turn_start, mob/append_system_context (meerkat_mob_append_system_context, the web runtime’s mob_append_system_context), mob/member_live_open, mob/member_live_control and mob/member_live_close. It reaches an entry, or a member whose name is reserved unreadable, only for a principal that holds AdminHost; any other caller is refused identity_member before anything runs (MobHandle::admit_member_host_door). On every other member it is unchanged: a host door never consulted external_addressable. The owner holds every scope, so a local console reaches its entries as before.
    • The internal origin of mob/submit_work acts as the mob’s host: it needs AdminHost, and a caller without it is refused ScopeDenied (required: admin_host) before anything is submitted. The public MCP tools and the web runtime take no origin.
    • The host’s own keyed send to its member is MobMcpState::mob_member_send_keyed_as_host: internal work, under the host door’s rule, never asked the external admission.
    • Session-level methods addressed to a session a mob owns, by its id (session/inject_context, comms/send), are not mob doors: no mob gate runs on them, and they act with the host’s authority.
  • A respawn of an entry’s member rotates it onto its own session (see Respawn, rotation and scoped release): the identity stays seated and an entry throughout, and a rotation (rotate_member) keeps the identity’s standing too. A retirement keeps the entry: the end of its member’s retirement leaves it unseated, owed its seat, with the session the retirement archived recorded, and the next declared apply seats it again there (see The declared apply); an archive’s retirement leaves it archived (Archived) instead, until its unarchive. Only the end of a retirement the apply’s seal or a one-entry unlist started (the host’s roster unlisted the identity), a removal, a host’s adoption, the destroy and the mob’s reset end an entry.
  • A host’s adoption of a declared entry (adopt_member_identity_declaration) takes the entry over: the identity reconciler marks the entry, retires its member with the cause HostAdopted (MemberRetirementCause) and materializes the member again under the host’s intent on its own session, as no entry; the end of that retirement ends the entry. A declared apply answers the identity host-adopted (DeclaredEntryRefusal::HostAdopted) from the mark on, and its seal neither marks nor unmarks it.
  • A rotation (rotate_member) of an entry no member holds is its fresh life, a seat of its name: without the entry’s custody it is refused MobError::IdentityMember before anything is created, and with it the entry’s archived session record is let go and the member is seated on a fresh session.
  • A placed member is an entry only when the declared apply hosts the entry on its first use: a placed spawn with entry custody of any other name is refused MobError::WiringError before anything is created.
MobHandle::adopt_legacy_identity_entry(identity) adopts, once, a member a host seated before identity entries existed: one whose spawn row carries the durable-identity continuity marker or the legacy agent_identity label naming its own identity. The label names it when it spells the member id, or when the member id is the label’s name in the one encoding of a name into a member id (member_id_for_name: the label team:alice names the member mk--team_calice, and the label mk--ops the member mk--mk--ops). The label is read trimmed; an empty one, or one in the generated runtime-alias namespace (rt:), names no member. MobMachine decides the adoption from that observation of the member’s current incarnation, read from the member binding history. The adoption is journaled on a session-binding row that carries the member’s binding unchanged, with its stored peer endpoint when the machine holds one, a restart restores the entry from it, and the label is never read for the member again. The answer is a LegacyIdentityEntryAdoption: Adopted, AlreadyAnEntry, NameHeldByWorker (no marker), NotAdoptable (a placed member, one bound to no session, a retiring one, or a created entry’s or reserved name), NotSeated or NonAuthoritative. A mob that is not running refuses it (an entry answers AlreadyAnEntry in any phase), and it needs ControlScope::Retire, checked before any answer. A member whose incarnation keeps changing while its binding history is read answers the retryable MobError::LifecycleOperationPending. An entry has one of two origins (IdentityEntryOrigin): Declared, an entry a host declared (a spawn or seat with entry custody, the declared apply, a legacy adoption), or, since 0.9.9, Created, an entry Core keeps as a created entry row of its own in the identity store (schema version 2, beside the host-adopted identity intents). The origin is the entry’s provenance and the custody of its desired record (the host’s roster for a declared entry, the identity store’s row for a created one): it decides which recovery and writer use that record, the name guards and the name reservations below. The entry’s member is built from its profile like any member, with its own labels, tools, grants and wiring, which custody never decides. A declared entry is a roster-owned identity and a created entry a store-owned one: Core’s own changes create, update, archive and remove a store-owned identity (see Store-owned identity changes), and an identity of either custody can be placed, externally bound, held for its host’s creation or prepared by its host before its first seat (see Host preparation and held constructions). A start reads every created entry row into MobMachine (RecoverCreatedIdentityEntry), after the journal replay and before the start’s member plan, and MobMachine decides each row from its presence and the replayed facts:
  • A present row of a seated local member records the member a Created entry. Whichever local member the journal seats under the name is the created one: a name holds one member at a time, and no declaration takes a created entry’s name (the apply notes such a listing, DeclaredEntryNote::OwnedByStore), so a member a predecessor seated under it, whose spawn row carries no origin, is that entry’s.
  • A present row whose identity the replay left without a member (a build that predates created entries retired it, or the spawn of a create’s kickoff failed) records the entry unseated, owed its seat (SeatOwed), with the session its member was last bound to as its revival record. Once the mob runs (after the start, or a stopped mob’s resume), Core seats it again, Dormant, under created custody, from the row’s spec: on that session, its history kept, on a fresh session with the HistoryUnavailable notice when that session is gone from the store, or on a fresh one when it has none; a spec that owes a kickoff is embodied. A row written before rows carried their spec seats the entry from its member’s last spawn row, its roster labels included, as any revive keeps its member’s spec, and only on its archived session. An entry it cannot seat stays owed its seat, and the failure is logged. The start also carries out what a row keeps owed: the role migration of a store-owned update whose restamp a crash prevented.
  • A row pending its host’s preparation records the entry unseated (PreparationPending), and no dormant seat takes it. A row whose held construction’s activation was accepted and whose member the journal seats (its publication committed before a crash) records the member a Created entry, and the start makes the row present.
  • An archived row records the entry unseated (Archived), with the session its member was bound to as its record. A removed or archived row whose member’s retirement the journal shows in flight records the removal or the archive in flight, and the start retires the member again.
  • A removed entry’s row reserves the name as removed: it is never an entry or a member again.
  • No held construction survives a restart. A store-owned identity’s row records its attempt, so a settle naming an earlier one is refused StaleAttempt. A roster-owned identity’s attempts are numbered within one process: after a restart they start again, and its activation binds no spec digest, so its host settles only an attempt it asked for in the current process.
  • A row this build cannot read (a later build’s format, a field it does not know, a broken seal), or one the journal contradicts (a declared entry of the name, a member where the row records none, a placed member, a removed entry of the name, a spawn under way for the name), reserves the name as unreadable: the name keeps what the journal recorded for it (an entry, a member), and nothing new acts on it.
A store read that fails fails the start: a name the store may reserve is never read as free. A member row whose stored origin this build does not know is kept as stored, and its name is reserved as unreadable rather than replayed as a worker’s. No spawn or dormant seat starts under a reserved name, and a custody of one origin never takes an entry of the other: both are refused MobError::IdentityMember before anything is created. A spawn already under way when its name is reserved is refused its seat with the same error and aborted: the name takes no member. A worker reconcile neither spawns nor retires a reserved name, the declared apply notes a created entry’s name and refuses a removed or unreadable one for that identity alone (see The declared apply), the legacy adoption answers NotAdoptable for any of them, and the agent lane refuses an unreadable member as an identity member. The host’s own retire and rotation of the member an unreadable name keeps are not refused by the reservation, and a respawn that keeps the member’s session is such a rotation. A fresh-life respawn of that member is refused MobError::IdentityMember before anything is retired, since its successor’s spawn would be refused: the member keeps its seat and session. A mob reset keeps the reserved names, which the identity store holds; the destroy clears them. The reads need no actor command and never wake a member: All but the journal read answer from the machine state the actor last published, as member_unmaterialized does: the answer can be stale by the time the host acts on it, and it never says whether a member is materialized. The journal read is a store read, and so are two reads of the identity store: identity_record(identity) answers a name’s row (CreatedIdentityObservation: a store-owned identity’s revision, spec, presence, pending role migration and provenance, a removed name’s tombstone with its removal’s record, or a row this build cannot read; None for a name the store holds no row of, such as a roster-owned identity, whose record is its host’s roster), and identity_record_census() counts what a downgrade settles first (IdentityRecordCensus: the store’s rows of the mob, the archived ones, the tombstones and the unreadable ones, the identities awaiting their host’s preparation, the held constructions, and the seated store-owned identities whose member’s last seat row predates their row’s labels or profile). They need ControlScope::List, and the journal read ControlScope::SubscribeEvents. The census’s row counts are also read with no running mob, by the same rule and read-only: MobStorage::read_identity_record_rows(path) counts a store file’s rows per mob, and MobStorage::identity_record_rows() a storage’s mob, a host store’s included (IdentityRecordRows). An entry read whose machine facts are incomplete answers MobError::Internal rather than a default. An entry’s fields are internal to the stored event codec, so an older release reads an entry’s rows as an ordinary member’s. Each fact of the journal read is one MobMachine decided in the step that wrote its row, read from the machine’s state before that step and from the step’s journal effect; the replay reads none of them. A rotation’s terminal step journals its link on both of its rows: the retirement names the rotation’s target and the incarnation and role the same step seated (RetiredRotation), and the seat names the incarnation that step retired (SeatRotation::Successor). rotate_member of an identity no member holds seats a fresh life whose row names the incarnation it follows (SeatRotation::FreshLifeAfter, the generation MobMachine retains for the identity before the step that seats the fresh life); the fresh life of an identity no incarnation preceded since the mob’s last reset names none (a mob reset ends the retained history, as a restart after it does). A respawn of a member no identity entry holds (respawn, respawn_with_successor_spec) is two machine steps, a retirement and then a seat: its retirement names no rotation, and its seat names the incarnation the respawn retired (SeatRotation::RespawnAfter, from MobMachine’s respawn hold, which the retirement left for its successor and the seat’s step takes). The replacement an identity reconcile seats for the member it retired is the same two steps, and its seat names the incarnation the reconcile retired (SeatRotation::RespawnAfter) too. Any other seat or retirement names no rotation, wherever it sits in the journal, so a reader pairs a retirement with the seat that follows it from the facts, never from their positions. A rotation’s retirement records no cause: the rotation keeps the identity’s standing. A placed seat names the session its member host keeps, which its membership step named. No row names the runtime registration an incarnation held, which belongs to one process. A retirement’s facts, a seat’s rotation and a placed seat’s session read as absent on a row written before they were recorded (a seat’s own session binding is one its row always recorded), and as absent in a form this build cannot read, so the row still decodes. Two rows that close a spawn record no facts: the row after a membership step that failed once the spawn row was durable, and the end of a resumed spawn’s rollback, which MobMachine records without a journal effect. The facts are internal to the stored event codec, under keys of their own, so an older release ignores them.

The declared apply

Since 0.9.9 a Rust host declares its identity entries as data, and Core applies them: MobHandle::apply_declared_roster(DeclaredRoster). The host’s roster is the authority. A DeclaredRoster holds the identities it declares (DeclaredEntry: a SpawnMemberSpec, whether the host holds the identity’s creation for its held protocol, and what the spec carries of the identity’s member build, DeclaredBuild, set with with_build), whether it is the host’s complete roster, the role migrations it declares (with_role_migration(identity, from)) and the host’s one-shot first-boot requests (FirstBootRequest). MobMachine decides each identity’s verdict from its declaration and its member facts (DeclareIdentityEntry), in memory, in chunks of DECLARED_APPLY_CHUNK identities, each one machine step, and the handle realizes the verdict with the mob’s own steps. Each identity is answered on its own (DeclaredRosterOutcome::entries, a DeclaredEntryOutcome): one identity’s refusal or failure never fails the pass. The call fails as a whole only for a mob that is not running, an identity store whose intent rows and member binding history (one read) cannot be read now (retryable), or another apply of the mob that is running: applies of one mob run one at a time, and a call while another runs is refused MobError::LifecycleOperationPending, retryable, and writes nothing. An apply’s end always lets the next one open: a call dropped at any point, a caller whose grant is revoked or expires while it runs, and a Shutdown that fails while it closes. It needs ControlScope::Retire (and ControlScope::SendCommand for its seats); the agent lane is refused MobError::ScopeDenied. A legacy member listed under its own name is adopted first (adopt_legacy_identity_entry) and then declared as the entry it is. A complete roster also offers MobMachine each member it does not list whose spawn row carries a legacy mark, with the raw observation of its current incarnation read from the member binding history and the intent rows: its two legacy marks, that the roster does not list it, and whether a host’s intent row names it, readable or not. The adoption’s own step reads the identity’s intent row again, so a host’s adoption committed after the history read still names the member. MobMachine decides (AdoptLegacyIdentityEntry). A member with both legacy marks (the durable-identity continuity marker and the legacy label naming its own identity) that no host’s intent row names is a legacy member. A local one bound to a session is adopted, and the seal then unlists the entry and retires the member. One placed on a member host, or bound to no session, is never an entry: it is retired without its adoption (RetireUnadopted), through the ordinary retirement at the incarnation MobMachine decided on (at its delivery scope when it is bound to a session), with no cause recorded, and its name is left free. One bound to no session is retired at the generation MobMachine decided on: the retirement’s own step retires the exact incarnation it read, so a respawn seated after the verdict is never retired by it, and the next complete apply decides that member again. One whose retirement started already is answered NotAdopted (NotAdoptedReason::Retiring), and its own retirement ends it. A member with one mark only, or one a host adopted, is left as it is, and a partial roster offers no member it does not list. A dormant seat never takes an identity hosted on its first use or awaiting its held creation (MobError::WiringError). A complete roster is sealed after its last chunk (SealDeclaredRoster): every Declared entry it does not list is unlisted (never a created entry, nor a name this build cannot read). Its member, when one is seated, is retired; the retirement’s start row records the cause Unlisted (MemberLifecycleEvent::RetirementStarted::cause), and the end of the retirement ends the entry; the retirement’s Retired fact names the cause and the entry it ended. An unlisted entry no member holds is removed at once. An entry whose spawn is still in flight (its spawn ladder is open, before or after its membership step) is left to a later complete apply, and the answer does not name it: once the spawn completes, that apply retires the member; when the spawn fails, the entry is left unseated, and that apply removes it. A worker is never unlisted, but for an unlisted legacy member the complete roster adopted first (above). The answer names each unlisted entry (DeclaredRosterOutcome::unlisted: Retired, Removed, or Failed, which the next complete apply retries). It names each unlisted legacy member MobMachine did not leave as it is too: Retired for one retired without its adoption, NotAdopted for one whose retirement started already, and Failed for a failed adoption or retirement, which leaves the member as it is for the next complete apply. Any other retirement keeps the entry: a host’s retire of a declared identity that stays listed ends its member, and the next apply seats the identity again. To take a declared identity out of the roster, unlist it (remove_identity is permanent: it tombstones the name). An unlisted identity listed again comes back as the same identity. The end of its retirement recorded the session it archived (MobMachine’s identity_entry_archived_sessions, also on the replay of the retirement’s row, which records whether the retirement archived the session: one it only released, such as a session another host owns, is not recorded), and the apply seats it Dormant on that session at its next generation; its first wake revives the session, with its history. When that session is gone from the store (its retention purged it), the identity is seated on a fresh session, and its answer and its seat’s journal row carry the typed notice HistoryUnavailable, naming the lost session (an identity that owes a kickoff is embodied with the same notice). When the store cannot read the session now, the identity is deferred, retryable, and never seated on a fresh session. A lost session never leaves a declared identity unseatable, but an intact one can: an archived session whose runtime refuses revival (its runtime was destroyed) is answered Failed (MobError::SessionUnavailableForResume, ArchivedNotRevivable) on every apply, since a fresh session would lose its history, until the host sends the identity’s Fresh first-boot request, which starts its fresh life. A session no build created holds no history, and the identity is seated on it again. An identity retired before 0.9.9 has no record and is seated on a fresh session, and so is one whose record a member seated under its name ended (a worker spawned while it was unlisted), with no notice. A first-boot request is carried out once: Resume { session_id } seats the identity on that session (a member already seated carries it out in its declaration’s own step), and Fresh starts its fresh life on a new session, letting its archived session record go. The answer names the requests the apply carried out (DeclaredRosterOutcome::first_boot_requests_carried_out), and the host consumes exactly those: a deferred, refused or failed answer leaves its request with the host for the next apply, and the host’s own creation carries out the request of an identity hosted on its first use or awaiting its held creation. The verdicts write no journal row of their own: an unseated entry, a role hold and a held creation are MobMachine facts in memory, and the next apply declares them again. An unchanged roster writes nothing and wakes no member. Between a restart and the first apply after it, MobMachine knows only the entries its journal rows record: the seated ones, and those whose member a retirement other than the seal’s ended (unseated, with the session the retirement archived). An entry no member ever held and a role hold are not restored: until that apply, a member whose role the host moved while the process was down is not held, and a peer delivery can wake it under its previous role. So a host applies its roster once after every start, before it wakes or seats its identities: a role moved while the process was down is held only from that apply on. The roster vocabulary keeps the residency words: a seated member is Dormant until its first delivery wakes it, whether the apply or a seat seated it; a held member is Dormant and refuses its wakes; an unseated entry is no member at all (EntryStanding::Unseated, with its UnseatedEntryCause: SeatOwed, HostedOnFirstUse, HeldCreation, PreparationPending for an entry its host’s preparation holds, or Archived for an archived store-owned entry) and is listed by the entry reads only. A host that records its roster changes as Core’s receipts names the change a partial apply carries out (DeclaredRoster::with_change, an IdentityRosterChange with its operation, requester and consent): the apply writes the change’s receipt in its declaration step’s store transaction (DeclaredRosterOutcome::receipt), and a retry of the same operation answers that receipt. MobHandle::unlist_declared_identity(identity) unlists one roster-owned identity, decided by MobMachine on that identity alone (UnlistIdentityEntry), as a complete roster’s seal decides an unlisted Declared entry: its member retires with the cause Unlisted, or an entry no member holds ends at once, and one whose spawn, retirement, removal or held construction is in flight is refused retryable. No other entry is read or changed, so an entry a spawn or an adoption records meanwhile stays listed. It writes no tombstone: it is a roster-owned identity’s archive, and listing it again its unarchive. Meanwhile its archived session record holds its name: a store-owned create of it is refused NameTaken (by an identity). The apply’s types are #[non_exhaustive], and the apply is Rust-only: the wire contracts do not carry it.

Store-owned identity changes

Since 0.9.9 Core writes store-owned identities itself, one change at a time: MobHandle::create_store_owned_identities (a batch of at most IDENTITY_CHANGE_BATCH_MAX identities, all or nothing), update_store_owned_identity, archive_identity, unarchive_identity and remove_identity (either custody). Each change names its operation (IdentityOperation: an operation id and the request’s digest), its requester and its consent. The requester (IdentityChangeRequesterInput) is the host on one of its surfaces, a human principal, or an agent: an agent’s change is admitted by its ordinary spawn grant (admit_identity_change_grant, from the session that carries the request, never from its arguments), whose prepared check is asked again at the commit, so a grant revoked meanwhile refuses the change (GrantRevoked). The grant binds what its change commits: it keeps the request its admission checked (IdentityGrantRequest: for_specs for a create, for_update for an update, the new spec and the identity’s current profile, and for_identity for a removal, archive, unarchive or settle, the identity’s current profile) and the operation’s request digest, and each commit step commits only what that request covers, refusing anything else InvalidRequest. An agent’s change of a roster-owned identity no member holds is refused the same way: its profile is its host roster’s, and its host changes it. The grant is process-local: an executor admits a change that waited across a restart (or in a settlement-first recovery) again from its recorded requester identity (identity_change_grant_for), which prepares the same check, reads no session and wakes nothing; a grant the identity no longer holds is refused GrantRevoked. The commit’s recheck reads the authority the runtime composes for the requester’s current profile unless the host registers a MemberGrantMint (MobBuilder::with_member_grant_mint) with the same rule as its member build hook: a host whose build hook narrows grants must register one, or the commit cannot see a revocation by that hook. The consent (IdentityChangeConsentInput) is a policy outcome, a decision whose permit binds the change and admits its commit now, or an import, which only the host operator may carry. The requester and the consent are recorded, never authority. MobMachine decides every change’s admission in the change’s commit step (AdmitIdentityChange): a receipt of the same request answers IdentityChangeOutcome::Replayed and writes nothing again; another request under the same operation id is refused OperationReused; and a candidate composition, a changes hold, a decision’s permit that binds another change or is past its deadline, imported consent from anyone but the host operator, a revoked grant, and a store-owned change decided against a spec that is no longer the identity’s (StaleSpec, with the identity’s current spec digest and revision: an update’s expected_spec_digest, and a removal’s or an archive change’s with_expected_spec_digest; a roster-owned identity’s spec is its host roster’s to check, and a removal of one naming a digest is refused InvalidRequest) are refused typed (MobError::IdentityChangeRefused, an IdentityChangeRefusal with a stable kind token), with nothing written. Of these admission refusals, only the changes hold (MobHandle::set_identity_changes_held, or MobBuilder::with_identity_changes_held at a start; not durable) is retryable unchanged. An admitted change writes its identities’ rows (schema version 2: the revision, the spec, the presence and the provenance with the requester and the consent), the journal rows of their seats and the change’s receipt (IdentityChangeReceipt, read back with identity_operation, which needs the List scope) in one store transaction, before any member is seated or retired. A custom storage composition without the built-in identity and member store commits no change (StorageUnsupported).
  • Create. Each identity’s name must be free: MobMachine records the new entry (RecordCreatedIdentityEntry), deriving its cause from the request’s facts (its host’s preparation rule, its placement, a held creation), and a name another identity (an unlisted roster-owned identity’s archived session record included), a worker, a removed identity or an unreadable row holds is refused NameTaken (IdentityNameHolder). Each identity is seated Dormant as a roster-owned identity of the same spec is (its profile’s tools, its labels, the definition’s wiring, its runtime mode), and nothing wakes; one that owes a kickoff is spawned once the change committed and runs its kickoff once, and the create answers each such spawn (IdentityCreateOutcome::embodied): embodied, or failed, the change still committed and the identity owed its seat, which the mob’s next start embodies once. A placed or externally bound identity is hosted on its first use, one whose creation its host holds (CreatedIdentityRequest::held_creation) awaits it, and one whose host prepares it first waits unseated; their host’s spawn holding the identity’s created custody (SpawnMemberSpec::with_created_custody) creates them.
  • Update. update_store_owned_identity is a declared apply of the identity under created custody: its declaration step decides the change’s admission and the identity’s verdict with the declared apply’s own arms (DeclareIdentityEntry), so a store-owned identity and a roster-owned one of the same spec answer the same verdict for the same change (IdentityUpdateOutcome::applied). A descriptive change (display name, labels, context, instructions) keeps the member and is in the row at once; a seat whose session no build created yet takes the new context and instructions at its first build; otherwise the seat keeps its recorded build inputs, as for a roster-owned identity, and its labels change when it is seated again or respawned with them (MemberRespawn::with_labels; IdentityRecordCensus::seats_stale counts such seats). A runtime-mode change restamps the member on its own session; a profile change is the declared migration from the member’s current role, kept in the row until the restamp commits, so a crash before it is carried out at the next start; either restamp seats the row’s labels. A placement, binding or backend change is read only while no member holds the identity. MobMachine routes the update in its declaration step, from that step’s own state: the update of an archived identity, or of one awaiting its preparation, changes its row only (RecordOnly), and its unarchive or settle seats the new spec. A retry of a committed update answers Replayed, never a stale spec; a roster-owned identity’s update is refused OwnerReadOnly.
  • Removal. remove_identity removes an identity of either custody. A seated identity’s member retires, its retirement’s start row recording the cause Removed (MemberRetirementCause), and the end of the retirement ends the entry and tombstones the name; an identity no member holds is tombstoned at once, and a held construction of it ends in the same MobMachine step (its hold aborts, its member never published). A roster-owned identity’s tombstone is written in the removal’s start transaction; a declared apply that lists it before its retirement ended defers it, and afterwards refuses its name. The call answers once the retirement ended. The name is never an identity or a member again: a create, a declaration, a spawn and an adoption of it are refused, and identity_tombstones lists it. A retirement that fails leaves the removal marked: a retry of the same operation, or the next start, retires the member again.
  • Archive and unarchive. A store-owned identity’s archive retires its member with the cause Archived and keeps its entry, unseated, with the session the member was bound to; its row records presence archived and its name stays reserved. Deliveries to an archived identity are refused MobError::IdentityArchived, and it can be updated or removed. An identity awaiting its host’s preparation is not archived: refused HostPreparationPending (retryable, naming its open construction attempt) until its preparation is settled, or the identity removed. Its unarchive seats it Dormant on its archived session, its history kept, or on a fresh session with the notice when that session is gone from the store. A roster-owned identity’s archive is refused OwnerReadOnly: its host unlists it (unlist_declared_identity) and lists it again.
  • Owned edges (0.9.11). A create’s peers (CreatedIdentityRequest::with_peers) and an update’s (StoreOwnedIdentityUpdate::with_peers_add, with_peers_remove) declare and end edges owned by the identity whose change lists them, each to an identity of either custody (the requester when it is one, or another identity of the same create) with how it entered (OwnedIdentityEdge, OwnedEdgeAdmission: the peer rule that admitted it, or adopted, which only an adoption declares). The owner’s row holds its edges, written in the change’s transaction, and MobMachine keeps the one custody map of the mob’s edges (identity_edge_owners): a peer that is no identity is refused PeerUnknown, and a second declaration of an owned edge gives no custody. An edge is in force while both its ends are identity entries, as MobMachine decides it; an edge in force is wired once both its ends are seated, by the change’s own step or by the seat of the end that was not (a create’s seat, an unarchive, a re-listing, a start), and it enables nothing: a member reaches its peers only with the tools its profile grants. An archive of an end leaves the edge declared and unwired, and the end’s next seat wires it again. An unlisting of an end leaves the edge declared and out of force: a worker that takes the end’s free name is no end of it (the edge wires nothing for it, and the worker’s own wiring takes a plain unwire), and the end’s re-listing brings the edge back in force; no record of the unlisting is kept, since the owner’s row holds the edge. The edge belongs to the end’s name: an identity created or adopted under a name that was unlisted brings that name’s owned edges back in force too, so end the edge (its owner’s change) when that is not wanted. An owned edge ends only by its owner’s change (another identity’s change ending it is refused InvalidRequest), by the host’s policy reload that denies it (MobHandle::end_denied_identity_edge, naming its owner: OwnedEdgeEndOutcome), by the removal of either end (in the removal’s own transaction, with its tombstone, which owns no edge) and by the mob’s reset or destroy. A plain unwire of an owned edge in force is refused MobError::OwnedIdentityEdge. A start recovers each edge from its owner’s row and wires those a crash left unwired; an import restores the edges of the rows it restores the same way, and a restored row keeps only the edges it restored (an edge another identity owns meanwhile stays that identity’s), so they are the edges the next start recovers.
  • Adoption into store custody (0.9.11). MobHandle::adopt_identity moves an existing member into store custody as one change (receipt kind adopt): a roster-owned identity (AdoptionSource::roster, with its roster’s spec) or a worker member (AdoptionSource::worker, at the generation its host observed; its row’s spec is its member’s role, labels and runtime mode). The member keeps its runtime, generation, session and history; its row (provenance adopted, with its former spawner), its binding row with the Created origin and the receipt are written in one transaction. The binding row makes the member a host root, live and on replay: its former spawner’s retirement no longer reaches it, and the agent lane answers identity_member for it. Its current edges to other identities become its owned edges (OwnedEdgeAdmission::Adopted), and its edges to members that are no identity stay plain wiring; the receipt names both. A member step in flight (admission_unsettled) and an open declared apply (declared_apply_open) are retryable refusals; a stale generation is refused predecessor_mismatch, and a shape no adoption takes not_adoptable with its reason (NotAdoptableReason: a placed member or one bound to no session, a roster-owned identity awaiting its host, a worker spawned with a profile or model override, and others), retryable for a roster-owned identity its host still settles: one hosted on its first use waits for that first use, one awaiting its held creation for the held creation’s release, and one awaiting its host’s preparation for that preparation. Only the host’s own action settles each, so a caller retries after it and bounds its retries. An identity placed on a member host is the exception: its first use seats a placed member, whose adoption is refused not_adoptable (placed), which is not retryable. No start, reconcile or declared apply adopts; a roster that keeps listing an adopted identity gets the quiet OwnedByStore note.
  • The downgrade cycle (0.9.11). A release before 0.9.9 does not read a mob’s rows of schema version 2 and later, so a downgrade to one runs the cycle docs/guides/upgrading.mdx states. MobHandle::export_created_identities exports every row, each with its session reference, sealed with a digest (CreatedIdentitySetExport). MobHandle::purge_created_identities (the host operator, under held identity changes, of a mob that holds such rows: none is refused purge_refused, nothing_to_purge) removes the rows in one transaction when their digest is the export’s, and nothing else; the mob then refuses identity changes (purged) until it restarts. A purge or an import commits at most 256 identities (batch_too_large above). A start that reads no row of a store-owned identity’s name reserves the name (it reads unreadable, and nothing seats it). MobHandle::import_created_identities (the host operator, imported consent) restores each record and re-binds its session in one transaction: in place (a plain member only on the session its record names), Dormant on its archived session (fresh, with the history unavailable notice, when that session is gone), or as recorded; a name another holder keeps is reported (name_taken_meanwhile).
An update, removal, archive or unarchive of an identity whose spawn, wake, retirement, removal, archive or accepted activation is in flight is refused retryable (MobError::LifecycleOperationPending), and so is an update while another declared apply of the mob runs or while a construction of the identity is held; a removal ends a held construction instead, and an archive of an identity awaiting its preparation is refused HostPreparationPending. The change types are #[non_exhaustive], and the changes are Rust-only: the wire contracts do not carry them.

Host preparation and held constructions

A host’s rule for an identity’s profile can require its preparation before the identity’s first seat (IdentityPreparationMode: None, Required or Constructed), for either custody: a store-owned identity’s create records it pending (CreatedIdentityRequest::with_preparation, presence PendingPreparation), and a roster-owned identity’s declaration names the mode (DeclaredEntry::with_preparation), derived again at each apply. Such an identity is unseated (PreparationPending) and never runs: a delivery to it is refused retryable. A Required preparation ends with its settle: a store-owned identity’s settle (MobHandle::settle_identity_preparation without an attempt) commits its receipt and its row, present again, and seats it Dormant from its row’s spec, embodied when its spec owes a kickoff, which runs once. A roster-owned identity is settled by its host’s next declaration, which names no preparation. A Constructed preparation is a held construction its host activates. The host asks for one (MobHandle::construct_identity): MobMachine holds a new attempt (BeginPreparationConstruction; a store-owned identity’s row records the attempt), and the mob holds the identity’s member constructed and unpublished under the entry’s custody, starting no run. The host reads the hold’s witnesses (HeldPreparation), then settles naming the attempt: decided before the attempt’s deadline and, for a store-owned identity, binding its current spec, the settle accepts the activation of exactly that construction (SettleHostPreparation), and its receipt journals the acceptance. The receipt is sealed before the activation is sent; a store write that fails after the send commits nothing: MobMachine still holds the attempt unaccepted, so the publication’s membership commit is refused, and the hold’s end ends the attempt as a failed publication, the identity still pending, with no receipt. The mob owns each attempt: a dropped observer or a lost reply ends nothing. Before an activation is accepted, an abort (abort_identity_construction), the attempt’s deadline, a refused construction, a crash or a removal ends the attempt (EndPreparationConstruction), and the identity stays pending. Once accepted, nothing replaces the activation until its publication ends: published (the membership commit that seats the member ends the attempt, and a store-owned identity’s row is present again), failed (the settle answers the error that ended it, PreparationSettlement::PublicationEnded’s cause: a ConstructionFailure with the error’s failure class, its wire error code when it has one and its text for diagnostics, and the mob logs it), or interrupted by a crash (a retry of the settle answers ConstructionEnd::PublicationInterrupted), after which the identity stays pending and a new attempt may follow. The construction’s own spawn opens its spawn ladder at its publication, after the settle accepted its activation. A Constructed preparation is never settled without an attempt (ConstructionRequired), and at most one attempt of an identity is open, held or activating. A roster-owned identity’s attempts count within one process: no construction survives a restart.

One-shot durable role migration

Role is durable context, but it is not member identity. MemberLaunchMode::Resume can carry resume_from_role on one exact trusted-host request. The declaration must name the role stored on the exact cold session. The mob id, AgentIdentity, and session id remain unchanged. When the stored and requested roles differ, an absent declaration fails with MemberRoleMigrationRequired; incorrect, inapplicable, and live-session attempts fail with MemberRoleMigrationRejected. Success retains the transcript and restamps the current comms name, typed member binding, role/profile labels, callback context, and explicitly configured tooling. The declaration is not persisted, so a SpawnMemberCustomizer must re-supply it at SpawnSource::Resume after process restart. A same-session respawn that names another profile is the one exception: its next incarnation’s spawn row records the declaration (see Respawn, rotation and scoped release). This seam exists on trusted in-process SpawnMemberSpec and private MaterializeLaunchMode::Resume only. Agent tools, standing profiles, CLI, public RPC/REST/MCP/SDK spawn contracts, and Web do not expose it.

Respawn rotation and scoped release

Since 0.9.9 a member can be respawned on its own session, a rotation finishes after a crash on its own, and a release can name the incarnation it means. MobHandle::respawn_member(identity, MemberRespawn) rotates a member onto its own session. One MobMachine step starts the current incarnation’s retirement and records the rotation while the member stays seated on its session. The retirement cancels the member’s running turn, releases its runtime registration and keeps the session. A second step retires the incarnation and seats the next one Dormant at the next generation on the same session, so the identity is never unseated, and the next incarnation keeps the member’s conversation and comms key. Under the member’s own profile it keeps the member’s endpoint, wiring edges and external peer edges too: the second step’s journal batch records the kept edges again after the next incarnation’s row, so a restart (under an older release too) restores them. Under another profile the next incarnation runs under that role’s comms name, so it keeps neither its descriptor nor its topology: its endpoint is the member’s key under the new name, the retirement unwires the member’s peers’ trust as a plain retirement does, the member’s edges end, and the next incarnation is wired to the edges the definition declares for its role, as a rotation’s next incarnation is. MobMachine records the profile the rotation started with, and every attempt of it seats that profile. When the member’s session commits each round, its queued inputs that may cross a stop (durable prompts not bound to an objective) stay queued: their waiters are answered RequeuedAcrossStop, and the next incarnation, woken by the work the handover leaves it, runs each of them once. Every other input ends stopped, typed. MemberRespawn can name an initial message (run once as the next incarnation’s first turn), a profile (a declared role migration: the next incarnation’s spawn row records the role its session was last built under, so its first build restamps the session, after a restart too), a runtime mode, the labels the next incarnation’s seat carries (with_labels; without it, the member’s own: the “Apply now” of a store-owned identity whose seat predates its row’s labels respawns it with the row’s), and with_wake(true) to wake the next incarnation before answering. The answer is the successor’s MemberRespawnReceipt. A wake that fails after the successor is committed does not fail the call: the member stays seated Dormant and the receipt reports the wake’s error (MemberRespawnReceipt::wake_error), since calling again would respawn the member again. A member a same-session successor cannot serve is refused MobError::UnsupportedForMode before anything starts. MobMachine’s facts refuse a placed member and one bound to no session of its own, and its start refuses one whose session the plan observed lost or archived. The shell’s own facts, which no machine holds, refuse one that is not runtime-backed or whose service keeps no durable sessions, a fork-derived member and one holding a forked participant attachment (the lineage belongs to the incarnation that holds it), and one whose session’s creation record, spawn continuity or (for a role migration) last role cannot be read. A member whose retirement is in flight is refused retryable (LifecycleOperationPending), unless that retirement is this rotation, which the call re-drives. MobMachine decides the route of an identity entry’s member: its fresh-life respawn (Respawn) admits no entry and answers RespawnRefused for one, in every phase an entry lives in (Running, Stopped and Completed), and the archive of a fresh-life respawn refuses an entry too. So MobHandle::respawn respawns an identity entry’s member this way, and MobHandle::respawn_with_successor_spec refuses an entry’s member (UnsupportedForMode) before anything starts; a worker’s respawn keeps its fresh-session successor. A rotation is durable from its start. Its retirement’s start row records it: its target (RotationTarget::FreshSession for rotate_member, SameSession for a respawn), its next session and endpoint, and what its next incarnation is seated with. A mob restarted at any point of the rotation records it again from that row and finishes it itself; no host record is needed. A host that keeps its own record of a rotate_member passes the identity host-pending (MobBuilder::with_host_pending_members) and calls rotate_member again with the same spec after the restart: the restarted mob does not re-drive that FreshSession rotation, logs that it holds it, and records its retirement stuck at admission with a cause that names the wait (MobHandle::retirement_settlement; MobHandle::rotation_held_for_host reads the hold), and the call finishes it once (redrive_retirement re-drives it too). A plain retire by any caller answers MemberRetirementStuck with that cause and leaves the rotation held, and so does redrive_retirement_unless_held_for_host, the re-drive of a caller other than the host, which a session archive through meerkat-mob-mcp uses: the actor decides the hold in the re-drive’s own step, by the one predicate the start and its own re-drives use. A same-session rotation is the mob’s own: the restart finishes it whatever the host-pending set says, and a host-pending member keeps its mark through it. Its successor is seated Dormant, and its wakes are refused HostPreparationPending until the host’s declaration of it ends the mark (a declared apply that carries the identity’s first-boot Resume request of its kept session, which keeps the member and names the request carried out): the host wakes first. Re-issued while its rotation stands, rotate_member re-drives that rotation; re-issued after it finished, it rotates the member again. So a host that does not mark the member host-pending reads the member once after a restart (the replay is done before the handle is returned) before it re-issues an unanswered call: a higher generation means the rotation finished; a Retiring member (list_members_including_retiring) means it stands, and the mob finishes it; the generation the call saw, not Retiring, means it never started, and the call is made again. While a rotation of the member stands, redrive_retirement re-drives the rotation and any other retirement of the member is refused retryable. A recorded rotation whose next incarnation cannot be planned again (its profile left the definition) stays recorded and stuck until a call plans the same next session: rotate_member with a spec the definition plans, for a FreshSession rotation. A same-session one seats the profile it started with, for which its retirement prepared the member’s topology: only respawn_member naming that profile re-drives it, a call naming any other profile is refused retryable, and rotate_member cannot end it (it plans a fresh session, refused while that rotation stands). Either is finished by restoring its profile: MobStorage::update_definition while the mob’s process is down (a pending rotation does not hold the definition’s epoch back), then a resume, whose start re-drives a same-session rotation from its record. The mob’s destroy, which ends either, is the last resort. A rotation never takes over a retirement of the member that started without it, whose start row records no rotation: rotate_member is refused retryable while that retirement stands, unless the member is host-pending, and a retry alone does not succeed until that retirement finishes or is re-driven. The record is internal to the stored event codec: an older Meerkat release replays the start row as a plain retirement’s, and a record this release cannot read fails the resume typed. MobHandle::release_member_at_scope(scope) releases a member’s materialization only when its MemberDeliveryScope (from capture_member_delivery_scope: the mob’s incarnation and the member’s runtime id, fence and session) names the member’s current incarnation on its current session, and answers ReleaseOutcome::Released. Any other scope answers ReleaseOutcome::Stale and releases nothing, so a release meant for an incarnation a respawn replaced never reaches its successor. MobMachine decides the verdict. release_member stays unscoped.

Runtime Bindings

Member construction and member placement are separate facts. Members can be local session-backed agents, explicitly bound external peers, or session-backed agents materialized on a bound member host. External members require an explicit runtime binding. A bare External backend tag is not enough because the runtime needs a concrete process identity before it can route work or trust peer messages. For a remote rkat member, generate the current binding with rkat run --comms-listen-tcp ... --comms-binding-out <path>.

Placed-member portability

The remote spawn lane accepts only a digest-covered PortableMemberSpec. Machine admission rejects, rather than drops, all controller-local executable or secret-bearing fields: Placement also cannot be combined with an explicit backend or RuntimeBinding; the member must have exactly one execution transport story. WorkGraph tools are controller-local because the WorkGraph store and execution binding remain on the controlling host. Schedule tools are portable. Memory and declarative MCP servers require matching member-host capabilities and must not carry the rejected secret fields. Host capability admission also checks autonomous members, durable sessions, tracked input cancel, bridge protocol v4, memory store, and MCP when the resolved member requires them.

Host-designated conversational input

Trusted Rust hosts can submit authenticated human input with MobHandle::submit_host_human_input_bounded(runtime_id, fence_token, spec, handling_mode, delivery_identity, deadline). It returns WorkDeliveryReceipt after runtime admission, not after an LLM response. start_host_human_input_bounded takes the same arguments and returns a WorkTurnHandle for separate runtime-owned completion observation. submit_host_human_input_for_identity_bounded resolves an identity once before the same fenced admission. A host already holding a delivery lease should pass its exact runtime and fence instead of resolving a replacement. submit_host_human_input_with_options_bounded and start_host_human_input_with_options_bounded add host-owned MemberTurnOptions to the same fenced admission. Selected skills (MemberTurnOptions::with_skill_references) resolve natively on that exact member, with typed SkillsResolved / SkillResolutionFailed events and a durable SkillContext. The options are part of the runtime’s exact replay identity, so a retry with the same delivery identity and options returns the original admission and a changed selection is MobError::WorkInputIdempotencyConflict. The selection is order-sensitive: a replay must repeat the same keys in the same order. An empty selection is treated as no selection. An interaction id that differs from the delivery correlation is refused with MobError::DeliveryInteractionConflict. This is a Rust-only designation, not a serializable WorkSpec role or an agent-tool flag. The host must authenticate the human before opting in. WorkOrigin still governs addressability. Existing generic work APIs retain their behavior, including autonomous SystemNotice::ExternalEvent delivery. Only the explicit human seam delivers the work content as canonical conversational Message::User. System and injected-context slots keep their own roles. Installed shared Live context receives conversational input and assistant text from the normal store-sealed commit path; platform notices are not promoted. Use MobDeliveryIdentity::new(stable_key, interaction_uuid.to_string()), retain both values across retries, and keep any WorkSpec.interaction_id identical to that UUID. The deadline is meerkat_core::time_compat::Instant (std::time::Instant on native hosts). ActorCommandTimedOut reports unknown execution fate: an already-started admission may finish after the observation deadline. Retry only with the same delivery identity. Dropping a completion handle does not cancel work. On replay, the receipt refers to the original admission, even if that work already terminalized. Completion-bearing replay reads the original runtime terminal result; an unavailable result is an explicit WorkInputCompletionUnavailable, never synthesized success. Human admission requests exact prompt replay from the runtime owner. A compact prompt witness is persisted atomically with the input row and survives terminal payload retirement. A repeated strict request must match the original prompt, typed context slots, handling mode, and correlation; a prior external event, changed body, or legacy row without a provable prompt witness returns WorkInputIdempotencyConflict. Generic callers keep their existing key-only deduplication contract. Stale runtime/fence pairs remain machine-refused.

Multi-Host Authority

Realms are not distributed. The controlling host and each member host retain one local realm and one set of local session authorities. One host acceptor serves many placed members and demultiplexes signed envelopes by recipient public key. The peer envelope stays identity-addressed. The private supervisor.bridge protocol shares that transport but has separate machine admission and is never promoted to ordinary agent authority. Remote observation is controller-proxied. Member history is read from the owning host, and remote event pages feed the controlling mob’s merged stream. The public identity-routed operations remain placement-transparent. Host reachability, route-install convergence, generation, fence, and provenance remain visible as typed evidence. Hosts advertise runtime.capabilities.features.multi_host_mobs; clients must treat false as an unsupported capability, not infer support from method presence. The placed session id remains realm-local to its owning member host. A client cannot feed it to the controller’s generic session/* methods. Use mob identity operations and mob/member_history; remote transcript revision and edit methods are not part of the v1 bridge.

Public Surfaces

Flows

Flows are declarative work graphs. They support one-to-one, fan-out, fan-in, branching, and frame/loop nodes. Flow status is persisted, so a host can check live state first and fall back to the terminal snapshot.

WorkGraph Bridge

WorkGraph commitments and Flow runs remain separate authorities. A durable WorkExecutionBinding records the exact effective Flow run configuration and deterministic run identity before execution. The generated execution lifecycle requests the launch, observes Mob-owned terminal state, requests evidence projection, and feeds the WorkGraph closure result back into the binding lifecycle. The external-delivery ledger is not a second run machine. Its realizing phase is a custody fence, crossed by the Mob actor only after the deterministic pending MobRun is durable. Mob remains the sole owner of run lifecycle. The ledger prevents blind target reinvocation across an unobserved reply boundary. Flow success is evidence, not completion authority. WorkGraph’s completion policy remains the only path that can terminalize the commitment. Failed and canceled runs remain terminal execution attempts and may be superseded by a new binding while the WorkGraph item stays open. For multi-host mobs, the binding and WorkGraph store remain on the controlling host. Remotely placed members execute Flow steps through normal Mob routing; they do not independently claim or mutate the controlling WorkGraph. Terminal run observation and evidence projection return to the controlling host, so distributed execution does not create replicated WorkGraph authority.

Persistence

Persistent mob state is SQLite/WAL-backed through SqliteMobStores. In-memory storage is used for tests and WASM. The previous exclusive-handle mob store is gone.

Admission and actor exit

Every exit of a mob actor closes the mob’s admission first, with a typed MobAdmissionCloseReason: Shutdown (an admitted Shutdown), Destroyed (an admitted destroy; a mob restored with an unfinished destroy starts closed), DurabilityFailStop, ActorLost and DispatchFailed, besides IdleClose, LastOwnerReleased and StandDown. An earlier close keeps its reason. While a Shutdown drains, requests the close does not refuse get the retryable MobError::ShutdownInProgress (wire SESSION_BUSY), and ActorCommandChannelClosed means the actor has exited or is exiting. The mobs guide’s Persistence section has the full rules.

Durable definition updates

The structural event log owns the durable definition. MobCreated establishes definition epoch 1; each MobDefinitionUpdated is a strict MobMachine-authorized successor. The mob spec store is only a projection and its revision must equal the authoritative epoch. Trusted Rust hosts update a cold process-local mob before resume:
The event authority commits first under an atomic CAS. If the process fails before the projection write, exact retry or resume repairs the stale projection. MobStorage::definition_projection_health() exposes missing, stale, healthy, and diverged states. A same-revision content mismatch or projection-ahead state fails resume with MobDefinitionProjectionMismatch; consumers must not append raw events, write only the spec projection, or weaken this refusal. Hosts that inspect a definition before resume use the storage-minted MobDefinitionSnapshot. Verified resume compares its full definition, epoch, and authoritative MobCreated/MobDefinitionUpdated event cursor against the actual replay before repair or actuation; a concurrent update returns MobDefinitionAuthorityChanged. The verified resume claim holds the database’s shared cross-process fence through runtime construction; update_definition takes the matching exclusive fence, so an external SQLite writer cannot cross the replay-to-actuation interval. Multi-host persistence is deliberately split. The controlling realm stores the mob, host bindings, placement, grants, route obligations, flow/work state, and merged event position. The owning member-host realm stores each placed session and its history. Restart recovery replays these authorities and re-establishes routes; it does not copy member realms into the controller. Mobpacks are portable mob artifacts. They package definitions, optional runtime assets, capability declarations, and optional signature metadata. Verifier trust policy and trusted-signer enrollment remain host-owned. Deploy through:
mob web build copies the required prebuilt wasm-pack output into the browser bundle; it does not compile wasm32.

Live Channels

Live channels execute in the owning session. For a mob member, use the identity-routed mob/member_live_open, close, status, and control family. It is placement-transparent: after the mob’s member checks, the member’s own live host serves the requested transport (the owning host’s WebSocket endpoint, or a WebRTC bootstrap answered with live/webrtc/answer on the same JSON-RPC server) or refuses it with LiveTransportUnsupported. A channel-scoped execution_identity (gpt-live-1) is served for a member local to the JSON-RPC server; a placed member refuses it with Unsupported. Session-scoped live/open refuses a mob member’s session. The old realtime attachment/status plane has been removed. Live channel lifecycle is caller-initiated through the live/* method family.

Source Pointers

See Also