FEN — Implementation Strategy (§5)
Part of the FEN design set. Overview, index, and the legacy-terminology map: fen.md.
5. Implementation Strategy
Platform priority: iOS first, Android second. Every milestone and POC must pass on a real iPhone before it is considered done. Android validation follows. Where platform behaviour diverges, iOS wins. The iOS App Store submission is the v1 ship gate; Google Play submission follows as a secondary release once iOS is live.
5.1 Framework Decision
Flutter — decided. This was the day-one decision that unblocked everything else; the monorepo scaffold (apps/fen, packages/*) is already built on it.
| Concern | Flutter | React Native |
|---|---|---|
| libsodium FFI bindings | Stable (dart:ffi) | In flux (JSI / NativeModules story still evolving) |
| SQLite | drift (ORM over sqflite) — type-safe, rock-solid | expo-sqlite / op-sqlite — ecosystem has been in flux |
| Crypto background work | Clean isolates | JS thread + bridge overhead |
| Logic-heavy / UI-light app fit | Strong | Acceptable |
Deep link #fragment handling | Same platform APIs | Same platform APIs |
The app is logic-heavy (event replay, balance calculation, crypto) and UI-light — Flutter's strengths align with this profile, which is why it was chosen over React Native.
Decision gate — cleared. Framework choice preceded all other implementation work, as required; screens and packages are built on Flutter from here on.
5.2 Critical Path
Framework decision
→ POC: crypto on real devices
→ POC: deep link + #fragment on both platforms
→ POC: event log replay performance at scale
→ Backend v0 (~100 lines — build it, don't mock it)
→ Event envelope schema + canonical serialization (canonical-serialization.md) ← FREEZE THIS EARLY
→ M1: Walking skeleton (two devices, one group, one expense, sync, balance)
→ M2: Expense feature set (split types, edit, delete, solo)
→ M3: Money correctness (multi-currency, rates, debt simplification, settlement)
→ M4: Sync hardening (offline queue, foreground polling, background fetch → SSE in v2)
→ M5: Product completion (close, archive, UI prefs, error states, backend portability)
The invite flow and the crypto layer are the two places where "works on my machine" does not mean "works on both platforms." Validate both on real devices before writing a single line of UI polish.
5.3 POC Sprint — Remove Existential Risk First (1–2 weeks, no UI)
The goal is to eliminate risks that would be catastrophic to discover in week six. Run these four spikes before committing to any feature work.
| POC | What to prove | Success criteria |
|---|---|---|
| Crypto on device | X25519 keygen, XChaCha20-Poly1305 encrypt/decrypt, Ed25519 sign/verify — on a real iPhone first, then on a real Android device | Runs correctly; < 5 ms per event; sodium_libs version pinned. All key material and nonces from sodium.randombytes.buf(...). |
Deep link + #fragment | Invite link https://fenapp.net/join#s=<invite_secret>&p=<encrypted_pkg>&v=2 (Universal/App Link — fen://join#s=...&p=...&v=2 is the equivalent custom-scheme fallback) handled in-app; fragment parsed client-side, never sent to a server | iOS Universal Links must pass first; Android App Links validated second; not logged anywhere |
| Event log replay at scale | Seed SQLite with 10 000 synthetic events; replay to derive balances; measure on a mid-range Android device | Replay < 200 ms; if slower, snapshot caching must be prioritised earlier |
| Backend adapters v0 | S3Backend (Garage): list/get/put/delete + conditional write (§2.7) | Two-device smoke test passes against a Garage bucket. (WebDAV dropped from the design — see §9.2 of appendices.md; a v2 BYO self-hosted-S3 smoke test reuses this same adapter later.) |
Do not POC reports, polished UI, or settlement UX. Those are downstream of these four gates.
5.4 Milestones & Roadmap
The project is structured into four distinct phases.
v0 — PoC Phase
Goal: Eliminate existential risk and prove the sync architecture.
- Scaffold the Flutter app.
- Backend running on NAS (Garage S3).
- Two-device smoke test: Simulate concurrent writes from two devices and prove both can pull and reconcile the event log correctly.
- Freeze the canonical event serialization shape.
v1 — Functional iOS App
Goal: A complete, usable expense-sharing application on iOS.
- Platform: iOS only.
- Backend: App-provided Garage S3 backend on your NAS (Managed Tier). WebDAV and other targets are completely removed to reduce integration surface.
- UI: All screens designed and implemented (expenses, balances, settlement, group creation, settings).
- Crypto: Ed25519 key exchange and XChaCha20-Poly1305 encryption all fully working and transparent.
- Sync: Foreground polling on app open/resume.
v1.5 — Functional Android App
Goal: Port the v1 experience to Android.
- Platform: Android compilation and release.
- Scope: No new features. Purely ensuring the UI, crypto (libsodium), and deep linking work flawlessly on Android devices.
v2 — Power User Features
Goal: Expand deployment options and UX conveniences.
- Bring Your Own Backend: Allow users to connect their own Garage S3 clusters (retains the same S3 API, but points to their own domains/credentials).
- Multi-Device Support: Sharding member logs by device (
logs/<pubkey>/<deviceId>.jsonl) so users can use an iPad and an iPhone simultaneously. - Silent Push Notifications (Opt-in): A push gateway that sends silent wake signals (no expense data, only hashed group IDs) to APNs/FCM so the device can sync in the background without needing to poll.
5.5 Sync Strategy
M5 — Product Completion (2–3 weeks)
Goal: finish v1 scope and reach app-store readiness.
Add:
- Group close
- Local archive on
GroupClosed(sealed, offline-readable) - Backend portability UI ("connect your own backend")
- Error states: invite expired, invite already used, bad key, wrong backend, corrupt/undecryptable event, unsupported schema version
- Onboarding — explain only what the user must understand (invite link, group key not recoverable if device lost)
- Visual polish and accessibility pass
- Managed group provisioning (implementation-review issue #68): add
ProvisionerClient.createGroup, callPOST /v1/groupsduring group creation or the first syncable bind, persist the returnedStorageDescriptor, and surface the group as local-only until this succeeds. This gates real organiser sync and invite creation; fake/random S3 credentials are no longer an acceptable placeholder. - Invite-credential hardening (issue #17, design-review finding #2) — done: provisioner
POST /v1/groups/:id/invites(mint a short-TTL, single-use Garage key distinct from the group's shared key) andPOST /v1/groups/:id/invites/:invite_id/complete(single-use redemption, exchanges the invite credential for the shared one); the reaper extended to deny+delete expired/used invite keys; client wiring (ProvisionerClient,CreateInviteService,JoinInviteService's complete-invite call) replacing thegroup_storage_binder.dartplaceholder credential for invites specifically. The remaining initial shared-credential path is tracked by #68 above, not by #17. - Invite consent + resumability (issues #67/#69): parse deep links without
side effects, show an explicit Join/Decline screen before
MemberJoinedor complete-invite, and make same-claimant completion retries idempotent. - Sync/authorization correctness from the implementation review: commit
per-file cursors only after successful ingest (#64), enforce organiser/self
authorship for roster and group-lifecycle mutations (#65), and validate
author_seqfrontiers at ingest (#73). - Wire/spec alignment hardening: keep
fmt = 1as signature-only context binding and plan AEAD AAD forfmt = 2(#66), choose the post-close reducer behavior explicitly (#71), harden flush dedupe/append scaling (#70), and make money arithmetic fail closed in release (#72).
Member removal / key rotation (key epoch) is explicitly out of scope for v1. State this clearly. It will creep in if not named.
5.5 Sync Strategy — v1 Polling (push is self-host-only)
Storage-model note. Garage/S3 (Managed today; the same for the planned v2 BYO self-hosted tier) cannot stream to the client — object stores have no push channel. So v1 is polling-only. Push is possible only when a self-hoster runs their own additional streaming endpoint alongside their storage (e.g. a small WebSocket sidecar) — independent of the storage tier. Treat the SSE design below as a self-host opt-in, not a universal v2.
The polling and (self-host) SSE approaches share the same client logic structure. This is deliberate: the upgrade is a small delta, not a rewrite.
v1 / v1.5 — Poll-triggered:
On trigger (app open / resume / network / manual):
flush outbox → POST unsent events to the Garage backend
GET /events?after=backend.last_seq → collect
merge by event_id (union across backends) → decrypt → apply in (lamport, event_id) order
update backend cursor + author frontier; request missing author_seq ranges
For v2, push notifications will simply act as a background trigger for this exact same polling loop, ensuring the sync logic remains identical regardless of what woke the app up.