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 duplicatenotificationKey, 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
UPDATEof 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.