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_custodyis set (with_entry_custody(true)) seats an entry: MobMachine records the member an entry in the step that seats it, and the member’sMemberSpawnedrow 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 kindidentity_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 withMobError::WiringErrorbefore anything is created; a host declares an entry withspawn_specorseat_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_workincluded), run-start release and wiring commands addressed to an entry are refusedMobError::IdentityMemberbefore 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_agentapplies the same rules on top of a principal’s grants, for a surface whose tools an agent calls: the agent mob tools ofmeerkat-mob-mcp(mob_retire_member,mob_wire,mob_unwireand 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 ofWorkOrigin::External) addressed to one is refusedMobError::IdentityMemberbefore anything is submitted, whatever its profile’sexternal_addressablesays (0.9.11). The host delivers to its entries through its own door, as internal work (submit_work_with_modewithWorkOrigin::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_memberfor every caller, the owner included:mob/member_sendwith or withoutidempotency_key,mob/ingress_interaction,mob/submit_workwith the defaultexternalorigin,meerkat_mob_member_sendand the web runtime’smob_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,NotExternallyAddressablefor 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, settlesrefused(reasonauthority_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 settlesstranded, 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’smob_append_system_context),mob/member_live_open,mob/member_live_controlandmob/member_live_close. It reaches an entry, or a member whose name is reserved unreadable, only for a principal that holdsAdminHost; any other caller is refusedidentity_memberbefore anything runs (MobHandle::admit_member_host_door). On every other member it is unchanged: a host door never consultedexternal_addressable. The owner holds every scope, so a local console reaches its entries as before. - The
internalorigin ofmob/submit_workacts as the mob’s host: it needsAdminHost, and a caller without it is refusedScopeDenied(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.
- An external-work door refuses an entry, and a member whose name is
reserved unreadable,
- 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 causeHostAdopted(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 refusedMobError::IdentityMemberbefore 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::WiringErrorbefore 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
Createdentry. 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 theHistoryUnavailablenotice 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 aCreatedentry, 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.
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 refusedNameTaken(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_identityis 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_stalecounts 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 answersReplayed, never a stale spec; a roster-owned identity’s update is refusedOwnerReadOnly. - Removal.
remove_identityremoves an identity of either custody. A seated identity’s member retires, its retirement’s start row recording the causeRemoved(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, andidentity_tombstoneslists 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
Archivedand keeps its entry, unseated, with the session the member was bound to; its row records presencearchivedand its name stays reserved. Deliveries to an archived identity are refusedMobError::IdentityArchived, and it can be updated or removed. An identity awaiting its host’s preparation is not archived: refusedHostPreparationPending(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 refusedOwnerReadOnly: 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, oradopted, 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 refusedPeerUnknown, 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 refusedInvalidRequest), 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 refusedMobError::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_identitymoves an existing member into store custody as one change (receipt kindadopt): 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 (provenanceadopted, 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 answersidentity_memberfor 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 refusedpredecessor_mismatch, and a shape no adoption takesnot_adoptablewith 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 refusednot_adoptable(placed), which is not retryable. No start, reconcile or declared apply adopts; a roster that keeps listing an adopted identity gets the quietOwnedByStorenote. - 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.mdxstates.MobHandle::export_created_identitiesexports 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 refusedpurge_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_largeabove). 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).
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-coveredPortableMemberSpec.
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 withMobHandle::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 durableWorkExecutionBinding 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 throughSqliteMobStores.
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 typedMobAdmissionCloseReason: 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:
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-routedmob/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.
