Skip to content

ADR-0074: Atomic cross-source mirror merge (bank recorded in place of Google Wallet)

Context

ADR-0020 made the bank app authoritative over a Google Wallet mirror, but the reconciliation ran in two separate steps: TransactionRepository deleted the Wallet row, then CapturePipeline inserted the bank row afterwards. TransactionNotificationListener dispatches each notification to its own coroutine, so deliveries can interleave. Two failure modes followed:

  • A failed bank insert (crash, constraint, process death) after the Wallet delete lost the charge entirely, together with the user's note/category and the itemization/attachment rows that FK-CASCADE on the Wallet row's id.
  • Two interleaved lookups could both miss each other and record the same charge twice.

Decision

Reconciliation is one Room @Transaction on TransactionDao, insertOrMergeCrossSource(incoming, wallet, start, end), returning a sealed CrossSourceMergeResult:

  • Inserted(id) — a new row was written;
  • Merged(id) — a bank capture was merged into the existing Wallet mirror;
  • Suppressed — nothing was written (a duplicate notificationKey, or a Wallet mirror of an existing bank row).

The lookup and the write share the transaction, so either arrival order — including simultaneous delivery — produces exactly one row; SQLite serializes the writer.

A bank capture merges in place: the mirror keeps its row id, so transaction_items and attachments (FK-CASCADE on that id) survive with no copy code. Precedence is frozen:

Group Fields Rule
Bank-owned (overwrite) notificationKey, amountMillis, currency, direction, status, occurredAt, merchant, rawText, rawJson, ruleId, parseSource, sourcePackage, capturedAt, isHidden (= status != COMPLETED) bank wins
User-owned (keep the mirror's value when set) note (non-blank), categoryId (non-null), accountId (mirror's if non-null, else bank's), the whole location tuple incl. placeName/placeId when the mirror had a fix, isReviewed (OR) user wins

CapturePipeline emits notification feedback only on Inserted. A merge is the same charge already recorded, so it must not notify again; a suppression stays silent. findWalletMatch/findNonWalletMatch keep the completed-only ±90 s amount/currency/direction window — no heuristic is widened.

CrossSourceMergeResult is the explicit duplicate result ADR-0020 left undefined: SQLite's unique notificationKey returns -1 from insert, which the DAO maps to Suppressed.

Consequences

  • A write failure rolls the whole reconcile back: the Wallet row and its children remain, and the user edits are never lost.
  • The surviving row after a merge is non-Wallet, so a Wallet replay hits findNonWalletMatch → Suppressed; a bank replay hits the unique key → Suppressed.
  • No schema change: the merge is an UPDATE of an existing row. The database stays at version 9.

Verification

TransactionRepositoryTest covers both arrival orders, duplicate-key suppression, replay-after-merge, and the frozen precedence table on a fake DAO that honours the unique notificationKey. CrossSourceMergeInstrumentedTest drives a real SQLCipher Room database: an injected UPDATE failure rolls back and leaves the mirror row, its item and its attachment intact; both arrival orders and simultaneous coroutines yield exactly one row; edited-mirror user-owned fields survive a merge.