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.