Skip to content

ADR-0046: Guard unsaved transaction changes on Back

  • Status: Accepted
  • Date: 2026-10-15

Context

Toolbar and system Back could silently drop manual, existing, and unmatched transaction edits, including staged attachment work (issue #119).

Decision

Compare editable draft content to a canonical baseline established after the complete asynchronous load, excluding derived totals, lookup lists, and messages. Use one guard for toolbar and system Back. Changed drafts offer Save, Discard, and Keep editing. Unchanged or fully reverted drafts leave without a prompt. System Back lets the IME and child overlays close first.

Keep editing preserves the entire draft and staged resources. Discard removes only file paths absent from the loaded transaction, including files added and then removed. Original transaction rows and files are preserved.

Serialize staging and saving against exits. Save commits transaction, items, attachment metadata, category override, and unmatched dismissal in one Room transaction; only success leaves the editor. Validation and persistence failures retain the draft. Remove obsolete files after the database commit.

The ViewModel retains draft state across configuration changes and the confirmation uses saved Compose state. Durable recovery after process death is outside this decision.

Consequences

Ordinary Back requires an explicit choice before dropping edits. No schema change or extra persistent copy of sensitive draft content is needed. Unexpected process termination can still lose a draft and leave staged files.

Verification

TransactionDetailScreenTest exercises edited/pristine/reverted drafts, manual/system Back, unmatched discard, keep-editing resources, failed persistence and retry, duplicate-save prevention, and confirmation state restoration.

Amendment — single-threaded ownership manifest (issue #293)

The draft's attachment ownership manifest (stagedByDraft / removedStoredPaths) is mutated on Dispatchers.IO while the recovery publisher snapshots it on Main. DraftOwnershipManifest now owns both sets behind one lock and hands out immutable stagedSnapshot() / removedSnapshot() copies; the recovery payload is always taken from a snapshot, never the live collections. Mutating a plain LinkedHashSet during iteration threw ConcurrentModificationException inside the publisher's coroutine, which silently stopped draft recovery for the rest of the session. DraftOwnershipManifestTest stresses concurrent stage/remove/snapshot.