Skip to content

ADR-0076: JSON import validates strictly and commits atomically

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 through ImportError.InvalidPayload(rowIndex, field):
  • currency must be one of the full built-in Currency catalog (ADR-0039), never the enabled picker subset — hiding a currency must not brick an old backup;
  • amount, and every items[].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's currency; the value is read with the row currency's minor digits (ADR-0009);
  • occurredAt may be any Long: a pre-epoch or epoch post time is a valid notification post time (ADR-0001), and the export includes such rows (#247);
  • direction and status must map through their dbValue;
  • a present parseSource must map through its dbValue.
  • Legacy tolerance is limited to absence. v1–v3 files predate portable identity and optional fields, so an absent notificationKey becomes null (then the existing synthetic portable key, ADR-0054), absent optional fields become null, and an absent parseSource keeps its REGEX column default. MIN_VERSION/CURRENT_VERSION remain the only version gate. Nothing else is lenient: a field that is present but invalid rejects the file.
  • The whole import is one transaction. ExportRepository gains an injected TransactionRunner (domain/TransactionRunner.kt, Room-backed by RoomTransactionRunner) and wraps its write loop in a single run { … }. A failure part-way rolls the whole import back. The returned Imported(inserted) count stays "rows the unique index actually accepted", so repeated valid imports remain idempotent.
  • Failures stay distinguishable. ImportError becomes a sealed hierarchy: Unreadable is a document-read failure, EmptyFile/MalformedJson/ UnsupportedVersion/InvalidPayload are validation failures, and SaveFailed is a ledger-write failure. The repository lets an unexpected persistence exception propagate; the ViewModel catches it, maps it to SaveFailed, rethrows CancellationException, and never logs the payload.
  • No Room schema change: import reuses the existing entities, unique index and replaceItems transaction.

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 USD would silently drop the conflicting USD; 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[].amount must 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.version is 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.