Skip to content

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.deleteByTransaction is removed; deleteFiles and sweepUnreferencedFiles replace it, and AttachmentRepository stays the sole owner of file paths.
  • No schema change: the attachments/transaction_items cascades 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 filesDir store (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.