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 — Managed (default, v1) | Garage (S3), FEN-hosted, via a Dart S3 client (SigV4) | Per-group bucket + scoped key minted at group creation; no per-member account. See backend-architecture.md. |
| Storage — BYO self-hosted S3 (v2, planned) | Any S3-compatible endpoint (user's own Garage, MinIO, …) via the same Dart S3 client | Full data sovereignty; user supplies the connection string. Invite carries the storage descriptor; no client code changes. WebDAV was considered and dropped — see §9.2. |
| Storage adapter | StorageBackend interface (§2.7) with S3Backend | All product / ledger / crypto code is backend-agnostic. |
| 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, all remaining members should generate a new group key and re-encrypt future events with it. The removed member retains decryption capability for past events (unavoidable without deleting their local copy) but cannot decrypt any new events. Implementation involves a KeyEpochRotated event type and storing an epoch_id on each event.
Bring-Your-Own S3 (Self-Hosted, v2)
Power users will be able to point their group at their own S3-compatible endpoint — their own Garage instance, MinIO, or any other S3-compatible object store — by pasting a connection string (endpoint, region, bucket, access_key_id, secret_access_key) instead of having FEN's provisioner mint one. Architecturally this is nearly free once the Managed tier's S3Backend/S3Descriptor ship (backend-architecture.md §2.3, §4): it is the exact same adapter and descriptor kind (kind: "s3"), just sourced from user input instead of the provisioner — no new adapter, no new StorageBackend implementation, no new descriptor kind. What's missing for v1 is only the UX to accept/validate a user-supplied connection string and skip the provisioner call; that is explicitly deferred to v2.
Why WebDAV was dropped instead (and why Nextcloud specifically might return later). WebDAV was the original planned BYO tier design. It was dropped because a BYO tier needs to be close to zero-config the same way the Managed tier is — 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 per-group share; the owner would have to log in and manually create/configure a share for every single group, which breaks one-tap group creation and doesn't scale past a handful of groups. Nextcloud is the one exception — its OCS Share API can create and manage public shares programmatically, so it's the only WebDAV-family server that could, in principle, support automated per-group provisioning the way Garage does. Even so, running a full Nextcloud instance (PHP app + database + cron + its own maintenance burden) is a heavy footprint just to get per-group file shares, next to a ~15 MB single-binary Garage container. So WebDAV — Nextcloud included — is out of the design for both v1 and the v2 BYO plan above. Nextcloud specifically may be reconsidered later as an additional power-user backend option (on top of, not instead of, BYO-S3) if there's real user demand for it, precisely because it is the only WebDAV server that wouldn't force manual per-group server-side work.
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. |
Resolved 2026-06-29, superseded 2026-07-05: originally best-effort client-side only (if multiple MemberJoined events shared an invite_id, clients accepted the one sorting first by (lamport, event_id)), with backend enforcement deferred. Issue #17 added the actual backend enforcement: the provisioner denies the invite credential the moment the first complete-invite call succeeds, so a losing racer's own call fails loudly instead of relying solely on the client-side tie-break. See §2.4/§4.3 of fen.md/security.md and design-review-findings.md finding #2. | |
| 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-controlled store that holds a group's encrypted log files. Blind to content — sees only ciphertext + file metadata. Managed (v1): FEN-hosted Garage (S3); BYO self-hosted S3 (v2, planned): the user's own S3-compatible endpoint (§2.7). |
| 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: a per-group S3 key (Managed/Garage today; same mechanism for the planned v2 BYO self-hosted S3 tier). A bearer secret — rotated on member removal via the two-phase hand-off (StorageKeyRotated/StorageKeyAck, deferred-deny; §3.10, §4.8, backend-architecture.md §6.1). |
| 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: {kind:"s3", endpoint, bucket, access_key_id, secret_access_key} — the same kind serves both the Managed tier (v1) and the planned BYO self-hosted tier (v2), differing only in who supplies the values (§2.4). |
| 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 per-group S3 credential (same endpoint/bucket, fresh keys). 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 and the provisioner's deny_old_at revocation deadline (§3.10). |
| 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 organiser asks the provisioner to revoke the old credential early (§3.10). |
| Two-tier storage model | Managed (default, v1): FEN-hosted Garage (S3) with a per-group bucket + key, no per-member account. BYO self-hosted S3 (v2, planned): the user's own S3-compatible endpoint, same descriptor/adapter. Both store only ciphertext (§2.7). |
| 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. |