ADR-0075: One deletion owner; recover orphaned attachment bytes with a grace window
Context
A transaction's attachment bytes live in filesDir/attachments while only the
metadata lives in the encrypted DB (ADR-0027). Three deletion paths each deleted
the row directly — the Activity list, the detail editor and Settings' "delete
all" — and the FK CASCADE drops the child metadata but not the files. Only
AttachmentRepository.deleteByTransaction() removed bytes, and not every caller
used it, so a deleted transaction left its bytes orphaned forever, invisible to
the user and never reclaimed.
Decision
domain/TransactionDeletionUseCase is the only deletion entry point:
suspend fun delete(transactionId: Long): DeleteOutcome
suspend fun deleteAll(): DeleteOutcome // transactions + review queue only
suspend fun sweepOrphans(graceMs: Long = 10 * 60_000L): OrphanSweepResult
data class DeleteOutcome(val filesDeleted: Int, val bytesFreed: Long)
data class OrphanSweepResult(val filesDeleted: Int, val bytesFreed: Long)
It is @Inject-constructed (no AppModule change) from
TransactionRepository, UnmatchedRepository, AttachmentRepository and the
TransactionRunner seam, so it stays JVM-testable.
Ordering and recoverability. The row delete commits first, inside one
TransactionRunner.run, with the attachment paths captured before the cascade.
The now-unreferenced files are unlinked afterwards, outside the DB transaction.
A failed unlink leaves the file; the next sweepOrphans reclaims it —
idempotent, and run once at app start and after every deletion.
Draft safety (why age alone is not enough). Staged files are UUID-named into
the same filesDir/attachments directory before any DB row exists (stage /
stageBytes), and a draft can sit on the back stack for arbitrarily long, so a
sweep cannot infer "garbage" from lastModified. AttachmentRepository
therefore tracks the paths it has staged in this process (activeStagedPaths)
and sweepOrphans skips them whatever their age; an entry leaves the registry
when its file is deleted through deleteFiles / deleteStagedFiles, or once the
path has a row — nothing stays exempt forever. The grace window (graceMs,
default 10 minutes) is the second line of defence: it covers files left behind by
a previous process, whose registry died with it, so those are still reclaimed.
Scope of deleteAll. Transactions and the review queue only. Budgets,
settings, categories and accounts must survive.
Observability. The returned DeleteOutcome / OrphanSweepResult is the
observable — asserted in tests and logged as a count at Log.d. There is no
telemetry: ADR-0015 forbids network and there is no analytics sink, so adding
one would be new infrastructure.
Consequences
- Every path removes metadata and bytes together; a caller can no longer delete a row without the use case owning the file lifecycle.
AttachmentRepository.deleteByTransactionis removed;deleteFilesandsweepUnreferencedFilesreplace it, andAttachmentRepositorystays the sole owner of file paths.- No schema change: the
attachments/transaction_itemscascades already drop the child metadata. The database stays at version 9.
Verification
TransactionDeletionUseCaseTest (JVM) drives the real AttachmentRepository
over a temp directory: delete removes the row and its bytes, another
transaction's bytes and a fresh draft survive, deleteAll keeps
budgets/settings/categories, and the sweep removes only old unreferenced files
and is idempotent. ExportRepositoryTest.deleteAllDataRemovesAttachmentBytes
proves the Settings path reclaims bytes. The instrumented
TransactionDeletionUseCaseInstrumentedTest runs the same flows against a real
SQLCipher Room database and the real attachments directory, including the FK
cascade and the deleteAll scope.
Amendment (2026-10-19): durable staged-file ownership for recovered drafts
The orphan sweep must never reclaim bytes a recoverable draft still owns, and an attachment row must never point at swept bytes:
- Staging reserves the path durably in a bounded, draft-scoped
filesDirstore (unique temp file,fsync, atomic rename over the destination without pre-deleting; a failed reservation fails the staging operation instead of exposing an unprotected path). Reservations expire after a TTL so nothing is exempt forever. - The sweep reads the reservation snapshot BEFORE the DB reference snapshot and protects the union of both; a present-but-unreadable store is treated as untrusted and the sweep deletes nothing (fail closed). Ownership is released only AFTER a save commits (never inside the save transaction), on Discard or Delete, or once the path has a persisted row — so a rollback or crash cannot strand a committed row without bytes.