Skip to main content

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.

ConcernFlutterReact Native
libsodium FFI bindingsStable (dart:ffi)In flux (JSI / NativeModules story still evolving)
SQLitedrift (ORM over sqflite) — type-safe, rock-solidexpo-sqlite / op-sqlite — ecosystem has been in flux
Crypto background workClean isolatesJS thread + bridge overhead
Logic-heavy / UI-light app fitStrongAcceptable
Deep link #fragment handlingSame platform APIsSame 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.

POCWhat to proveSuccess criteria
Crypto on deviceX25519 keygen, XChaCha20-Poly1305 encrypt/decrypt, Ed25519 sign/verify — on a real iPhone first, then on a real Android deviceRuns correctly; < 5 ms per event; sodium_libs version pinned. All key material and nonces from sodium.randombytes.buf(...).
Deep link + #fragmentInvite 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 serveriOS Universal Links must pass first; Android App Links validated second; not logged anywhere
Event log replay at scaleSeed SQLite with 10 000 synthetic events; replay to derive balances; measure on a mid-range Android deviceReplay < 200 ms; if slower, snapshot caching must be prioritised earlier
Backend adapters v0S3Backend (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, call POST /v1/groups during group creation or the first syncable bind, persist the returned StorageDescriptor, 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) and POST /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 the group_storage_binder.dart placeholder 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 MemberJoined or 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_seq frontiers at ingest (#73).
  • Wire/spec alignment hardening: keep fmt = 1 as signature-only context binding and plan AEAD AAD for fmt = 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.