Skip to content

ADR-0054: Export/import carries stable transaction identity (format v4)

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.

  • ExportRow gains a nullable notificationKey; ExportRepository writes the stored key on export and uses it verbatim on import.
  • ExportFile.version bumps 3 -> 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.IGNORE reports -1. Restoring into an empty ledger recreates the rows; repeating adds zero.
  • Returned counts are actual inserts (insert returned > 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: notificationKey already 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.