Skip to main content

FEN — Data Model & Contracts (§3.1–3.10)

Part of the FEN design set. Overview, index, and the legacy-terminology map: fen.md.

3. Data Schema & Contracts

3.1 Primitive Types

type UUID = string // UUIDv4, device-generated
type MemberId = UUID
type ExpenseId = UUID
type GroupId = UUID
type EventId = UUID
type MinorUnits = number // integer only — €21.60 stored as 2160; ¥12,500 stored as 12500
type CurrencyCode = string // ISO 4217: "EUR", "CHF", "GBP"
type ISODate = string // "YYYY-MM-DD" — calendar date of the purchase
type ISOTimestamp = string // "YYYY-MM-DDTHH:mm:ss.sssZ" — event authored at
type Base64 = string // standard base64url encoding

Rule: All monetary amounts are stored as integers in minor units (2 for CHF/EUR/USD, 0 for JPY, etc.). Floating-point arithmetic is never used for money. Display formatting (€21.60) happens only in the UI layer.

3.2 Entities (Materialised / Projected)

These tables are derived by replaying the event log locally. They are never synced.

Local Friend

type FriendStatus = "invited" | "confirmed" | "expired"

type LocalFriend = {
friend_id: UUID
group_id: GroupId // the group this friend was invited into
invite_id: string // ties this row to the CreateInviteService invite minted alongside it
display_name: string // chosen by the owner at invite-creation time
public_key: Base64 | null // filled in once status becomes "confirmed"; null while "invited"
status: FriendStatus
expires_at: ISOTimestamp // the invite's own TTL — the local backstop for expiry detection
created_at: ISOTimestamp
}

A local_friends row is created the moment the group owner mints an invite (CreateInviteService.createInvite), starting in invited status with the display name the owner chose for that invitee and no public_key yet. When the organiser's admission-reconciliation pass (MemberAdmissionService.reconcilePendingAdmissions) later learns which pubkey actually won that invite, the row resolves to confirmed and public_key is filled in — rows are disambiguated by (group_id, invite_id), the same key admission reconciliation already uses, so concurrent pending invites for the same group never cross-resolve.

An invite that times out unused — reported by the provisioner as expired, or its locally-stored expires_at passing without a resolution — flips the row to the terminal expired status. Local expires_at is only a client-side clock guess, never authoritative: a provisioner-confirmed winner reported after a row has already (wrongly) been marked expired still promotes it to confirmed, since the provisioner's report always wins over the local guess when they conflict. There is no distinct declined/rejected state — an invitee client phoning home that it saw-and-declined an invite is a privacy cost with no product payoff.

The friends list is a local-only convenience table in v1. It is not part of the event log and is not synced across the user's devices. Multi-device friend-list sync, and saving a friend outside the invite flow (e.g. after a known-friend direct-add), are deferred to a future version after this local table exists.

Group

type ExpenseEditPermission = "creator_only" | "any_member"

type Group = {
group_id: GroupId
name: string
settlement_currency: CurrencyCode // declared at creation; never changes; all balance calculations use this currency
expense_edit_permission: ExpenseEditPermission // who may edit/delete expenses (default: "creator_only")
status: "open" | "closed"
created_by: MemberId // the organiser — only they may close the group or change settings
created_at: ISOTimestamp
closed_by: MemberId | null
closed_at: ISOTimestamp | null
}

Participant

type Participant = {
participant_id: MemberId
group_id: GroupId
display_name: string
public_key: Base64 // X25519 public key; used to verify event signatures
role: "organiser" | "member"
status: "invited" | "active" | "removed"
joined_at: ISOTimestamp | null
removed_at: ISOTimestamp | null
}

Expense

type SplitMethod = "equal" | "custom" | "percentage"
type ExpenseCategory = "food_and_drink" | "accommodation" | "transport"
| "activities" | "shopping" | "utilities" | "other"

type Split = {
participant_id: MemberId
settlement_minor_units: MinorUnits // resolved integer amount in the group's settlement currency
}

type Expense = {
expense_id: ExpenseId
group_id: GroupId
name: string
currency: CurrencyCode
amount_cents: Cents // invariant: must equal sum(splits[i].amount_cents)
paid_by: MemberId // who physically paid
splits: Split[] // includes the payer's own share
split_method: SplitMethod // stored for UI labelling only; calc uses splits[]
expense_date: ISODate // when the purchase occurred (not when logged)
category: ExpenseCategory
created_by: MemberId // the ONLY person who may edit or delete this expense
created_at: ISOTimestamp
last_updated_at: ISOTimestamp
is_deleted: boolean // set by ExpenseDeleted event (soft tombstone)
}

Invariant: sum(splits[i].settlement_minor_units) === settlement_amount_minor_units Verified by every client before writing any expense event. Events violating this are rejected during sync.

Settlement

type Settlement = {
settlement_id: UUID
group_id: GroupId
from_participant: MemberId // debtor paying
to_participant: MemberId // creditor receiving
amount_cents: Cents
currency: CurrencyCode // always the group's settlement_currency
note: string | null // e.g. "Revolut transfer", "cash at airport"
recorded_at: ISOTimestamp
}

Exchange Rate

There is no ExchangeRate entity. Settlement amounts are stored as confirmed integers in each expense event at write time. The UI may fetch a display-only rate (from a free API such as frankfurter.app) to suggest a pre-filled settlement amount; the user confirms or adjusts this before the event is signed. The rate itself is never stored.

3.3 Event Log (Source of Truth)

The event log is the only thing synced between devices. All entities above are derived from it.

Event Envelope

type GroupEvent = {
fmt: "1" // canonical serialization format version
event_id: EventId // author-assigned UUIDv4 (also an AAD input, §3.10)
group_id: GroupId
epoch: number // key epoch this event is encrypted under (§3.10); AAD input
event_type: EventType
author_id: MemberId // author's Ed25519 public key (lowercase hex)
author_seq: number // per-author monotonic counter, starts at 1, strictly
// contiguous (no gaps the author is allowed to skip).
// Makes event loss detectable without a central sequencer.
lamport: number // Lamport logical clock: 1 + max(lamport of every event
// this author held locally at authoring time). Defines the
// device-independent total order, immune to wall-clock skew.
happened_at: ISOTimestamp // device wall clock at authoring time (display/audit only)
payload: { ciphertext: Base64Url; nonce: Base64Url } // AEAD of the plaintext payload
// (the typed payloads below are what sits INSIDE ciphertext)
author_signature: Base64Url // Ed25519 over the CANONICAL envelope bytes (this field
// excluded). Encrypt-then-sign, so authorship verifies
// without decrypting. See doc/canonical-serialization.md.
}

Canonical wire form (normative). How this envelope becomes bytes — key ordering (RFC 8785 JCS), every numeric field encoded as a decimal string, base64url-nopad for blobs / lowercase hex for ids, NFC text, fixed-precision UTC timestamps, and the exact sign/verify steps — is specified in canonical-serialization.md. FEN is encrypt-then-sign: the payload is XChaCha20-Poly1305-encrypted first, and the Ed25519 signature covers the envelope containing the ciphertext, so any peer can verify authorship without holding the decryption key.

Why these two fields exist. FEN has no central authority that assigns a global order or guarantees storage (backends are best-effort transport — see §2.7, §3.5). The two fields each recover one guarantee the backend used to provide:

  • lamport → convergence (safety). Every device sorts the same event set by (lamport, event_id) and therefore computes the same balances. No backend sequence is consulted. See §3.9.
  • author_seq → completeness (liveness). Because each author numbers its own events contiguously, any device can detect a missing event ("I have Alice 1, 2, 4 — #3 is missing") and prove its log is gap-free before allowing an irreversible settlement. See §3.9, §3.12.

Both fields are inside the signed envelope, so neither a backend nor a peer can forge or reorder them. Any peer or backend may re-serve a signed event (healing a gap) but cannot alter it.

Event Types & Payloads

type EventType =
| "GroupCreated"
| "GroupSettingsUpdated"
| "MemberInvited"
| "GroupInviteRelayed"
| "MemberJoined"
| "MemberAdmitted"
| "MemberLeft" // voluntary departure
| "MemberRemoved" // organiser-forced removal
| "ExpenseLogged"
| "ExpenseUpdated"
| "ExpenseDeleted"
| "SettlementRecorded"
| "GroupClosed"
| "StorageMigrated" // organiser moves the group to a new storage backend
| "KeyEpochUpdate" // organiser rotates group key; wraps to remaining members
| "SettlementSnapshot" // crystallises balances (e.g. after member removal)
| "KeyRewrapRequest" // member asks organiser to re-publish their key wrap
| "AttachmentUploaded" // receipt registered as available after attachment upload
| "StorageKeyRotated" // organiser distributes a rotated per-group storage credential
| "StorageKeyAck" // member confirms it has switched to the rotated credential

// GroupCreated — initial group state
type GroupCreatedPayload = {
group_id: GroupId
name: string
settlement_currency: CurrencyCode // ISO 4217; used for all balance calculations; never changes
expense_edit_permission: ExpenseEditPermission // default: "creator_only"
}

// GroupSettingsUpdated — organiser changes group-level settings after creation
// Only valid if: author_id === group.created_by AND group.status === "open"
type GroupSettingsUpdatedPayload = {
expense_edit_permission: ExpenseEditPermission
}

// MemberInvited — records that an invite was issued
type MemberInvitedPayload = {
invited_member_id: MemberId // pre-assigned UUID for the invitee
invitee_display_name: string // as entered by organiser
invite_id: string // local correlation id; the invite package is self-contained in the link
expires_at: ISOTimestamp
}

// GroupInviteRelayed — organiser sends an invite package to a known friend
// through a group log both parties already share. Only valid if:
// - author_id === target group.created_by for target_group_id
// - target_group_id is open
// - relay group is an eligible active shared group between organiser and target
// - target_friend_pubkey matches the wrapped package recipient
type GroupInviteRelayedPayload = {
target_group_id: GroupId
target_friend_pubkey: Base64 // recipient public key from the local friends table
relay_selection: "newest_eligible_active_shared_group"
wrapped_package: Base64 // same invite package as link flow, wrapped via existing X25519/HKDF primitives
}

// MemberJoined — invite accepted; public key now available to all
type MemberJoinedPayload = {
member_id: MemberId
display_name: string
public_key: Base64 // Ed25519 (32 bytes) — used by all peers to verify this member's signatures
invite_id: string | null // invite this join claims; null only for the organiser's founding self-join
}

// MemberAdmitted — organiser ratifies which pubkey actually won one invite
// Only valid if: author_id === group.created_by AND a pending MemberJoined
// exists with the same public_key and invite_id.
type MemberAdmittedPayload = {
public_key: HexString
invite_id: string
}

// MemberLeft
// Only valid if: author_id === member_id.
type MemberLeftPayload = {
member_id: MemberId
}

// MemberRemoved — organiser removes a member; must be preceded by a
// SettlementSnapshot (this ordering is enforced, not advisory; see §2 Flow 7).
// Only valid if: author_id === group.created_by, member_id !== group.created_by,
// and group.status === "open". Removing the organiser is unsupported; close the
// group instead.
type MemberRemovedPayload = {
member_id: MemberId
}

// ExpenseLogged — creates a new expense (v1); full snapshot
// Only valid if: author_id is active AND group.status === "open"
type ExpenseLoggedPayload = Expense

// ExpenseUpdated — supersedes prior version of same expense_id; full snapshot
// Only valid if: author_id === expense.created_by AND group is open
type ExpenseUpdatedPayload = Expense

// ExpenseDeleted — soft tombstone
// Only valid if: author_id === expense.created_by AND group is open
type ExpenseDeletedPayload = {
expense_id: ExpenseId
}

// SettlementRecorded — records a payment between two members. May still be
// accepted after GroupClosed so members can settle an archived group.
type SettlementRecordedPayload = Settlement

// GroupClosed — one-time; freezes new expenses, edits/deletes, settings, and
// membership mutations. SettlementRecorded remains allowed after close.
// Only valid if: author_id === group.created_by AND group.status === "open"
type GroupClosedPayload = {
reason: string | null
}

// StorageMigrated — organiser signals move to a new storage backend
// Only valid if: author_id === group.created_by
// All members update their stored StorageDescriptor after reading this event
type StorageMigratedPayload = {
new_storage: StorageDescriptor // s3 endpoint/bucket/keys (see §2.4); v2 BYO self-hosted reuses the same "s3" kind
epoch: number // monotonically increasing; prevents replay/downgrade
effective_after: string // "(lamport,event_id)" after which the new backend is authoritative
reason: string | null
}

// KeyEpochUpdate — new group key epoch; wraps key to all remaining members
// Only valid if: author_id === group.created_by AND group.status === "open"
type KeyEpochUpdatePayload = {
epoch: number // monotonically increasing epoch index
prev_epoch: number // must equal current highest confirmed epoch
member_keys: Array<{
member_id: MemberId
pubkey: HexString // Ed25519 public key of recipient (converted to X25519 for the wrap)
wrapped_key: Base64 // group_key encrypted to this member's pubkey
}>
}

// SettlementSnapshot — crystallises balances at a point in time
// Published by organiser before MemberRemoved; signed and authoritative
type SettlementSnapshotPayload = {
epoch: number
balances: Array<{
member_id: MemberId
net_minor_units: number // positive = owed by group; negative = owes group
}>
reason: "member_removal" | "history_cutoff" | "manual"
}

// KeyRewrapRequest — member signals it cannot decrypt; asks organiser to re-wrap
// Sent as an unencrypted event envelope (no sensitive payload content)
type KeyRewrapRequestPayload = {
epoch: number // epoch the member cannot decrypt
requester_pubkey: HexString // member's Ed25519 public key
}

// AttachmentUploaded — updates an expense with the attachment storage URL of a receipt photo
// Only valid if: author_id === expense.created_by AND group.status === "open"
type AttachmentUploadedPayload = {
expense_id: ExpenseId
url: string // https:// attachment storage URL
sha256: HexString // sha256(ciphertext) — content address
size_bytes: number
}

// StorageKeyRotated — organiser distributes a rotated per-group storage credential
// (two-phase rotation; hand-off protocol in §3.10, provisioner API in
// backend-architecture.md §6.1)
// Only valid if: author_id === group.created_by
// The payload is encrypted under the epoch named in the event envelope, like
// any other event. After a member removal this event MUST be published under
// the post-removal epoch: KeyEpochUpdate wrapped K(n+1) only to remaining
// members, so the removed member — who may still hold the *old* S3 credential
// during the hand-off window — can fetch the ciphertext but cannot read the
// new credential. A rotation without a membership change (e.g. suspected
// credential leak) is published under the current epoch.
type StorageKeyRotatedPayload = {
rotation_id: string // UUIDv4; correlates StorageKeyAck events with this rotation
new_storage: StorageDescriptor // same endpoint/bucket; fresh access_key_id / secret_access_key
deny_old_at: ISOTimestamp // provisioner's hard deadline for revoking the old credential
reason: "member_removal" | "credential_leak" | "scheduled"
}

// StorageKeyAck — member confirms it holds the rotated credential and has
// switched to it. Written to the member's own log USING the new credential.
// The organiser counts acks: once every current-epoch member has acked, it
// asks the provisioner to revoke the old credential early (before deny_old_at).
type StorageKeyAckPayload = {
rotation_id: string // the StorageKeyRotated this acknowledges
}

3.4 SQLite Schema (Local Device)

-- ── EVENT LOG (synced between peers; source of truth) ─────────────────────
CREATE TABLE group_events (
event_id TEXT NOT NULL, -- author-assigned UUIDv4 (AAD input, §3.10); NOT unique/identity
group_id TEXT NOT NULL,
event_type TEXT NOT NULL,
author_id TEXT NOT NULL,
author_seq INTEGER NOT NULL, -- per-author contiguous counter (signed); gap detection
lamport INTEGER NOT NULL, -- Lamport logical clock (signed); canonical order scalar
happened_at TEXT NOT NULL, -- ISO-8601 UTC (display/audit only)
payload TEXT NOT NULL, -- JSON blob
signature TEXT NOT NULL, -- base64 author signature
received_at TEXT NOT NULL, -- local ingestion timestamp (not synced)
local_ingest_seq INTEGER NOT NULL, -- local AUTOINCREMENT assigned on insert; never synced.
-- Projection replay-cursor only (replaces the old server
-- seq_num watermark). Purely local; not an ordering key.
-- Identity/dedup key is (author_id, author_seq), NOT event_id: event_id is
-- author-chosen so keying on it lets a member suppress an honest member's
-- event by reusing its id (issue #19). This also serves per-author
-- contiguity/completeness (§3.12) — no separate unique index needed.
PRIMARY KEY (group_id, author_id, author_seq)
);
-- Canonical replay order is (lamport, event_id) with (author_id, author_seq)
-- as an unforgeable final tiebreak — see §3.9.
CREATE INDEX idx_events_group_order ON group_events (group_id, lamport, event_id, author_id, author_seq);
CREATE INDEX idx_events_ingest ON group_events (group_id, local_ingest_seq);

-- ── AUTHOR FRONTIER (per-author high-water marks; derived, never synced) ───
-- One row per (group, author). contiguous_seq is the highest N such that this
-- device holds author_seq 1..N with no gaps. claimed_seq is the highest author_seq
-- known to exist for this author — the max of (the highest author_seq this device
-- actually holds) and (high-water marks advertised by peers during sync).
-- contiguous_seq < claimed_seq ⇒ a detectable, requestable gap exists.
CREATE TABLE author_frontier (
group_id TEXT NOT NULL,
author_id TEXT NOT NULL,
contiguous_seq INTEGER NOT NULL DEFAULT 0,
claimed_seq INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (group_id, author_id)
);

-- ── LOCAL FRIENDS (device-local convenience cache; never synced in v1) ────
-- One row per (group_id, invite_id): created in 'invited' status when the
-- owner mints an invite (public_key unknown yet), resolved to 'confirmed'
-- with the real public_key once admission reconciliation identifies the
-- invite's actual winner, or flipped to the terminal 'expired' status if the
-- invite times out unused. A provisioner-confirmed winner reported after a
-- local expires_at-based guess already marked a row 'expired' still promotes
-- it to 'confirmed' — the local clock is a backstop, never authoritative.
CREATE TABLE local_friends (
friend_id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
invite_id TEXT NOT NULL,
display_name TEXT NOT NULL,
public_key TEXT, -- NULL until status = 'confirmed'
status TEXT NOT NULL DEFAULT 'invited'
CHECK (status IN ('invited', 'confirmed', 'expired')),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (group_id, invite_id)
);

-- ── MATERIALISED STATE (derived; never synced) ────────────────────────────
CREATE TABLE groups (
group_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
settlement_currency TEXT NOT NULL, -- ISO 4217 code; set at creation; never changes
expense_edit_permission TEXT NOT NULL DEFAULT 'creator_only', -- 'creator_only' | 'any_member'
status TEXT NOT NULL DEFAULT 'open',
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
closed_by TEXT,
closed_at TEXT
);

CREATE TABLE participants (
participant_id TEXT NOT NULL,
group_id TEXT NOT NULL,
display_name TEXT NOT NULL,
public_key TEXT NOT NULL, -- base64 X25519
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'invited',
joined_at TEXT,
removed_at TEXT,
PRIMARY KEY (participant_id, group_id)
);

CREATE TABLE current_expenses (
expense_id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
name TEXT NOT NULL,
original_currency_code TEXT NOT NULL,
original_amount_minor_units INTEGER NOT NULL,
settlement_currency_code TEXT NOT NULL, -- always == group.settlement_currency
settlement_amount_minor_units INTEGER NOT NULL,
paid_by TEXT NOT NULL,
splits TEXT NOT NULL, -- JSON: [{participant_id, settlement_minor_units}, ...]
split_method TEXT NOT NULL,
expense_date TEXT NOT NULL, -- YYYY-MM-DD
category TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
last_updated_at TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_expenses_group ON current_expenses (group_id, is_deleted, expense_date);

CREATE TABLE settlements (
settlement_id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
from_participant TEXT NOT NULL,
to_participant TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
note TEXT,
recorded_at TEXT NOT NULL
);

-- exchange_rates table removed: settlement amounts are stored as confirmed integers
-- in each expense event (settlement_amount_minor_units). No runtime rate lookup needed.

-- ── SYNC STATE ────────────────────────────────────────────────────────────
-- One storage backend per group (the owner's). The complete log is the union of all
-- per-author log files in the group folder; per-author contiguity (author_frontier)
-- decides whether that union is complete. ETags are private per-file pull cursors.
CREATE TABLE group_storage (
group_id TEXT NOT NULL,
backend_kind TEXT NOT NULL, -- 's3' (v1: Managed Garage only; v2 BYO self-hosted reuses the same kind)
descriptor TEXT NOT NULL, -- JSON StorageDescriptor (§2.4): s3 endpoint/bucket/keys
-- write access is backend-native (per-group S3 key); NOT a cryptographic credential
file_cursors TEXT NOT NULL DEFAULT '{}', -- JSON map: logs/<pubkey>.jsonl -> last-seen ETag/mtime (private per-file cursor; NOT a global order)
last_push_at TEXT,
last_pull_at TEXT,
PRIMARY KEY (group_id)
);

CREATE TABLE outbox (
event_id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
ciphertext BLOB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | pushed | failed
created_at TEXT NOT NULL,
pushed_at TEXT
);

-- Local UI preferences per group. Never synced; survives app restarts.
CREATE TABLE group_ui_prefs (
group_id TEXT PRIMARY KEY,
show_deleted INTEGER NOT NULL DEFAULT 0, -- boolean; 0=hidden, 1=shown
show_without_involvement INTEGER NOT NULL DEFAULT 0 -- boolean; 0=hidden, 1=shown
);

3.5 Membership Admission Rule

MemberJoined is a self-authored claim, not roster authority. Replaying a MemberJoinedPayload records the participant as status = "invited" and keeps the invite_id it claims, so the join is visible to the organiser but cannot spend, edit, split, settle, vote, or count as an active member yet. The only exception is the founding organiser row established by GroupCreated; its founding MemberJoinedPayload has invite_id = null and must not downgrade the organiser from active status.

An invitee becomes active only when the organiser publishes a MemberAdmitted event whose (public_key, invite_id) exactly matches a pending MemberJoined. The organiser decides which pubkey to admit by querying the provisioner's organiser-authenticated invite-status endpoint, GET /v1/groups/:id/invites/:invite_id. That endpoint returns the pubkey that won the provisioner's atomic single-use ClaimInvite record. Clients do not resolve duplicate joins by sorting attacker-chosen (lamport, event_id) values.

If two pending joins claim the same invite_id, both remain pending until the organiser asks the provisioner which one actually completed the invite. Only the reported pubkey receives MemberAdmitted; all other pending claims stay inactive and are ignored by active-roster checks. A MemberAdmitted for an unknown (public_key, invite_id) pair is a no-op, not a crash.

This is a reducer rule, and its cryptographic enforcement is now provided by the ingest layer: VerifyingEventSink (issue #26) verifies that the MemberAdmitted signature actually belongs to the organiser key named in author_id before the event is ever appended to the local log. The reducer itself still only compares the plaintext author_id field during replay — that's correct and sufficient because, by the time an event reaches replay, it has already passed through the one ingest chokepoint that can see (and verify) the original signed envelope.

3.6 Split Calculation Rules

All split amounts are in settlement-currency minor units. The creating device resolves them once at write time; the projector never divides (see §3.11 for the full arithmetic spec).

EQUAL SPLIT
base_share = floor(settlement_amount_minor_units / participant_count)
remainder = settlement_amount_minor_units mod participant_count
Distribute extra minor units to participants sorted ascending by participant_id
(deterministic across all devices — everyone computes the same result)

Example: CHF 86.00 (8600) / 3 → 2867 + 2867 + 2866 = 8600 ✓
Example: CHF 10.00 (1000) / 3 → 334 + 333 + 333 = 1000 ✓
(Alice gets the extra minor unit — first alphabetically by participant_id)

CUSTOM AMOUNTS
User enters per-participant amounts in settlement currency.
Validation: sum(entered_amounts) must equal settlement_amount_minor_units exactly.
Stored directly as splits[].

PERCENTAGE
User enters percentages (must sum to 100%).
Stored as: percentage × 100 in basis points (e.g. 50% → 5000 bps)
Resolved to settlement_amount_minor_units at save time using same remainder rule.
Both bps AND resolved settlement_minor_units stored in the event payload.
Calculation always uses resolved settlement_minor_units; bps is for display only.

3.7 Balance & Settlement Calculation

BALANCE (pure function, computed on device, never stored)

All arithmetic is in settlement-currency minor units.
settlement_amount_minor_units is read directly from each expense event — no conversion.

For each active (non-deleted) expense in the group:
net[paid_by] += expense.settlement_amount_minor_units
for each split in expense.splits:
net[split.participant_id] -= split.settlement_minor_units

For each recorded settlement:
net[from_participant] += settlement.amount_minor_units
net[to_participant] -= settlement.amount_minor_units

Positive net → person should receive money
Negative net → person owes money
Cross-check: sum(net.values()) === 0 always (guaranteed by split-sum invariant)

DEBT SIMPLIFICATION (greedy, optimal)
creditors = [(participant, net) for net > 0], sorted desc by net
debtors = [(participant, net) for net < 0], sorted asc by net

while creditors and debtors are non-empty:
amount = min(creditors[0].net, abs(debtors[0].net))
emit: debtors[0] pays creditors[0] amount (in settlement currency)
creditors[0].net -= amount
debtors[0].net += amount
remove any entry where net === 0

Result: at most n-1 transactions for n participants (provably optimal)
All values are exact integers — no rounding floor needed.

TOTAL COST BY MEMBER (pure function, separate from balance)

Purpose: answer "how much did this trip actually cost me in total?"
Includes solo/personal expenses; excludes deleted expenses.
Does NOT include settlements (settlements move money between members, not create costs).

For each active (non-deleted) expense in the group:
for each split in expense.splits:
total_cost[split.participant_id] += split.settlement_minor_units

Note: a solo expense where splits = [{May, 1200}] contributes 1200 to May's total
and zero to every other member's total. It also nets to zero in the BALANCE
calculation (May paid → +1200, May owes → −1200), so it creates no inter-member debt.

Example — Dublin4Ever (settlement currency: EUR, all expenses already in EUR):
May paid €21.60, owed €29.53 → net: −€7.93
Bob paid €27.00, owed €29.53 → net: −€2.53
Alice paid €40.00, owed €29.53 → net: +€10.46 (after rounding)
→ May pays Alice €7.93
→ Bob pays Alice €2.53 (2 transactions for 3 people)

3.8 Expense Lifecycle

Storage-model note. The sync-state names and sequence diagrams in this section use the legacy backend vocabulary (POST /events, server_sequence, a Backend participant). Read them through the §1 terminology map: POST /events → append to logs/<pubkey>.jsonl, GET /events?after=N → list + conditional get, backend → storage backend, server_sequence → per-file ETag cursor. The state machine and ordering are unchanged.

An expense passes through two independent lifecycle dimensions:

  • Content state — what the expense is (draft, active, deleted, frozen). Determined by events in the group’s append-only event log.
  • Sync state — where the expense’s events are in the delivery pipeline (local-only vs. uploaded to backend). Displayed as status icons in the group expense list.

These are kept separate to avoid conflating what an expense is with where its data lives.


Content Lifecycle

stateDiagram-v2
direction LR

[*] --> Draft : user taps “New Expense”
Draft --> Discarded : cancel or force-close\n(nothing written to log)
Draft --> Active : Save + validation passes\n(ExpenseLogged event written)

Active --> EditDraft : permitted member opens expense to edit
EditDraft --> Active : Save + validation passes\n(ExpenseUpdated event written)
EditDraft --> Active : app force-closed\n(previous save preserved — no event written)

Active --> Deleted : permitted member taps Delete\n(ExpenseDeleted event; soft tombstone)
Active --> Frozen : GroupClosed event received
Deleted --> Frozen : GroupClosed event received

Discarded --> [*]
StateVisible in app?In event log?Counted in balances?
DraftYes — form only, not yet savedNoNo
DiscardedNoNoNo
ActiveYes — full contrastYes (ExpenseLogged + any ExpenseUpdated)Yes
EditDraftYes — edit form onlyNo (not until saved)Based on last saved version
DeletedHidden by default; shown in grey when “Show deleted” is onYes (ExpenseDeleted tombstone)No
FrozenYes — read-only, no edit or delete controlsYesYes (final settlement view only)

Key rules:

  • Draft and EditDraft are local UI states only. No event is written until Save is tapped and all validations pass.
  • Force-closing the app during EditDraft discards the in-progress edit. The last saved version (Active) is preserved unchanged in the local event log.
  • Deleted expenses remain in the event log as a soft tombstone. Excluded from balance and settlement calculations. Revealed by toggling “Show deleted items” in the UI, where they appear in reduced contrast (grey).
  • Frozen is triggered by the GroupClosed event. No further ExpenseLogged, ExpenseUpdated, or ExpenseDeleted events are accepted once a group is closed. SettlementRecorded remains valid after close so members can settle an archived group without reopening it.
  • Who may edit or delete depends on the group’s expense_edit_permission setting (see §3.2 and §3.8 Invariants).

Sync Lifecycle

The sync state applies to every expense event (ExpenseLogged, ExpenseUpdated, ExpenseDeleted). It is orthogonal to content state — an expense may be Active (content) and LocalOnly (sync) simultaneously.

stateDiagram-v2
direction LR

[*] --> LocalOnly : event appended to local SQLite\n(outbox entry created)
LocalOnly --> LocalOnly : no network or backend unreachable\n(automatic retry with backoff)
LocalOnly --> Synced : PUT logs/<pubkey>.jsonl succeeds\nbackend returns ETag

Synced --> [*]
Sync stateUI iconMeaning
LocalOnly📱 device iconSaved on this device only. Not yet uploaded. Peers cannot see it.
Synced☁️✓ cloud + checkmarkUploaded to backend. Peers can pull via GET /events?after=N.

Key rules:

  • An expense is immediately visible to its creator the moment it enters Active content state, regardless of sync state. The local device is the source of truth.
  • LocalOnly is the normal transient state between Save and a successful backend upload. On a fast connection this lasts milliseconds; offline it persists until the next sync opportunity.
  • “Synced” means written to the backend — not on all peers, and not necessarily durable. The device appends to its own logs/<pubkey>.jsonl and treats the event as reasonably safe once the write is acknowledged. Each peer pulls independently at its own cadence (app open, timer, or manual refresh).
  • Each changed log file is detected by its ETag/mtime — a private per-file cursor the device stores in group_storage.file_cursors to fetch only what changed. The cursor is committed only after the fetched lines have been durably ingested or deliberately rejected; advancing it before ingest can permanently lose events after a crash or local-DB failure (tracked by #64). It is never the cross-device order (that is the Lamport clock, §3.9). Completeness is judged separately, by per-author author_seq contiguity (§3.12).

Invariants

#Invariant
1No event is written until Save + validation succeed. Drafts and in-progress edits exist only in UI memory and are lost on force-close.
2Deletion is always soft. ExpenseDeleted appends a tombstone; the original ExpenseLogged event is never removed from the log.
3Edit and delete permission is governed by expense_edit_permission on the group. creator_only: only the expense’s created_by member may edit or delete. any_member: any active group member may.
4Closed groups reject new expense state. Once GroupClosed is received, ExpenseLogged, ExpenseUpdated, and ExpenseDeleted events are rejected by all clients; SettlementRecorded remains allowed for settle-up after archival.
5Deleted expenses are excluded from all calculations. They remain in the log and can be displayed on request.
6The local device is the source of truth. An expense is visible to its creator the moment it is saved locally — sync state does not affect content state.

Projection Mechanics

The event log is append-only and is the canonical source of truth. The UI never queries it directly. Instead, a projector folds events into a materialised expenses SQLite table that holds the latest snapshot of each expense. The table is a disposable cache — dropping it and replaying the full event log must always produce an identical result.

expense_id is the aggregate key. Every ExpenseLogged, ExpenseUpdated, and ExpenseDeleted event carries the same expense_id UUID for the same expense. This is the only identity signal. The projector reads it from the payload and routes the event to the correct row.

Projection rule:

for event in events ordered by (lamport ASC, event_id ASC):
ExpenseLogged → INSERT expenses row (expense_id, all payload fields)
ExpenseUpdated → UPDATE expenses SET <all payload fields>, updated_at
WHERE expense_id = event.expense_id
ExpenseDeleted → UPDATE expenses SET is_deleted = 1, deleted_at
WHERE expense_id = event.expense_id

Materialised expenses table (excerpt — full schema in §3.4):

expense_id TEXT PRIMARY KEY, -- UUID assigned at ExpenseLogged; never changes
original_amount_minor_units INTEGER NOT NULL, -- display/audit only
original_currency_code TEXT NOT NULL,
settlement_amount_minor_units INTEGER NOT NULL, -- used for all balance calculations
settlement_currency_code TEXT NOT NULL,
paid_by TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, -- from original ExpenseLogged; never overwritten
updated_at TEXT NOT NULL, -- from latest ExpenseUpdated; else = created_at
_last_ingest INTEGER NOT NULL -- high-water mark: highest local_ingest_seq folded into this row

Normal read path:

SELECT * FROM expenses
WHERE group_id = ? AND is_deleted = 0
ORDER BY expense_date DESC, created_at DESC;

_last_ingest watermark. Every event gets a purely-local local_ingest_seq (a monotonic AUTOINCREMENT) the moment it is inserted into group_events — this is a local cursor, never synced, and is not an ordering key. After receiving a sync batch the projector folds in newly ingested events. Because a late-arriving event can carry a lamport lower than rows already projected (it slots into the middle of history, not the end), the projector cannot blindly append: when a batch contains any event whose lamport precedes the current projection frontier for an affected expense_id, that aggregate is recomputed by replaying its events in (lamport, event_id) order. Full replay from zero remains available and is the correct path for schema migrations: bump the schema version, drop the derived tables, replay the whole log in canonical order.

ExpenseUpdated is a full snapshot replacement. The payload contains the complete current state of the expense, not a diff. Applying the same ExpenseUpdated event twice must yield the same result (idempotency). Partial-patch semantics are explicitly excluded.

ExpenseLogged payload is frozen at write time. The initial event's required fields — expense_id, created_by, created_at, original_amount_minor_units, original_currency_code, settlement_amount_minor_units, settlement_currency_code, paid_by, splits[] — must be complete and valid when the event is written. They cannot be retroactively added, because the append-only log has no UPDATE operation. Enforcement belongs at the write layer, not the read layer.

Out-of-order events (pending-orphan rule). Causality guarantees that ExpenseLogged carries a lower lamport than any later ExpenseUpdated/ExpenseDeleted for the same expense (the editor necessarily held the original when authoring the edit, so its Lamport clock is strictly higher). But there is no global sequencer to guarantee arrival order — events stream in from several best-effort backends and peers — so an edit can reach a device before the create it depends on. The rule:

  • ExpenseUpdated or ExpenseDeleted for an expense_id not yet in the projection → park in a pending_events buffer; do not create a partial row
  • When ExpenseLogged for that expense_id is later processed → INSERT the row, then flush and apply buffered events in (lamport, event_id) order

Silently upserting a partial row from an orphaned ExpenseUpdated is explicitly forbidden — it would create an expense with missing required fields and incorrect created_at / created_by provenance.

expense_id stability is a hard contract. A UUID is assigned once at ExpenseLogged time and must never appear on a different event type with a different intent. No event may alter an expense's expense_id. Any code path that generates a new expense_id for an edit instead of reusing the original is a bug.


Use Cases

UC-1 — Happy Path: Group from Invite to Closure

Scenario: Bob creates a shared expenses group for a Dublin trip. He invites May and Alice, who both join immediately. Over one week the three of them log expenses split equally in the same currency. At the end Bob closes the group; all three members produce a permanent local archive.

Participants: Bob (organiser), May, Alice, the shared Backend.

Preconditions: All three users have the app installed. Network is available throughout.

sequenceDiagram
actor Bob
actor May
actor Alice
participant BobDev as "Bob's Device"
participant MayDev as "May's Device"
participant AliceDev as "Alice's Device"
participant Backend

Note over Bob,Backend: ① Group Creation
Bob->>BobDev: Create group "dublin4ever"
BobDev->>BobDev: Generate group_key · Ed25519 device_keypair (member_privkey / member_pubkey)
BobDev->>Backend: POST /v1/groups (GroupCreated · seq=1 · encrypted)
Backend-->>BobDev: 200 OK
BobDev->>BobDev: Store group locally — sync status ☁️✓

Note over Bob,Backend: ② Invitations (out-of-band link sharing)
Bob->>BobDev: Invite May
BobDev->>BobDev: Build invite package · encrypt with fresh invite_secret
BobDev->>BobDev: Build deep link https://fenapp.net/join#35;s=#lt;invite_secret#gt;&p=#lt;encrypted_pkg#gt;&v=2\n(fen://join#35;... equivalent custom-scheme fallback)
Bob->>May: Send invite link (WhatsApp / iMessage / …)

Bob->>BobDev: Invite Alice
BobDev->>BobDev: Build invite package · encrypt with fresh invite_secret
BobDev->>BobDev: Build invite deep link
Bob->>Alice: Send invite link (out-of-band)

Note over May,Backend: ③ May Joins
May->>MayDev: Tap invite link
MayDev->>MayDev: Extract invite_secret · encrypted_pkg from #35;fragment (local only — never sent to server)
MayDev->>MayDev: Decrypt with invite_secret yields group_key · storage descriptor
MayDev->>MayDev: Fetch full event log from backend & replay
MayDev->>Backend: POST /v1/groups/dublin4ever/events\n(MemberJoined · seq=2 · encrypted)
Backend-->>MayDev: 200 OK
MayDev->>MayDev: Store group locally — sync status ☁️✓

Note over Alice,Backend: ④ Alice Joins
Alice->>AliceDev: Tap invite link
AliceDev->>AliceDev: Extract invite_secret · encrypted_pkg from #35;fragment (local only)
AliceDev->>AliceDev: Decrypt yields group_key · storage descriptor
AliceDev->>AliceDev: Fetch & replay event log (sees GroupCreated + MemberJoined/May)
AliceDev->>Backend: POST /v1/groups/dublin4ever/events\n(MemberJoined · seq=3 · encrypted)
Backend-->>AliceDev: 200 OK
AliceDev->>AliceDev: Store group locally — sync status ☁️✓

Note over Bob,Backend: ⑤ Expense Logging (representative — one per person)
Bob->>BobDev: Fill in "Dinner · €60 · 3 equal shares" yields tap Save
BobDev->>BobDev: Validate · store locally — 📱 LocalOnly
BobDev->>Backend: POST /v1/groups/dublin4ever/events\n(ExpenseCreated · seq=4 · encrypted)
Backend-->>BobDev: 200 OK
BobDev->>BobDev: Update sync status yields ☁️✓ Synced

May->>MayDev: Fill in "Museum tickets · €30 · 3 equal" yields tap Save
MayDev->>MayDev: Validate · store locally — 📱 LocalOnly
MayDev->>Backend: POST /v1/groups/dublin4ever/events\n(ExpenseCreated · seq=5 · encrypted)
Backend-->>MayDev: 200 OK
MayDev->>MayDev: yields ☁️✓ Synced

Alice->>AliceDev: Fill in "Lunch · €45 · 3 equal" yields tap Save
AliceDev->>AliceDev: Validate · store locally — 📱 LocalOnly
AliceDev->>Backend: POST /v1/groups/dublin4ever/events\n(ExpenseCreated · seq=6 · encrypted)
Backend-->>AliceDev: 200 OK
AliceDev->>AliceDev: yields ☁️✓ Synced

Note over Bob,Backend: ⑥ Background Peer Sync (continuous, shown once)
BobDev->>Backend: GET /v1/groups/dublin4ever/events?after=3
Backend-->>BobDev: seq 4,5,6 (ExpenseCreated × 3)
BobDev->>BobDev: Decrypt & apply — all expenses visible to Bob

MayDev->>Backend: GET /v1/groups/dublin4ever/events?after=4
Backend-->>MayDev: seq 5,6
MayDev->>MayDev: Decrypt & apply

AliceDev->>Backend: GET /v1/groups/dublin4ever/events?after=5
Backend-->>AliceDev: seq 6
AliceDev->>AliceDev: Decrypt & apply

Note over Bob,Backend: ⑦ Group Close (organiser only)
Bob->>BobDev: Close group "dublin4ever"
BobDev->>BobDev: Verify outbox empty (all local events synced)
BobDev->>Backend: POST /v1/groups/dublin4ever/events\n(GroupClosed · seq=7 · encrypted)
Backend-->>BobDev: 200 OK
BobDev->>Backend: DELETE /v1/groups/dublin4ever/invites (revoke open invites)
BobDev->>BobDev: All expense states yields Frozen\nLocal archive created automatically 📦

Note over May,Backend: ⑧ May & Alice Receive GroupClosed yields Archive
MayDev->>Backend: GET /v1/groups/dublin4ever/events?after=6
Backend-->>MayDev: GroupClosed (seq=7)
MayDev->>MayDev: All expenses yields Frozen\nLocal archive created 📦

AliceDev->>Backend: GET /v1/groups/dublin4ever/events?after=6
Backend-->>AliceDev: GroupClosed (seq=7)
AliceDev->>AliceDev: All expenses yields Frozen\nLocal archive created 📦

Post-conditions:

  • All three devices have identical materialised state (7 events, 3 expenses, group=Frozen)
  • Every expense is Frozen: visible in app at reduced contrast, excluded from balance calculations
  • Each member holds a sealed local archive — readable offline, independent of backend availability
  • Backend retains the event log during the grace period then may purge (see §5.2 Closed Group Archiving)
  • Backend tokens for this group are invalidated; no further writes are accepted

Key design properties illustrated:

  • Local-first: expense appears immediately on creator's device (📱 LocalOnly) before backend confirmation
  • Sync status icons: 📱 = stored locally only; ☁️✓ = confirmed on backend (not necessarily on all peers)
  • Invite secret never leaves the #fragment: backend and backend logs never see the raw invite_secret
  • Backend URL travels in invite link: May and Alice configure nothing manually — the storage descriptor is embedded in the link
  • Archive is automatic on GroupClosed: no manual export step required; all three members get their own copy

UC-2 — Multi-Currency: Mixed CHF/EUR Group with Settlement Amount at Entry Time

Scenario: Bob creates a trip group with CHF as the settlement currency and EUR as a secondary input currency for local Amsterdam spending. He invites Melissa. Both log CHF-denominated travel expenses (Flights, Train) and several EUR-denominated local expenses. For each EUR expense the app suggests a CHF settlement amount from a display-only rate; each user confirms or adjusts before saving. Both devices independently compute the same settlement plan in CHF — no rate event or coordinator needed. Melissa pays Bob out-of-band; Bob records the settlement. Balances reach zero and Bob closes the group.

Participants: Bob (organiser), Melissa, the shared Backend, FX API (display-only rate suggestion; value never stored).

Preconditions: Both users have the app installed. Network available.

sequenceDiagram
actor Bob
actor Melissa
participant BobDev as "Bob's Device"
participant MelissaDev as "Melissa's Device"
participant Backend
participant FxAPI as FX API (display only)

Note over Bob,FxAPI: ① Group Creation — settlement currency CHF
Bob->>BobDev: Create group "AmsterdamWeekend"\nsettlement_currency: "CHF"
BobDev->>Backend: POST /v1/groups\n(GroupCreated · seq=1 · settlement_currency:"CHF")
Backend-->>BobDev: 200 OK
Bob->>BobDev: Invite Melissa
BobDev->>Backend: POST /v1/groups/.../invites
Backend-->>BobDev: invite token
Bob->>Melissa: Share invite link (out-of-band)

Note over Melissa,Backend: ② Melissa Joins (abbreviated — see UC-1 for full detail)
Melissa->>MelissaDev: Tap invite link yields decrypt yields replay log
MelissaDev->>Backend: POST /events (MemberJoined · seq=2)
Backend-->>MelissaDev: 200 OK

Note over Bob,Backend: ③ Travel Expenses in CHF (original == settlement currency)
Bob->>BobDev: Log "Flights · CHF 400 · equal share" yields Save
BobDev->>BobDev: original CHF 400 == settlement CHF 400\nValidate · store locally 📱
BobDev->>Backend: POST /events (ExpenseLogged · seq=3\noriginal:CHF 40000 · settlement:CHF 40000)
Backend-->>BobDev: ☁✓

Melissa->>MelissaDev: Log "Train · CHF 100 · equal share" yields Save
MelissaDev->>MelissaDev: original CHF 100 == settlement CHF 100\nValidate · store locally 📱
MelissaDev->>Backend: POST /events (ExpenseLogged · seq=4\noriginal:CHF 10000 · settlement:CHF 10000)
Backend-->>MelissaDev: ☁✓

Note over Bob,FxAPI: ④ EUR Expense — settlement amount confirmed at entry time
Bob->>BobDev: Log "Hotel · EUR 320 · equal share"
BobDev->>FxAPI: GET /latest?from=EUR&to=CHF (display only — never stored)
FxAPI-->>BobDev: 0.9500 (approximate)
BobDev->>Bob: "Original: EUR 320.00\nSettlement (CHF): ~304.00 at today's rate\n(tap to adjust)"
Bob->>BobDev: Adjust to CHF 305.00 · Confirm
BobDev->>BobDev: sum(splits) = 15250 + 15250 = 30500 CHF\n== settlement_amount_minor_units ✔\nstore locally 📱
BobDev->>Backend: POST /events (ExpenseLogged · seq=5\noriginal:EUR 32000 · settlement:CHF 30500)
Backend-->>BobDev: ☁✓

Note over Melissa,Backend: EUR expense from Melissa — same pattern
Melissa->>MelissaDev: Log "Dinners · EUR 180 · equal share"
MelissaDev->>FxAPI: GET /latest?from=EUR&to=CHF (display only)
FxAPI-->>MelissaDev: 0.9500
MelissaDev->>Melissa: "Original: EUR 180.00\nSettlement (CHF): ~171.00"
Melissa->>MelissaDev: Confirm CHF 171.00
MelissaDev->>MelissaDev: sum(splits) = 8550 + 8550 = 17100 CHF ✔
MelissaDev->>Backend: POST /events (ExpenseLogged · seq=6\noriginal:EUR 18000 · settlement:CHF 17100)
Backend-->>MelissaDev: ☁✓

Note over Bob,MelissaDev: ⑤ Independent Local Calculation — no rate event needed
BobDev->>BobDev: Balance calc (pure local, CHF):\n read settlement_amount_minor_units from each event\n sum paid minus owed · debt simplification\n yields Melissa owes Bob: CHF X
MelissaDev->>Backend: GET /events?after=4
Backend-->>MelissaDev: ExpenseLogged seq=5 (Hotel)
MelissaDev->>MelissaDev: Same stored amounts · identical result\n yields Melissa owes Bob CHF X

Note over Bob,Backend: ⑥ Out-of-Band Payment and Settlement Recording
Bob->>Melissa: "Settlement: you owe me CHF X"
Melissa->>Bob: Transfer CHF X (Twint / bank / cash)
Bob->>BobDev: Record "settlement received from Melissa"
BobDev->>Backend: POST /events (SettlementRecorded · seq=7)\n(from:Melissa, to:Bob, amount:X, currency:"CHF")
Backend-->>BobDev: 200 OK
BobDev->>BobDev: Recalculate yields all balances = 0 ✅

MelissaDev->>Backend: GET /events?after=6
Backend-->>MelissaDev: SettlementRecorded (seq=7)
MelissaDev->>MelissaDev: Recalculate yields all balances = 0 ✅

Note over Bob,Backend: ⑦ Group Close and Archive (see UC-1 for full detail)
Bob->>BobDev: Close group "AmsterdamWeekend"
BobDev->>Backend: POST /events (GroupClosed · seq=8)
Backend-->>BobDev: 200 OK
BobDev->>BobDev: All expenses yields Frozen · archive created 📦

MelissaDev->>Backend: GET /events?after=7
Backend-->>MelissaDev: GroupClosed (seq=8)
MelissaDev->>MelissaDev: All expenses yields Frozen · archive created 📦

Settlement calculation example (using the numbers from this scenario):

ExpenseOriginalSettlement (CHF, user-confirmed)Bob shareMelissa share
Flights (Bob paid)CHF 400CHF 400−200−200
Train (Melissa paid)CHF 100CHF 100−50−50
Hotel (Bob paid)EUR 320CHF 305−152.50−152.50
Dinners (Melissa paid)EUR 180CHF 171−85.50−85.50

Net before settlement (positive = receives, negative = owes):

MemberPaid (CHF)Owed (CHF)Net
Bob400 + 305 = 705200 + 50 + 152.50 + 85.50 = 488+217
Melissa100 + 171 = 271200 + 50 + 152.50 + 85.50 = 488−217

Settlement plan: Melissa pays Bob CHF 217 (1 transaction, exact integer — no rounding delta).

Post-conditions:

  • Both devices hold identical materialised state with all balances = 0
  • Each expense event permanently records both the original currency/amount and the confirmed settlement amount — auditable and reproducible without any rate lookup
  • SettlementRecorded event closes out the debt in the event log
  • All expenses frozen; both members have a sealed local archive 📦

Key design properties illustrated:

  • Settlement currency declared once: settlement_currency: "CHF" in GroupCreated; EUR is a secondary input currency only; the field never changes
  • Settlement amount confirmed at write time: for each EUR expense, the user sees the suggested CHF amount and confirms before saving; the event stores both the EUR original and the CHF settlement as integers; no conversion ever runs at projection time
  • Display-only rate, never stored: the app fetches a rate from frankfurter.app purely to pre-fill the suggestion field; it is labeled as approximate and is never written to any event or the local database
  • Balance calculation is pure addition: the projector reads settlement_amount_minor_units from each event and sums integers — no multiplication, no rate lookup, no rounding at projection time
  • Both devices compute the same balances independently: the settlement amounts are in the events, so any device with the same event log reaches the same result with no further coordination
  • Settlement is recorded by the creditor: Bob confirms receipt of Melissa's payment and writes SettlementRecorded; any member may record a settlement, but the creditor's confirmation is the natural trigger
  • No hard prerequisite for GroupClosed: the app warns if balances are non-zero but the organiser may close anyway; in this scenario balances are zero before close

UC-3 — Total Cost Recording: Solo Expense & Per-Member Cost Report

Scenario: Bob creates the Dublin4Ever trip group and invites Alice and May. Before the trip starts, May logs her train ticket to the airport (CHF 12) as a personal expense — split "only me" — because it is part of her total trip cost but not shared with the group. In the group overview, Bob and Alice do not see this expense by default; they can reveal it via a filter. May’s Total Cost report includes both her shares in group expenses and her personal train cost.

Participants: Bob (organiser), Alice, May, the shared Backend.

Preconditions: Group is open. All three members have joined. Network available.

sequenceDiagram
actor Bob
actor Alice
actor May
participant BobDev as "Bob's Device"
participant AliceDev as "Alice's Device"
participant MayDev as "May's Device"
participant Backend

Note over Bob,Backend: ① Group Creation & Joins (abbreviated — see UC-1)
Bob->>BobDev: Create group "Dublin4Ever"
BobDev->>Backend: GroupCreated (seq=1)
Bob->>BobDev: Invite Alice + May
BobDev->>Backend: POST /invites ×2
Alice->>AliceDev: Join yields MemberJoined (seq=2)
May->>MayDev: Join yields MemberJoined (seq=3)

Note over May,Backend: ② May Logs a Solo / Personal Expense
May->>MayDev: New Expense “Train to Airport”
MayDev->>May: Show split selector
May->>MayDev: Select split: “Only me”
MayDev->>MayDev: Build expense
Note right of MayDev: paid_by: May<br/>settlement: CHF 1200<br/>original: CHF 1200<br/>splits: [May only]<br/>split_method: equal
May->>MayDev: Tap Save
MayDev->>MayDev: Validate: sum(splits) === settlement_amount_minor_units ✔
MayDev->>MayDev: Store locally 📱
MayDev->>Backend: POST /events (ExpenseLogged · seq=4 · CHF)
Backend-->>MayDev: 200 OK
MayDev->>MayDev: Sync status yields ☁️✓

Note over Bob,Backend: ③ Bob’s Device Receives May’s Expense
BobDev->>Backend: GET /events?after=3
Backend-->>BobDev: ExpenseLogged (seq=4)
BobDev->>BobDev: Decrypt yields apply to local state
BobDev->>BobDev: Evaluate: Bob not in splits - no-involvement
BobDev->>BobDev: Default view: expense hidden

Bob->>BobDev: Toggle “Show expenses without involvement”
BobDev->>BobDev: Re-render list including no-involvement expenses
BobDev->>Bob: May's train CHF 12 visible in grey (no balance impact)

Note over Alice,Backend: ④ Alice’s Device — Same Behaviour
AliceDev->>Backend: GET /events?after=3
Backend-->>AliceDev: ExpenseLogged (seq=4)
AliceDev->>AliceDev: Alice not in splits - hidden by default

Note over May,Backend: ⑤ Group Shared Expenses (representative — normal flow)
Bob->>BobDev: Log “Dinner €60 · 3 equal shares”
BobDev->>Backend: ExpenseLogged (seq=5)
Backend-->>BobDev: 200 OK
MayDev->>Backend: GET /events?after=4
Backend-->>MayDev: ExpenseLogged (seq=5)
MayDev->>MayDev: May ∈ splits yields shown in default view
AliceDev->>Backend: GET /events?after=4
Backend-->>AliceDev: ExpenseLogged (seq=5)

Note over May,MayDev: ⑥ May Views "Total Cost" Report
May->>MayDev: Open Reports yields Total Cost by Member
MayDev->>MayDev: Calculate total cost for May (local function)
Note right of MayDev: Train CHF 12 (solo) + shared expense portions
MayDev->>May: Total trip cost displayed

Post-conditions:

  • May’s solo expense is in the group event log and visible to all members who enable the filter
  • Bob and Alice’s default view is uncluttered — they see only expenses that affect them
  • The solo expense contributes CHF 0 to inter-member balances (May paid and owes herself)
  • The solo expense is fully included in May’s Total Cost report
  • The solo expense is excluded from settlement calculation and debt simplification

Key design properties illustrated:

  • "Only me" is just a split with one participant: no new event type or data model change needed; the existing splits: Split[] array with a single entry where participant_id === paid_by fully represents a solo expense
  • Balance impact is zero: solo expense nets to +1200 (paid) − 1200 (owes) = 0 for May; all other members are untouched; the cross-check sum(net.values()) === 0 still holds
  • Involvement is a derived property: involves_member(m) = paid_by === m || splits.some(s => s.participant_id === m) — computed locally, never stored
  • "Without involvement" filter mirrors the "show deleted" filter: same reduced-contrast (grey) treatment, same toggled-off-by-default behaviour, same label showing why the expense appears greyed
  • Privacy note: solo expenses are encrypted with the group key — all members can decrypt them. The filter is a UI/UX choice, not a security boundary. All group members implicitly consent to shared visibility when joining the group
  • Total Cost is a separate computation from Balance: Total Cost answers "how much did this trip cost me personally"; Balance answers "what do I owe or am I owed after group payments are factored in". They are distinct views on the same event log
  • Total Cost is currency-aware: splits are stored in settlement-currency minor units (settlement_minor_units); the report sums these directly — no conversion needed, since the settlement amount is already in the group's settlement currency
  • Solo expenses appear in archived group: the sealed local archive (created on GroupClosed) includes all events, giving May a permanent record of her full trip cost including personal items

UC-4 — Delete Expense: Erroneous Entry & Soft Tombstone

Scenario: Alice is a member of multiple active groups. She accidentally creates an expense in the wrong group. She catches the mistake, opens the expense, and deletes it. The expense is soft-deleted (tombstoned) — other members' views update on their next sync and the expense disappears from the default list. Any member may reveal deleted expenses via a persistent per-group toggle, where they appear greyed out and are excluded from all calculations.

Participants: Alice, one or more other members of the same group ("Peer"), the shared Backend.

Preconditions: Group is open. Alice created the expense (she is created_by). The group's expense_edit_permission is creator_only (default) — so only Alice may delete her own expense. Network is available.

sequenceDiagram
actor Alice
actor Peer as Other Member
participant AliceDev as "Alice's Device"
participant PeerDev as "Peer's Device"
participant Backend

Note over Alice,Backend: ① Erroneous Expense Already Exists
Note right of AliceDev: Alice created "Groceries €45" in the wrong group.\nExpense is Active · ☁️✓ Synced · visible to all members.

Note over Alice,Backend: ② Alice Opens the Expense & Taps Delete
Alice->>AliceDev: Open group yields tap expense "Groceries €45"
AliceDev->>Alice: Show expense detail view
Alice->>AliceDev: Tap red "Delete" menu item
AliceDev->>Alice: Confirmation dialog:\n"Are you sure you want to delete\n'Groceries €45'?\nThis cannot be undone."
Alice->>AliceDev: Confirm "Delete"

Note over Alice,Backend: ③ Permission Check & Event Write
AliceDev->>AliceDev: Check: author_id === expense.created_by ✔\n(creator_only mode — Alice may delete)
AliceDev->>AliceDev: Write ExpenseDeleted event locally\nexpense content state: Active yields Deleted
AliceDev->>AliceDev: Append to outbox · mark sync status 📱
AliceDev->>AliceDev: Recalculate balances — "Groceries €45" removed ✅
AliceDev->>AliceDev: Default expense list re-renders:\nexpense hidden (show_deleted = false for this group)

Note over Alice,Backend: ④ Sync to Backend
AliceDev->>Backend: POST /v1/groups/(id)/events\n(ExpenseDeleted · encrypted · (expense_id))
Backend-->>AliceDev: 200 OK · seq=N
AliceDev->>AliceDev: Outbox entry cleared · sync status yields ☁️✓

Note over Peer,Backend: ⑤ Peer Receives the Tombstone on Next Sync
PeerDev->>Backend: GET /v1/groups/(id)/events?after=N-1
Backend-->>PeerDev: ExpenseDeleted (seq=N)
PeerDev->>PeerDev: Apply tombstone:\nis_deleted = true on local expense record
PeerDev->>PeerDev: Recalculate balances — expense removed ✅
PeerDev->>PeerDev: Default list re-renders:\nexpense hidden (show_deleted = false for this group)

Note over Alice,Backend: ⑥ Any Member Reveals Deleted Expenses (optional, persistent)
Alice->>AliceDev: Menu yields "Show deleted expenses" (toggle ON)
AliceDev->>AliceDev: Persist: group_ui_prefs.show_deleted = true\n(stored locally · per group · survives app restart)
AliceDev->>Alice: Re-render list:\n"Groceries €45" visible in grey · strikethrough\nbadge "Deleted" · no balance contribution shown

Peer->>PeerDev: Menu yields "Show deleted expenses" (toggle ON)
PeerDev->>PeerDev: Persist: group_ui_prefs.show_deleted = true
PeerDev->>Peer: Re-render: "Groceries €45" visible in grey

Post-conditions:

  • ExpenseDeleted event is permanently in the event log (soft tombstone — content is never purged)
  • is_deleted = true in every member's local current_expenses table after sync
  • Expense is excluded from balance calculation, debt simplification, and Total Cost report on all devices
  • Expense is hidden from the default expense list on all devices (show_deleted = false by default)
  • Any member who enables "Show deleted" sees it greyed out; that preference is persisted per group
  • The ExpenseLogged event remains in the log — the full audit trail is intact

Permission matrix for delete:

expense_edit_permissionWho may delete?
creator_only (default)Only the member who created the expense (author_id === expense.created_by)
any_memberAny active member of the group

If a received ExpenseDeleted event violates the active permission rule, it is rejected by all clients during sync (see §4.4 Validation Rules).

Key design properties illustrated:

  • Soft delete only — no hard purge: the ExpenseDeleted event is a tombstone; the original ExpenseLogged event is preserved in the log forever. This keeps the event log append-only and the audit trail intact
  • Confirmation dialog is the UX safeguard: the data model has no "undo" concept once the event is written and synced; the confirmation step is the only protection against accidental deletion
  • Immediate local effect: the expense disappears from Alice's view the moment she confirms, before the backend round-trip completes (local-first)
  • Peers update on next poll: there is no real-time push in v1 — peers see the deletion on their next foreground sync or app-open poll (see open item: sync notification strategy)
  • Display preferences are per-group and persistent: show_deleted and show_without_involvement are stored in group_ui_prefs (local SQLite, never synced), independently per group, and survive app restarts
  • show_deleted and show_without_involvement are independent toggles: a member may show deleted expenses without showing non-involvement expenses, and vice versa
  • Deleted expenses are visible to all members when revealed: same privacy scope as any other expense — encrypted with the group key, so all members can decrypt. The filter is UX convenience, not a security boundary

UC-5 — Edit Expense: Amount Correction & Split Reconfiguration

Scenario: Bob logged "3 Guinness" for EUR 18 split equally among Bob, Alice, and May (EUR 6 each). After checking his credit card statement he sees the actual charge was EUR 21.60. He also decides to treat May — so he removes her from the split and assigns a custom breakdown: Bob pays EUR 14.40, Alice pays EUR 7.20. He opens the expense, edits it, and saves. The updated expense supersedes the original. There is no change log in v1 — only the latest snapshot is visible to members.

Participants: Bob (creator of the expense), Alice, May, the shared Backend.

Preconditions: Group is open. Expense "3 Guinness" is Active and ☁️✓ Synced. expense_edit_permission = creator_only (default). Bob is the created_by member.

Original expense state:

FieldValue
settlement_amount_minor_units1800 (EUR 18.00 — settlement currency is EUR)
paid_byBob
split_methodequal
splitsBob 600 · Alice 600 · May 600
currencyEUR

Updated expense state after edit:

FieldValue
settlement_amount_minor_units2160 (EUR 21.60)
paid_byBob
split_methodcustom
splitsBob 1440 · Alice 720
currencyEUR
sequenceDiagram
actor Bob
participant BobDev as "Bob's Device"
participant AliceDev as "Alice's Device"
participant MayDev as "May's Device"
participant Backend

Note over Bob,Backend: ① Bob Opens the Expense to Edit
Bob->>BobDev: Open group yields tap "3 Guinness EUR 18.00"
BobDev->>Bob: Show expense detail view (Active)
Bob->>BobDev: Tap menu yields "Edit"
BobDev->>BobDev: Content state: Active yields EditDraft\n(local UI state only · no event written yet)
BobDev->>Bob: Show edit form pre-filled with current values:\nEUR 18.00 · equal · Bob ✓ Alice ✓ May ✓

Note over Bob,Backend: ② Bob Makes the Changes
Bob->>BobDev: Change amount: 18.00 yields 21.60
BobDev->>BobDev: Switch split_method to "custom"
Bob->>BobDev: Unselect May from participants
BobDev->>BobDev: Recalculate equal split for Bob + Alice:\n2160 / 2 = 1080 each (shown as starting point)
Bob->>BobDev: Set Bob = 14.40 (1440 cents)
Bob->>BobDev: Set Alice = 7.20 (720 cents)
BobDev->>BobDev: Live validation:\nsum(splits) = 1440 + 720 = 2160 === settlement_amount_minor_units ✔

Note over Bob,Backend: ③ Bob Taps Save — Permission Check & Event Write
Bob->>BobDev: Tap "Save"
BobDev->>BobDev: Permission check:\nauthor_id === expense.created_by (Bob) ✔\ngroup.status === "open" ✔
BobDev->>BobDev: Build ExpenseUpdated payload (full snapshot):\n(expense_id, original:EUR 2160, settlement:EUR 2160,\npaid_by:Bob, splits:[(Bob,1440),(Alice,720)],\nsplit_method:"custom", last_updated_at:now())
BobDev->>BobDev: Content state: EditDraft yields Active (new version)\nAppend ExpenseUpdated to local event log\nAppend to outbox · sync status yields 📱
BobDev->>BobDev: Recalculate balances immediately:\n Before: Bob +1200 · Alice -600 · May -600\n After: Bob +720 · Alice -720 · May 0\n Balance view updates instantly ✅
BobDev->>Bob: Expense list shows updated "3 Guinness EUR 21.60"

Note over Bob,Backend: ④ Sync to Backend
BobDev->>Backend: POST /v1/groups/(id)/events\n(ExpenseUpdated · encrypted · full snapshot)
Backend-->>BobDev: 200 OK · seq=N
BobDev->>BobDev: Outbox entry cleared · sync status yields ☁️✓

Note over Alice,Backend: ⑤ Alice Syncs — Balance Updates
AliceDev->>Backend: GET /v1/groups/(id)/events?after=N-1
Backend-->>AliceDev: ExpenseUpdated (seq=N)
AliceDev->>AliceDev: Apply: reduce local expense to latest snapshot\n(ExpenseUpdated supersedes ExpenseLogged for this expense_id)
AliceDev->>AliceDev: Recalculate:\n Alice was owed -600 · now -720\n Balance view updates ✅
AliceDev->>Alice: "3 Guinness" now shows EUR 21.60 · Alice share EUR 7.20

Note over May,Backend: ⑥ May Syncs — Removed from Expense
MayDev->>Backend: GET /v1/groups/(id)/events?after=N-1
Backend-->>MayDev: ExpenseUpdated (seq=N)
MayDev->>MayDev: Apply: May no longer in splits for this expense_id
MayDev->>MayDev: involves_member(May) = false for this expense\nyields expense moves to "no involvement" category
MayDev->>MayDev: Recalculate:\n May was owed -600 · now 0 from this expense\n Balance view updates ✅
MayDev->>May: "3 Guinness" hidden by default (no involvement)\nRevealable via "Show without involvement" toggle

Balance impact summary:

MemberBefore editAfter editChange
Bob+EUR 12.00 (paid 18, owed 6)+EUR 7.20 (paid 21.60, owed 14.40)−4.80
Alice−EUR 6.00−EUR 7.20−1.20
May−EUR 6.00EUR 0.00 (not in splits)+6.00
Cross-checksum = 0 ✅sum = 0 ✅

Post-conditions:

  • ExpenseUpdated event is in the log; the original ExpenseLogged event remains (append-only — never removed)
  • The reducer uses only the latest ExpenseUpdated snapshot for this expense_id when materialising state
  • All three members' local views reflect the new state after their next sync
  • May's involves_member() evaluates to false for this expense → hidden by default in her list
  • No change history is shown in the UI — only the current snapshot is visible (v1 decision)

Key design properties illustrated:

  • ExpenseUpdated is a full snapshot, not a diff: the entire new expense object is written; no delta or patch format. This keeps the reducer simple: always take the latest event for a given expense_id
  • No change log in v1: the event log contains both ExpenseLogged and ExpenseUpdated for the same expense_id, but the app only surfaces the latest version. The original values are technically recoverable from the raw event log, but no UI exposes this. If audit history becomes a requirement it can be surfaced in v2 without any data model changes — the data is already there
  • split_method changes with the edit: equalcustom because the new split is not proportional. split_method is stored for UI labelling only; the splits[] array is the source of truth for calculations
  • Live validation while editing: sum(splits) === settlement_amount_minor_units is enforced in real-time in the edit form. Save is disabled until the invariant holds, preventing the user from accidentally leaving an unbalanced expense
  • Content state transition: Active → EditDraft → Active. If Bob force-closes the app during editing, the EditDraft state is discarded and the last Active version (EUR 18) is preserved unchanged
  • Removed participant becomes a non-involved member: May is no longer in splits[], so involves_member(May) = false. The expense moves to her "without involvement" category on next sync — she is not notified out-of-band in v1 (see open item: sync notification strategy)
  • Permission check on save, not on open: the edit form opens regardless (for UX previewing), but the Save action validates author_id === expense.created_by before writing the event

3.9 Conflict Resolution & Event Ordering

The append-only event log eliminates most traditional data conflicts, but concurrent offline edits and network partitions create ordering ambiguities that must be resolved deterministically.

Ordering rules

The canonical order is intrinsic to the events — every device derives it from the signed envelope alone, with no backend or coordinator involved. Events are ordered by a two-level key:

LevelKeySourceTie-break priority
1 (primary)lamportLamport logical clock, in the signed envelopeHighest
2event_idAuthor-assigned UUIDv4, in the signed envelopeMiddle
3(author_id, author_seq)Author pubkey + per-author counter, both signedLowest — always unique

The Lamport clock is the authoritative order. When a device authors an event it stamps lamport = 1 + max(lamport over every event it currently holds for the group); on receiving a remote event it advances its own clock to max(local, received) . This yields a total order that is consistent with causality (a cause always sorts before its effect) and identical on every device given the same set of events. The event_id tiebreak deterministically orders genuinely concurrent events (same lamport) the same way everywhere.

Why a third level? event_id is an author-assigned UUIDv4, not a content hash, so it is not guaranteed unique across authors — a member could reuse another member's event_id (issue #19). (author_id, author_seq) is unforgeable across authors (author_id is signature-verified, author_seq is signed) and globally unique, so appending it makes the order provably total even under a deliberate (lamport, event_id) collision. In the honest case it never triggers — distinct random UUIDv4s already break every tie.

Why not the backend's sequence number anymore? Earlier drafts used a backend-assigned global seq_num as the authority. That only works if exactly one backend sees every event and never drops one — i.e. a central component. FEN publishes to several best-effort backends (§3.5), so no global seq_num exists (backend A's seq 42 ≠ backend B's seq 42). The Lamport clock replaces it as the ordering authority; per-author author_seq replaces it as the completeness signal (below). A backend's last_server_sequence survives only as a private per-backend cursor for incremental GET ?after= pulls — never as a cross-device order.

happened_at (wall clock) is never an ordering key — it is display/audit only. This is the crucial consequence of removing the central sequencer: with no global seq_num, wall-clock skew would corrupt order if it were used, so ordering relies solely on the skew-immune Lamport clock.

Balance recalculation always replays the full event log in (lamport, event_id) order. The local SQLite materialised state is derived, never canonical. The ordering rules are lossless — rewinding and replaying always produces the same result on any device.

Rules for specific conflict scenarios

1. Two ExpenseUpdated events for the same expense_id (concurrent offline edits)

Higher lamport wins; on a lamport tie, the higher event_id wins. The losing version is silently superseded. Because the order is intrinsic, every device picks the same winner without consulting any backend.

lamport=5 ExpenseUpdated(expense_id=X, amount=45.00) ← Alice's edit
lamport=7 ExpenseUpdated(expense_id=X, amount=48.00) ← Bob's edit (saw more history)
Result: amount = 48.00 (lamport=7 wins)

User-visible treatment: no conflict notification in v1. The winning value is shown silently. A future "edit history" feature could surface this.

2. ExpenseDeleted + ExpenseUpdated for the same expense_id

Deletion always wins, regardless of sequence. Once a tombstone exists for an expense_id, all ExpenseUpdated events for that ID are ignored by the reducer.

Rationale: an edit arriving after a deletion is almost certainly a race condition from an offline device that didn't know the expense was deleted. Silently losing the edit is safer than resurrecting a deleted expense.

3. MemberRemoved + ExpenseCreated by the removed member (concurrent)

lamport=10 MemberRemoved(member_id=Bob) ← organiser-authored
lamport=12 ExpenseLogged(created_by=Bob, ...) ← Bob was offline, never saw the removal

Acceptance here is not decided by comparing logical clocks — Bob's event is genuinely concurrent with the removal (it does not descend from it), so "before/after" is undefined. Acceptance is instead governed by epoch membership (§3.10), which does not need a global order:

  • Bob's pubkey leaves the member set at the KeyEpochUpdate that follows MemberRemoved. Any event authored by a pubkey not present in the current epoch's member list is rejected by all clients regardless of how it decrypts or where it sorts (§3.10).
  • A concurrent expense Bob authored before he observed the removal (its lamport does not descend from MemberRemoved) is held under the REVOKED_MEMBER_PRE disposition (§3.12 Error 4): stored, not projected, and surfaced to the organiser, who may re-enter it on Bob's behalf. Events authored after Bob observed the removal are REVOKED_MEMBER_POST and discarded.

This makes the rule independent of any sequencer: membership is tracked through the signed prev_epoch chain, not through a backend's arrival order.

4. Clock skew

ScenarioTreatment
happened_at up to 5 min in the futureAccepted; displayed with wall-clock time, not the future timestamp
happened_at more than 5 min in the futureAccepted but flagged as suspicious; logged locally for diagnostics
happened_at in the past (any amount)Always accepted — offline devices may have been disconnected for days

Clock skew does not affect correctness because the authoritative order is the Lamport clock, not happened_at. happened_at influences only how a row is displayed, never how events are ordered or balances computed. (This is why ordering had to move off any wall-clock value once the backend's global seq_num was removed.)

5. Duplicate events (same (author_id, author_seq))

Silently idempotent. Dedup is keyed on (group_id, author_id, author_seq) — an author's identity plus its own signed per-author counter — not on the author-assigned event_id. This handles at-least-once delivery (a peer or a second backend re-serving the same signed event) while making it impossible for one member to suppress another member's event by reusing its event_id (issue #19). Two genuinely different events that collide on event_id but come from different authors both survive; the second event from the same author reusing its own author_seq (equivocation) is the only thing dropped here, and that only ever discards the equivocator's own event.

6. Unknown event types

Events with an unrecognised event_type are:

  • Stored in the local event_log table (never discarded — forward compatibility)
  • Silently ignored by the reducer (no state change, no error)
  • Not surfaced in the UI
  • Included in archive exports

This allows newer app versions to produce events that older versions gracefully skip.

7. Settlement + concurrent expense edit changing the settled amount

A settlement records the amount at the time of settlement. A concurrent ExpenseUpdated event arriving after the SettlementCreated event does not retroactively adjust the settlement. The settlement stands; the balance is recalculated from the updated expense going forward. The user may see a small residual balance appear after a race — this is correct behaviour, and they can record another settlement for the delta.

Invariants the reducer enforces

1. An ExpenseUpdated or ExpenseDeleted event is only applied if
a matching ExpenseLogged event with the same expense_id exists
in the log at lower (lamport, event_id). If it has not yet
arrived, the event is parked as a pending-orphan (see §3.8).

2. A SettlementRecorded event is only applied if both referenced
member_ids are current members of the group in the epoch the
event was authored under (§3.10) — not a backend position.

3. No event is applied if its Ed25519 signature does not verify
against the author_id's pubkey. The signature covers author_seq
and lamport, so neither can be forged or rewritten by a backend/peer.

4. No event is applied if group_id in the envelope does not match
the local group_id.

5. An event whose (group_id, author_id, author_seq) is already
present is a duplicate and is silently ignored (idempotent
re-delivery / a peer or second backend re-serving the same signed
event). Dedup is deliberately NOT keyed on event_id: event_id is
an author-assigned UUIDv4, so keying on it would let a member
reuse an honest member's event_id and suppress their event as a
false "duplicate" (issue #19). (author_id, author_seq) is bound to
the author by the signature, so one author can only collide with
its own slot.

6. An author's events must be contiguous in author_seq. A received
event with author_seq > contiguous_seq + 1 for that author reveals
a gap; the missing range is requested from other backends/peers
before the group is considered fully synced (see §3.12 Error 7).

7. A MemberJoined event only creates or refreshes a pending participant.
Active roster status is granted only by a MemberAdmitted event authored
by group.created_by and matching the pending participant's
(public_key, invite_id), as defined in §3.5.

8. Roster and group-lifecycle mutations have explicit authorship rules:
MemberRemoved, GroupSettingsUpdated, GroupClosed, KeyEpochUpdate,
StorageKeyRotated, and StorageKeyAck orchestration actions that change
group authority are organiser-only; MemberLeft is self-only
(event.author_id must equal payload.member_id). Removing the organiser is
invalid. This authorization gap is tracked by implementation-review issue
#65.

Per-author sequence numbers & provable completeness

FEN does not use full vector clocks or a general CRDT library — they are heavier than this problem needs. It uses two lightweight, signed scalars that together give the same guarantees a central server used to provide, with no central server:

Convergence (safety). The ledger is mostly commutative — a balance is a sum of signed amounts, and addition commutes, so the bulk of events (plain ExpenseLogged) converge regardless of order. Only the order-sensitive minority (edits, tombstones, MemberRemoved, KeyEpochUpdate, GroupClosed) needs a deterministic order, and (lamport, event_id) supplies it identically on every device. DeleteWins remains the only non-trivial merge policy.

Completeness (liveness). author_seq makes event loss detectable and provable. A device knows the current roster from membership events; the log is complete exactly when it holds a contiguous author_seq 1..Nᵢ for every current member i. A member's high-water mark claimed_seqᵢ is advanced only by receipt of a validly-signed event authored by i (invariant 3 above) bearing that author_seq — never by an unauthenticated advertisement. The compact per-author high-water-mark vectors peers exchange during sync ({Alice: 5, Bob: 3, May: 7}) are therefore routing hints — they tell a device which ranges to request — not authority that raises claimed_seq: a peer advertising Bob: 999 moves nothing until it actually serves Bob's signed event #999. Two properties §3.12 depends on follow directly (design-review finding #5 / issue #20): (a) a member can only ever raise their own claimed_seq, so no member can inflate another's frontier; and (b) a frontier can only be raised by a real, held signed event, never a fabricated future one. A declared gap is thus always backed by a signed event beyond it — you know Bob's #4 is missing because you hold Bob's signed #5. This is the basis for the settlement gate in §3.12 (Error 7): money never moves on a log that cannot be proven gap-free.

The ingest layer must maintain that contiguous frontier, not merely store the counter. A received event with author_seq > contiguous_seq + 1 marks the group history incomplete and requests the missing range; a second distinct event from the same author with an already-accepted author_seq is a sequence conflict, even if its event_id differs. Until issue #19 resolves the final dedupe key, author_seq validation remains a separate tamper-evidence requirement tracked by implementation-review issue #73.

Healing is decentralised. Every event is signed, so any peer or any of the group's backends can re-serve any event without being able to forge it. The complete log is the union over all devices and all backends; no single party need ever hold all of it. The irreducible residual risk — an event that briefly exists on only one device before any second party copies it — is bounded by publishing to ≥2 backends immediately, and is contained by the §3.12 settlement gate.

Full vector clocks (one entry per member, on every event) would also work but cost O(members) per event for ordering FEN doesn't need; the single Lamport scalar plus per-author author_seq is the minimal sufficient mechanism. This may be revisited only if concurrent-edit collisions become a user-reported problem.


3.10 Key Epochs & Membership Changes

Membership changes are the most cryptographically consequential events in FEN. Because the group key is a symmetric secret shared across all members, every change to the member set must be reflected in which key material is distributed — and to whom. This section defines the key epoch model, the rules for add and remove operations, and the edge cases that must be handled correctly.

The Core Rule

Key possession determines access, not membership status. The append-only event log in the storage backend stores permanent ciphertext. Any party holding the correct group key for a given epoch can decrypt all events encrypted under it, regardless of their current membership. Membership records in the projection control what the UI shows; they do not cryptographically gate access.

Key Epoch Model

Each group maintains a sequence of key epochs. Every epoch has:

FieldDescription
epochMonotonically incrementing integer, starting at 1
keyFresh random symmetric key (XChaCha20-Poly1305)
membersSet of member pubkeys authorised for this epoch
created_atTimestamp of the KeyEpochUpdate event
created_byOrganiser pubkey

Every event in the log carries its epoch in the envelope (unencrypted). The projector uses this to select the correct decryption key. Epoch transitions are made unambiguous by the signed prev_epoch chain (each KeyEpochUpdate names the epoch it supersedes) together with the (lamport, event_id) order — not by any backend sequence.

Important: K(n+1) must always be a fresh random key. Never derive K(n+1) = H(K(n)) — a removed member holds K(n) and can compute all future keys if derivation is used.

KeyEpochUpdate Event

A single atomic event published by the organiser whenever the member set changes. All member key-wrappings are included in one payload; the epoch is not considered active until this event is fully written to and confirmed by the storage backend.

{
"type": "KeyEpochUpdate",
"epoch": 3,
"members": {
"<pubkey_alice_hex>": "<K3 wrapped to Alice's pubkey, base64>",
"<pubkey_bob_hex>": "<K3 wrapped to Bob's pubkey, base64>",
"<pubkey_carol_hex>": "<K3 wrapped to Carol's pubkey, base64>"
},
"prev_epoch": 2,
"created_by": "<organiser_pubkey_hex>",
"created_at": 1751110800
}

Clients validate that the member count in the payload matches the expected post-change member list before accepting any events under the new epoch. If the backend yields a partial or malformed KeyEpochUpdate, clients reject it and request a retry from the organiser.

Each encrypted event envelope carries signed context fields. In fmt = 1, those fields are authenticated by the Ed25519 signature over the canonical envelope, which receivers verify before decrypting; AEAD additionalData is empty. The planned fmt = 2 wire format also binds the same fields into the XChaCha20-Poly1305 layer as authenticated associated data (issue #66):

AAD = JCS({ a: author_pubkey, e: event_type, g: group_id, i: event_id, p: epoch })
// canonical bytes of this fixed object (RFC 8785) — unambiguous, NOT a raw concat.
// See doc/canonical-serialization.md §7.

For both formats, changing these fields invalidates the signature. In fmt = 2 it also makes symmetric decryption fail closed.

Member Addition

When a new member is added, the organiser generates a new epoch and distributes keys as follows:

History access granted?What the organiser distributesNew member sees
Full history (explicit opt-in)All prior epoch keys wrapped to new member's pubkey, plus the new epoch keyComplete expense history from group creation
From now (default)Current epoch key onlySettlementSnapshot (current balances) + all future events

Default behaviour is from now. Granting full history requires the organiser to explicitly confirm: "Adding [name] will give them access to all expenses since [group creation date]. Share full history?"

A SettlementSnapshot event is published under the new epoch immediately after KeyEpochUpdate when history is not shared. It carries the current member list, open balances, and a summary of active (non-deleted) expenses — enough to participate correctly in future settlements without exposing historical details.

Member Removal

Removal has two distinct phases that may or may not happen simultaneously:

PhaseActionEffect
Disable future participationMemberRemoved event published; removed member excluded from splits going forwardSocial/accounting effect only — they can still decrypt events under keys they hold
Revoke future accessKeyEpochUpdate published with new epoch key, wrapped only to remaining membersCryptographic effect — they cannot decrypt any events under the new epoch

Both phases must always happen together on explicit removal. They are separated conceptually to clarify what each action achieves — not to allow one without the other.

Unavoidable: the removed member retains decryption capability for all events they have already synced, and for any backend-held events under old epoch keys. This cannot be changed. The product must state this honestly; it must never imply cryptographic erasure of past access.

Removal sequence (order matters):

  1. Force a sync of all pending events for the group
  2. Compute and publish a SettlementSnapshot under the current epoch, signed by the organiser, capturing the removed member's final balances
  3. Publish MemberRemoved event
  4. Generate new epoch key K(n+1) (fresh random, not derived)
  5. Publish KeyEpochUpdate with K(n+1) wrapped to all remaining members
  6. Call the provisioner's POST /v1/groups/:id/rotate-key → returns a fresh StorageDescriptor plus a deny_old_at deadline. The old S3 credential remains valid — revocation is deferred (see Storage Credential Hand-off below)
  7. Publish StorageKeyRotated under epoch n+1, carrying the new descriptor and deny_old_at
  8. All subsequent group events use K(n+1); the organiser's device switches to the new credential once step 7 is confirmed written

Step 2 must complete before step 3, and step 5 before step 7 (StorageKeyRotated is encrypted with K(n+1)). The organiser's client enforces this ordering, blocks publication of new expense events until steps 5–7 are all confirmed written, and blocks if the backend is unreachable.

Removed member UX: show a read-only screen — "You were removed from [group] on [date]. Here is your record of shared expenses during your membership." Do not black-screen them; they are entitled to their own financial history from their membership period.

Storage Credential Hand-off (Two-Phase Rotation)

Rotating the per-group S3 credential can never be a single atomic step: the credential is shared by every member, and the bucket it protects is the only in-band channel for delivering its replacement. Revoking the old credential at rotation time would therefore lock out every remaining member (design-review finding #1 / issue #15). Rotation is two-phase:

Phase 1 — mint and hand off. The provisioner creates the new key and grants it on the bucket; the old key stays valid but gets a hard revocation deadline deny_old_at (default 7 days; backend-architecture.md §6.1). The organiser publishes StorageKeyRotated — encrypted under the post-removal epoch — while the old credential can still read it.

Switch and ack. Each remaining member, still syncing with the old credential, processes KeyEpochUpdate, decrypts StorageKeyRotated, atomically swaps the descriptor in local storage, and publishes StorageKeyAck using the new credential. All subsequent S3 operations use the new credential.

Phase 2 — revoke. When the organiser has observed a StorageKeyAck for this rotation_id from every epoch-n+1 member (itself excluded), it calls POST /v1/groups/:id/rotate-key/complete and the provisioner revokes the old key immediately. If acks never complete — a member stays offline, the organiser's device dies — the provisioner revokes unilaterally at deny_old_at. Deferral is bounded; revocation is never skipped.

The overlap window, honestly. Until phase 2, the removed member may still hold a valid old credential. What that does and does not give them: (a) reading — no confidentiality loss: events under K(n+1) (including StorageKeyRotated itself) are ciphertext to them, and pre-removal ciphertext they could already decrypt from their local replica; (b) writing — their events are rejected by every client regardless: their pubkey left the epoch member list (invariant 6), and they cannot forge other members' signatures; (c) deleting/overwriting — vandalism is possible until revocation, and recoverable from any member's full local replica. The S3 credential is an availability/integrity control; the confidentiality cut on removal is the key epoch, which takes effect immediately at step 5 of the removal sequence. For hostile removals the organiser can force phase 2 early ("revoke now"), accepting that members who have not yet switched will need a re-invite (see Edge Cases).

Settlement Before Removal

Before finalising removal, the app should surface any outstanding balances involving the departing member and offer a settlement flow. The SettlementSnapshot is the canonical departure record — if the removed member's local log is stale (they haven't synced a recent ExpenseUpdated), the organiser's client forces a sync and recomputes before publishing the snapshot.

Edge Cases

Organiser cannot be removed Removing the organiser is not supported. The organiser role is the sole authority for publishing KeyEpochUpdate and MemberRemoved events; removing it would leave the group in an ungovernable state with no party able to perform key rotations or further membership changes. If the organiser wants to leave, the group must be closed first (see §2 Flow 8 — Group Close). The UI does not offer a "Remove" action for the organiser; any attempt to do so is rejected by the client before it reaches the backend.

Re-adding a previously removed member Treated identically to adding any new member. The standard add procedure applies: the organiser chooses between "full history" (all prior epoch keys granted) or "from now" (current epoch key only, with a SettlementSnapshot). The fact that they previously held epoch keys from an earlier membership period has no bearing on this decision — the organiser makes the same explicit history-access choice they would for any first-time addition.

Key rotation atomicity failure The organiser wraps keys for N remaining members and publishes KeyEpochUpdate. If the backend confirms the event but the organiser goes offline before some members have fetched it, those members temporarily cannot decrypt new events. Resolution: any online member who has received KeyEpochUpdate can re-publish their own wrapped copy on request; or the organiser's client retries on reconnect. Members who cannot decrypt events after a rotation can signal this with a KeyRewrapRequest event (signed, unencrypted envelope) asking the organiser to re-publish their wrapping.

Backend unavailable during rotation Key rotation requires the KeyEpochUpdate event to reach the storage backend. If the backend is unreachable, the organiser's client queues the event and blocks publication of any new expense events under either old or new key until rotation is confirmed. This prevents epoch ambiguity.

Organiser crashes between rotate-key and StorageKeyRotated No one is locked out — the old credential is still valid, so the group simply continues on it. On restart the organiser's client resumes from its persisted pending-rotation record and publishes the event. If the fresh credential was lost with the device state, the organiser calls rotate-key again: the provisioner supports overlapping rotations by keeping a revocation deadline per superseded key, so the removed member's exposure stays bounded by the first rotation's deny_old_at.

Member misses the hand-off window A member who stays offline past deny_old_at (or past an explicit "revoke now") comes back to a credential that fails every S3 call. The client recognises auth failures on a previously-working descriptor and shows "Your access to [group] was rotated while you were away." Recovery is a re-invite: the organiser re-issues an invite link/QR, whose package (§2.4) already carries the current StorageDescriptor. Identity and membership are unchanged — no MemberJoined is published; the re-invite is purely a credential re-delivery channel. If the member also missed epoch updates, the existing KeyRewrapRequest flow applies once bucket access is restored. A straggler cannot self-recover without the organiser; that is the accepted cost of bounding the removed member's window.

Rotation without removal (credential leak) The same two-phase protocol runs standalone when the organiser suspects the S3 credential leaked (e.g. an exposed invite link) without any membership change: no KeyEpochUpdate is needed, and StorageKeyRotated is published under the current epoch with reason: "credential_leak". A leak holder — who has the bearer credential but not the group key — can read ciphertext and vandalise until revocation: the same bounded window, the same mitigations.

Concurrent membership changes All KeyEpochUpdate events are authored by the single organiser, so they are never actually concurrent: they come from one device and carry a strictly increasing author_seq (hence a strictly increasing lamport). Whichever has the lower (lamport, event_id) is epoch N; the next is N+1. The signed prev_epoch chain is the real guard — clients never accept an epoch whose prev_epoch does not match the current highest confirmed epoch — so canonical ordering needs no backend sequence. (If a second device ever held the organiser key and both authored an epoch update, the prev_epoch mismatch causes one to be rejected, not silently reordered.)

Offline removed member writes a stale-epoch event A removed member who hasn't yet received KeyEpochUpdate may attempt to publish an expense event under the old key. Other members' clients reject any event authored by a pubkey not present in the current epoch's member list, regardless of whether it decrypts correctly. Correct decryption is necessary but not sufficient for acceptance.

Post-removal expense edits If an expense involving a removed member is edited after their departure, the removed member's local projection diverges from the group's. Their view of what they owe may differ from the group's view. The SettlementSnapshot is the mitigation: it crystallises the agreed balances at departure time, signed by the organiser, and serves as the canonical departure record for any future dispute.

Invariants

  1. K(n+1) is always a fresh random key — never derived from K(n) or any prior key
  2. KeyEpochUpdate is atomic — all member wrappings in one event; no partial epochs
  3. Every event carries epoch in its envelope; clients reject events whose epoch doesn't match a known key
  4. Every encrypted event binds group_id || epoch || event_id || author_pubkey || event_type as authenticated associated data
  5. The organiser's client publishes SettlementSnapshot before MemberRemoved — this ordering is enforced, not advisory
  6. Stale-epoch events from removed-member pubkeys are rejected even if they decrypt correctly
  7. Key rotation always accompanies member removal — there is no removal-without-rotation; this covers both the key epoch and the storage credential (two-phase, invariants 8–10)
  8. A StorageKeyRotated that follows a member removal is published under the post-removal epoch — never under an epoch whose key the removed member holds
  9. The old storage credential is revoked only after StorageKeyRotated is confirmed written (client-enforced ordering), and no later than deny_old_at (provisioner-enforced deadline, independent of client liveness) — deferral is bounded, revocation is never skipped
  10. StorageKeyAck is written with the new credential; the organiser triggers early revocation only after acks from all current-epoch members

3.11 Split & Balance Arithmetic

The full split-resolution and balance/settlement arithmetic lives in §3.6 (Split Calculation Rules) and §3.7 (Balance & Settlement Calculation); this pointer exists so the earlier cross-reference to "§3.11 for the full arithmetic spec" resolves. All amounts are settlement-currency minor units resolved once at write time — the projector never divides.


3.12 Provable Completeness & the Settlement Gate

Settlement moves real money and is irreversible, so it is the one operation FEN refuses to perform on a log it cannot prove is gap-free. This section defines that gate, why a naïve version is a one-actor denial-of-service weapon, and the bounded escape that fixes it (design-review finding #5 / issue #20). The normative surface is this section, §3.9 (per-author sequence numbers), and error-handling.md Error 7.

The gate

For every current member i, compare contiguous_seqᵢ (highest N with author_seq 1..N all held) to claimed_seqᵢ (that member's provable high-water mark, §3.9). While any member has contiguous_seqᵢ < claimed_seqᵢ the group history is provably incomplete: the INCOMPLETE_HISTORY flag is set, balances render as partial_balance, and settlement and membership writes are hard-blocked. The flag clears automatically the moment every member's frontier is contiguous — a checkable property of the local log, not an assumption that a server had everything.

Why the naïve gate is a weapon

The gate is safe only because claimed_seq is provable. The original design let claimed_seq rise from any peer's unauthenticated high-water-mark advertisement, so one member could advertise author_seq: 999, never upload 1..999, and permanently freeze settlement for the entire group — a single actor denying the app's core purpose, at zero cost, without even holding a key. §3.9 closes that: claimed_seq advances only on receipt of a validly-signed event, advertisements are mere routing hints, and no member can touch another member's frontier. What remains is bounded and attributable — a member can stall only their own frontier, and only by actually publishing a signed event at some seq N while withholding the run below it (detectable misbehaviour by a named member) or by simply going offline mid-range (an honest transient gap). The escape below handles both without letting either freeze the group.

The bounded escape

Three layers, weakest hammer first:

  1. Auto-heal (normal path). A transient gap — a peer briefly offline, a put not yet retried — closes by itself: any peer or backend holding the missing signed events re-serves them (§2.5, Error 7 recovery option 1) and the flag clears. Most incompleteness is latency, and no money moves while it resolves.
  2. Per-user acknowledge (individual; never moves group money). A single user may proceed on known-incomplete data (Error 7 recovery option 3). The hard block lifts for that user only; other members are unaffected. This never authors a group settlement.
  3. Organiser-signed SettlementSnapshot (group-level override). When a member's frontier stays stalled past a bounded timeout T (auto-heal has demonstrably failed, not merely started), the organiser may author a SettlementSnapshot that pins the balances computed over the provable subset of the log and overrides the gate for the group. This is the same organiser-signed "settle on a cut" event already used before member removal (§3.10 Settlement Before Removal), now also serving as the completeness-gate escape. Once a valid snapshot exists, settlement proceeds on its balances even though a member's tail is still missing — "settle on what's provable."

SettlementSnapshot and its guarantees

The payload (packages/fen_events/lib/src/payloads.dart, SettlementSnapshotPayload) carries:

  • balances — net position per member, computed over the provable subset only.
  • epoch — key-epoch binding (anti-replay/downgrade), like every membership-consequential event.
  • covered_frontier — member pubkey → the author_seq up to which that member's events were included. This is the exact cut any other member replays to reproduce, and therefore check, the snapshot's balances.
  • stalled_members — the members whose frontier was overridden, pubkey → the claimed_seq the gate could not reach. For each, covered_frontier[m] < stalled_members[m], recording exactly which events were left out; empty for a snapshot over an already-complete log (e.g. the pre-removal case).
  • as_of — the (lamport, event_id) cut after which later events are ordered after the snapshot, so replay stays deterministic and events that arrive late reconcile against the record rather than being silently dropped.

That makes the gate:

  • Bounded — the timeout T lets honest transient gaps heal first; only a persistent stall triggers the manual override, so it is never the normal path.
  • Authorised — only the organiser may author a SettlementSnapshot, enforced at ingest by VerifyingEventSink's organiser-authorship rule (issue #26) — the same enforcement that gates MemberAdmitted. A non-organiser member cannot force-settle.
  • Checkable, not trusted — because covered_frontier names the exact cut, every member recomputes the balances from their own copy of the provable subset; a snapshot whose balances disagree with the provable log is rejected. The organiser chooses only the cut, never the numbers.
  • Auditablestalled_members + as_of preserve exactly what was overridden and when, so a member whose withheld events surface later is reconciled against the record instead of the freeze simply being forgotten.

Honestly remaining

  • A malicious or absent organiser. The escape is organiser-gated, so it does not cover an organiser who is themselves the bad actor (withholding their own events) or who never returns. A dishonest organiser still cannot forge balances (checkable, above) but could decline to snapshot; an absent organiser leaves per-user acknowledge (layer 2) as the individual fallback. A quorum/rotation override that removes the organiser as a single point of failure is deliberately out of v1 scope (it rides with the never-returning-organiser open question in the design review's Absent topics). v1's threat model is the single ordinary member DoS, which this fully closes.
  • The gate is specified here but not yet built. No author_frontier table, gate evaluation, timeout T, or snapshot-authoring UI exists in code yet; SettlementSnapshotPayload is now shape-complete and round-trips through the payload codec, but reducer/projector wiring is a separate implementation milestone. This section fixes the design the finding flagged so that milestone builds the DoS-resistant gate, not the naïve one.