Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

SyncLedger: offline-first is only half the story — give the account/server relationship an explicit typestate

Источник: dev/fera-research/docs/oc4/idea-05-sync-ledger-typestate.md (read-only; сгенерировано scripts/sync-docs.mjs)

SyncLedger: offline-first is only half the story — give the account/server relationship an explicit typestate

Section titled “SyncLedger: offline-first is only half the story — give the account/server relationship an explicit typestate”
  • kind: idea
  • area: storage / protocol / ui / offline-first
  • evidence:
    • ~/dev/fera/project-design/context/ADR.md:307-325 — ADR-010 offline-first: outbox pattern, reconnect lives “entirely in the protocol actor”, “must not leak into how the UI/domain layer reads data”
    • ~/dev/fera/project-design/context/ADR.md:338-346 — ADR-011: the only explicit sync state machine in the corpus, and it is scoped to read-receipts inside one chat (ReadState { Synced, Pending, Offline })
    • ~/dev/fera/project-design/context/TECHSPEC.md:118-123 — OutboxStatus { Pending, Sent, Failed } — again the only other one
    • ~/dev/fera/project-design/context/NAMING-UX.md:187-190 — “Offline state should never look degraded … the ‘no network’ signal should be a distinct, honest indicator, not an empty/broken history view” — a committed UI requirement with no state model behind it
    • ~/dev/fera/project-design/context/RFC.md:147-153 — §4.8 multi-account: one ProtocolActor per account, merged streams, policy decided globally across accounts
    • ~/dev/fera/project-design/context/ADR.md:479-488 — ADR-025: secret chats are per-device, no key transfer, separate sanakirja instance, TTL deletion
    • ~/dev/fera/project-design/context/ADR.md:439-441 — ADR-024: module rollback keeps N-1 on disk
    • ~/dev/fera/project-design/context/OQ.md:608-727 — OQ-021: data/accounts/<id>/{profile,messages,media,state,predictor,secrets} — per-account layout with a state/ domain, unspecified
    • ~/dev/fera/project-design/context/ADR.md:841 — ADR-021: “the on-disk data format is versioned independently of API editions — a module swap does not imply a storage migration, and vice versa” (i.e. skew is expected by design)
    • Negative evidence (grep over the whole context/ corpus): diverg, sync state, catching up, behind_by, partially synced, syncing → zero relevant hits

Offline-first (ADR-010) answers “can I read what I have?”. It does not answer “what is my relationship to the server right now, and what do I tell the user about it?”

The project has exactly two modelled sync states, both narrow: ReadState (read receipts, per chat) and OutboxStatus (per queued operation). Everything else is implicit — and the corpus has no vocabulary at all for the interesting cases (the grep above is the evidence: not one occurrence of “diverged”, “catching up”, “behind N”, or a per-account sync state).

Meanwhile the architecture deliberately manufactures out-of-sync conditions:

Mechanism Source Produces a state with no name
Outage / reconnect ADR-010, TECHSPEC §1 backoff+jitter Reconnecting (no model)
Catch-up after long offline ADR-010 “Behind N” — no model
Secret chats are per-device ADR-025:479-482 Device A’s secret chats simply don’t exist on B — by design, permanently
Module rollback to N-1 ADR-024:439-441 Older module reading newer data format
Data format versions independently of editions ADR-021:852-853 Skew is the expected steady state, not an error
Passkey rotation / re-login ADR-027 Session valid, local ciphertext maybe not
Multi-account RFC §4.8 Per-account state with globally-shown indicator

And NAMING-UX §3 commits to an honest indicator. There is no type to render honestly from.

The specific hole: ADR-015 (“parse, don’t validate”, typestate/enums over boolean flags) is applied rigorously to ReadState and OutboxStatus — and nowhere else. The account↔server relationship, which is where the genuinely awkward states live, got no typestate at all.

Варианты / Options (with tradeoffs)

Section titled “Варианты / Options (with tradeoffs)”

A. Ad-hoc booleans/log lines (status quo).

  • is_offline: bool, last_sync: Option<DateTime>, ad-hoc if in ChatActor.
  • Pros: zero design cost.
  • Cons: exactly what ADR-015 exists to prevent. Each new divergence condition becomes another scattered branch with its own policy, and the UI indicator has nothing coherent to read. Failure is silent and user-visible.

B. One boolean online: bool + a spinner.

  • Pros: simplest thing that satisfies a naive reading of NAMING-UX §3.
  • Cons: it is the lie NAMING-UX explicitly rejects. “Online” with 40,000 messages pending catch-up, running a rolled-back module, and holding secret chats that cannot exist on this device, renders as fine. This is the “degraded-looking offline” problem in a different costume.

C. SyncLedger typestate per account (proposed).

1. One enum per account, owned by the account’s actor, rendered by one indicator

Section titled “1. One enum per account, owned by the account’s actor, rendered by one indicator”
// ADR-015 discipline, applied where it hasn't been yet.
enum AccountSync {
Offline { since: SystemTime, queued_ops: u32 },
Dialing { attempt: u32, backoff: Duration }, // jittered (idea-02's Clock)
CatchingUp { behind: u32, resumed_from: MessageId },
Live { last_update: SystemTime },
Diverged(Divergence),
}
enum Divergence {
SecretChatsAreDeviceLocal, // ADR-025 — permanent, inert, explainable
ModuleRolledBack { from: Version, to: Version }, // ADR-024
DataFormatAhead { have: u32, host_reads: u32 }, // ADR-021, expected skew
SessionReplaced { at: SystemTime }, // re-login elsewhere
OutboxRejected { op: OpKind, server_reason: String }, // server said no, permanently
ClockSkew { drift: Duration },
}

2. The point is not the enum — it is that each variant forces a decision

Section titled “2. The point is not the enum — it is that each variant forces a decision”

Every Divergence variant must be answered with exactly one of:

  • auto-reconcile (a policy function, unit-testable — e.g. DataFormatAhead where the host can read an older format is a no-op warning);
  • block and explain (e.g. OutboxRejected — the message will never send; the UI must say so rather than leaving a permanent spinner);
  • inert and expected (e.g. SecretChatsAreDeviceLocal — do nothing, but the user can be told why device B doesn’t see them).

That trichotomy is the design forcing function. Today those three answers get invented ad hoc, per call site, once each divergence first bites. With the enum, the compiler asks first. It is ADR-015 applied to the one place the project decided to skip it.

Note that SecretChatsAreDeviceLocal is the interesting one: it is not a bug and not recoverable, it is ADR-025 working as designed, and today there is no place to say so. Without a name for it, the only two available behaviours are “silent” (user thinks it’s broken) or “error” (user is told to do something impossible).

3. The honest indicator, per NAMING-UX §3

Section titled “3. The honest indicator, per NAMING-UX §3”

One compact affordance in the account/chats header, rendered from the enum, never from ad-hoc connectivity state:

  • Offline → a quiet, non-blocking line. Not an empty list, not an error. Cached history renders exactly as it does online (ADR-010).
  • CatchingUp { behind } → “syncing, N behind” with live progress. This is the state users of offline-first clients most need and official clients most lack — it converts a wait into an honest number.
  • Diverged(...) → one plain sentence per variant. Not a dialog, not a code. “Secret chats stay on the device they were created on.” (matches the tone ADR-003 asks for in the trusted-key UI: “approachable, not security-theater-scary”).

4. Global aggregation across accounts, mirroring RFC §4.8

Section titled “4. Global aggregation across accounts, mirroring RFC §4.8”

Notification policy is already decided globally, not per account. The sync indicator should aggregate the same way: one status line for the whole app, computed from N per-account ledgers, where the worst account wins but a Diverged account never masks a healthy Live one into looking fine. NotificationActor is the natural owner, or a small sibling to it.

Diverged variants are exactly what a fault-injection harness should be producing — which is why this idea and idea-02-determinism-seam-trace-replay.md belong in the same series. With a TraceClock and a scripted network, “went offline 40% through catch-up, module rolled back to N-1 while offline, secret chat created on another device, came back” is a reproducible trace, and the assertion is a single matches!(ledger, AccountSync::Diverged(Divergence::ModuleRolledBack { .. })). Today that scenario is not expressible as a test at all.

  • It is a new concept, and concepts cost. Someone has to write the per-variant policies, and some will discover they want a state not in the list. That is the point — discovering it at design time is cheap; discovering it from a user report is not.
  • Deliberately not exhaustive. Divergence is a small closed set of cases we know about. It is not a general consistency checker. Its job is to force a decision per known case and to make the unknown case visible (an #[non_exhaustive] enum lets future variants be added without breaking matches).
  • Cost of deferral is low but the reputational cost is not. NAMING-UX §3 already promised an honest indicator. Promising honesty and shipping a boolean is worse than not promising it.