Audience: anytype-ts / anytype-swift / anytype-kotlin developers.
Scope: the Event.Account.Recovery.Update stream and the AccountRecoveryState RPC (GO-7471),
which describe what the middleware is doing between AccountSelect being called and every space
having loaded — on every app open, not only the first sync on a device.
Engineering reference: the design spec.
- Nothing existing changes.
AccountSelectblocks and returns exactly as before;Event.Space.SyncStatus.UpdateandEvent.P2PStatus.Updateare untouched. This surface is purely additive: render it or ignore it. - One event, one RPC.
Event.Message.accountRecoveryUpdate(field 206) carries typed updates with arunIdand a monotonicid;AccountRecoveryStatereturns the foldedSnapshotbuilt from the same state, so the two never disagree. - A minimal client binds one label to
Snapshot.phase/PhaseChanged.phaseand is done. A rich client renders peers and spaces from the rest of the feed. Finishedends the run. Nothing is emitted after it.Finished.viewsConfirmedsays whether "all your spaces are here" was actually verified (§6).- Per-space object progress is not here. It stays on
Event.Space.SyncStatus.Update, which the user sees when they open a space.
Event.Account.Recovery.Update { runId, id, timestampMs, oneof payload { ... } }
Rpc.Account.RecoveryState.Request {} -> Response { snapshot: Event.Account.Recovery.Snapshot }
AccountRecoveryState is total. Call it whenever you like — before AccountSelect, racing
it, during it, or long after — and you always get a snapshot and never an error:
snapshot.runId == "": no recovery run is in progress and none has left a verdict — either none has begun in this process, or the last one ended without a verdict (itsAccountSelectwas cancelled byAccountStop, or the account was stopped beforeDone).phaseisNotStarted. Ignore the rest of the payload and render nothing.- otherwise: this is the state of the current (or last) run. Apply subsequent events with the
same
runIdandid > snapshot.lastEventId. A finished run keeps reporting itself (done = true) until the nextAccountSelectstarts a new one.
A run that ends without a verdict emits nothing to say so: the client that stopped it learns it
from its own AccountStop / AccountSelect responses, and a client attaching later sees the idle
snapshot. Reset your state on an empty runId and you are covered in both cases.
The RPC is lock-free with respect to AccountSelect, so it answers immediately while that RPC
blocks. ACCOUNT_IS_NOT_RUNNING exists in the response enum for wire compatibility only; it is
never returned.
A client that simply subscribes never needs the RPC in the normal flow: it sees Started as
id = 1 and, on every new ListenSessionEvents session, receives a snapshot from the session
hook (§2.1). The RPC is for two cases only: attaching while a run is already in progress (the
hook's snapshot arrives late, see §2.1), and recovering from an id gap (§3).
Every new ListenSessionEvents session receives one Update whose payload is snapshot and whose
id equals snapshot.lastEventId — unless the last run ended without a verdict (see §2), in
which case nothing is sent: there is no run to report. Treat it exactly like the RPC result. It is
an optimization: never wait for it. If the session attaches while AccountSelect is still
running, the hook fires only once AccountSelect returns (it runs behind the same lock) — call
the RPC instead.
- Ids are contiguous within a
runId, starting at1(Started). Apply an update only ifid == lastApplied + 1. - On a gap (
id > lastApplied + 1): re-pull the snapshot, replace local state, then apply updates withid > snapshot.lastEventId. Do not try to reconstruct the missing ones. - On a new
runId: a new app start began (e.g.AccountStop+AccountSelect). Reset local state; the first update of the new run isStartedwithid = 1. - Every payload is a level, never a delta.
attempt,openConnections, a space'sstateare absolute values. Re-applying an update after a re-pull is harmless. - Several updates may arrive in one
Event(coalesced bursts). Apply them in order.
LookingForPeers -> Connecting -> FetchingAccount -> LoadingSpaces -> Done
\-> Failed
WaitingForNetwork (overlay: calm, auto-retrying — NOT an error screen)
| Phase | Render as |
|---|---|
LookingForPeers |
"Looking for peers…" |
Connecting |
"Connecting…" (dials are happening; DialFailed underneath is normal) |
FetchingAccount |
"Fetching your account…" — a network node is connected and the account is being pulled |
LoadingSpaces |
"Loading spaces…" — the account is here; AccountReady was emitted |
WaitingForNetwork |
"Waiting for network…" with PhaseChanged.error.class as a hint (NoNetwork, PeerUnreachable, IncompatibleVersion). Retrying automatically; do not show a failure. |
Done |
Dismiss. See §6 for what may be claimed. |
Failed |
AccountSelect itself failed; PhaseChanged.error carries the class (AccountDeleted, AccountNotFound, IncompatibleVersion, …). The RPC error remains the authoritative signal. |
Rules:
- Phases are not monotone. Always render the phase you were last told. The phase is derived
from the run's current condition, so it moves back when that condition regresses: losing the
last node connection during the account fetch goes
FetchingAccount -> Connecting, and theWaitingForNetworkoverlay returns to the phase it interrupted (fromPhase = WaitingForNetwork). A client that drops "backward" transitions will stick on a phase the run has already left. OnlyDoneandFailedare terminal. - Phases may be skipped. A warm start goes
Connecting -> LoadingSpaceswithout ever enteringFetchingAccount. Accept any forward jump. PhaseChanged.previousPhaseDurationMsis how long the previous phase lasted, for telemetry.AccountReadyis informational whileAccountSelectis still blocking: the RPC returning is still what lets the UI start. After the RPC returns, treat everything as a background indicator.
- Ignore unknown enum values in every enum (
Phase,ErrorClass,SpaceState,PeerKind, …). Render an unknown phase as the last known one.Phase.NotStartedappears only in the idle snapshot (runId == ""), never on the event stream. - Ignore unknown payload kinds in
Update.payload. New kinds are not a breaking change. Mode.ModeUnknownis never emitted; treat it as "unknown", never as a cold recovery.transportandnodeTypesare strings on purpose (quic,yamux, …;tree,coordinator,file, …). Do not enumerate them.
Finished { spacesTotal, spacesLoaded, spacesFailed, totalDurationMs, viewsConfirmed } is the
run's verdict. The run is terminal after it: no more updates, and the snapshot is frozen.
viewsConfirmed = true: the middleware checked the list of spaces against the network and every one of them is present locally. You may say "all your spaces are here — objects inside them may still be syncing".viewsConfirmed = false: the completeness check could not be completed — one entry never arrived and the middleware gave up on it after two consecutive sync rounds, or no responsible sync round ever happened at all (an app opened with no connectivity loads every space from disk and never gets to ask the network). Render a softer "ready" with no completeness claim, and keep relying on your own SpaceView subscription for spaces that appear later — the run will not report them.
In both cases a SpaceDiscovered after Finished never happens; new spaces reach you through the
SpaceView subscription as they always did.
SpaceDiscovered { spaceId, spaceViewId, kind } then SpaceStateChanged { spaceId, state, fromState, error, attempt }.
Queued -> Loading -> Pulling -> Loading -> Loaded (a space fetched from the network)
Queued -> Loaded (a space already on disk from last time)
any -> Error (the loader gave up; error.class says why)
any -> Stalled (settled around it; no load result ever arrived)
any -> Removed (deleted while recovering: drop it, it is not counted)
Loadedmeans the space controller finished loading it: mandatory objects fetched, sync started. How many objects are still syncing inside it is onEvent.Space.SyncStatus.Update, not here.Stalledmeans the rest of the run settled around this space and it still never reported. It is not terminal: the load may yet complete, and you will get anotherSpaceStateChangedif it does. Render it as a determinate stall with a retry — "79 of 80 loaded, 1 stalled" — never as ongoing progress.Finisheddoes not fire while any space is stalled, because the account is demonstrably not recovered.kind = Techis the account's tech space; filter it out of user-facing lists.- Names are not on this stream (no user content). Resolve them from your SpaceView
subscription via
spaceViewId— the SpaceView exists by the timeSpaceDiscoveredfires. spaceViewIdmay be empty on a firstSpaceDiscoveredand filled by a second one for the samespaceId; both are idempotent.attemptcounts fetch attempts for aPullingspace; it is a level.
DialStarted, PeerConnected, DialFailed, PeerDisconnected, PeerDiscovered — each carries
peerId, kind (NetworkNode | LocalPeer) and nodeTypes.
PeerConnected.openConnections/PeerDisconnected.openConnectionsare counts, not a boolean: a peer may briefly have two connections while one replaces the other.durationMsis0on inbound connections (direction = Inbound). Suppress it; never render "0 ms".addris display-only and not comparable across directions.DialFailed.attemptis the number of dials since the last successful connection to that peer; repeated failures for one peer arrive coalesced as a single updated level.LocalDiscoveryState(Possible|NoInterfaces|Restricted) may never arrive on a healthy network: it is only emitted on a change, and the snapshot's default isPossible. Never wait for it.WaitingForNetworkis derived server-side (no open connection, a dial failed, ~10 s elapsed — or the device reported offline). Bind to the phase; do not derive your own from peer events.
Connecting to a LAN peer says nothing about whether it holds your account: that is answered by
the space exchange right after the connection. PeerSpaceExchange { peerId, exchanged, hasAccountSpace, sharedSpaceCount } reports that answer as a fact, and the middleware folds every
LAN peer's dial state and answer into one headline, LocalPeersStateChanged { state, fromState }
(also Snapshot.localPeers):
LocalPeersState |
Meaning |
|---|---|
NoLocalPeers |
nothing discovered on the LAN |
LocalPeersConnecting |
found a peer; dialing, or connected and waiting for its answer |
LocalPeersUnreachable |
every discovered peer failed to connect |
AccountNotOnLocalPeers |
every connected peer has answered and none holds the account — "looking for others" |
AccountOnLocalPeer |
at least one peer holds your account's tech space — "connected to a device with your account" |
The negative state never appears while a connected peer has not answered yet, and one positive
answer flips it immediately, whatever the other peers said. Bind copy to this enum, not to the
raw PeerSpaceExchange events.
Caveat until GO-7492 ships: on a cold device the exchange is sent with zero tokens (the local
store has no spaces yet), so every LAN peer answers "nothing shared" even when it holds the
account. AccountNotOnLocalPeers is therefore reliably reached on a fresh device today and does
not mean the peer lacks your data — do not surface "your account data was not found on this
device" copy yet; render AccountNotOnLocalPeers as neutral ("looking for peers…"). Once
GO-7492 lands, the re-exchange produces a second PeerSpaceExchange for the same peer
(hasAccountSpace = true) and the state flips to AccountOnLocalPeer with no client change.
ErrorInfo { class, retryable, debugMessage } appears on PhaseChanged (WaitingForNetwork,
Failed), DialFailed, AccountFetchError, SpaceStateChanged and in the snapshot.
- Headline
class(NoNetwork,PeerUnreachable,IncompatibleVersion,NotAuthorized,SpaceDeleted,AccountDeleted,AccountNotFound,RateLimited,Unexpected) and honourretryable: a retryable class is a calm state, not a failure. debugMessageis raw error text for logs and Sentry only. Never display it.AccountFetchStarted.attemptcounts pull rounds; a new round afterAccountFetchError(or after a silent bounded wait) means "still trying", not "gave up" — the middleware never gives up on the account fetch by itself.
onSessionOpen:
snap = AccountRecoveryState() // or wait for the snapshot Update
state = fold(snap); last = snap.lastEventId; run = snap.runId
onEvent(u: Update):
if u.runId != run: state = {}; run = u.runId; last = 0
if u.id != last + 1: snap = AccountRecoveryState(); state = fold(snap); last = snap.lastEventId; return
apply(state, u.payload) // unknown payload kinds: ignore
last = u.id
render:
headline = label(state.phase) // unknown phase: keep the previous label
if state.phase == Failed: show error(state.error); stop // done is also true here
if state.done: dismiss; if !state.viewsConfirmed: no "all spaces are here" claim
stalled = count(state.spaces, s -> s.state == Stalled)
if stalled > 0: show "N of M loaded, K stalled" + retry, not a spinner
done is true for both terminals, so test Failed first — dismissing on it would hide the
one message the user needs (AccountDeleted, IncompatibleVersion, …).
ANYTYPE_LOG_LEVEL=core.recovery=debug logs every published update as id:Kind (ids and kinds
only, never content). The snapshot RPC is safe to poll while AccountSelect blocks.