ADR-0076: JSON import validates strictly and commits atomically
- Status: Accepted
- Date: 2026-10-18
- Deciders: maintainers
- Related: ADR-0009, ADR-0039, ADR-0054, ADR-0055, ADR-0073
Context
ExportRepository.importJson promises that a rejected import never mutates the
ledger, but it inspected each row while writing it: an amount that did not
parse through AmountParser was silently skipped (?: return@forEachIndexed),
and a direction or status that did not map through its dbValue was
silently defaulted (getOrDefault). A file with one bad row imported the rest
and reported success. Worse, the writes were not atomic: a persistence failure
after the first insert left the earlier rows committed. SettingsViewModel
collapsed read failures and every other exception into ImportError.Unreadable,
so the user could not tell a broken file from a failed save.
ADR-0054/0055 established the portable identity and the typed rejection, but left per-row leniency and non-atomic writes.
Decision
The import boundary stays "validate everything, then write everything, or change nothing."
- Semantic validation runs before any write, in
ExportCodec.parseJson, for every transaction in the file. A failure rejects the whole file and names the offending row throughImportError.InvalidPayload(rowIndex, field): currencymust be one of the full built-inCurrencycatalog (ADR-0039), never the enabled picker subset — hiding a currency must not brick an old backup;amount, and everyitems[].amount, must be one whole money token (StrictAmount) that consumes the entire field — no surrounding prose, a single amount — and every explicit currency token it carries (symbol or ISO code, before or after the number) must equal the row'scurrency; the value is read with the row currency's minor digits (ADR-0009);occurredAtmay be anyLong: a pre-epoch or epoch post time is a valid notification post time (ADR-0001), and the export includes such rows (#247);directionandstatusmust map through theirdbValue;- a present
parseSourcemust map through itsdbValue. - Legacy tolerance is limited to absence. v1–v3 files predate portable
identity and optional fields, so an absent
notificationKeybecomesnull(then the existing synthetic portable key, ADR-0054), absent optional fields becomenull, and an absentparseSourcekeeps itsREGEXcolumn default.MIN_VERSION/CURRENT_VERSIONremain the only version gate. Nothing else is lenient: a field that is present but invalid rejects the file. - The whole import is one transaction.
ExportRepositorygains an injectedTransactionRunner(domain/TransactionRunner.kt, Room-backed byRoomTransactionRunner) and wraps its write loop in a singlerun { … }. A failure part-way rolls the whole import back. The returnedImported(inserted)count stays "rows the unique index actually accepted", so repeated valid imports remain idempotent. - Failures stay distinguishable.
ImportErrorbecomes a sealed hierarchy:Unreadableis a document-read failure,EmptyFile/MalformedJson/UnsupportedVersion/InvalidPayloadare validation failures, andSaveFailedis a ledger-write failure. The repository lets an unexpected persistence exception propagate; the ViewModel catches it, maps it toSaveFailed, rethrowsCancellationException, and never logs the payload. - No Room schema change: import reuses the existing entities, unique index and
replaceItemstransaction.
Consequences
- A file with any unparseable or ambiguous amount, any explicit currency that disagrees with its row, or an unknown direction/status/currency is rejected whole; no row is added.
- A persistence failure part-way leaves the ledger exactly as it was.
- Valid retries still report the real inserted count (zero when every row already exists), preserving ADR-0054 idempotency.
- The Data screen shows distinct messages for a document that could not be read, a payload that failed validation (naming the row) and a save that failed.
- JVM tests exercise validation and the transaction seam with a pass-through
TransactionRunner; the real rollback proof lives in the instrumented suite (#218).
Rejected alternatives
- Skip or default the bad row and keep importing — silently drops or rewrites money, the exact failure this ADR removes.
- Trust the single currency the amount parser consumes — a dual-sided field
such as
MYR 12.50 USDwould silently drop the conflictingUSD; every explicit token must be checked. - Validate while writing — cannot reject the whole file without a transaction, and still leaves a partial write when a later row fails.
- Validate row currency against the enabled subset — a currency removed from the pickers would make previously valid backups unimportable.
- Per-row transactions — a later failure still leaves earlier rows committed.
- Catch every exception in the repository and report success — hides real
save failures as a false
Imported(0)(see ADR-0055).
Amendment (2026-10-19): whole-field amounts and portable metadata identity
- An
amount/items[].amountmust be ONE whole money token for the row's currency (StrictAmount): surrounding prose, a second amount, malformed separator runs, out-of-Long-range digits, or ANY explicit currency token that differs from the row's (including a second token on the other side of the number) reject the file. A present-but-blank enum token is malformed, not legacy; only absence is legacy. ExportFile.versionis always emitted (@EncodeDefault), so an older reader rejects a newer schema instead of silently falling back to its own default and running the lossy resolver.- v5 carries portable identity: category
(kind, name)and account(bankLabel, maskedAccount, type, currency). Import resolves or creates that metadata inside the same transaction; more than one destination row matching the complete identity, or a v1–v4 reference that does not match exactly one existing row, stays null and is reported as an unresolved reference count — never guessed and never overwritten.
Amendment (2026-10-19): occurredAt is accepted across the whole Long range
The original "occurredAt must be positive" check is removed. ADR-0001 defines
occurredAt as the notification post time; nothing in the entity, the Room
schema, the export or the UI restricts it to the positive range, and
observeAllIncludingHidden/ExportDao.visibleTransactions are deliberately
unbounded (#247), so a pre-1970 or epoch row is a legitimate, exported post
time. Rejecting it on import meant the app could report Ready for its own
backup and then refuse to restore it — the same class of bug ADR-0077's export
preflight guards for size. Import now accepts any Long occurredAt and
round-trips it unchanged (covered by ExportCodecTest,
ExportRepositoryTest and ImportRollbackInstrumentedTest). Non-Long or
missing values are still malformed JSON and rejected before any write. A
non-positive post time cannot arise from capture (post time is wall-clock) or
manual save (the picker supplies an epoch-millis value); it only enters through
an import, which is now symmetric with the export.