FEN — Appendices & Future Iterations (§9)
Part of the FEN design set. Overview, index, and the legacy-terminology map: fen.md.
9. Appendices & Future Iterations
9.1 Technology Stack
| Layer | Technology | Notes |
|---|---|---|
| Mobile framework | Flutter + Riverpod | Single codebase, iOS + Android |
| Local database | SQLite via drift (Dart ORM) | No CRDT library needed — append-only log is conflict-free |
| Cryptography | sodium_libs (libsodium bindings; cross-platform) | XChaCha20-Poly1305 (AEAD) + Ed25519 (signing) + X25519 (ECDH epoch-key wrapping), all from libsodium. The former bip340 / secp256k1 dependency is dropped (§4.8 decision). |
| Storage — S3-compatible (BYO) | Any S3-compatible endpoint (owner's Garage, MinIO, AWS S3, …) via a Dart S3 client (SigV4) | Owner self-hosts and configures once; one bucket, prefix per group, one shared credential in the invite. See backend-architecture.md §3. |
| Storage — Nextcloud (BYO) | Owner's Nextcloud via WebDAV + OCS Share API | Owner configures base URL + app password once; per-group folder + password-protected public link; members connect account-lessly via the public WebDAV endpoint. See backend-architecture.md §4. |
| Storage adapter | StorageBackend interface (§2.7) with S3Backend + NextcloudBackend; GroupProvisioner for in-app control-plane ops | All product / ledger / crypto code is backend-agnostic. No developer-hosted backend or control-plane service. |
| Exchange rates | frankfurter.app (free, open source) | No API key required. Fallback: manual entry. |
| Push notifications | None in v1 (FCM/APNs deferred) | S3-compatible object-store backends cannot push without a server. Sync is poll-based. |
9.2 Future Iterations
Key Epoch Rotation (member removal)
When a member is removed, the organiser generates a new group key epoch and wraps it to the remaining members only, so future events are re-encrypted under a key the removed member never receives. The removed member retains decryption capability for past events (unavoidable without deleting their local copy) but cannot decrypt any new events. Implementation uses the KeyEpochUpdate event type (fen_crypto wrapEpochKeyForMembers) and the epoch stamped on each event envelope.
Storage backends (self-hosted, shipped design)
FEN ships no developer-provided backend. The owner self-hosts and configures one of two BYO backends once, and provisioning runs in-app via GroupProvisioner. See backend-architecture.md for the normative design and implementation-plan-selfhost-transition.md for the migration.
- S3-compatible. Owner pastes
endpoint,region,bucket,access_key_id,secret_access_keyfor any S3-compatible store. One bucket, prefix per group, one shared credential in the invite (kind: "s3"). - Nextcloud. Owner enters base URL + username + app password. Group create =
MKCOLfolder + OCS password-protected public link; members connect account-lessly through the public WebDAV endpoint with the share token + password (kind: "nextcloud").
Why Nextcloud works where plain WebDAV and the consumer clouds don't. A BYO backend must be near zero-config: creating a group must not require touching the storage provider by hand. Almost every WebDAV server (
dufs,rclone serve webdav, Caddy, a NAS's built-in WebDAV, ownCloud) has no API to provision an isolated, writable, account-less per-group share, and Google Drive / Dropbox / OneDrive are not owner-hostable (a third party stays the central server) and don't expose account-less writable per-folder shares programmatically. Nextcloud is the exception — its OCS Share API creates and manages password-protected public links programmatically, and those links are reachable over the public WebDAV endpoint by non-account-holders. That is exactly what the premise needs, which is why Nextcloud is now a first-class BYO backend alongside S3 rather than a deferred maybe. Its cost — running a full PHP + DB + cron instance — is the owner's to bear, not the developer's.
Key Epoch Rotation is authoritative for removal
Member removal relies on key-epoch rotation for forward secrecy; raw storage-access revocation is the weak, best-effort shared-credential model on both backends, with no server-enforced deadline. This is an accepted limitation of running no developer backend — see backend-architecture.md §6.
Group-to-Group Settlement
Allow a member to carry a balance from one closed group into a new one. Useful for recurring friend groups.
Receipt Attachment
Attach an encrypted receipt photo to an expense event. Storage uses the attachment storage protocol (BUD-01/02) — a content-addressed HTTPS blob store identified by sha256(ciphertext).
How it works:
- Client encrypts the photo with
attachment_key+ random nonce (XChaCha20-Poly1305) → ciphertext - Client computes
sha256(ciphertext)→blob_hash - Client uploads ciphertext to the user's configured attachment storage server via
PUT /<blob_hash>with auth - The expense event's encrypted payload references the blob by hash:
{"receipt": {"sha256": "<hash>"}}— the reference lives inside the encrypted content - Other members decrypt the event, fetch
attachments/<sha256>from the group backend, and decrypt withattachment_key
Where attachments live: Receipt blobs are stored in the same group backend as events, under attachments/<sha256(ciphertext)> (see backend-architecture.md). They are encrypted with attachment_key before upload, so the backend sees only ciphertext, and any member fetches them with the group's backend credential — no separate attachment server.
Resilience: The event stores multiple URLs ("urls": [...]). If the primary URL 404s, the app falls back to secondary URLs and can trigger a re-upload. Group members who have fetched a receipt can re-upload it to a new server if the original disappears.
UX posture: Receipt photos are a power feature. If no attachment storage server is configured, the receipt camera icon is hidden and the feature is silently unavailable rather than broken. FEN works fully without it.
Recurring Groups
Support groups with no formal closure — e.g. flatmates tracking shared bills month after month. Requires periodic balance snapshots and archiving of old events to keep the log size manageable.
Web Companion
A read-only web view that runs entirely in the browser. The user pastes their export JSON and sees the group summary — no server involved, no upload.
Local P2P Sync (Deferred)
Local peer-to-peer sync via QR code, Bluetooth, or WiFi Direct was evaluated and deliberately deferred from v1. The backend handles the sync need for 99%+ of real-world scenarios (including offline logging with later reconnection). P2P would add significant platform-specific complexity: MultipeerConnectivity (iOS), Nearby Connections (Android), Bluetooth/local network permissions, QR scanner UI, and a local HTTP server — all for the edge case where a user has no mobile data at all and cannot wait a few minutes to sync. Revisit only if real users consistently report this scenario as blocking. If added, it should use the same encrypted event envelopes as backend sync so no new crypto surface is introduced.
Closed Group Archiving — Local Export
When a group closes, every member receives a permanent, self-contained local archive of all expenses and receipt images. The archive is independent of backend availability — once exported it can be read on any computer without FEN.
9.1 Platform Feasibility
Core principle: build the archive inside the app sandbox, then hand it to the OS share sheet. No special permissions are required on either platform.
iOS
| Mechanism | API | Notes |
|---|---|---|
| Share sheet (recommended default) | share_plus → Share.shareXFiles() | Zero extra permissions; iCloud Drive, AirDrop, Files app, and Mail all appear as destinations |
| Files app (browsable in-app folder) | getApplicationDocumentsDirectory() + UIFileSharingEnabled = YES + LSSupportsOpeningDocumentsInPlace = YES in Info.plist | Archive appears under “On My iPhone → FEN” |
Android (API 29+)
| Mechanism | API | Notes |
|---|---|---|
| Share sheet | share_plus | Simplest; user picks destination (Downloads, Drive, SD card, etc.) |
| Downloads (no permission) | media_store_plus | Inserts directly into public Downloads collection; no MANAGE_EXTERNAL_STORAGE needed |
| Folder picker (SAF) | file_picker → ACTION_CREATE_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE | User picks any folder including SD card; covers all scoped-storage targets |
Flutter package set:
path_provider: ^2.1.0 # sandbox temp directory
share_plus: ^7.2.0 # share sheet (iOS + Android)
archive: ^3.4.0 # pure-Dart ZIP, no native deps
file_picker: ^8.0.0 # optional SAF folder/SD-card picker
# media_store_plus if one-tap Downloads on Android is desired
9.2 Archive Format
Format: ZIP with a structured folder layout.
fen_group_vacation-paris_2026-06-27.zip
├── README.md ← human-readable; no app needed to open
├── manifest.json ← machine-readable metadata (see below)
├── members.json ← pubkeys + display names
├── events/
│ ├── events.json ← all expenses, decrypted, sorted by date
│ └── schema.json ← JSON Schema (self-documenting)
└── receipts/
├── {sha256}.jpg ← content-addressed; integrity verifiable with sha256sum
└── missing.json ← hashes that could not be fetched (see §9.4)
Why ZIP over SQLite:
- Universally openable on any computer without special tooling
- JSON files are readable in any text editor
- Receipt filenames = attachment storage content hashes — integrity is verifiable after the fact with
sha256sum - Compresses JSON 60–80 %; produces one shareable file
- SQLite requires tooling; storing image BLOBs inside it is awkward
manifest.json — machine-readable:
{
"format": "fen-group-archive",
"format_version": "1.0",
"app_version": "2.1.0",
"export_timestamp_iso": "2026-06-27T14:32:00Z",
"group": {
"id": "<uuid>",
"name": "Summer Trip 2026",
"currency_default": "CHF",
"created_at_iso": "2026-05-01T10:00:00Z",
"closed_at_iso": "2026-06-27T14:30:00Z",
"member_count": 5,
"group_key_fingerprint_hex": "a3f9bc12..."
},
"storage": {
"kind": "s3",
"bucket": "grp_<group_id>"
},
"stats": {
"total_expenses": 142,
"total_receipts_referenced": 45,
"receipts_included": 38,
"receipts_missing": 7
},
"encryption": "none"
}
group_key_fingerprint_hex — first 16 bytes of SHA-256(group_key), hex-encoded. Lets a user identify the right key later without exposing it.
README.md — human-readable:
This archive was exported from FEN on 2026-06-27.
Group: Summer Trip 2026 (5 members, 142 expenses, CHF 3,840 total)
To view: unzip this file and open any .json file in a text editor,
or import it back into FEN.
Receipts are in the /receipts folder (named by content hash).
7 receipts were unavailable at export time (see receipts/missing.json).
9.3 Encryption
Default: plaintext ZIP. Optional: XChaCha20-Poly1305 wrapper — but NOT raw ZIP password encryption.
| Option | Default? | Pro | Con |
|---|---|---|---|
| Plaintext ZIP | ✅ | Openable on any computer, no key needed | Readable by anyone with physical file access |
| Password-encrypted (user-chosen, Argon2 → XChaCha20-Poly1305) | Off | Protects financial data at rest | User must remember password; archive is permanently locked if forgotten |
Rationale for plaintext default:
- iOS encrypts the file system at rest (Apple Data Protection); Android uses file-based encryption behind the lock screen
- Cloud destinations (e.g. iCloud Drive) encrypt at rest, in transit and storage
- The
group_keyis a transient session secret — if the user loses it, the archive is permanently unreadable; don’t couple archive longevity to the session secret
If encryption is enabled:
- Derive the archive encryption key from a user-chosen password via Argon2id (not from
group_key) - Wrap the entire ZIP bytes in XChaCha20-Poly1305; store the wrapped file as
.fen.enc - Do not use ZIP internal password encryption (typically AES-128, weak, implementation-inconsistent)
- Include a plaintext
.key-instructions.txtalongside explaining how to decrypt - Store
"encryption": "argon2id+aes-256-gcm"inmanifest.json(inside the envelope, decrypted on open)
9.4 Receipt Fetching
Receipt images must be eagerly fetched at export time — do not assume they are cached.
Fetch strategy — parallel with bounded concurrency:
Future<ReceiptResult> fetchReceipt(String hash, List<String> servers) async {
// 1. Check local cache first
final cached = await cache.getFileFromCache('attachment:$hash');
if (cached?.file != null) return ReceiptResult.hit(cached!.file);
// 2. Race all attachment storage servers — first response wins
final response = await Future.any(
servers.map((url) => http.get(Uri.parse('$url/$hash'))
.timeout(const Duration(seconds: 10))),
);
if (response.statusCode == 200) {
// 3. Verify content hash before trusting
final actual = sha256.convert(response.bodyBytes).toString();
if (actual != hash) throw ArchiveIntegrityError('Hash mismatch: $hash');
return ReceiptResult.fetched(response.bodyBytes);
}
return ReceiptResult.missing(hash, servers);
}
// Process in batches of 4 to avoid hammering servers
const concurrency = 4;
for (var i = 0; i < hashes.length; i += concurrency) {
final batch = hashes.sublist(i, min(i + concurrency, hashes.length));
results.addAll(await Future.wait(batch.map(fetchReceipt)));
onProgress(i + batch.length, hashes.length);
}
For receipts that cannot be fetched:
- Write
receipts/missing.json:[{"sha256": "abc123...","referenced_by": "expense-042","description": "Dinner receipt","path": "attachments/abc123...","error": "HTTP 404"}] - Never silently omit a missing receipt — the user must know the archive is incomplete
- Set a 5-minute total timeout; show a Cancel button
- At completion, display a summary: “38/45 receipts included — 7 unavailable” with a “View Missing” link
Decrypt each receipt locally (using the appropriate sub-key) before writing to the ZIP (plaintext archive) or re-encrypt with the archive key (encrypted archive).
9.5 UX Flow
User taps "Close Group" (or balances reach zero and app offers to close)
↓
[■ Dialog]
┌──────────────────────────────────────────┐
│ This group has 142 expenses and │
│ 45 receipt images (~18 MB est.) │
│ │
│ Export an archive before closing? │
│ [Export & Close] [Close Without] [×] │
└──────────────────────────────────────────┘
↓ "Export & Close"
[■ Progress] Fetching receipts (12 / 45)… [Cancel]
↓ Done
[■ Summary]
✓ Archive saved
• 142 expenses exported
• 38 / 45 receipts included
⚠ 7 receipts were unavailable [View Missing]
[Share Archive] [Close Group]
↓ "Share Archive"
■ Platform share sheet (iOS) / save dialog (Android)
↓ "Close Group"
Group enters Closed state; all events frozen
If user taps “Close Without”:
- Group enters a soft-close state: fully accessible in a “Closed groups” view for 30 days
- A dismissible banner on the group: “No archive saved — [Export Archive]”
- After 30 days: permanent close with a final export prompt
Manual export (always available):
- Group Settings → “Export Archive” — identical flow, without the close step
- Works for both open and soft-closed groups
Do not auto-export silently — the user must consent to where financial data is saved.
9.3 Open Questions
| Question | Notes |
|---|---|
| What happens if the organiser loses their device before closing the group? | Consider: any member can propose closure, organiser must confirm. Or: after 90 days of inactivity, any member can close. |
| Sync notification strategy (resolved for v1) | v1: poll on app-open, foreground-resume, network-reconnect, and a 30 s foreground timer. OS background-fetch every 15–30 min. No APNs/FCM. Backend signal is an opaque cursor only — no content metadata. v2: SSE stream (GET /stream) emitting id:{cursor}\ndata:{}\n\n; client runs the identical poll-then-apply loop on SSE trigger. Design the M4 sync loop as a single syncGroup() function so the v2 SSE trigger is a drop-in replacement for the timer. See §5.5 for the full strategy. |
| How are push notification device tokens registered and rotated? | Deferred to v2+. APNs/FCM explicitly out of v1 scope. If added, treat push as a hint only — never as a delivery guarantee. Token registration, refresh, and expiry handling adds real infrastructure complexity. |
| Should exchange rates be set per-expense or per-group-per-currency? | Current design: per-group-per-currency (one agreed rate for all CHF expenses). Per-expense rates are more accurate but more friction. |
Superseded 2026-07 by the self-hosted transition (#96): atomic single-use invites are an explicitly-dropped guarantee — there is no developer backend and thus no server to record a single redeemer. Invites now carry the group's single shared credential with a short client-enforced TTL; a same-inviteId race is broken by the organiser admitting the lexicographically-smallest pubkey via MemberAdmitted. (Historical: earlier drafts used a client-side (lamport, event_id) tie-break, then issue #17 added provisioner-enforced single-use — both removed with the provisioner.) See §2.4/§4.3 of fen.md/security.md and backend-architecture.md §5–§6. | |
| Maximum group size? | Design is sound for 2–20 members. Above that, event fan-out volume should be monitored. |
| Catching Name? | Find a catching name, possibly from another language (Japanese, French). In the best case it involves a play of words. |
| Security & threat model review | The current threat model (§4.3) covers the primary attack surface but has not been subjected to a formal security review. Before public launch, commission or conduct a structured threat analysis (e.g. STRIDE or PASTA) covering: backend impersonation, invite link interception, event log tampering, key material exposure on device loss, and backend-side metadata inference. Update §4.3 with findings. |
| Backend housekeeping policy | Resolved in §9.2 Closed Group Archiving (§9.6). Default 90-day grace period; backend retains a lightweight tombstone (group_id + closed_at + closed_by_pubkey) indefinitely; explicit deletion by organiser as alternative to auto-purge. |
| Friends list multi-device sync | Deferred to a future version. v1 stores friends only in a device-local table used for display names, invite resolution, and known-friend direct-add. Sync can be designed after that local model exists. |
| Known-friend direct-add relay selection | Resolved: when adding a confirmed friend by public key, relay the wrapped invite through the newest-created eligible active shared group. Closed groups, cold/local-only archives, groups scheduled for deletion, and purged groups are ineligible. If no eligible active shared group exists, fall back to the normal invite-link flow. |
9.4 Glossary
| Term | Definition |
|---|---|
| Event log | The append-only sequence of GroupEvent records. The ground truth. The only data synced. |
| Materialised state | Local SQLite tables derived by replaying the event log. Never synced. |
| Storage backend | The owner-self-hosted store that holds a group's encrypted log files. Blind to content — sees only ciphertext + file metadata. One of two BYO backends: an S3-compatible endpoint or a Nextcloud instance (§2.7, backend-architecture.md §3–§4). No developer-hosted backend. |
| Group key | A 32-byte symmetric key used to encrypt all event data for a group. Shared only with members. |
| Storage write capability | The backend-native, non-cryptographic credential granting write access: the group's single shared credential (an S3 key, or a Nextcloud share token + password). A bearer secret handed to every member. Member removal uses the weak shared-credential model — no server-enforced revocation; forward secrecy comes from key-epoch rotation (backend-architecture.md §6). |
| Invite secret | A one-time 32-byte key that decrypts the invite package. Travels only in the URL #fragment. |
| Invite package | An encrypted blob containing {group_key, storage_descriptor, group_id, member_pubkeys, expiry}, encrypted with the one-time invite_secret. Travels entirely in the deep link #fragment — never stored on any server. |
| StorageDescriptor | The pointer to a group's backend carried in the invite and key-backup bundle. Two kinds: {kind:"s3", endpoint, region, bucket, access_key_id, secret_access_key} or {kind:"nextcloud", baseUrl, shareToken, sharePassword}. Always owner-supplied (§2.4, backend-architecture.md §5). |
| ETag / file cursor | A per-file revision tag used to detect which logs/<pubkey>.jsonl changed since last sync. A private pull cursor — never a global order (that is the Lamport clock). Replaces the old server_sequence. |
| Settlement currency | The currency chosen by the group for final balance calculation. Each expense's original currency is preserved; conversion uses agreed exchange rates. |
| Outbox | Local table of events not yet successfully written to the backend. Flushed on next connectivity. |
| Debt simplification | Algorithm that reduces N pairwise IOUs to at most N-1 settlement transactions. |
| StorageMigrated event | A signed organiser-only group event instructing members to switch to a new StorageDescriptor. Written to both old and new backends while the old one is still readable; carries effective_after and a monotonic epoch to prevent replay (§3.3). |
| StorageKeyRotated event | A signed organiser-only group event distributing a rotated shared storage credential (a fresh S3 key, or a recreated Nextcloud public link). Encrypted under the post-removal epoch so a removed member cannot read it; published while the old credential can still fetch it; carries rotation_id (§3.10). Rotating the raw old credential is best-effort, owner-driven — there is no server-enforced revocation deadline (backend-architecture.md §6). |
| StorageKeyAck event | A member's confirmation — written with the new credential — that it has switched after a StorageKeyRotated. Once every current-epoch member has acked, the owner's device can retire the old credential (best-effort; no server involved) (§3.10). |
| Storage model (two BYO backends) | FEN ships no developer backend. Each group is bound to one store the owner self-hosts: an S3-compatible endpoint (one bucket, prefix per group) or a Nextcloud instance (per-group public share). Both use one shared credential per group and store only ciphertext (§2.7, backend-architecture.md §3–§4). |
| expense_edit_permission | A group-level setting (creator_only or any_member) that governs who may edit or delete expenses. Set by the organiser at group creation via GroupCreated; changeable afterwards via GroupSettingsUpdated. Default: creator_only. |
| GroupSettingsUpdated event | An organiser-only event that changes group-level settings (currently expense_edit_permission) without closing or re-creating the group. |
| Solo expense | An expense where splits contains exactly one entry and that entry’s participant_id equals paid_by. Represents a personal cost the member wants to track as part of their total trip spend without sharing the cost with others. Contributes zero to inter-member balances; fully included in Total Cost by Member. |
| Involvement | A member M is "involved" in an expense if paid_by === M or splits.some(s => s.participant_id === M). Derived locally; never stored. Used to drive the default expense list filter. |
| "Show expenses without involvement" filter | A toggle in the group expense list (parallel to "Show deleted") that reveals expenses the current user is not involved in. Non-involved expenses are shown at reduced contrast (grey) with a label indicating no balance impact. |
| Total Cost by Member | A report (available in the Reports section) showing each member’s gross spend: Σ(split.amount_cents for participant === M) across all active expenses, converted to the settlement currency. Includes solo expenses; excludes deleted expenses and settlement transactions. Distinct from the net Balance (which reflects who owes whom after group payments). |
| Soft delete / tombstone | An ExpenseDeleted event that marks an expense as deleted without removing any data from the event log. The original ExpenseLogged event is preserved permanently. is_deleted = true is a derived flag on the local materialised expense record. |
group_ui_prefs | A local-only SQLite table (never synced) storing per-group display preferences: show_deleted and show_without_involvement. Both default to false and persist across app restarts independently per group. |
show_deleted toggle | A per-group UI preference that reveals soft-deleted expenses in the expense list. Deleted expenses appear at reduced contrast (grey) with a strikethrough and “Deleted” badge; they are excluded from all calculations regardless of this toggle. |