FEN / 分 — Design Document
Version 0.13 · June 2026 Status: Draft
0.13 — Decentralised ordering & completeness. Event order and event-loss detection no longer depend on a backend-assigned global
seq_num(a central component). Each event now carries a signed Lamport clock (canonical cross-device order via(lamport, event_id)) and a signed per-authorauthor_seq(contiguous numbering that makes loss provable). The storage backend is best-effort transport: clients reconcile the union of per-author log files, and a per-file ETag is only a private pull cursor. Settlement is hard-blocked until the local log is provably gap-free for every member. Affected: §2.5, §2.8, and the data-model / security companion docs.
Document set
FEN's design is split across this overview and focused companion documents. Rule: overview and rationale live here; deep, normative, or bulky material lives in its own file. Reference, don't duplicate.
| Document | Covers | Status |
|---|---|---|
| fen.md (this) | Executive summary (§1), architecture overview (§2), this index | living |
| data-model.md | §3.1–3.10 — event log, entities, SQLite schema, storage/API contract, split & balance rules, expense lifecycle, conflict resolution, key epochs | normative |
| money.md | §3.11 — fixed-point arithmetic, split allocation, rounding, multi-currency | normative |
| error-handling.md | §3.12–3.13 — error-state dispositions & optimistic-UI policy | normative |
| canonical-serialization.md | Byte-level event serialization, signing, AAD — fmt = 1 | normative · frozen |
| security.md | §4 — crypto primitives, key hierarchy, threat model, privacy, portability/deletion, key backup, storage-migration review | normative |
| key-management.md | §8 — identity, backup, recovery/restore, device management, rotation | normative |
| backend-architecture.md | Managed Garage (v1) + planned BYO self-hosted S3 (v2) backends, provisioning, migration, resilience | design |
| implementation.md | §5 — framework, critical path, POC sprint, milestones, sync strategy, timeline | design |
| testing.md | §6 — unit/integration/golden/E2E + conformance, CI | design |
| operations.md | §7 — support, remote config, crash reporting, sync health, incident response | design |
| appendices.md | §9 — tech stack, future iterations, legacy /attachment storage hosting (superseded), open questions, glossary | reference |
Table of Contents (this document)
Sections 3–9 now live in the companion documents listed above.
1. Executive Summary & Goals
What FEN Is
FEN is a mobile-first expense-sharing app for small groups of friends on trips or shared events. A group of friends travelling together (e.g. "Dublin4Ever" — Alice, Bob, and May) can:
- Create a shared group and invite members via a link
- Log expenses in any currency (who paid, how much, who it's split between)
- Calculate who owes whom at the end, converting all amounts to a chosen settlement currency
- Record settlements and formally close the group
Core Design Principles
| Principle | Meaning |
|---|---|
| Data sovereignty | Users own their data. No vendor lock-in. Data lives on members' devices and in a storage backend the group owner controls — never in a proprietary FEN database. |
| Minimal server, maximal sovereignty | FEN operates only one small, dumb, ciphertext-only store for the Managed tier; power users self-host instead. The server never sees plaintext. |
| Low setup friction | Non-technical users get a working group in one tap on the Managed backend — no per-member account, no server configuration. |
| Offline first | The app works fully without internet. Sync happens in the background when connectivity is available. |
| Privacy by design | The storage backend holds only encrypted ciphertext and cannot read any expense data — it sees only ciphertext plus access metadata (pseudonymous pubkeys, sizes, timestamps). |
| Open & portable | Data is exportable as plain JSON/JSONL at any time. The on-storage event-file layout is documented. |
Storage Model
A group's encrypted event log lives in one backend the group is bound to (full design: backend-architecture.md):
| Tier | Ships in | Who it's for | Backend | How members get write access |
|---|---|---|---|---|
| Managed (default) | v1 | Non-technical users | Garage (S3), FEN-hosted | The app provisions a per-group bucket + scoped key at creation; members read/write directly with that key. No per-member account. |
| Bring your own S3 | v2, planned | Power users | Any S3-compatible endpoint the user runs or trusts (their own Garage, MinIO, …) | The user supplies a connection string (endpoint, bucket, access key id, secret access key); reuses the Managed tier's S3Backend unchanged — same adapter, user-sourced credentials. |
Both tiers store only ciphertext — the cryptography (see security.md) is identical across backends — and both keep one backend per group as the hub. They sit behind one StorageBackend adapter (§2.7) so all product, ledger, and crypto code is backend-agnostic, whether or not the BYO tier has shipped yet.
Note. WebDAV was considered as the BYO tier and dropped from the design; a self-hosted S3-compatible endpoint is a strict simplification (one adapter serves both tiers) and is the planned v2 BYO option instead. See §9.2 of appendices.md for the full rationale, including why Nextcloud specifically might be reconsidered later as an additional power-user option.
Note. The backend is untrusted and sees only ciphertext; the per-member Ed25519 signing key and all content encryption are backend-independent. Detailed backend design is in backend-architecture.md; the migration/critical review is in security.md §4.8.
Non-Goals (v1)
- No web client
- No receipt scanning or OCR
- No recurring expenses
- No multi-group settlement
- No currency futures / locked-in rates beyond what is agreed in the group
2. Architectural Overview
2.1 System Context
C4Context
title FEN — System Context
Person(alice, "Alice (Organiser)", "Creates group, invites members, manages exchange rates, closes group")
Person(bob, "Bob / May (Member)", "Logs expenses, views balances, records settlements")
System(app, "FEN App", "Flutter mobile app for iOS and Android. All logic, calculation and encryption runs on-device.")
System_Ext(store, "Storage Backend", "One ciphertext-only store per group. Managed (v1): FEN-hosted Garage (S3). BYO self-hosted S3 (v2, planned): user's own S3-compatible endpoint. Cannot read any data.")
System_Ext(fxapi, "Exchange Rate API", "Free public REST API (e.g. frankfurter.app). Queried for live rates.")
Rel(alice, app, "Creates group, logs expenses, sets rates, closes group")
Rel(bob, app, "Logs expenses, views balances, records settlements")
Rel(app, store, "Writes own encrypted log file / lists & pulls others'", "HTTPS (S3)")
Rel(app, fxapi, "Fetches live exchange rates", "HTTPS")
%% No push tier: the backends cannot push without a server. Peers sync on open/resume/timer (see §2.9).
2.2 Component Architecture
graph TB
subgraph Device["📱 User Device (source of truth)"]
UI["UI Layer\n(Flutter / Riverpod)"]
Engine["Ledger Engine\n- balance calc\n- debt simplification\n- FX conversion"]
Crypto["Crypto Layer\n- libsodium\n- XChaCha20-Poly1305\n- X25519 ECDH"]
DB["Local SQLite\n- event log table\n- projected tables\n- outbox"]
Sync["Sync Manager\n- outbox flush\n- pull by cursor\n- retry on failure"]
end
subgraph Store["☁️ Storage Backend (owner-controlled)"]
API["StorageBackend adapter\n- putObject(path, ciphertext)\n- getObject(path)\n- list(prefix)\n- delete(path)"]
KV["the Managed backend (happy path)\nor a self-hosted S3-compatible endpoint (v2, planned)\nHolds encrypted per-author log files"]
end
UI --> Engine
UI --> Crypto
Engine --> DB
Crypto --> DB
Sync --> DB
Sync <--> API
API --> KV
style Device fill:#f0f4ff,stroke:#4a6cf7
style Store fill:#fff4e6,stroke:#f97316
2.3 Local-First Data Flow
sequenceDiagram
participant U as User
participant App as FEN App
participant DB as Local SQLite
participant Store as Storage Backend (Garage)
participant Other as "Other Members' Apps"
U->>App: Log expense "Guinness €21.60"
App->>App: Create & sign GroupEvent (ExpenseLogged)
App->>DB: INSERT into event_log (immediate)
App->>App: Rebuild materialised state (balances update instantly)
App-->>U: ✅ Expense visible immediately
App->>Store: PUT own append-only log file (encrypted event appended)
Store-->>App: 200 OK (ETag / revision)
Note over Store,Other: No server push — peers sync on app open / resume / 30s timer / manual
Other->>Store: LIST group folder + GET changed log files (by ETag/mtime)
Store-->>Other: [encrypted event blobs]
Other->>Other: Decrypt yields verify signature yields INSERT into local DB
Other->>Other: Rebuild materialised state
2.4 Invite & Key Exchange Flow
The invite package is self-contained in the deep link — no backend round-trip is needed to decrypt or read it. Each member generates their own Ed25519 keypair on-device; no shared signing key is ever distributed.
The one exception is storage access. Through v1's design-review, embedding the group's long-lived shared S3 credential directly in the invite package turned out to be a permanent, irrevocable compromise if the link ever leaked (issue #17): expiry was client-enforced only, and there was no way to tell a link that had merely been seen apart from one that had been used. Alice's app now asks the provisioner to mint a short-TTL, single-use invite credential distinct from the group's shared key, and Bob's app exchanges it for the real shared credential only once, immediately after recording his own MemberJoined claim locally:
sequenceDiagram
participant Alice as Alice's App
participant Provisioner
participant InviteLink as Invite Link (QR / iMessage)
participant Bob as Bob's App
participant Store as Storage Backend (Garage)
Alice->>Alice: Generate group_key (32 random bytes)
Alice->>Alice: Generate own Ed25519 keypair (member_privkey_A / member_pubkey_A)
Alice->>Alice: Generate invite_secret (32 random bytes)
Alice->>Alice: Derive sub-keys via HKDF from group_key
Note over Alice: content_key, attachment_key, roster_key
Alice->>Provisioner: POST /v1/groups/:id/invites (owner-signed)
Provisioner->>Provisioner: Mint short-TTL, single-use Garage key<br/>(distinct from the group's shared key)
Provisioner-->>Alice: {invite_id, access_key_id, secret_access_key, expires_at}
Alice->>Alice: Build invite package
Note over Alice: {group_id, group_key, invite_id, storage_descriptor (invite credential),<br/>member_pubkeys: [pubkey_A],<br/>expiry: expires_at}
Alice->>Alice: encrypted_pkg = XChaCha20-Poly1305(invite_secret, invite_package)
Alice->>InviteLink: https://fenapp.net/join#35;s=BASE64URL(invite_secret)&p=BASE64URL(encrypted_pkg)&v=2\n(fen://join#35;... is the equivalent custom-scheme fallback)
Alice->>Bob: Share link (iMessage / WhatsApp / AirDrop / QR)
Bob->>Bob: App intercepts deep link
Bob->>Bob: Extract invite_secret and encrypted_pkg from #fragment (local only — never sent to any server)
Bob->>Bob: invite_package = XChaCha20-Poly1305-Decrypt(invite_secret, encrypted_pkg)
Bob->>Bob: Validate expiry field — reject if past (defense-in-depth; see below)
Note over Bob: Bob now has group_key, invite credential, invite_id, group_id
Bob->>Bob: Generate own Ed25519 keypair (member_privkey_B / member_pubkey_B)
Bob->>Bob: Derive same sub-keys via HKDF(group_key)
Bob->>Bob: Encrypt MemberJoined event payload with content_key
Bob->>Bob: Sign event with member_privkey_B (over canonical event id)
Bob->>Bob: Write MemberJoined to the local event log + outbox (not yet synced to Store)
Bob->>Provisioner: POST /v1/groups/:id/invites/:invite_id/complete<br/>(signed by member_privkey_B — proves Bob holds a real keypair,<br/>not an authentication to Store itself)
Provisioner->>Provisioner: Reject if already used or past expiry (server-enforced);<br/>otherwise deny the invite credential (single-use)
Provisioner-->>Bob: The group's current shared credential
Bob->>Bob: Persist the shared credential as this group's storage credential,<br/>replacing the single-use invite credential
Note over Bob,Store: On Bob's next sync pass: flush the outbox (PUT bob.log)<br/>and LIST/GET every member's log file, using the shared credential
Store-->>Bob: All prior encrypted group events
Bob->>Bob: Decrypt with content_key — full group history available
Alice->>Store: Next sync — sees Bob's MemberJoined event
Alice->>Alice: Decrypt, verify signature, record Bob as pending
Alice->>Provisioner: GET /v1/groups/:id/invites/:invite_id<br/>(organiser-signed)
Provisioner-->>Alice: {status: used, admittedPubkey: member_pubkey_B}
Alice->>Alice: Emit organiser-signed MemberAdmitted(member_pubkey_B, invite_id)
Alice->>Store: Next sync — push MemberAdmitted
Note over Alice,Bob: Active roster membership depends on MemberAdmitted,<br/>not on Bob's self-signed MemberJoined alone
What the current v1 client actually does at join, precisely: the invite credential from storage is embedded in the join package so a future sync pass can use it, but the shipped join flow does not perform an S3 write with it — it records the join purely locally (event log + outbox) and calls the provisioner's complete-invite endpoint immediately afterward, authenticated by Bob's own signature (not by presenting the invite credential to Store). Only after that exchange succeeds does Bob's device have the group's real shared credential, which is what any subsequent sync pass — pushing the outbox, pulling the full log — actually uses. Bob is not an active roster member merely because MemberJoined exists: Alice's organiser device must later query the provisioner for the invite winner and emit MemberAdmitted. (The sync pass itself, i.e. wiring SyncManager/S3Backend into the app, is a separate, pre-existing milestone not yet complete — see implementation.md §5.5.)
If the complete-invite call fails — the invite was already used by someone else, or its server-side TTL passed — Bob's app treats the join as failed and rolls back, rather than silently keeping a dead credential (see security.md §4.3).
What the invite package contains:
type InvitePackage = {
version: 2
group_id: GroupId // stable UUID
group_key: Base64 // 32-byte XChaCha20-Poly1305 root key
invite_id: HexString // identifies the invite credential in `storage`, for the complete-invite call
storage: StorageDescriptor // the invite credential — where the group's log lives + how to reach it
member_pubkeys: HexString[] // pubkeys of existing members (bootstrap trust)
invited_by: HexString // inviter's member_pubkey
expiry: UnixTimestamp // mirrors the provisioner's own expires_at; client-enforced too, but not the sole enforcement (§4.3)
}
// Tells the joiner which backend to read/write and how to authenticate.
type StorageDescriptor =
| { kind: "s3"; endpoint: string; region: string; bucket: string;
access_key_id: string; secret_access_key: string }
// v1: Managed (Garage). access_key_id/secret_access_key here are the
// provisioner-minted invite credential (short-TTL, single-use) — never
// the group's long-lived shared key. The joiner exchanges it for the
// shared credential via POST .../invites/:invite_id/complete once its
// own MemberJoined write succeeds (issue #17).
// v2 (planned): same "s3" kind, user-supplied endpoint/bucket/keys for a self-hosted BYO backend.
Key separation:
| Secret | Scope | Distributed via |
|---|---|---|
group_key | Group content encryption root | Invite package (protected by invite_secret) |
invite_secret | One-time package decryption | URL #fragment only — never touches any server. Note: since invite_secret (s) and the ciphertext (p) travel in the same URL fragment, this only protects against something that observes part of the link, not one that captures the whole link — see §4.3. |
member_privkey | Per-member event signing (author attribution) | Generated on-device; never leaves the device. Storage write access is a separate, non-cryptographic credential — a short-TTL, single-use invite credential during join, then the group's shared key (per-group S3 key) — see §4.8 |
content_key | Event payload encryption | Derived: HKDF(group_key, "content") |
attachment_key | Receipt photo encryption | Derived: HKDF(group_key, "attachment") |
roster_key | Member roster encryption | Derived: HKDF(group_key, "roster") |
Storage access in the invite:
- The invite carries a
StorageDescriptor(not a raw endpoint). The signing key (member_privkey) is unchanged and still proves authorship of every event, so forgery protection is unaffected by the backend. - Managed (Garage): the joiner records its own
MemberJoinedevent locally first (event log + outbox), then immediately exchanges the invite credential from the descriptor for the group's shared S3 key via the provisioner's complete-invite call — a signed request, not an S3 write with the invite credential itself. Only that exchange's success or failure decides whether the join claim remains local or is rolled back (§2.4's sequence diagram). Active roster membership still requires the organiser's laterMemberAdmittedevent, based on the provisioner's recorded invite winner. The shared key is a shared bearer write capability (any member can read/write the bucket), scoped to this one group. The invite credential itself is scoped even tighter: single-use and short-TTL, so a leaked unredeemed link self-expires quickly, and a leaked link redeemed by someone else makes the real invitee's own join fail loudly instead of silently. Consequences and mitigations are in security.md §4.3 and §4.8. - Self-host BYO S3 (v2, planned): same descriptor kind and the same bearer-key properties for the shared credential as Managed above — the only difference is the user supplied the endpoint/bucket/keys instead of the provisioner. There is no provisioner to mint a scoped invite credential in this tier, so the invite package's storage descriptor is the user's own shared credential directly; treat it like
group_keyand rotate it on member removal (§3.10, §4.8). - The encrypted package still travels entirely in the deep link
#fragment, which is never sent to any server. Bob still generates his own keypair; no signing key is ever shared. - Invite revocation: the provisioner denies the invite credential once used or past its server-enforced
expires_at(default 30 minutes) — this is the actual enforcement, not just advisory. The client-embeddedexpiryfield and an encryptedInviteRevokedgroup event (honoured by peers on next sync) are defense-in-depth/UX on top of that. Caveat: none of this revokes the group's shared storage write access — that still requires rotating the per-group S3 key (§4.8).
2.5 Per-Event Sync Protocol
The ETag/revision values above are per-file receipt cursors, used only to detect which
logs/<pubkey>.jsonlchanged since last sync. They are not the order events are applied in (that is the envelope'slamport, §3.9) and not how completeness is judged (that is per-authorauthor_seq, below).
Completeness reconciliation (no central authority)
Even with a single owner-controlled backend, a device cannot assume "I listed the folder once" means "I have every event": a writer may have been mid-append, or the backend may be briefly inconsistent. Completeness is established from the events themselves, not from the store:
- List + fetch the union.
list(groups/<id>/logs/), thengeteachlogs/<pubkey>.jsonlthat changed (by ETag/mtime), and merge byevent_id(deduplicated — the same signed event may also sit in a local peer cache). Advance a per-file ETag cursor only after those fetched lines have been durably ingested or explicitly rejected. - Update the author frontier. For each known member, track
contiguous_seq(highestNwithauthor_seq 1..Nall present) andclaimed_seq(that member's provable high-water mark — advanced only by receipt of a validly-signed event authored by them, never by an advertisement; §3.9, §3.12).contiguous_seq < claimed_seq⇒ a provable gap. - Exchange high-water marks. Peers can advertise per-author high-water marks
(
{Alice: 5, Bob: 3, May: 7}) when they sync device-to-device. These are routing hints only — a device uses them to request exactly the missingauthor_seqranges, but an advertised number no one can back with a signed event never raisesclaimed_seq, so no peer can inflate another member's frontier to jam the settlement gate (§3.12). Since every event is signed, any holder can re-serve a requested event without being able to forge it. - Heal residual gaps. If the backend is missing an event (e.g. a writer's
putfailed and was never retried), the authoring device re-uploads from its outbox on next connect, or a peer that holds it re-uploads. The complete log is the union over the backend and all devices; no single party need ever be complete.
A device considers a group fully synced only when contiguous_seq == claimed_seq for every
current member. Until then it operates on a known-partial view — fine for viewing and adding
expenses, but the irreversible settlement step is hard-blocked. That block is bounded, not a
one-actor freeze: a persistently stalled frontier is escaped by an organiser-signed
SettlementSnapshot over the provable log (§3.12 Error 7).
2.6 Balance & Settlement Calculation
flowchart TD
A([Event Log]) --> B[Filter: active expenses\nnot deleted, group not closed]
B --> C[For each expense:\nuse settlement_amount_minor_units\n(stored in event at write time)]
C --> D[For each expense:\npayer balance += amount\neach split member balance -= split_amount]
D --> E[For each settlement recorded:\nfrom_member balance += amount\nto_member balance -= amount]
E --> F{All balances == 0?}
F -- Yes --> G([✅ Settled])
F -- No --> H[Debt Simplification:\nsort creditors desc\nsort debtors asc\ngreedily match largest debtor\nto largest creditor]
H --> I([Settlement Plan:\nmin transactions, max n-1])
2.7 Storage Backends
A group's encrypted log lives in one backend the group is bound to, behind a single
StorageBackend adapter.
| Tier | Ships in | Backend | Who runs it | Member access |
|---|---|---|---|---|
| Managed (default) | v1 | Garage (S3), FEN-hosted | FEN (a small Garage + provisioner; NAS today → VPS later) | per-group S3 key, minted at group creation |
| Bring your own S3 | v2, planned | any S3-compatible endpoint | the user | user-supplied connection string (same S3Descriptor/S3Backend as Managed) |
On-storage layout (identical for both):
(bucket grp_<group_id>)/
roster/roster.jsonl
logs/<member_pubkey>.jsonl # one append-only file per author (single writer per object)
epochs/<epoch>.json
attachments/<sha256(ciphertext)>
Each member writes only their own logs/<pubkey>.jsonl, so concurrent writers never collide;
ordering is (lamport, event_id), never a server sequence. The backend sees only ciphertext.
The full design — provisioning, the per-group bucket+key model, exposure via Cloudflare Tunnel,
member-removal key rotation, and migration (NAS → hosted) — is in
backend-architecture.md. v1 ships only the Managed tier; the planned v2
bring-your-own tier reuses the same s3 StorageDescriptor kind with user-supplied credentials
(§9.2 of appendices.md).
2.8 Offline Behaviour & Event Queue
FEN's "local-first" principle means the app must be fully functional without a backend connection. This section defines exactly which operations are available offline, how pending changes are queued, and what the user sees in degraded states.
What works fully offline
| Operation | Offline? | Notes |
|---|---|---|
| View all expenses | ✅ | Served from local SQLite |
| View current balances | ✅ | Recalculated from local event log |
| Add a new expense | ✅ | Event written to local outbox, synced on reconnect |
| Edit an existing expense | ✅ | Event written to local outbox |
| Delete an expense | ✅ | Tombstone event written to local outbox |
| Take / attach a receipt photo | ✅ | Photo stored locally; attachment upload queued |
| Record a settlement | ✅ | Event written to local outbox |
| View receipt photos (cached) | ✅ | Served from local blob cache if previously fetched |
| Create a new group | ✅ | GroupCreated event queued |
| Send an invite link (Managed tier) | ❌ | Requires a live call to the provisioner to mint the single-use invite credential (issue #17) — no longer self-contained |
| Accept a pending invite (Managed tier) | ❌ | The local MemberJoined write is queued as usual, but completing the join also requires a live call to the provisioner to exchange the invite credential for the group's shared key; if that call fails the join itself fails (§4.3) rather than being queued for later |
| View receipt photos (uncached) | ❌ | Requires fetch from the backend |
| Receive new events from others | ❌ | Requires backend connection |
Outbound event queue
All events created while offline (or while the backend is unreachable) are stored in the outbox SQLite table with status = 'pending':
CREATE TABLE outbox (
event_id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
ciphertext BLOB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | pushed | failed
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
pushed_at TEXT
);
Retry policy:
- On reconnect: flush all
pendingevents for affected groups increated_atorder - On backend rejection (non-auth error): mark
status = 'failed', surface to user - On backend auth failure: re-authenticate to the backend (per-group S3 key), then retry
- Exponential backoff for transient backend errors: 5 s → 30 s → 2 min → 10 min → 1 h ceiling
- No automatic purge of
failedevents — user must acknowledge
Ordering guarantee: Events from the same device are written in created_at order and numbered with a contiguous per-author author_seq. The authoritative order across all devices is the envelope's Lamport clock, applied as (lamport, event_id) — derived identically on every device, with no server involved (see §3.9).
Attachment upload queue
Receipt photo uploads are queued separately from log events (the event referencing the receipt is written immediately with attachment_status: "pending_upload"; the blob upload happens independently):
Photo taken → encrypt locally → compute sha256(ciphertext)
→ write expense event to outbox (with blob_hash = sha256)
→ write expense event when backend available
→ upload encrypted blob to attachments/<sha256> (separate queue)
→ on success: emit AttachmentUploaded event marking it available
→ on failure: retry with backoff; surface "receipt not yet uploaded" indicator
UI indicators
| State | User-visible treatment |
|---|---|
| Backend unreachable | Amber banner: "Working offline — changes will sync automatically" |
| Events pending sync | Clock icon (🕐) on each unsynced expense row; badge count in header |
| Attachment upload pending | Receipt thumbnail shows upload-progress indicator |
| Attachment upload failed | Receipt thumbnail shows ⚠️ with retry tap target |
| Backend rejects write | Red inline error on affected expense: "Sync failed — tap to retry" |
| Reconnected & syncing | Spinner in header replacing sync badge; clears when queue is empty |
Stale balance indicator: If the last successful backend sync is more than 30 minutes ago, the balance view shows a subtle timestamp: "Balances as of 14:32 — sync pending". This makes the offline limitation visible without blocking the UI.
Reconnection behaviour
On reconnect (detected via ConnectivityResult stream + backend reachability check):
- Flush the outbound event queue by appending to the member's own
logs/<pubkey>.jsonl, increated_atorder - List the group folder and fetch changed log files (by ETag/mtime); merge the
results by
event_id(deduplicated) - Update the author frontier and request any missing
author_seqranges (§2.5 completeness reconciliation) before treating the group as fully synced - Apply the merged events to local SQLite in
(lamport, event_id)order (reducer replay) - Re-attempt any queued attachment uploads
- Dismiss offline banner; clear pending-sync indicators (the stale-balance/incomplete-history
markers clear only once
contiguous_seq == claimed_seqfor every member)
If the device reconnects while the user has the app open, the balance view refreshes automatically after step 4.
2.9 Notification Strategy
Storage-model note (v1). The Managed (Garage/S3) backend can't push — object stores have no push channel without a server. v1 therefore ships no remote push: members see new activity on app open / resume / 30 s foreground poll / manual refresh. (The planned v2 self-hosted BYO-S3 tier has the same limitation, being S3-compatible too.) Any FCM/APNs design below is deferred to a self-host-only opt-in bridge.
v1 — Polling only (no push infrastructure)
v1 uses no push notifications. Engagement relies on the user opening the app. This is explicitly a v1 constraint — detailed in §5.5 — and is acceptable because:
- No centralised token registration infrastructure is needed
- No group metadata leaks through a push gateway
- Background fetch every 15–30 minutes keeps data reasonably fresh
- The target audience (small friend groups) have high enough intrinsic motivation to open the app
v1 engagement mechanisms:
- Badge count on the app icon (updated on each foreground sync): shows unacknowledged new events
- Share sheet for invite links drives initial onboarding engagement
- A re-sync button for the user to trigger a sync to the backend manually
v2 — Silent push (opt-in)
Silent push wakes the app in the background to poll the backend. The push payload contains no expense content — it is a pure wake signal:
{
"aps": { "content-available": 1 },
"fen": { "group_id": "<group_id_hash>" }
}
group_id in the push payload is the SHA256(group_id)[:16] — enough for the app to know which group to poll, but not the human-readable group name. This minimises metadata exposure to the push gateway operator.
Push gateway architecture:
Backend () ──webhook──▶ FEN Push Gateway ──APNs/FCM──▶ Device
(centralised,
opt-in only)
Registration flow (once per device, once per group):
- User opts in to notifications for a specific group
- OS provides a push token (APNs on iOS, FCM on Android)
- App sends a -signed registration to the FEN Push Gateway:
{ device_token, pubkey, group_id_hash: SHA256(group_id)[:16] } - Gateway stores only
(device_token, group_id_hash)— no group names, no expense data - Gateway registers a webhook with the storage backend for new events matching that
group_id_hash
Delivery flow (when any member posts a new event):
- Member posts an encrypted event to the storage backend
- Backend fires the registered webhook to the FEN Push Gateway
- Gateway looks up all device tokens registered for that
group_id_hash - Gateway sends a silent push to each registered device via APNs/FCM
- Device wakes in the background and polls the backend from its cursor — no content travels through the gateway
Offline devices: APNs and FCM are store-and-forward platforms. If a device is offline when the push is sent:
- APNs holds the notification for up to 30 days (configurable TTL per push)
- FCM holds it for up to 4 weeks
- When the device reconnects (mobile data, Wi-Fi), the platform delivers the stored push
- The app polls the backend and syncs from its last cursor, catching up fully
- If the push expires before delivery (device unreachable beyond the TTL), the next manual app-open triggers a normal polling sync — silent push is a convenience, not a delivery guarantee
Privacy properties:
- The gateway operator knows: which pubkeys have FEN installed, which group_id_hashes they belong to
- The gateway operator does not know: group names, expense amounts, member names, or message content
- Registration with the gateway is opt-in; polling-only operation remains the default and always works
- APNs/FCM device tokens are rotated by the OS periodically; gateway must handle token refresh
v2 notification types:
| Trigger | Notification copy | Privacy |
|---|---|---|
| New expense added | "New activity in one of your groups" | No amount, no payer name |
| Settlement recorded | "A balance was settled" | No amount |
| New member joined | "Someone joined a group" | No name |
| Invited to group | "You've been invited to a FEN group" | No group name in push |
| Group closed | "A group has been closed" | — |
On tap, the app opens and polls the backend to show the actual content.
User controls (v2):
- Per-group notification toggle (stored locally, never synced)
- Global quiet hours
- Backend-unreachable silent-fail: if push gateway is down, polling continues normally
2.10 Product & UX Flows
This section specifies the human experience for each core interaction. It is the product complement to the cryptographic and sync flows documented in §2.1–2.9.
Flow 1 — First Launch & Onboarding
App first open
│
▼
┌─────────────────────────────────────────┐
│ Welcome screen │
│ │
│ FEN / 分 │
│ Shared expenses, private by design. │
│ │
│ [Create a group] [Join via link] │
└─────────────────────────────────────────┘
│ │
▼ (Create) ▼ (Join — see Flow 4)
Key generation (silent, background)
│
▼
No account creation required.
No email. No password. No server sign-up.
A keypair is generated locally —
the user never sees it unless they go
to Settings → Identity → Backup.
│
▼
Prompt: "Back up your identity"
│
├── [Back up to iCloud / the Managed backend] (recommended)
│ → Encrypted key bundle written to app's cloud container
│ → Confirmation: "Identity backed up ✓"
│
└── [Skip for now]
→ Dismissible; reminder shown again after first expense is added
Key generation note: The Ed25519 keypair is generated silently on first launch. The user's identity is ready before they complete onboarding. There is no "account" concept — the app is immediately functional.
Flow 2 — Create a Group
[+] New Group
│
├── Group name (text input) e.g. "Paris Trip 2026"
├── Default currency (picker, pre-filled from device locale)
├── Split mode: Equal (default) | Custom per expense
│
└── [Create Group]
│
▼
GroupCreated event written locally → queued for backend
Group screen opens immediately (no network wait)
Header: "Paris Trip 2026 · just you · ☁️ syncing…"
The group is functional immediately, even before the backend confirms. The sync indicator clears when the backend acknowledges the event.
For the Managed tier, creation has a second, mandatory binding step: Alice's
device calls the provisioner POST /v1/groups to create the bucket and obtain
the real shared StorageDescriptor, then stores that descriptor before the
first successful sync or invite mint. The UI may still open the group
immediately while offline, but it must show the group as local only / not yet
syncable until that provisioner call succeeds; writing random placeholder S3
credentials is invalid. Tracked by implementation-review issue #68.
Flow 3 — Invite a Member
Group screen → [Invite Member]
│
▼
Choose who to add:
├── Known friend → direct-add fast path, if an eligible shared group exists
└── New person / no active link → normal invite link flow
Normal invite-link methods:
├── [Share link] → system share sheet (iMessage, WhatsApp, AirDrop…)
├── [Show QR code] → full-screen QR for in-person invite
└── [Copy link] → clipboard
Link format: https://fenapp.net/join#s=<secret>&p=<encrypted_pkg>&v=2
(Universal Link / App Link — the link that's actually shared and tapped;
fen://join#s=<secret>&p=<encrypted_pkg>&v=2 is the equivalent
custom-scheme fallback, used when the OS can't route the https form)
Invite is single-use, with a short expiry enforced by the provisioner (default 30 minutes, §2.4/§4.3) — not the long, user-configurable durations (1/7/30 days / no expiry) of earlier drafts; issue #17's design review found a long-lived, client-enforced-only expiry gave a leaked invite no real time bound.
Single-use enforcement is server-enforced by the provisioner (issue #17): the invite credential is denied the moment the first complete-invite call succeeds, so a losing racer's own complete-invite call fails loudly (see Flow 4) rather than silently discarding their join. Because `MemberJoined` is self-authored, it does not grant roster membership. Alice's organiser device later asks the provisioner which pubkey won that invite and emits `MemberAdmitted(invited_pubkey, invite_id)` for that pubkey only. Clients no longer break duplicate-join races by sorting attacker-chosen `(lamport, event_id)` values.
Important limitation: a device that successfully completes the invite exchange has the group key and shared storage credential. It can read any synced group content it pulls before the organiser notices and, until storage-key rotation lands for the group, it still holds the shared write credential. `MemberAdmitted` fixes active roster authority; it is not retroactive secrecy.
After sharing: invite appears as "Pending — Alice" in the Members list
with a [Revoke] option.
**Friends list (v1):** FEN keeps a local-only friends table (doc/data-model.md §3.2 `local_friends`). A row is created the moment the owner mints an invite — `invited` status, the display name the owner chose, no public key yet — and resolves to `confirmed` with the joiner's real public key once the organiser's admission-reconciliation pass identifies who actually won that invite (rows are disambiguated by `(group_id, invite_id)`, so concurrent pending invites never cross-resolve). An invite that times out unused flips its row to `expired` instead; a later provisioner-confirmed winner still promotes an `expired` row to `confirmed`, since the local clock is only a backstop, never authoritative over the provisioner's report. It is a convenience cache for display names and public keys on this device; it is not synced between the user's devices in v1. Multi-device friend-list sync, and saving a friend outside the invite flow, are future-version tasks after the local friends table exists.
**Direct-add known-friend path:** If the organiser selects a confirmed friend whose public key is already known, FEN can skip the out-of-band link. The organiser creates the same invite package used by the link flow, wraps it to the friend's public key with the existing X25519/HKDF wrapping primitives, and writes a `GroupInviteRelayed` event into an already-shared group's log. The target friend ingests it on their next normal sync; there is no realtime push requirement.
The relay group must be an eligible active shared group: open, not closed, not a cold/local-only archive, not scheduled for backend deletion, not already purged, backend-writable/reachable by the organiser, and normally syncable by the invitee. If several eligible active shared groups exist, use the one with the newest creation date. If no eligible active shared group exists, the direct-add fast path is unavailable and the UI falls back to the normal invite-link flow.
Only the target group organiser may add members, for both invite links and direct-add. Direct-add does not create a server mailbox or pubkey-indexed lookup service; it reuses group logs that both parties already share.
If Bob hasn't installed FEN: the link opens a web fallback page at fenapp.net/join (or configured fallback URL) showing QR code + App Store / Play Store links.
Flow 4 — Accept an Invite (Bob's side)
Bob taps the link https://fenapp.net/join#s=<invite_secret>&p=<encrypted_pkg>&v=2
(or the fen://join#... custom-scheme fallback)
│
▼
If app not installed:
→ Web fallback page → App Store / Play Store → install → link re-opened
If app installed:
│
▼
Parse the invite package locally. Do not write MemberJoined or redeem the
invite credential yet.
│
▼
┌──────────────────────────────────────────┐
│ You've been invited to a group │
│ │
│ Paris Trip 2026 │
│ Organised by Alice │
│ 3 members · CHF │
│ │
│ [Join Group] [Decline] │
└──────────────────────────────────────────┘
│ [Decline]
▼
Dismiss. No event is written, no credential is redeemed, and the invite remains
usable until its normal provisioner expiry.
│ [Join Group]
▼
Bob's keypair generated if not yet existing.
MemberJoined event written to Bob's local event log + outbox (not yet synced).
App calls the provisioner to complete the invite (exchanges the invite
credential for the group's real shared credential — §2.4).
If this fails because the invite was used by someone else, or expired:
join fails with a visible error; no partial group is left behind.
Bob can ask Alice for a fresh invite.
If the provisioner has already recorded Bob's own pubkey as the winner but a
transient backend error prevented returning the descriptor, retry is
idempotent and returns the same shared credential instead of reporting a
compromise.
On the next sync pass, using the now-obtained shared credential:
flush the outbox (Bob's MemberJoined reaches the bucket)
download the full event log from backend for this group
Group screen opens; historical expenses visible immediately.
Alice's device: receives MemberJoined event on next sync.
Alice's device records Bob as pending, queries the provisioner for the invite
winner, and emits an organiser-signed MemberAdmitted event if Bob's pubkey is
the recorded winner. Only after peers receive MemberAdmitted does Bob move from
pending to active in the Members list and become selectable for expenses,
balances, and other active-roster checks.
The accept/decline gate is a security control, not just onboarding polish: no identity-binding event and no single-use credential redemption happen until the user explicitly chooses Join. This is tracked by implementation-review issue #67; idempotent completion by the same claimant is tracked by #69.
If Bob was added through the direct-add known-friend path, there is no accept/decline gate because Alice already targeted Bob's known public key and is the target group's organiser. Bob instead sees a passive landing notice such as "Alice added you to Paris Trip 2026" when the relayed invite is processed, then the group appears normally.
Flow 5 — Add an Expense
Group screen → [+ Add Expense]
│
▼
┌──────────────────────────────────────────┐
│ Amount [CHF ] [ 45.00 ] │
│ Description [Dinner at La Cantina ] │
│ Date [Today ▼ ] │
│ Paid by [Me (Alice) ▼ ] │
│ │
│ Split [Equally ▼ ] │
│ ─ Alice CHF 15.00 │
│ ─ Bob CHF 15.00 │
│ ─ Carol CHF 15.00 │
│ │
│ Receipt [📷 Add photo] │
│ │
│ [Save Expense] │
└──────────────────────────────────────────┘
Split modes:
- Equally — total ÷ number of selected participants; remainder assigned to payer (see §3.6)
- By amount — enter exact CHF amount per person; must sum to total (inline validation)
- By percentage — enter % per person; must sum to 100% (inline validation)
Validation (inline, before Save):
- Amount > 0 required
- Description ≤ 140 characters
- Split amounts / percentages must sum correctly before Save is enabled
- If receipt photo added: photo encrypted & uploaded to attachment storage before or concurrently with event push (see §2.8)
After Save:
- ExpenseCreated event written locally → queued for backend
- Expense appears immediately in the list with clock icon (🕐) if not yet synced
- Clock icon clears when backend acknowledges
Flow 6 — Edit / Delete an Expense
Edit (creator only by default; configurable per group):
Expense row → tap → Expense detail
│
└── [Edit]
→ Pre-filled form (same as Add, but fields populated)
→ [Save Changes] → ExpenseUpdated event queued
→ Expense row updates immediately; clock icon until synced
Delete:
Expense detail → [Delete]
│
▼
Confirm: "Delete this expense? This cannot be undone."
[Delete] [Cancel]
│
▼
ExpenseDeleted (tombstone) event queued.
Expense disappears from default list immediately.
Still visible via "Show deleted" toggle in group settings (greyed out).
Permission enforcement:
- Edit/Delete buttons hidden (not just disabled) for expenses the current user did not create, unless group has
expense_edit_permission: any_member - Frozen group (after GroupClosed): Edit and Delete are always hidden
Flow 7 — Settle Up
Group screen → Balance section
│
"You owe Bob CHF 32.50"
│
└── [Settle Up]
│
▼
┌──────────────────────────────────────────┐
│ Settle with Bob │
│ │
│ Amount CHF [32.50] (pre-filled) │
│ Note [Optional — e.g. "Revolut"] │
│ │
│ [Confirm Settlement] │
└──────────────────────────────────────────┘
│
▼
SettlementCreated event queued.
Balance view updates immediately (optimistic).
Bob's device: receives event on next sync → balance clears.
FEN does not facilitate the actual payment transfer.
The settlement records the fact that payment happened
outside the app (cash, bank transfer, etc.).
Partial settlement: The amount is editable — user can settle a partial amount. The remainder stays outstanding.
Flow 8 — Group Close & Cold Archive
When a trip is settled, FEN archives the data locally and purges the server, keeping your Garage footprint at zero.
Group screen → ⋮ → Close Group
│
▼
Owner's app downloads all missing events and receipt photos.
GroupClosed event is pushed to Garage.
│
▼
Owner's app packages the encrypted history into a local archive.
Archive is stored in personal iCloud Drive (iOS) / Local Storage (Android).
│
▼
Provisioner triggers a 45-day server-side deletion timer.
Other members' devices see the GroupClosed event on next sync,
download their own archives automatically, and store them locally.
│
▼
After 30 days, the owner's app attempts a client-side DELETE /v1/groups/:id (first line of defense).
After 45 days, the backend cron job physically deletes the bucket (the ultimate guarantee).
Local Reading & Deduplication: After archiving, the group is moved to a "Closed Groups" section in the app. FEN reads these groups directly from iCloud/local storage. Crucially, the local archive takes precedence: if the local archive exists during the 30-day grace period while the bucket is still alive on Garage, the app automatically filters out any duplicated data from the bucket and serves the local copy to avoid overlap.
Flow 9 — New Device / Restore
New device → Install FEN → First launch
│
▼
┌──────────────────────────────────────────┐
│ Welcome screen │
│ [Create a group] [Join via link] │
│ │
│ Already have FEN? [Restore identity] │
└──────────────────────────────────────────┘
│ [Restore identity]
▼
Choose restore method:
├── [Restore from iCloud]
│ → Finds encrypted key bundle and Cold Archives in iCloud container
│ → Decrypts identity with user password or device biometric
│ → Keypair restored → active groups re-appear on next backend poll
│ → Closed groups are instantly available from iCloud storage
│
├── [Scan QR from old device]
│ → Old device: Settings → Identity → Transfer to new device
│ → Shows QR encoding encrypted keypair
│ → New device scans → keypair transferred
│
└── [Enter recovery phrase]
→ 12-word BIP-39 mnemonic entry
→ Keypair re-derived
→ groups re-appear on next backend poll
After restore: app polls backend for each known group's event log.
History is fully recovered. No data loss.
If restore fails / no backup:
"No backup found. Without your identity, you cannot rejoin
existing groups. You can start fresh — existing group members
can send you a new invite."
[Start fresh] [Try again]
Flow 10 — Orphaned Group Pruning & Keep-Alive
To prevent Garage from accumulating orphaned groups indefinitely, the Managed Tier automatically sweeps inactive buckets.
Deep Inactivity Sweep (6 Months): If a group is abandoned without being formally closed, the server will delete it after 6 months of no new expenses.
Group opened by member (last event was > 5 months ago)
│
▼
┌──────────────────────────────────────────────┐
│ ⚠️ Server Deletion Warning │
│ This group has been inactive and will be │
│ purged from the server soon. │
│ │
│ [Archive & Close] [Keep Alive] │
└──────────────────────────────────────────────┘
│
├─ If [Archive & Close] → Triggers Flow 8
│
└─ If [Keep Alive]:
▼
App encrypts and pushes a `GroupPing` event to S3.
S3 `LastModified` timestamp is instantly updated.
Backend deletion timer is reset for another 6 months.
If the bucket is already deleted: If a user opens the app after the 6-month server purge, the app gracefully traps the 404 NoSuchBucket error, freezes the UI, marks the group as "Server Purged", and prompts the user to move their remaining local cache into a local Cold Archive.
3. Data Schema & Contracts
📄 Moved — see
data-model.md(§3.1–3.10) ·money.md(§3.11) ·error-handling.md(§3.12–3.13).
4. Security & Compliance
📄 Moved — see
security.md.
5. Implementation Strategy
📄 Moved — see
implementation.md.
6. Testing Strategy
📄 Moved — see
testing.md.
7. Operations & Debugging
📄 Moved — see
operations.md.
8. Identity, Key Management & Recovery
📄 Moved — see
key-management.md.
9. Appendices & Future Iterations
📄 Moved — see
appendices.md.