ADR-0054: Export/import carries stable transaction identity (format v4)
- Status: Accepted
- Date: 2026-10-16
- Deciders: maintainers
- Related: ADR-0009, ADR-0010, ADR-0015, ADR-0017, ADR-0037, ADR-0055
Context
The portable JSON export omitted the transaction's notificationKey, the
capture identity backed by the unique transactions index. On import,
ExportRepository.importJson synthesised a fresh key from
import:<occurredAt>:<merchant>:<amount>. A row restored into the ledger it
came from therefore never collided with its original, so the whole backup was
re-inserted: an audited 22-row export doubled spending (RM491.93 → RM983.86)
and merged nothing. A second import then inserted zero, because the synthetic
keys were deterministic — the damage was done on the first restore.
"Own backup restored over itself is a no-op" is the core safety property of a backup: it must not be able to inflate the ledger.
Decision
ExportRow carries the transaction's identity through the export/import
boundary, and import relies on the existing unique index instead of inventing
identity from visible attributes.
ExportRowgains a nullablenotificationKey;ExportRepositorywrites the stored key on export and uses it verbatim on import.ExportFile.versionbumps3 -> 4. Import accepts v1–v4; versions above the current one are rejected (ADR-0055).- A new-format (v4) export imported into its original ledger adds zero rows:
every key already exists and
OnConflictStrategy.IGNOREreports-1. Restoring into an empty ledger recreates the rows; repeating adds zero. - Returned counts are actual inserts (
insertreturned> 0), never the file length. - Legacy (v1–v3) files have no portable identity. Import does not infer identity from merchant/amount/time — that would silently merge legitimately distinct rows. Each legacy row gets a deterministic synthetic key built from its file position plus its visible attributes. Distinct rows (even two byte-identical rows) stay distinct, while repeated imports of the same file remain idempotent. Legacy files are imported on a best-effort basis and are never silently dropped.
- No Room schema change:
notificationKeyalready exists and is unique. CSV export is unchanged (it is a human-readable projection, not a re-import format), so its column order stays stable.
Consequences
- The headline acceptance criterion holds: an own new-format backup restored over its ledger changes no totals; empty restore is exact.
- Rows that share every visible attribute but differ in currency, direction, amount or source stay separate.
- A v1–v3 backup imported after this change gets new synthetic keys, so a file that was already imported under the old scheme can be inserted a second time. This is bounded to pre-v4 files the user re-imports and is the conservative choice over merging unrelated rows.
- Itemization, category/account mapping, notes and location continue to round-trip; only identity is added.
Rejected alternatives
- Derive identity from merchant + amount + time — merges distinct legitimate transactions (the exact bug being fixed in reverse).
- Deduplicate legacy rows by content hash — same merge hazard.
- Gate import on
version == 4— would reject every existing backup; additive nullable identity needs no gate. - Rewrite the DAO/unique index — unnecessary; the existing index already provides insert-ignore semantics.
Amendment (2026-10-19): the export is a transactions-only backup
The exported file is a transaction export, not a complete application backup:
it carries visible transactions (with their items, notes and location snapshot)
plus the portable category/account identity JSON restore needs, and it excludes
hidden transactions, attachment bytes, budgets, settings and security data,
custom parse rules, correction mappings and the review/unmatched queue. The
Settings export card must state that scope and those exclusions before and after
saving, distinguish analysis-only CSV from restorable JSON (16 MiB restore
limit), and claim no encryption for the plain files. The copy is static and
pinned by SettingsExportCoverageStringsTest (the string resources) plus the
on-device ExportCoverageNoteTest (rendered, large-text agreement), which
guarantee the copy keeps naming the transaction scope and the included/excluded
data. They pin the copy, not the export codec, so a codec change that adds or
drops a category of data does not trip them — the copy must be updated
deliberately in the same change.