Architecture

Overview

Kid Money’s ledger is local-first. SwiftUI and App Intents share a SwiftData store and a small domain service. The isolated CloudKit connection probe has completed Phase 6 transport validation. The real shared-ledger foundation now defines local sync state, deterministic CloudKit mappings, and a durable change queue. It can strictly decode and locally merge supplied CloudKit records. A transport-independent queue processor now exercises send policy, retry, account gating, and optimistic conflicts. A concrete CKSyncEngine delegate now maps engine events into those persistence and merge boundaries. A durable migration coordinator stages existing local ledgers without changing their rows. The sharing screen instantiates setup only for explicit owner/participant actions. Once that consented flow activates a household, one app-scoped coordinator owns its sync engine and begins guarded automatic synchronization. An owner setup runner composes setup behind an injected CloudKit boundary.

SwiftUI views ──────┐
                   ├── LedgerService ── SwiftData ModelContext ── local store
App Intents ───────┘

The visual home screen is the primary interaction surface. App Intents remain a second caller of the same service rather than a separate product path.

Domain model

Child

  • stable UUID, name, creation date, last-modified date, sort order, and archive flag
  • relationship to ledger transactions

Archiving is preferred to destructive deletion so historical transactions remain understandable.

LedgerTransaction

  • stable UUID and optional relationship to Child
  • signed amountCents: Int64
  • creation date, optional note, and persisted source
  • optional reversesTransactionID linking an undo entry to the transaction it compensates

Amounts are signed: +10 adds ten cents and -25 removes a quarter. The balance is always the sum of a child’s transactions.

Service boundary

LedgerService owns domain mutations and queries. SwiftUI uses it to add, rename, and archive children; add transactions; fetch history; and calculate balances. App Intents reuse the same operations. Archiving only changes the child’s active flag and never deletes ledger history.

The all-children quick action is one service operation. It fetches active children, validates every resulting balance before inserting anything, creates one transaction per child with a shared timestamp and note, queues every CloudKit record when sharing is active, and saves once. Any validation or save failure rolls back the entire local batch. This keeps balances transaction- derived and prevents a partially applied family-wide adjustment.

Before inserting a transaction, the service verifies that adding its signed cents to the current derived balance cannot overflow Int64. A rejected transaction is not inserted or saved.

The service is @MainActor because its ModelContext is main-actor-bound in the current small application. Revisit context ownership only if App Intent execution demonstrates a concrete concurrency need.

Visual quick-action preferences

The ordered quick-amount list is stored as positive integer cents in @AppStorage, not SwiftData. It defaults to 5, 10, 15, 20, 25, and 50 cents, is capped at six unique values, and is used only by the home screen. Child detail retains arbitrary adjustments and history. Input still goes through exact MoneyConversion; no floating-point money enters the domain. The preference is intentionally per device and is not part of the shared family ledger, allowing each parent to choose a different button layout without creating CloudKit conflicts.

Persistence

AppModelContainer lazily creates one process-wide ModelContainer for production and can create isolated in-memory or URL-based containers for tests. The app, App Entity queries, and App Intents all use that shared production container. Its ModelConfiguration explicitly sets cloudKitDatabase: .none so future CloudKit entitlements cannot cause SwiftData to start an independent automatic synchronization stack.

Physical Phase 2 testing confirmed that background App Intent execution opens the same store safely and that values survive termination and relaunch. Do not create a separate intent-only database.

Family sharing boundary

Private parent-to-parent sharing is being introduced in two stages. The current FamilySharingProbe validates iCloud account availability, private custom-zone creation, invitation acceptance, and two-way writes with one disposable counter. It has no path to AppModelContainer or LedgerService, so enabling the probe cannot upload names, balances, notes, or transactions.

Probe connection metadata is persisted only after the initial counter and zone-wide share both save successfully. On upgrade from build 8, the probe also recognizes the narrow incomplete-owner state where the counter and share are both absent and clears only its disposable UserDefaults pointer. Ledger models and the SwiftData container are not involved in that recovery.

That physical checkpoint passed on two Apple Accounts in build 9. SwiftData remains the local working store and LedgerService remains the mutation boundary. SharedLedgerState records the household, zone, database scope, role, phase, and schema version. PendingCloudChange durably and idempotently records a pending save or deletion by deterministic CloudKit record name.

When no shared household exists, ordinary ledger operations remain purely local. A preparing household also remains local unless it has an explicit CloudLedgerMigrationState. During that migration, LedgerService saves new local edits and their coalesced pending changes together so an edit made during setup cannot fall outside the initial upload. Once a household is active, the same queueing rule continues for normal synchronization. An attention-required household rejects further ledger mutations instead of silently accumulating changes that cannot safely converge. Only explicit sharing setup can activate real-ledger networking. Before activation, app launch and ordinary local-only mutations remain offline. Build 9 on TestFlight remains counter-only.

CloudLedgerRecordMapper maps Household, Child, and LedgerTransaction values without floating-point money. Children and transactions use their stable UUIDs for record names. Undo transactions derive a deterministic UUID from the original transaction UUID so two devices cannot create two distinct undo records for the same ledger entry. A future direct CloudKit layer will drain the queue and merge records through one private, zone-wide CKShare; it will not use SwiftData’s automatic CloudKit mode or an additional Core Data container. See FAMILY_SHARING_DESIGN.md for the approved conflict, authentication, and migration rules.

CloudLedgerMergeService validates record types, deterministic names, zone identity, schema versions, exact integer cents, and domain fields before any merge. A batch saves atomically or rolls back. Transactions are immutable and idempotent; a conflicting payload for an existing ID is rejected. Child metadata uses modification time followed by stable payload ordering as its last-write-wins tie-breaker. Remote changes are written directly through a dedicated merge context and never enter the outgoing queue.

CloudLedgerQueueProcessor drains active-household changes oldest-first through an injected CloudLedgerQueueTransport. CloudLedgerSyncState persists status, last attempt/success, the next retry time, error code, database scope, and a reserved opaque engineStateData slot. Transient failures use bounded exponential backoff and honor a longer server retry interval. Missing or restricted iCloud accounts and irreconcilable data conflicts move the household to attention-required state; queued work is retained and ordinary ledger writes stop until recovery is explicit.

For optimistic-save conflicts, child metadata is merged with the same deterministic last-write-wins policy as inbound records. A server winner completes the local change; a local winner is retried using the server record’s system fields and change tag. Ledger transactions remain immutable: a different payload for an existing transaction ID is never overwritten. The processor is covered through an in-memory transport, including restart-safe retry state.

CloudLedgerSyncEngineDelegate scopes fetches and sends to exactly one household zone, materializes send batches from the durable queue, merges fetched records, classifies per-record failures, and stops on remote deletions or account changes that require a product decision. Successful saves clear the corresponding local queue entries. CloudLedgerRecordMetadata stores only encoded CloudKit system fields so retries retain server change tags without duplicating ledger payloads. The durable SwiftData queue is authoritative: initialization adds any missing engine pending changes, while a stale engine-only save is discarded rather than re-uploaded. If a local mutation coalesces into the queue while an older record version is in flight, the successful-send event compares that accepted payload with current local state and immediately requeues the newer version.

Each state-update event is JSON-encoded into CloudLedgerSyncState.engineStateData and restored when constructing CloudLedgerSyncEngineRuntime. Setup and tests leave automatic scheduling disabled. For an already activated household, CloudLedgerAutomaticSyncCoordinator retains one runtime with automatic scheduling enabled, registers for remote notifications, and services foreground, debounced post-mutation, and manual requests through the same engine.

CloudLedgerMigrationCoordinator persists the owner setup phases: awaiting zone creation, uploading the initial ledger, awaiting share creation, ready to activate, completed, and attention required. Beginning setup inserts a preparing SharedLedgerState and queues the household plus every child and transaction by deterministic record name without rewriting the ledger. Resume reconstructs missing queue entries after interruption; re-sending an already accepted deterministic record is safe through stored CloudKit system fields or optimistic conflict handling. The coordinator will not advance beyond initial upload while pending records remain, will not activate before a share exists, and permits local cancellation only before a remote zone can exist. A terminal failure preserves every ledger row and blocks further shared mutations.

Participant adoption has a separate preflight that rejects a device containing any local children or transactions. A later product flow must offer an explicit keep/export/replace decision instead of silently merging independent ledgers. The sync-engine store may service a preparing household only during the persisted initial-upload phase, and its retry or terminal errors update the migration record.

CloudLedgerSetupRunner resumes that durable state machine after validating the iCloud account. Its live transport idempotently creates the owner’s private zone, constructs the dormant sync engine for one explicit initial send, fetches or creates the zone-wide private CKShare, and stops at readyToActivate. Injected tests cover lost responses after zone/share creation, an incomplete upload, unavailable iCloud, terminal errors, and retryable cleanup. Local sync state is removed only after remote zone deletion succeeds; children and transactions are never deleted. Sharing-screen actions construct the runner and live transport only after explicit user input; app launch does not.

CloudLedgerParticipantAdoptionCoordinator persists a validated zone-wide, read-write invitation before accepting it. It rejects any existing independent local ledger. The live transport checks whether CloudKit already accepted the share, then fetches changes from the beginning of only the invited shared zone. The coordinator requires exactly one Household record whose UUID matches the zone name. It merges the complete first snapshot with the local preparing household in one save; malformed records roll back the batch. An interrupted acceptance or fetch can be retried from durable invitation state. LedgerService blocks mutations throughout participant adoption until guarded activation marks it complete. The app invokes this coordinator only after an explicit Join.

CloudLedgerActivationCoordinator is the sole path from prepared to active. It requires completed owner upload/share setup or participant first import, no deferred participant transactions, a reachable zone-wide share, and the current iCloud account identity. Activation persists that identity locally with the shared-ledger phase in one save. Subsequent access checks keep transiently offline ledgers writable (edits remain queued), but freeze new mutations without deleting data if the account changes or share access is revoked. Attention-required state is deliberately not auto-recovered: the product still needs a clear user decision for a different iCloud account or a removed invitation. The local build constructs the live transport only for explicit Sharing actions. These paths are covered by injected tests, not yet physical devices. CloudKit access errors are classified using the installed SDK’s codes: missing zones/shares and managed-account restrictions freeze shared edits, while transient network/service failures leave the local queue writable. Nested per-record failures in a partialFailure are inspected. A successful access recheck after an outage changes status to pending, not synced, because fetch and send have not yet completed. PHASE6_ACTIVATION_FLOW.md specifies the consent and invitation entry points before app wiring.

CloudLedgerSyncSession remains the guarded entry point for immediate network work. It requires an activated household with matching completed owner or participant setup state, checks the pinned account and share before fetching, fetches the household zone, checks again before sending any queued data, and only reports synced after successful work. Transient CloudKit failures persist retry time and leave local edits writable; missing zones, changed accounts, or terminal errors freeze new edits without removing the queue. The app-scoped coordinator constructs its live transport with CKSyncEngine.automaticallySync enabled only after an activated ledger passes the access preflight. It retains one engine and delegate for the process and reuses them for Sync Now; this avoids multiple production engines targeting the same database. Setup transports keep automatic sync disabled. Before sending, the runtime adds durable changes queued after the engine was constructed.

Every successful local LedgerService mutation posts a process-local wakeup. The coordinator coalesces rapid edits for 750 milliseconds, then runs another guarded session even if an older session was already in flight. Entering the foreground forces an immediate guarded fetch/send. System scheduling and silent CloudKit notifications provide power-aware background opportunities; there is no repeating poll or BGTaskScheduler job. If sharing is inactive or attention-required, no automatic runtime is created and durable queued changes remain intact.

CloudKit can deliver a transaction before its referenced child. Such a record is stored as a DeferredCloudTransaction, survives process relaunch, and is automatically applied after the child arrives. Undo entries converge by their original transaction ID. This also canonicalizes undo entries created by older builds without changing the derived balance.

Money

Ledger values use Int64 cents. MoneyFormatter converts integer cents to Decimal for localized USD display.

MoneyConversion accepts USD only, uses Decimal arithmetic, requires exact whole cents, rejects zero and unsupported currency, and detects Int64 overflow. It also parses manual text using the device locale before applying the same exact conversion rules. The arbitrary-amount give and take intents convert a positive requested value before adding a signed positive or negative ledger transaction.

App Intents

ChildEntity is a lightweight, sendable representation of a persisted child. ChildEntityQuery resolves identifiers, suggests active children, and delegates case-insensitive string matching to LedgerService. Duplicate exact names are returned together so the system can disambiguate rather than silently choosing one.

All ledger intents use iOS 26’s background intent mode and open the same local SwiftData store as the app. GiveMoneyIntent and TakeMoneyIntent accept arbitrary USD amounts; GetBalanceIntent is read-only; and UndoLastTransactionIntent creates a compensating entry. Each validates persisted entities immediately before use and returns spoken/display dialog.

GiveCoinIntent and TakeCoinIntent supplement—not replace—the arbitrary currency intents. Their CoinDenomination enum gives Siri a finite vocabulary for nickel, dime, quarter, half dollar, and dollar because physical testing showed that IntentCurrencyAmount rejected coin nouns before invoking the app.

Undo

Undo creates a signed compensating transaction and stores the original transaction’s UUID in reversesTransactionID; it never deletes history. An undo entry is not itself undoable, and an already reversed original is skipped. Repeated undo therefore walks backward through the remaining unreversed original transactions across all children. Tests cover compensation, repetition, global newest-first selection, and an empty ledger.