Skip to main content

FEN — Error States & Optimistic-UI Policy (§3.12–3.13)

Part of the FEN design set. Overview, index, and the legacy-terminology map: fen.md.

3.12 Error State Dispositions

This section defines the canonical user-visible outcome, internal handling, and recovery path for every error category that can arise in the app. It is normative: features must match these dispositions.

Governing Conventions

Visual language

SignalMeaning
⛅ hollow-cloud iconItem has unsynced or unresolvable state; tap to open sync-status sheet
Sync-status sheetPer-backend queue state, per-attachment state; the single consistent surface for sync detail
Amber group bannerGroup-level warning requiring user attention
Inline row copyShort calm explanation directly beneath the affected expense row

Tone rule: calm and concrete. Never "something went wrong." Always state what happened and what the user can do. Never blame the user.

Internal tables involved:

TablePurpose
outbox_eventsAll events pending backend publication; owns retry state; persists across crashes
undecryptable_eventsRaw ciphertext + failure reason for events the projector cannot process
attachment_upload_queueAttachment uploads decoupled from expense event creation
epoch_pending_eventsEvents buffered during key-epoch transitions
write_phasesWrite-phase state machine for crash recovery

Error 1 — Backend Unreachable at Add-Expense Time

User-visible outcome

Expense appears immediately in the list; balances update optimistically. Neutral ⛅ icon on the row. Non-modal banner: "Changes saved locally. Waiting to sync." Banner is grey, not red.

Internal handling

SQLite transaction: expense row written to local_events first, then outbox_events row inserted atomically. Background SyncWorker retries with exponential backoff:

5s → 30s → 2m → 10m → 30m → 1h → 6h

After 50 consecutive failures → status = stalled; group banner: "1 expense is saved only on this device. Tap to retry."

Recovery

Fully automatic. Pull-to-refresh or tapping the banner → "Retry now." No data loss is possible; events are durable in SQLite.


Error 2 — Event Cannot Be Decrypted

User-visible outcome

Placeholder row in the expense list: "1 expense is waiting to be decrypted." Tapping the row shows a bottom sheet with reason-specific copy:

Failure reasonUser-facing copy
WRONG_KEY"This device does not have the key needed to read an event from [date]."
CORRUPT_CIPHERTEXT"An event from [date] could not be verified and was ignored."
UNKNOWN_EPOCH"This event uses a newer group key. Sync keys from another device to continue."

No balance change. After 24 h unresolved → amber badge in Settings → Security.

Internal handling

Raw ciphertext stored in undecryptable_events with failure_reason. Projector skips the event entirely.

  • UNKNOWN_EPOCH → fires a key-request DM to key-custodian members; event moved to epoch_pending_events once the right epoch key arrives.
  • WRONG_KEY → re-attempted after every key sync.
  • CORRUPT_CIPHERTEXTUNRECOVERABLE after 3 key-sync cycles with no improvement.

Recovery

ReasonRecovery
UNKNOWN_EPOCHAutomatic after key sync
WRONG_KEYAutomatic after key sync
CORRUPT_CIPHERTEXTUser action: Settings → Security → "Remove placeholder." One-tap confirmation. Originating device can re-publish.

Error 3 — Key Rotation in Progress When Expense Arrives

User-visible outcome

Invisible in the happy path. If processing delay > 3 s → ⛅ with tooltip: "Verifying encryption key — almost done."

Internal handling

Epoch state machine:

Epoch statusAction
stable — known and currentDecrypt immediately
rotation_prepared — installingBuffer in epoch_pending_events
rotation_committed — awaiting propagationBuffer in epoch_pending_events
rotation_applied — completeFlush and decrypt buffer
unknownTreat as Error 2 (UNKNOWN_EPOCH)

Non-reentrant mutex on key_epochs writes prevents partial state escaping to the projector.

Recovery

Fully automatic once KeyEpochUpdate propagates. If KeyEpochUpdate never arrives (organiser device crashed mid-rotation) → organiser: Settings → Group → Security → "Repair key rotation" → re-publishes the epoch event.


Error 4 — Revoked Member's Late Events Arrive

User-visible outcome

Nothing changes for other members. Organiser receives a one-time in-app badge on Group → Members: "[Name] was removed. [N] of their offline changes arrived after removal and were not applied." A read-only list of rejected events is available. No other member sees this notification.

Internal handling

Every incoming event is checked against membership_epochs using the event's created_at timestamp (not backend-arrival time).

  • Event authored before revocation (REVOKED_MEMBER_PRE): stored, not projected. Organiser can review.
  • Event authored after revocation (REVOKED_MEMBER_POST): never stored; discarded at the backend-message handler.

Recovery

CategoryRecovery
REVOKED_MEMBER_PREOrganiser can tap "Apply this expense" → creates a new admin-authored event with on_behalf_of provenance referencing the original event ID
REVOKED_MEMBER_POSTNo recovery. Copy: "Re-add them to the group if this was an error."

Error 5 — Backend Rejects a Write

User-visible outcome

⛅ on item; sync-status sheet with reason-specific copy:

Rejection codeUser-facing copy
AUTH_REQUIRED"Backend sign-in expired. Tap to reconnect."
RATE_LIMITED"Backend is busy. Retrying in [countdown]."
QUOTA_EXCEEDED"Backend storage is full. Switch backend or contact the backend operator."
INVALID_EVENT"This backend could not accept an event. Tap to export diagnostics."
UNKNOWN"Backend declined this event ([NOTICE text])."

Internal handling

CodeInternal action
AUTH_REQUIREDchallenge/response in background; event reset to pending after re-auth
RATE_LIMITEDBackoff per backend response Retry-After header if present; otherwise exponential
QUOTA_EXCEEDEDStatus → blocked_quota; no auto-retry
INVALID_EVENTStatus → terminal; do not retry; offer diagnostics export
UNKNOWNExponential backoff; surface after 5 failures

Recovery

CodeRecovery
AUTH_REQUIREDAutomatic re-auth; user action only if backend needs a new token
RATE_LIMITEDAutomatic
QUOTA_EXCEEDEDUser action: Settings → Backends → add or switch backend; all blocked_quota events re-queued automatically
INVALID_EVENTUser action: export diagnostics; contact support; consider re-creating the expense

Error 6 — Attachment Upload Fails

Architecture decision: Expense creation and attachment upload are fully decoupled. The expense event is created with the deterministic attachment storage content URL (SHA-256-addressed) immediately. Upload is a separate background operation. This means the expense syncs to all devices regardless of whether the attachment is available.

User-visible outcome

Expense saved and synced to all devices. Thumbnail visible locally. In expense detail: "Receipt — upload pending" + retry icon. On other devices before upload completes: "Receipt not yet available." No balance impact.

Internal handling

attachment_upload_queue table with its own exponential backoff worker, independent of outbox_events. Failure categories:

CategoryStatus
Transient (network)pending; retry
Auth failureauth_required; re-auth then retry
Size/type rejectedterminal; user action required
Cache evicted before uploadevicted; cannot retry without re-attach

Recovery

CategoryRecovery
TransientAutomatic. Tap retry icon → "Retry upload now."
AuthUser action: Settings → attachment storage → re-authenticate
Cache evicted"The receipt file was removed from this device. Re-attach to upload." Tap → re-opens file picker
Terminal / permanent"Remove receipt reference" with explanation that other devices will see it as unavailable

Error 7 — Sync Gap / Backend Missing Events

User-visible outcome

Group view shows a blocking amber banner: "This group's history may be incomplete — we could not find all events from the backend. Balances shown may not be accurate." All balance figures rendered with ⚠️ prefix. Settlement and member-management actions are hard-blocked (not just warned) while the flag is active.

Internal handling

Gap detection is provable and backend-independent: for every current member, compare author_frontier.contiguous_seq to claimed_seq. claimed_seq is itself provable — it advances only on receipt of a validly-signed event authored by that member (§3.9, data-model §3.12), never from an unauthenticated high-water-mark advertisement — so no peer can raise another member's flag by advertising a fabricated future seq. Any member with contiguous_seq < claimed_seq has a known-missing author_seq range ⇒ set the INCOMPLETE_HISTORY flag on the group row and record exactly which (author_id, seq-range) are missing (so recovery can request precisely those). This does not depend on any single backend's cursor — a backend that silently pruned events is detected because the per-author numbering reveals the hole. The projector continues over available events but marks all balance output as partial_balance. Settlement and membership writes are hard-blocked while the flag is set: money never moves on a log that cannot be proven gap-free. That block is bounded, not a permanent one-actor freeze: a frontier that stays stalled past the auto-heal window is escaped by recovery option 4 below (data-model §3.12).

Conversely, the flag clears — and the hard block lifts — the moment contiguous_seq == claimed_seq for every member, i.e. the device can prove it holds a contiguous author_seq 1..Nᵢ for all of them. "Complete" is a checkable property of the local log, not an assumption that a server had everything.

Recovery

Four options surfaced in a sheet (the app already knows the exact missing (author_id, seq-range) list, so each option targets precisely those events):

  1. Pull from other backends / re-request from peers — query every backend in group_storage and, via the high-water-mark exchange (§2.5), ask any peer that advertises the missing author_seq range to re-serve those signed events. Clears INCOMPLETE_HISTORY automatically once every member's contiguous_seq reaches its claimed_seq. This is the normal, automatic path.
  2. Sync from another member's device (QR) — direct device-to-device transfer of the encrypted events covering the missing ranges, for when no backend/peer is reachable online.
  3. Acknowledge incompleteness — amber warning stays permanently visible; user can proceed with known-incomplete data. Hard block lifts for this specific user only. Other members are unaffected.
  4. Organiser settlement snapshot (group-level, bounded escape) — when a member's frontier stays stalled past a bounded timeout (options 1–2 have demonstrably failed, not merely started), the organiser authors a SettlementSnapshot pinning the balances computed over the provable subset of the log; it overrides the gate for the whole group ("settle on what's provable"). Unlike option 3 this settles group money, so it is organiser-only (enforced at ingest by VerifyingEventSink, issue #26) and fully auditable — the snapshot records the exact per-member cut and which members were overridden. This is the escape that stops one member permanently freezing settlement (design-review finding #5 / issue #20); the mechanism is specified in data-model §3.12.

Error 8 — App Crash Mid-Write

User-visible outcome

Usually invisible — recovery is automatic on next launch. If a draft was partially written: bottom sheet on group open: "We found an expense that did not finish saving. Would you like to finish saving it or discard it?" — two buttons: Finish saving / Discard.

Internal handling

Six-phase write state machine persisted in write_phases:

draft_created → event_built → local_committed → projected → queued_for_publish → published

On launch, any row in phase draft_created or event_built → recovery prompt. local_committed and beyond → treated as Error 1 (in outbox, will sync automatically). Re-publishing a signed event with the same ID is idempotent at the backend — no duplicate.

Recovery

PhaseRecovery
draft_created / event_builtUser decision: Finish or Discard
local_committed and beyondAutomatic

Error 9 — Concurrent Writes from Two Devices

User-visible outcome

Both expenses appear. No merge, no suppression. Each shows normally in the timeline. This is correct behaviour — they are two separate valid facts, not a conflict.

Internal handling

Each device writes its own event with a unique event_id and its own author_seq. Both reach all peers (via the backend union). The projector on every device applies both in (lamport, event_id) order — a device-independent total order, so every device renders the two events identically. No conflict resolution needed.

Recovery

None required. If the user intended only one expense (human error), they delete one using the normal expense-deletion flow. If users find the duplicate confusing, the UI may offer a one-tap "Remove duplicate" helper that deletes the more-recently-created event.


Error 10 — Key Restore Failure (Wrong Recovery Phrase)

User-visible outcome

The Continue button stays disabled until the phrase validates. After a failed attempt: inline copy beneath the phrase field: "This phrase doesn't match. Check for typos — each word matters." After 3 failed attempts, additional copy: "If you don't have your phrase, you can create a new identity — but you'll need a group member to re-invite you." A 5-second cooldown between attempts serves as a UX signal that something is wrong; no hard lockout (phrases are not brute-forceable within a meaningful threat model for this use case).

Internal handling

Phrase → Ed25519 seed derivation is fully local. No network call. Failed derivation is immediate (wrong checksum or wrong words). Local diagnostic attempt counter incremented for support bundle only.

Recovery

User action only — the app cannot recover a wrong phrase. Two paths:

  1. Try again (phrase may be mistyped or words in wrong order).
  2. Create a new identity → ask group admins to re-invite to each group.

Error State Summary Table

#ErrorUser seesAutomatic?User action if needed
1Backend unreachable⛅ + calm bannerTap "Retry now"
2Can't decryptPlaceholder row + reason copy✅ for UNKNOWN_EPOCH / WRONG_KEYRemove placeholder (CORRUPT)
3Key rotation in progressInvisible / ⛅ tooltipRepair rotation (organiser)
4Revoked member late eventsOrganiser badge onlyPartialApply or ignore (organiser)
5Backend rejection⛅ + reason copy✅ for auth / rateSwitch backend (quota); export diagnostics (invalid)
6attachment storage upload fails"Receipt — upload pending"✅ transientRe-attach (evicted); re-auth (auth)
7Sync gap / pruned eventsAmber banner + hard blockDevice sync / backup / acknowledge
8Crash mid-writeRecovery sheet (draft only)✅ post-commitFinish or Discard (draft)
9Concurrent writesBoth appear (correct)Delete duplicate (human error)
10Wrong recovery phraseInline error + cooldownRe-try or create new identity

3.13 Optimistic UI Policy

Governing Principle

FEN is local-first. The backend is a sync transport, not an authority. A write is durable the moment it is committed to the local append-only log and applied to the SQLite projection — backend publication is asynchronous distribution metadata and is never required for local visibility.

The right gate is projected. The UI updates when the projection updates. It does not update when the backend confirms. These are independent events.

In a traditional client-server app, "optimistic UI" means showing a result before the server validates it — with the risk of server rejection and rollback. In FEN, the local node is the authoritative writer for its own events. The append-only log means there is no rollback: only compensating events. The optimistic projection and the ground truth are always aligned by construction.

Three usages of "optimistic" that must not be conflated:

TermMeaningFEN stance
Bad optimismShow before event is built, validated, signed, and durably stored❌ Never
Good local-first optimismShow after local_committed + projected, before backend publication✅ Default
Misleading optimismImply other participants have seen or accepted the change❌ Never

Write-Phase Reminder

draft_created → event_built → local_committed → projected → queued_for_publish → published
GateWhat it meansUI update here?
event_builtEvent object exists in memory❌ Crash/power loss loses it
local_committedEvent is in the append-only logNot yet — projection not updated
projectedSQLite read model reflects the eventYes — default gate
publishedAt least one backend acknowledgedOnly for Tier C operations (see below)

Operation Tiers

Tier A — Optimistic at projected, silent background sync

The ⛅ icon carries the communication burden. No additional user-facing signal needed.

OperationRationale
Create expenseFully reversible via deletion event
Edit expenseNew event referencing original; local projection wins until peers sync
Add note / attachmentNo financial state; low-stakes divergence
Create groupNo pre-existing peer state to conflict with
Record a paymentAppend-only math; reversible

Tier B — Optimistic at projected, prominent unsynced signal

Same update speed as Tier A — still shows immediately at projected. The difference is the language around the ⛅ state. A grey cloud next to a number is fine for a line item; it is not fine when the user believes a financial fact is settled.

OperationRequired signal
Delete expense (tombstone)"Pending deletion — not yetvisible to others"
Mark debt as settledSettlement confirmation screen shows ⛅ with "Peers may still see an outstanding balance until this syncs"
Remove a group member"Removal pending sync — this person retains read access until the change reaches all backends"

Tier C — Wait for published before declaring success

These operations create cryptographic preconditions that other participants must satisfy before they can function. Showing local success while peers are locked out creates incorrect group state and a support catastrophe.

Block the UI. Show a progress indicator. Require at least one backend echo before proceeding.

OperationWhy confirmed
Key rotation / KeyEpochUpdateOther users cannot decrypt new events until they receive the new key bundle
Storage rotation / StorageKeyRotatedThe old S3 credential is on a revocation deadline the moment the provisioner rotates; members can only switch once this event is durably readable in the bucket (data-model.md §3.10)
Group invitation with key bundleInvitee cannot participate until the backend delivers their encrypted bundle
Account / device registrationIdentity operations that other actors depend on to verify event signatures

General rule: if the operation creates a precondition another user must satisfy to function, wait for published.


What Can Go Wrong

Storage credential rotated while a member was offline

A member who was offline through an entire storage-rotation hand-off window (data-model.md §3.10) returns to a StorageDescriptor whose every S3 call now fails with an authorization error. Distinguish this from ordinary unreachability: the endpoint responds, but authentication is rejected on a previously-working descriptor.

User-visible outcome: "Your access to [group] was rotated while you were away. Ask [organiser] for a new invite link to reconnect." Local data stays fully readable; the outbox holds unsynced events instead of dropping them.

Recovery: the organiser re-issues an invite link (a pure credential re-delivery — identity and membership are unchanged, no MemberJoined is published). After the descriptor is restored, the outbox flushes normally, and any missed epochs ride the existing KeyRewrapRequest flow (Error 2 / Error 3 machinery).

Backend permanently unreachable

The event stays projected locally; the outbox retries indefinitely with exponential backoff. Never auto-delete a locally-committed event. When the backend becomes reachable again — even days later — the outbox resumes. The user's view of their own data does not change due to network failure. Surface a human-readable warning ("3 items not synced for 7 days") but do not revert local state.

Permanent publish failure (publish_failed)

If all configured backends permanently reject an event, mark it publish_failed — a more severe and prominent state than ordinary ⛅. Other members may never see the change.

User actions to surface:

  • Retry
  • Change backend
  • Export / share event manually
  • Append a correction or void event
  • Keep as local-only (with explicit disclosure)

The design doc must decide: are long-term local-only committed events permitted? Yes — but they must remain permanently and visibly marked as local-only. They are never silently promoted.

Concurrent edits (the critical case)

Alice and Bob both edit expense #42 while offline. Both see their own version optimistically at projected. When they sync, both events arrive at the backend in some order.

Conflict resolution rule: last-write-wins by created_at Unix milliseconds; tie-break by event_id lexicographic order. This rule is deterministic, auditable, and consistent across all nodes given the same event set.

Silent conflict resolution is never acceptable for financial data. When an incoming peer event would overwrite a locally-projected value on an item that is still ⛅, the projector applies the rule AND surfaces a conflict notification:

"Alice also edited this expense. Her version was applied. [See both versions]"

"See both versions" links to an event log diff. The user is not forced to choose — they are simply informed. No code path may silently flip a financial figure without notification.

Balance views and derived-value contamination

If an unsynced expense contributes to a balance, that balance is also partly unsynced. The UI must not display a clean total that hides contributing ⛅ events.

Any view that shows a monetary total or debt balance must state:

"You owe CHF 42.00 (includes 2 unsynced changes — peers may see a different total)"

Settlement flows must show a stronger warning before a user settles based on local-only pending state.

Undo

  • Before local_committed: discard the draft — no event was written.
  • After local_committed: undo is a new compensating event (void, correction, revert). History is never mutated invisibly. The original event may already have reached one backend even if another backend failed; the compensating event is the only safe path.

Projection failure

Projection must be fully replayable from the local event log at any time. On startup or on any projection error, offer "rebuild read model from log" as a recovery path. Never discard log events to fix a projection.

Phantom confirmation bias

Users learn the UI always confirms immediately and stop checking ⛅. When a long-offline device is used, they may believe debts are settled that peers have never seen.

Mitigation: ⛅ must be persistent and legible — not a tiny grey dot. In all debt summary views, unsynced item counts must be explicit. Financial summary screens are not permitted to show a clean total alongside hidden unsynced events.


Policy Statement

FEN is local-first. All user-initiated write operations take effect in the local read model immediately upon projected and never block on backend availability, except operations that establish cryptographic preconditions for other users (key operations, group invitations with key bundles), which must reach published before the UI declares success.

The backend is a sync transport, not an authority. The ⛅ icon is the primary signal for the backend gap. Extended unsynced states trigger informational (not blocking) warnings, except for the hard block on incomplete-history settlement (§3.12 case 7).

No balance or debt view may show a clean monetary total that hides contributing unsynced events. Concurrent financial edits resolve deterministically with a visible conflict notification — never silently.


Invariants

IDInvariant
I-OPT-1UI update gate. The UI reflects the projection state. It updates at projected, never before, never tied to published for Tier A/B operations.
I-OPT-2⛅ gate. ⛅ appears at queued_for_publish and clears only at published. No code path short-circuits this.
I-OPT-3No phantom rollback. The local projection never regresses due to backend failure. Only a committed compensating event may change already-projected state.
I-OPT-4Compensating events are first-class. Every event kind that can fail post-commit must have a defined compensating event kind in the protocol schema. The outbox may generate and commit compensating events without user interaction when warranted.
I-OPT-5Conflict visibility. When an incoming peer event overwrites a locally-projected value on an item still ⛅, the projection applies the deterministic resolution rule AND surfaces a conflict notification. Silent overwrites of unconfirmed local state are never acceptable.
I-OPT-6Total ordering is canonical. All events are ordered by (created_at_unix_ms, event_id_lexicographic). This is the single source of truth for conflict resolution. Network arrival order and backend timestamps do not override it.
I-OPT-7Key operations are synchronous to the backend. Any operation that changes the encryption key set used by other participants blocks until at least one backend acknowledges. The success screen includes backend confirmation state. Timeout = operation not complete.
I-OPT-8Projection is fully replayable. The SQLite projection contains no state that cannot be reconstructed by replaying the local event log from genesis. Projection is a cache; schema migrations replay the log against the new schema.
I-OPT-9Sync-state propagation. Any view showing a monetary total or debt balance must also show the count of ⛅ items contributing to it. Financial summary screens may not show a clean total alongside hidden unsynced events.
I-OPT-10Confirmation semantics. published means only "accepted by the configured backend policy." It does not mean "delivered," "read," or "agreed to" by any participant.
I-OPT-11Dependency ordering. A dependent event (e.g. ExpenseUpdated) shares the expense_id of the event it depends on and, by the Lamport rule, always carries a strictly higher lamport than that dependency. Dependents are not projected until the depended-on event exists locally; out-of-order arrivals are buffered as pending-orphan events and flushed in (lamport, event_id) order (see §3.8 pending-orphan rule).
I-OPT-12Idempotent publish. Retries publish the same event bytes with the same event ID. Never rebuild a new event to resolve a publish failure. The outbox is idempotent by event_id.

Decision Tree for New Write Operations

When designing a new write operation in FEN, answer in order:

  1. Does this operation change the encryption key set used by other participants? → Yes: Tier C. Wait for published.
  2. Does this operation carry financial finality in the user's mental model (settled debt, confirmed deletion, member removal)? → Yes: Tier B. Optimistic at projected with prominent sync-pending language.
  3. Does this operation have a well-defined compensating event kind? → No: define one before shipping. Post-commit operations with no compensating path are a protocol gap.
  4. Anything else? → Tier A. Optimistic at projected, ⛅ icon.