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
| Signal | Meaning |
|---|---|
| ⛅ hollow-cloud icon | Item has unsynced or unresolvable state; tap to open sync-status sheet |
| Sync-status sheet | Per-backend queue state, per-attachment state; the single consistent surface for sync detail |
| Amber group banner | Group-level warning requiring user attention |
| Inline row copy | Short 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:
| Table | Purpose |
|---|---|
outbox_events | All events pending backend publication; owns retry state; persists across crashes |
undecryptable_events | Raw ciphertext + failure reason for events the projector cannot process |
attachment_upload_queue | Attachment uploads decoupled from expense event creation |
epoch_pending_events | Events buffered during key-epoch transitions |
write_phases | Write-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 reason | User-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 toepoch_pending_eventsonce the right epoch key arrives.WRONG_KEY→ re-attempted after every key sync.CORRUPT_CIPHERTEXT→UNRECOVERABLEafter 3 key-sync cycles with no improvement.
Recovery
| Reason | Recovery |
|---|---|
UNKNOWN_EPOCH | Automatic after key sync |
WRONG_KEY | Automatic after key sync |
CORRUPT_CIPHERTEXT | User 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 status | Action |
|---|---|
stable — known and current | Decrypt immediately |
rotation_prepared — installing | Buffer in epoch_pending_events |
rotation_committed — awaiting propagation | Buffer in epoch_pending_events |
rotation_applied — complete | Flush and decrypt buffer |
unknown | Treat 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
| Category | Recovery |
|---|---|
REVOKED_MEMBER_PRE | Organiser can tap "Apply this expense" → creates a new admin-authored event with on_behalf_of provenance referencing the original event ID |
REVOKED_MEMBER_POST | No 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 code | User-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
| Code | Internal action |
|---|---|
AUTH_REQUIRED | challenge/response in background; event reset to pending after re-auth |
RATE_LIMITED | Backoff per backend response Retry-After header if present; otherwise exponential |
QUOTA_EXCEEDED | Status → blocked_quota; no auto-retry |
INVALID_EVENT | Status → terminal; do not retry; offer diagnostics export |
UNKNOWN | Exponential backoff; surface after 5 failures |
Recovery
| Code | Recovery |
|---|---|
AUTH_REQUIRED | Automatic re-auth; user action only if backend needs a new token |
RATE_LIMITED | Automatic |
QUOTA_EXCEEDED | User action: Settings → Backends → add or switch backend; all blocked_quota events re-queued automatically |
INVALID_EVENT | User 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:
| Category | Status |
|---|---|
| Transient (network) | pending; retry |
| Auth failure | auth_required; re-auth then retry |
| Size/type rejected | terminal; user action required |
| Cache evicted before upload | evicted; cannot retry without re-attach |
Recovery
| Category | Recovery |
|---|---|
| Transient | Automatic. Tap retry icon → "Retry upload now." |
| Auth | User 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):
- Pull from other backends / re-request from peers — query every backend in
group_storageand, via the high-water-mark exchange (§2.5), ask any peer that advertises the missingauthor_seqrange to re-serve those signed events. ClearsINCOMPLETE_HISTORYautomatically once every member'scontiguous_seqreaches itsclaimed_seq. This is the normal, automatic path. - 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.
- 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.
- 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
SettlementSnapshotpinning 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 byVerifyingEventSink, 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
| Phase | Recovery |
|---|---|
draft_created / event_built | User decision: Finish or Discard |
local_committed and beyond | Automatic |
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:
- Try again (phrase may be mistyped or words in wrong order).
- Create a new identity → ask group admins to re-invite to each group.
Error State Summary Table
| # | Error | User sees | Automatic? | User action if needed |
|---|---|---|---|---|
| 1 | Backend unreachable | ⛅ + calm banner | ✅ | Tap "Retry now" |
| 2 | Can't decrypt | Placeholder row + reason copy | ✅ for UNKNOWN_EPOCH / WRONG_KEY | Remove placeholder (CORRUPT) |
| 3 | Key rotation in progress | Invisible / ⛅ tooltip | ✅ | Repair rotation (organiser) |
| 4 | Revoked member late events | Organiser badge only | Partial | Apply or ignore (organiser) |
| 5 | Backend rejection | ⛅ + reason copy | ✅ for auth / rate | Switch backend (quota); export diagnostics (invalid) |
| 6 | attachment storage upload fails | "Receipt — upload pending" | ✅ transient | Re-attach (evicted); re-auth (auth) |
| 7 | Sync gap / pruned events | Amber banner + hard block | ❌ | Device sync / backup / acknowledge |
| 8 | Crash mid-write | Recovery sheet (draft only) | ✅ post-commit | Finish or Discard (draft) |
| 9 | Concurrent writes | Both appear (correct) | ✅ | Delete duplicate (human error) |
| 10 | Wrong recovery phrase | Inline error + cooldown | ❌ | Re-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:
| Term | Meaning | FEN stance |
|---|---|---|
| Bad optimism | Show before event is built, validated, signed, and durably stored | ❌ Never |
| Good local-first optimism | Show after local_committed + projected, before backend publication | ✅ Default |
| Misleading optimism | Imply other participants have seen or accepted the change | ❌ Never |
Write-Phase Reminder
draft_created → event_built → local_committed → projected → queued_for_publish → published
| Gate | What it means | UI update here? |
|---|---|---|
event_built | Event object exists in memory | ❌ Crash/power loss loses it |
local_committed | Event is in the append-only log | Not yet — projection not updated |
projected | SQLite read model reflects the event | ✅ Yes — default gate |
published | At least one backend acknowledged | Only 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.
| Operation | Rationale |
|---|---|
| Create expense | Fully reversible via deletion event |
| Edit expense | New event referencing original; local projection wins until peers sync |
| Add note / attachment | No financial state; low-stakes divergence |
| Create group | No pre-existing peer state to conflict with |
| Record a payment | Append-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.
| Operation | Required signal |
|---|---|
| Delete expense (tombstone) | "Pending deletion — not yetvisible to others" |
| Mark debt as settled | Settlement 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.
| Operation | Why confirmed |
|---|---|
Key rotation / KeyEpochUpdate | Other users cannot decrypt new events until they receive the new key bundle |
Storage rotation / StorageKeyRotated | The 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 bundle | Invitee cannot participate until the backend delivers their encrypted bundle |
| Account / device registration | Identity 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
projectedand never block on backend availability, except operations that establish cryptographic preconditions for other users (key operations, group invitations with key bundles), which must reachpublishedbefore 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
| ID | Invariant |
|---|---|
| I-OPT-1 | UI 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-3 | No phantom rollback. The local projection never regresses due to backend failure. Only a committed compensating event may change already-projected state. |
| I-OPT-4 | Compensating 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-5 | Conflict 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-6 | Total 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-7 | Key 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-8 | Projection 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-9 | Sync-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-10 | Confirmation semantics. published means only "accepted by the configured backend policy." It does not mean "delivered," "read," or "agreed to" by any participant. |
| I-OPT-11 | Dependency 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-12 | Idempotent 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:
- Does this operation change the encryption key set used by other participants?
→ Yes: Tier C. Wait for
published. - Does this operation carry financial finality in the user's mental model (settled debt, confirmed deletion, member removal)?
→ Yes: Tier B. Optimistic at
projectedwith prominent sync-pending language. - 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.
- Anything else?
→ Tier A. Optimistic at
projected, ⛅ icon.