FEN / 分 — Design Document
Version 0.15 · July 2026 Status: Draft
0.15 — Self-hosted backend transition. FEN now ships no developer-provided backend (the previous Managed Garage cluster and its
fen-provisionercontrol plane are removed). Each group's encrypted log lives in a store the group owner self-hosts and configures once in the app. There are exactly two bring-your-own (BYO) backends: an S3-compatible object store or a Nextcloud instance. Invites carry the group's single shared credential with a short, client-enforced TTL (no server-minted single-use invite credential). Member removal uses the weak shared-credential model: forward secrecy via key-epoch rotation (authoritative) plus a best-effort, owner-device-driven storage-credential rotation. The normative design is in backend-architecture.md; see appendices.md §9.2 for the rejected alternatives andimplementation-plan-selfhost-transition.mdfor the migration. Affected here: the Storage Model summary, §2.1–2.4, §2.7–2.10, and §10.
0.14 — Documentation alignment pass. No design changes. Docs were checked against the implementation: event names in the UX flows corrected to the wire names (
ExpenseLogged,SettlementRecorded), the on-bucket layout aligned with the shippedSyncManagerpaths, section-numbering collisions betweendata-model.md,money.md, anderror-handling.mdresolved, remnants of dropped designs (WebDAV BYO tier, Nostr relays, Blossom attachment servers, the centralisedPOST /eventsbackend) removed from the normative text and consolidated — with the consumer-cloud options and their rationale — into a newappendices.md§9.2 (Rejected Design Choices), and the legacy-terminology map added below (§10).
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, §3.12 — event log, entities, SQLite schema, storage/API contract, split & balance rules, expense lifecycle, conflict resolution, key epochs, settlement gate | normative |
| money.md | §3.11 — fixed-point arithmetic, split allocation, rounding, multi-currency | normative |
| error-handling.md | §3.13–3.14 — 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 | Self-hosted BYO backends (S3-compatible or Nextcloud), in-app provisioning, invites, member removal, lifecycle | normative |
| 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, rejected design choices, future iterations, closed-group archiving, open questions, glossary | reference |
Table of Contents (this document)
Sections 3–9 now live in the companion documents listed above. Section 10 (below) is the legacy-terminology map referenced by every companion document.
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. |
| No developer backend | FEN operates no server. The app developer runs nothing. Each group's encrypted log lives in a store the group owner self-hosts — one of two BYO backends — and the backend never sees plaintext. |
| Low setup friction | The owner configures a backend once; after that, creating a group and inviting members needs no configuration outside the app — no per-member account, no per-group setup at the storage provider by hand. |
| 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
FEN ships no developer-provided backend. Each group's encrypted event log lives in a store the group owner self-hosts and configures once in the app. There are exactly two bring-your-own (BYO) backends (full design: backend-architecture.md):
| Backend | Owner configures once | How members get access |
|---|---|---|
| S3-compatible (BYO) | endpoint, region, bucket, access_key_id, secret_access_key for any S3-compatible store (their own Garage, MinIO, AWS S3, …) via S3Backend (SigV4) | One bucket, prefix per group. The single shared credential (the owner's S3 key) is carried in the invite; every member uses it. |
| Nextcloud (BYO) | baseUrl, username, and an app password, via NextcloudBackend (WebDAV + OCS Share API) | Per-group folder + one password-protected public link, created in-app. The single shared credential (the {baseUrl, shareToken, sharePassword} triple) is carried in the invite; members connect account-lessly through the public WebDAV endpoint. |
Both backends store only ciphertext — the cryptography (see security.md) is
identical across backends — and each group is bound to one backend as its hub. They sit behind one
StorageBackend adapter (§2.7) so all product, ledger, and crypto code is backend-agnostic.
Note. The backend is untrusted and sees only ciphertext; the per-member Ed25519 signing key and all content encryption are backend-independent. Because the group shares one storage credential, member removal relies on key-epoch rotation (authoritative forward secrecy) plus a best-effort, owner-driven storage-credential rotation — the weak shared-credential model. 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, self-hosted by the group owner: an S3-compatible endpoint or a Nextcloud instance. 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 owner-self-hosted backend\n(S3-compatible or Nextcloud)\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 (owner-hosted)
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.
Storage access travels the same way. Because FEN runs no always-online server, there is no party that could mint a per-invite credential or record who redeemed it. The group's single shared credential (the S3 key of §3, or the Nextcloud share triple of §4 in backend-architecture.md) is embedded directly in the encrypted invite package, readable only by the intended recipient's key, and carries a short, client-enforced TTL (default 30 minutes). This is a deliberate, accepted downgrade from the earlier provisioner design, which minted single-use, server-expiring invite credentials and atomically recorded the winning pubkey (issue #17); the tradeoff is analysed in security.md §4.3/§4.8 and backend-architecture.md §5–§6.
sequenceDiagram
participant Alice as Alice's App
participant InviteLink as Invite Link (QR / iMessage)
participant Bob as Bob's App
participant Store as Storage Backend (owner-hosted)
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->>Alice: Build invite package
Note over Alice: {group_id, group_key, storage_descriptor (the group's shared credential),<br/>member_pubkeys: [pubkey_A], expiry: now + TTL}
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 (client-enforced; §4.3)
Note over Bob: Bob now has group_key, the group's shared storage credential, 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
Bob->>Bob: Persist the storage descriptor as this group's shared 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->>Alice: Emit organiser-signed MemberAdmitted(member_pubkey_B, invite_id)<br/>(if two pubkeys race one inviteId, admit the lexicographically-smallest)
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 client does at join, precisely: the shared credential from storage is persisted locally, and Bob's MemberJoined is recorded purely locally first (event log + outbox). There is no server call — no provisioner, no complete-invite exchange. Bob's next sync pass uses the shared credential to push the outbox and pull the full log. Bob is not an active roster member merely because MemberJoined exists: Alice's organiser device must later observe it on sync and emit MemberAdmitted. If two pubkeys race the same inviteId, the organiser breaks the tie with a deterministic client-side rule — admit the lexicographically-smallest Ed25519 pubkey (a pure function of the competing set, deliberately not attacker-choosable (lamport, event_id)). (Wiring SyncManager/S3Backend into the app is a separate, pre-existing milestone — see implementation.md §5.5.)
If the invite has expired at parse time, Bob's app rejects it before writing anything and Bob asks Alice for a fresh invite (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 this invite, for MemberAdmitted reconciliation
storage: StorageDescriptor // the group's single shared credential + where the log lives
member_pubkeys: HexString[] // pubkeys of existing members (bootstrap trust)
invited_by: HexString // inviter's member_pubkey
expiry: UnixTimestamp // client-enforced TTL (default 30 min); the only enforcement (§4.3)
}
// Tells the joiner which backend to read/write and how to authenticate.
// This IS the group's single shared credential — there is no separate,
// narrower per-invite credential and no server to redeem it against.
type StorageDescriptor =
| { kind: "s3"; endpoint: string; region: string; bucket: string;
access_key_id: string; secret_access_key: string }
// BYO S3-compatible: the owner's S3 key. One bucket, prefix per group.
| { kind: "nextcloud"; baseUrl: string; shareToken: string; sharePassword: string }
// BYO Nextcloud: the per-group public-share triple. Members reach the
// folder account-lessly via the public WebDAV endpoint.
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 — the group's single shared credential (S3 key, or Nextcloud share triple), carried in the invite — 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. - The descriptor is the group's single shared credential — a shared bearer write capability (any member can read/write the group's storage), scoped to this one group. There is no separate per-invite credential and no provisioner exchange: the joiner persists the descriptor and syncs directly. Treat it like
group_keyand rotate it on member removal (§3.10, §4.8). Consequences and mitigations are in security.md §4.3 and §4.8. - Active roster membership still requires the organiser's later
MemberAdmittedevent; a self-signedMemberJoinedalone does not grant it. - The encrypted package 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 expiry is client-enforced only (default 30 minutes): the recipient app rejects an expired invite at parse time. With no always-online server this deadline is a convenience, not a hard guarantee. An encrypted
InviteRevokedgroup event (honoured by peers on next sync) is UX on top of that. Caveat: none of this revokes the group's shared storage write access once a link has been redeemed — that requires rotating the shared credential (§4.8), which is best-effort and owner-driven.
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-handling.md 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. FEN ships no developer-provided backend; the group owner self-hosts and
configures one of two BYO backends once.
| Backend | Who runs it | Group creation | Member access |
|---|---|---|---|
| S3-compatible (BYO) | the owner (their own Garage, MinIO, AWS S3, …) | none needed — the prefix springs into existence on first put | the owner's single shared S3 key, carried in the invite |
| Nextcloud (BYO) | the owner | in-app: MKCOL per-group folder + one password-protected OCS public link | the per-group {baseUrl, shareToken, sharePassword} triple, carried in the invite |
On-storage layout (paths as implemented by SyncManager/AttachmentStore in packages/fen_sync):
# S3-compatible: one owner bucket, prefix per group
(owner bucket)/groups/<group_id>/
logs/<member_pubkey>.jsonl # one append-only file per author (single writer per object)
attachments/<sha256(ciphertext)> # encrypted receipt blobs
roster/roster.jsonl # design-only — not yet written by the client
epochs/<epoch>.json # design-only — not yet written by the client
# Nextcloud: the per-group public share IS the group root (same tree, minus the groups/<id>/ prefix)
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 — the two backends and their seams, in-app provisioning via GroupProvisioner,
invites, the weak shared-credential member-removal model, and lifecycle cleanup — is in
backend-architecture.md. The rejected alternatives (developer-hosted
Garage, generic WebDAV, the consumer clouds) are recorded in §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 queued. S3: no storage-side call at all. Nextcloud: the folder + public link are created on the owner's device when the backend is next reachable |
| Send an invite link | ✅ | Self-contained — the shared credential is embedded in the link; no backend round-trip to mint or record it |
| Accept a pending invite | ✅ | The shared credential is parsed from the link locally; the MemberJoined write is queued like any other event |
| 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). Neither BYO backend can push — an S3 object store and a Nextcloud public share have no push channel without a server, and FEN runs none. v1 therefore ships no remote push: members see new activity on app open / resume / 30 s foreground poll / manual refresh. 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 implementation.md §5.6 — 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] (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.
Group creation binds the group to the owner's configured backend via the in-app
GroupProvisioner, running with the owner's own credentials:
- S3-compatible: no storage-side call is needed — the group is a path prefix
inside the owner's bucket, and it springs into existence on the first
put. - Nextcloud: the owner's device
MKCOLs the per-group folder and creates one password-protected OCS public link, yielding the group's sharedStorageDescriptor. The UI may open the group immediately while offline, but it shows it as local only / not yet syncable until that in-app provisioning succeeds.
A backend must be configured and validated before the group can sync (Settings → Backend; see settings-ui.md).
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 carries a short, **client-enforced** expiry (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 expiry gave a leaked invite no real time bound. The invite embeds the group's single shared credential directly; with no always-online server there is no single-use redemption and no server-recorded winner.
Because `MemberJoined` is self-authored, it does not grant roster membership. Alice's organiser device observes the joins on sync and emits `MemberAdmitted(invited_pubkey, invite_id)`. If two pubkeys race the same `inviteId`, the organiser breaks the tie deterministically — **admit the lexicographically-smallest Ed25519 pubkey** — a pure function of the competing set, deliberately *not* attacker-choosable `(lamport, event_id)`.
Important limitation: anyone who obtains the invite before it expires holds the group key and the group's single shared storage credential. They can read any synced group content they pull, and they retain raw storage write access until the shared credential is rotated (best-effort, owner-driven; §4.8). `MemberAdmitted` fixes active roster authority; it is not retroactive secrecy, and it does not revoke raw storage access.
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 admission still promotes an `expired` row to `confirmed`, since the local clock is only a backstop. 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 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 and nothing is persisted; the invite remains usable
until its client-enforced expiry.
│ [Join Group]
▼
Bob's keypair generated if not yet existing.
The group's shared storage credential (from the invite's StorageDescriptor) is
persisted locally.
MemberJoined event written to Bob's local event log + outbox (not yet synced).
There is no server call — no provisioner, no complete-invite exchange.
On the next sync pass, using the shared credential:
flush the outbox (Bob's MemberJoined reaches the backend)
download the full event log from the 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 and emits an organiser-signed
MemberAdmitted event (breaking any same-inviteId race by admitting the
lexicographically-smallest pubkey). 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 is written until the user explicitly chooses Join. This is tracked by implementation-review issue #67.
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:
- ExpenseLogged 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] │
└──────────────────────────────────────────┘
│
▼
SettlementRecorded 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 cleans up the owner's backend, keeping the owner's storage footprint at zero.
Group screen → ⋮ → Close Group
│
▼
Owner's app downloads all missing events and receipt photos.
GroupClosed event is pushed to the backend.
│
▼
Owner's app packages the encrypted history into a local archive.
Archive is stored in personal iCloud Drive (iOS) / Local Storage (Android).
│
▼
Other members' devices see the GroupClosed event on next sync,
download their own archives automatically, and store them locally.
│
▼
Backend cleanup is best-effort and runs on the owner's device (there is no
server-side reaper). Optionally, after a client-configured delay, the owner's
app deletes the group's data:
S3-compatible: a DeleteObject sweep of groups/<id>/
Nextcloud: OCS delete the public share + WebDAV DELETE the folder
See backend-architecture.md §7.
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 while the backend copy is still present during the cleanup window, the app automatically filters out any duplicated data from the backend 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 Cleanup (owner-driven, best-effort)
There is no server-side reaper. With no developer backend, nothing sweeps
inactive groups on a schedule — the previous Managed-tier inactivity sweep and the
GroupPing keep-alive event are removed (backend-architecture.md §7). An
abandoned group simply persists in the owner's own storage until the owner cleans
it up.
Cleanup is therefore an optional, best-effort behaviour that only runs on the owner's device:
- The owner can Close & Archive a settled group (Flow 8), after which the owner's app optionally deletes the backend copy.
- An owner who wants zero footprint can delete a stale group's data directly from
Settings; it removes
groups/<id>/(S3) or the share + folder (Nextcloud).
If the group's backend data is already gone: if a member opens the app after
the owner has deleted the group's data (or the owner's backend is permanently
offline), the app gracefully traps the missing-object / 404 error, freezes the
UI, marks the group as "Backend unavailable", 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.
10. Legacy Terminology Map
Earlier drafts of this design targeted a centralised FEN backend (a small HTTP event server) and,
before that, Nostr relays with Blossom attachment servers; a later draft had FEN operate a Managed
Garage cluster + fen-provisioner. All are gone: FEN ships no developer backend, and each
group's store is a dumb ciphertext-only backend the owner self-hosts — an S3-compatible endpoint
or a Nextcloud instance. Some sequence diagrams and sync-state names in the companion documents
(notably data-model.md §3.8's use cases) still use the legacy vocabulary; read them through this
map — the semantics are unchanged, only the transport differs.
| Legacy term | Current meaning |
|---|---|
POST /events (or POST /v1/groups/:id/events) | Append to the author's own groups/<group_id>/logs/<pubkey>.jsonl object |
GET /events?after=N | List the group's logs/ folder + conditionally fetch changed files (ETag/mtime), merge by event_id |
seq_num / server_sequence (global order) | Removed. Order is the signed (lamport, event_id); completeness is per-author author_seq (§2.5, §3.9) |
backend cursor (after=N) | Per-file ETag pull cursor — private receipt tracking, never an ordering authority |
| relay / relay list / "publish to ≥2 backends" | The group's one bound storage backend; multi-backend fan-out was removed (security.md §4.8 invariant E-4). (Unrelated: the friend direct-add relay in §2.10 Flow 3 relays an invite through an already-shared group log — no server component.) |
npub… / nsec… (Bech32 keys) | Lowercase-hex Ed25519 public keys / on-device private keys — Bech32 encoding is not used anywhere |
| Blossom / BUD-01/02 attachment server | Encrypted blobs in the same group bucket at attachments/<sha256(ciphertext)> (AttachmentStore) |
Managed (Garage) tier / fen-provisioner | Removed. The owner self-hosts one of two BYO backends (S3-compatible or Nextcloud); provisioning runs in-app via GroupProvisioner (backend-architecture.md) |
| WebDAV BYO tier | Generic WebDAV was dropped, but Nextcloud (a WebDAV-family server whose OCS Share API supports in-app per-group shares) is a first-class BYO backend (appendices.md §9.2, backend-architecture.md §4) |