FEN — Implementation & Design Review (July 2026)
Status: recorded · full pass over the design docs (
doc/*.md,ARCHITECTURE.md) and the implementation (packages/*,apps/fen,services/fen-provisioner,infra/*) as of033c47e(branchfix/organiser-display-name, 2026-07-06). This is a follow-up todoc/design-review-findings.md(issues #15, #17–#35). That review's scope was the design at5db2fed; this one focuses on what has landed since — the M1 flows,VerifyingEventSink, the invite-credential redesign, two-phase rotation, friends list, CI — and on new findings not tracked by an existing issue. Legend: 🔴 high · 🟠 medium · 🟡 low. Every new finding is tracked as a GitHub issue (#64-#73); the issue numbers are listed in the summary table. Companion script:scripts/file-review-issues.shdrafts one GitHub issue per new finding (N-1…N-10).
1. Where the project stands
The architecture described in ARCHITECTURE.md is now real, end to end, in code: a
local-first Flutter app whose only trust anchor is Ed25519 identity keys; a canonical,
signable JSONL envelope (fen_events); a pure reducer with roster-authority semantics
(fen_ledger); libsodium-backed crypto (fen_crypto); an S3/Garage storage adapter with
on-device SigV4 signing (fen_sync); and a Go provisioner that mints per-group and
single-use invite credentials against Garage's admin API.
Substantial, well-executed work has landed since the first review:
- Signature verification at ingest is real (#26 →
VerifyingEventSink). Decode → verify-before-decrypt → decrypt → dedupe → roster-authority check, with a typedEventRejectionvocabulary that is part of theEventSinkinterface contract, and a genuine production call site (GroupSyncService, wired intogroupBalanceProvider). - Membership acceptance is organiser-authoritative (#18): self-certifying
MemberJoinedonly ever creates a pending row;MemberAdmitted(organiser-signed, provisioner-adjudicated winner viaGET …/invites/{id}) promotes it. The duplicate-join tiebreak is no longer attacker-winnable. - Invites are no longer a permanent compromise (#17): the link carries a short-TTL,
single-use Garage credential; the joiner exchanges it via
…/invites/{id}/complete(atomicClaimInviteunder the store lock); a reaper denies and deletes used/expired keys. - Two-phase storage-key rotation (#15): rotate mints the new key and defers denial of the old one until owner confirmation or a deadline, with close-time demotion to read-only.
- Provisioner hygiene is genuinely good: replay cache keyed on decoded pubkey/sig bytes
(hex-case malleability closed), NUL-separated canonical signing message, constant
unauthorizederror, secrets never logged, best-effort rollback of orphan buckets/keys, quota per group,X-Forwarded-Foruntrusted. - Canonical serialization remains one of the strongest parts: re-encode-and-compare canonicality gate, decimal-string numbers, fixed-millisecond timestamps, sorted keys, and an explicit "no nulls, no bare numbers" encoder.
- CI now runs
flutter analyze+ tests, pure-Dart package tests, and the Go gates (vet/build/test -race) on every PR.
The overall shape is unusually disciplined for a project at this stage: the layering in
ARCHITECTURE.md matches the code, security-relevant decisions carry inline citations to
the docs and issues that motivated them, and past review findings were actually closed with
mechanisms rather than comments.
The findings below are, in that context, the next ring of problems: mostly integration gaps between components that are individually correct, plus a few authorization and durability holes that the existing issue set does not cover.
2. Summary of new findings
| # | Sev | Area | One-line | Tracked |
|---|---|---|---|---|
| N-1 | 🔴 | Sync | pull() advances the ETag cursor before ingest — a crash or ingest failure permanently loses those events | #64 |
| N-2 | 🔴 | Authorization | Any active member can author MemberRemoved / MemberLeft-for-others / GroupClosed / GroupSettingsUpdated; reducer applies them without an organiser/self check | #65 |
| N-3 | 🔴 | Integration | The app never calls POST /v1/groups; GroupStorageBinder writes random fake S3 credentials — the organiser's device can never sync, and invite minting will 404 against a real provisioner | #68 |
| N-4 | 🟠 | Crypto/spec | sealEvent() computes aadBytes() but never binds it into the AEAD — §3.10's AAD binding is not on the wire | #66 |
| N-5 | 🟠 | Onboarding/security | Invite deep links auto-join silently — no accept/decline UI (doc/fen.md Flow 4 is specified but unbuilt) | #67 |
| N-6 | 🟠 | Invite flow | Provisioner failure after ClaimInvite burns the invite and the client misreports it as inviteAlreadyUsed (a "compromise" signal) | #69 |
| N-7 | 🟡 | Spec/impl | Closed-group immutability is partial: ExpenseLogged and SettlementRecorded are still accepted after GroupClosed | #71 |
| N-8 | 🟡 | Log integrity | author_seq contiguity is never checked at ingest — the §3.9 loss-provability mechanism has no implementation | #73 |
| N-9 | 🟡 | Sync | flush() dedupe uses a substring contains(event_id) over the whole log file; whole-file read-modify-write makes every flush O(log size) | #70 |
| N-10 | 🟡 | Money | Currency-mismatch guard is a bare assert (no-op in release); Money.fromDecimalString silently truncates excess precision | #72 |
3. Findings in detail
N-1 🔴 pull() advances the sync cursor before ingest
Tracked: #64
Where: packages/fen_sync/lib/src/sync_manager.dart, StorageBackendSyncManager.pull().
The pull loop does, per changed log file: fetch the object, append its lines to fresh,
then immediately cursors.setCursor(groupId, path, etag) — and only after the loop calls
sink.ingest(...). Two failure modes lose data permanently:
- The app crashes (or is killed) between
setCursorandingest— on the next pull the file's ETag matches the stored cursor, the file is skipped, and the events in it are never fetched again unless that author happens to write more. ingestthrows for an infrastructure reason (theEventSinkcontract explicitly allows throwing for local-DB failure). The cursor is already advanced; same loss.
The rejection-path design work (typed EventRejection, "never silently dropped") is
undermined by the cursor being committed before the sink has accepted the batch. The
local group_events table is the durable record; the cursor must only move once the
lines it covers are either in that table or deliberately rejected.
Direction. Move setCursor after a successful ingest, per file (fetch file →
ingest its lines → set its cursor), or collect (path, etag) pairs and commit them after
the sink returns. Re-ingesting a file is already safe (dedupe by event_id), so
crash-between-ingest-and-cursor merely re-processes — the safe side of the race.
N-2 🔴 Roster-mutation events have no authorship authorization
Tracked: #65
Where: packages/fen_ledger/lib/src/event_reducer.dart (_memberStatus,
GroupClosedPayload, and the default arm), apps/fen/lib/core/data/verifying_event_sink.dart
(_isAuthorized).
VerifyingEventSink._isAuthorized special-cases GroupCreated (trust root),
MemberJoined (self-certifying, pending only) and MemberAdmitted (organiser only); every
other event type passes if the author is any active member. The reducer then applies:
MemberRemoved { memberId: X }— status of X set toremoved, regardless of who signed it. Any active member can remove any other member, including the organiser.MemberLeft { memberId: X }— same handler; nothing requiresmemberId == authorId, so "leaving" on someone else's behalf is accepted.GroupClosed— any active member can freeze the whole group (and since closure gates expense edits, this is a one-event griefing tool).GroupSettingsUpdated— currently a no-op in the reducer, but the same gap will apply the moment it is implemented.
This was named in #26's resolution as "deliberately NOT covered" and it overlaps #31
(permission_rules.dart is stubbed), but neither issue actually tracks roster-mutation
authorship as a concrete hole, and the severity changed once VerifyingEventSink became
the real production gate: today the E2E-verified pipeline will happily apply a hostile
MemberRemoved end to end. Removal also has knock-on effects on _isActiveAuthor — a
removed organiser… remains authorized (createdBy check short-circuits), but a removed
member's later events are rejected, so a malicious member can silence another member's
future events by removing them.
Direction. In _isAuthorized (and mirrored in the reducer for defense in depth):
MemberRemoved, GroupClosed, GroupSettingsUpdated require authorId == createdBy;
MemberLeft requires payload.memberId == authorId. Add sink tests for each hostile case.
N-3 🔴 Group creation never provisions real storage credentials
Tracked: #68
Where: apps/fen/lib/core/data/group_storage_binder.dart,
apps/fen/lib/core/net/provisioner_client.dart (no createGroup method),
services/fen-provisioner/internal/api/server.go (handleCreateGroup).
CreateGroupService binds every new group to the managed backend by writing a
group_storage row containing locally generated random hex as
access_key_id/secret_access_key. The provisioner's POST /v1/groups — which creates
the bucket, quota, and the real shared key — is never called anywhere in the app;
ProvisionerClient doesn't even have the method. Consequences, in order of appearance:
- The organiser's
GroupSyncService.sync()builds anS3Backendfrom garbage credentials; every flush 403s (silently —sync()swallows into a debug log), so the organiser'sGroupCreated/MemberJoined/expenses never reach the bucket. CreateInviteService.createInvitecallsPOST /v1/groups/{id}/invitesfor a group the provisioner has never heard of →404 not found→ invite creation throws. The entire invite → join → admit pipeline, individually well-built, is unreachable end to end against a real provisioner.
The binder's comment honestly declares this "a later milestone's job", but no issue tracks it, and it now sits directly between the two flagship flows (#17/#18 machinery on both sides of it). Anyone running the stack today will observe "everything works locally, nothing syncs" with no surfaced error.
Direction. Add createGroup to ProvisionerClient; call it in
CreateGroupService/GroupStorageBinder and store the returned descriptor. Decide the
offline story (group creation currently works fully offline — perhaps keep local creation
and bind storage lazily on first flush, with a visible "not yet synced" state). Surface
persistent flush failures to the UI instead of developer.log.
N-4 🟠 AAD is computed but never bound into the AEAD
Tracked: #66
Where: apps/fen/lib/core/data/event_envelope_codec.dart (sealEvent),
apps/fen/lib/core/data/verifying_event_sink.dart (decrypt call),
packages/fen_events/lib/src/serialization.dart (aadBytes),
doc/canonical-serialization.md §3.10/§7.
The spec's {a,e,g,i,p} AAD object exists (aadBytes()), but sealEvent() encrypts with
no additionalData, and VerifyingEventSink decrypts with none — with an inline comment
acknowledging the gap ("tracked separately from #26", though no issue number is cited in
the repo). Today this is not independently exploitable, because the Ed25519 signature
covers the same fields and is checked before decryption. But the layering intent of §3.10
is that the ciphertext itself refuses to decrypt in the wrong context — a defense that
holds even for code paths that decrypt without re-verifying (none today, but nothing
enforces that), and for cross-epoch/cross-group confusion once epochs are real.
Direction. Either wire aadBytes() into seal/decrypt behind a fmt bump (fmt=2 with
AAD; accept fmt=1 without, for the existing wire history), or amend
canonical-serialization.md to state that AAD is intentionally not bound and signature
coverage is the sole context binding. The half-state — spec says bound, code computes it
and throws it away — is the worst of the three options.
N-5 🟠 Invite links auto-join with zero user consent
Tracked: #67
Where: apps/fen/lib/features/onboarding/invite_link_listener.dart (_handleUri →
unawaited(_join(package))), doc/fen.md Flow 4 (specifies [Join Group] [Decline]).
Any successfully parsed invite link — via Universal Link or, until AASA/assetlinks are
real (#34), the hijackable fen:// custom scheme — is joined immediately: the
MemberJoined event is written, the single-use invite credential is redeemed with the
provisioner, and the device navigates to the joined screen. The user is never asked.
Combined with #34 this means tapping a link (or any app that can forge a custom-scheme
open) consumes a real invite and inserts an identity into a group. It also burns the
legitimate invitee's credential if a link is opened on the wrong device.
The planning notes acknowledge the missing accept/decline screen as future work, but no issue tracks it, and it is a security control (consent before identity-binding + credential redemption), not just UX polish.
Direction. Parse → show group name/inviter → explicit Join/Decline before
acceptPackage runs. Keep the parse-only path silent.
N-6 🟠 A failed completion after ClaimInvite burns the invite and misreports compromise
Tracked: #69
Where: services/fen-provisioner/internal/api/server.go (handleCompleteInvite),
apps/fen/lib/features/onboarding/join_invite_service.dart (failure mapping).
ClaimInvite marks the invite used atomically before the Garage calls (correct for
single-use). But if DenyBucketKey/GetKeyInfo then fail (502), the joiner never receives
the shared credential, and their local join has been rolled back. Every retry now hits
ErrInviteAlreadyUsed → the client maps this to inviteAlreadyUsed, which the code
documents as "surface as a detectable compromise". So a transient upstream blip:
- permanently consumes the invite (the winner recorded is the honest joiner, who has nothing);
- tells the honest joiner someone else stole their invite;
- leaves the organiser's reconciliation seeing
used+ a winner pubkey that will never produce a pendingMemberJoined(the join was rolled back) — a permanent no-op, and alocal_friendsplaceholder stuck atinviteduntil expiry.
Direction. Make completion resumable: if ClaimInvite finds the invite already used
by the same memberPubkey, treat the call as an idempotent retry and return the shared
credential again (the reaper already handles denying the invite key). Client-side,
distinguish "used by me, retryable" from "used by someone else, compromise".
N-7 🟡 Closed-group immutability is only partial
Tracked: #71
Where: packages/fen_ledger/lib/src/event_reducer.dart (_expenseLogged,
_settlementRecorded), doc/data-model.md §"Group Status" ("GroupClosed — triggers full
immutability") vs invariant 4 (lists only ExpenseUpdated/ExpenseDeleted).
The reducer freezes edits and deletes after GroupClosed, but new ExpenseLogged and
SettlementRecorded events are still applied. The doc contradicts itself: the payload
comment promises full immutability, the invariant table names only two event types. As
implemented, any member can keep appending brand-new expenses to a closed (archived,
settled) group forever, silently changing its balances. (Settlements after close are
arguably a feature — settle-up happens after closing — which is exactly why the spec
should say so explicitly.)
Direction. Decide the intended semantics, fix whichever side is wrong, and add reducer
tests for post-close ExpenseLogged/SettlementRecorded.
N-8 🟡 author_seq contiguity is never validated
Tracked: #73
Where: apps/fen/lib/core/data/verifying_event_sink.dart,
doc/data-model.md §3.9 ("makes loss provable").
author_seq is assigned, signed, stored — and never read for its stated purpose. No
component detects a gap (evidence of suppression, truncation by a bearer-key-wielding
member, or storage rollback) or an inconsistency (two different accepted events from one
author with the same seq — which the event_id dedupe does not catch, since a malicious
author can sign two distinct events with the same seq and different ids). Since any member
holds the shared write credential and can truncate others' log files (acknowledged in
§4.3), gap detection is the only tamper-evidence mechanism the design has — and it is
currently decorative. Related: the settlement completeness gate (#20) is not implemented
either, so nothing consumes these counters today.
Direction. Track per-author max contiguous seq at ingest; surface gaps and duplicate
seqs as a distinct EventRejection/warning state rather than silence. Coordinate with #19
(dedupe by (author_id, author_seq) would solve both).
N-9 🟡 flush() dedupe-by-substring and O(file) append
Tracked: #70
Where: packages/fen_sync/lib/src/sync_manager.dart (flush).
Two smaller problems in the append path (both adjacent to, but not covered by, #23):
- Idempotent re-flush skips entries when
existingText.contains(e.eventId)— a substring match over the entire log file. A UUID-shaped substring can legitimately occur inside another line's base64url ciphertext (the alphabet includes-), in which case a real event is silently never uploaded. Unlikely per-event, but the failure is silent permanent local-only data, and the check is trivially hardenable (parse lines'event_idfields, or match"event_id":"<id>"). - Because S3 has no append, every flush downloads the full log and re-uploads
existing + new. Cost per flush grows linearly with history (and it round-trips the whole file twice more when the server omits the PUT ETag). With #23's per-event-object redesign on the table, this argues for deciding that redesign before the log format ossifies.
N-10 🟡 Money release-mode arithmetic and silent precision truncation
Tracked: #72
Where: packages/fen_money/lib/src/money.dart.
Money.+/- guard currency mismatch with assert(...) only — in release builds mixing
currencies silently adds minor units. And fromDecimalString('21.999', 'EUR') silently
truncates to 21.99 (.substring(0, minorUnitDigits)) instead of rejecting, contradicting
the codebase's own "reject, don't sanitize" rule (canonical-serialization.md §9). Today's
call sites don't hit either path (single-currency v1, UI-validated input), so this is
hardening — but this is exactly the class of layer that must not depend on its callers
being polite.
Direction. Throw ArgumentError on mismatch/excess precision unconditionally.
4. Observations not filed as issues
Recorded here so they aren't lost; each either overlaps an existing issue or is an accepted trade-off that deserves visibility.
Storage credential at rest (overlaps #33). The group's shared S3
secret_access_key lives in plaintext in SQLite (group_storage.descriptor), while the
docs' "must never sit in SQLite" list covers only member_privkey and group_key. The
storage credential is a bearer write+delete capability over the whole group; if #33's
at-rest work happens, the descriptor belongs in scope (or in Keychain alongside the group
key).
Epoch is ornamental in v1. Envelopes carry epoch, but VerifyingEventSink derives
content_key from the current group key regardless, and ReplayProjector reads events
back with epoch: 0. Fine while rotation is out of scope (#22), but the first
KeyEpochUpdate implementation must add epoch-aware key selection at decrypt and start
persisting the envelope's epoch — worth remembering in #22's scope.
Full replay on every touch. project() (full log fold + projection rebuild) runs on
every group view, every expense add, every sink batch (twice when admissions reconcile).
Deliberate and documented as the unoptimised spike, and the benchmark exists — but the
number of call sites multiplying that cost is growing; snapshot caching will likely be
forced earlier than the docs assume.
Attachment fetch never re-verifies the hash. AttachmentStore.fetch(path) returns
whatever bytes the backend serves without checking sha256(ciphertext) against the path.
AEAD decryption will fail for tampered blobs, so this is defense-in-depth, not a hole —
cheap to add when attachments get UI.
Join flow holds a DB transaction across a network call. JoinInviteService runs
completeInvite (HTTP) inside the SQLite transaction to get atomic rollback. Correct
semantics, but a slow provisioner stalls the writer lock; consider a journal/saga if it
shows up in traces.
S3 XML parsing by regex. Keys containing XML-escaped characters would be mis-read.
All FEN-generated keys are ASCII-safe, but any member with the bearer key can create
arbitrary keys in the bucket; robustness argues for entity-decoding <Key> at least.
GET invite-status carries the signature in query params. Signatures aren't secrets and the replay cache bounds reuse, but URLs land in proxy/access logs; within the replay window a logged URL is a replayable request. Harmless today (read-only endpoint, owner-only data), worth remembering if more GETs are added.
Joiner display names are placeholders. JoinInviteService hardcodes
'Member <pubkey-prefix>' — the joiner is never asked for a name (the organiser-side
fix/organiser-display-name work solves the organiser's own name only). The accept/decline
screen (N-5) is the natural place to collect it.
5. Suggested order of attack
- N-3 (provision real credentials) — it gates any real end-to-end use of everything built in the last two milestones, and its failure mode is silent.
- N-1 (cursor-after-ingest) and N-2 (roster-mutation authz) — both are cheap, contained fixes to the two mechanisms the security story leans on hardest.
- N-5 + N-6 (consent UI + resumable completion) — they close the remaining sharp edges of the invite pipeline, which is otherwise in good shape.
- N-4, N-7, N-8 with their spec updates — alignment work best done before the wire format and reducer behavior accrete more history.
- N-9, N-10 as hardening alongside #23 and normal package work.