ADR-0037: Export carries the captured-location snapshot (format v3)
- Status: Accepted
- Date: 2026-09-13
- Deciders: maintainers
- Related: ADR-0015, ADR-0017, ADR-0021, ADR-0027, ADR-0034
Context
The portable CSV/JSON export (ExportCodec + ExportRepository) projected a
transaction onto occurred_at, merchant, amount, currency, direction, status,
category, account, source, note, parse_source, raw_json, items only. The
captured-location columns (latitude, longitude, accuracyMeters,
locationProvider, locationCapturedAt, placeName, placeId) added in
schema v6 (ADR-0021) and populated by opt-in capture (ADR-0026/0034) were never
serialised, so a user backup silently dropped location and a restore lost it.
The ADR-0017 in-editor JSON preview diverged from a full export for the same
reason.
Location is user data captured opt-in and stored encrypted on device. Export is a user-initiated SAF save with no network, so including it keeps the backup complete while staying consistent with the offline-first posture (ADR-0015).
Decision
The portable export row is the single complete projection of a transaction; it carries the captured-location snapshot alongside the existing fields.
ExportRowgains seven nullable fields withnulldefaults:latitude,longitude,accuracyMeters,locationProvider,locationCapturedAt,placeName,placeId.- CSV: the seven columns are appended after
items, in the order above. No existing column index shifts, so older CSVs stay readable by header name. Absent values are written as empty cells. - JSON: non-null values are written; null values are omitted
(
encodeDefaults = falseon the codec).ignoreUnknownKeys = trueplus nullable defaults means v1/v2 files without location parse unchanged. ExportFile.versionbumps2 -> 3to signal the richer schema. Import does not gate on version, so v1/v2 files still import and simply have no location.ExportRepositorymaps the seven columns in both directions, andTransactionDraft.toExportRowmaps the draft's location fields so the ADR-0017 preview matches a full export.- The review-queue developer export (
UnmatchedJsonCodec/UnmatchedExportCodec) is a separate surface and keeps its current shape. - Attachments stay out of the export (ADR-0027). Reverse-geocoded
placeName/placeIdare still placeholders (ADR-0021) and are serialised only when already present.
Consequences
- A CSV/JSON backup is complete and re-importable, including the optional
location snapshot;
noteround-trips with explicit regression coverage. - One export format serves both the bulk save dialog and the in-editor preview.
- No Room schema change, no migration — codec/repository only.
- Because a user-run export writes location into the exported file, the privacy statements must disclose that; location is still never transmitted (ADR-0015).
Rejected alternatives
- Separate location file / join key — adds a second artifact to a backup and breaks the "one portable file" promise for no benefit at this size.
- Gate import on
version == 3— would reject every existing v1/v2 backup; additive nullable fields need no gate. - Insert location columns mid-header (next to
raw_json) — shifts existing column indices and breaks header-indexed consumers. - Include location in the developer unmatched export now — different payload purpose; left as a follow-up.
Amendment (2026-10-19): the export is read as one snapshot
Every export read (visible transactions, their items, category and account
metadata) happens through one ExportDao.snapshot() @Transaction; children are
bulk-loaded with a single transactionId IN (...) query instead of one query
per transaction, and mapping/serialization runs after the transaction closes.
An export is therefore self-consistent under concurrent edits, and no file
picker or output-stream work happens inside the database transaction.