What this is

Tenancy verification requires users to upload a private document (tenancy agreement, rent statement, or landlord letter) to object storage. Once an admin has decided on that verification — approved or rejected — the document has served its purpose. Keeping private legal documents around indefinitely is a liability, so Swappr deletes the underlying object 30 days after the decision (QUESTIONS.md §8.4, BACKEND_PLAN §5.6). Crucially, the Upload row is kept (soft-deleted) for audit and quota signal — only the object in DigitalOcean Spaces is physically removed.

The 30-day clock

The clock starts at tenancy_verifications.reviewedAt — the moment an admin flips the row from PENDING to APPROVED or REJECTED (see approve / reject). It is not anchored on upload time or submission time. A row that sits in the review queue for two weeks does not start aging until it is decided.

The daily job

A BullMQ scheduled job runs at 03:00 UTC daily. Each run:
  1. Computes cutoff = now − 30 days (built from the injected Clock, never a raw wall-clock read).
  2. Asks the store for due rows: reviewStatus ∈ { APPROVED, REJECTED } AND reviewedAt < cutoff AND uploads.deletedAt === null, bounded to 500 rows per run.
  3. For each row, in a per-row try/catch: storage.deleteObject(fileKey) then retentionStore.softDeleteUpload(uploadId, now).
  4. Logs a run summary: { scanned, deleted, failed, cutoff }.
The defaults are exported for tests:
export const RETENTION_DAYS_DEFAULT = 30;
export const PER_RUN_LIMIT_DEFAULT = 500;

Idempotency

The job is idempotent by design — re-running it (or running the daily job and a manual trigger on the same day) cannot double-delete or corrupt state:
  • Object delete is idempotent at the S3 layer. deleteObject resolves whether or not the object exists — no NoSuchKey error.
  • Already-cleaned rows are skipped at query time. listDueForCleanup filters on uploads.deletedAt === null, so a row whose Upload was already soft-deleted is never returned again.
  • Failures self-heal. If the object delete or the soft-delete throws, neither side-effect is committed as “done” — the Upload row stays deletedAt === null, so the next day’s run picks the row up again and retries.

Per-run cap and backlog draining

Each run processes at most 500 rows (PER_RUN_LIMIT_DEFAULT) to bound run duration and storage-API load. If a backlog builds up (e.g. after a launch spike of approvals 30 days prior), it drains across multiple days, 500 at a time. Operators who don’t want to wait can fire the manual trigger repeatedly to drain faster.

Failure semantics

The job uses a per-row try/catch and never fails the whole batch on a single bad row:
FailureHandling
deleteObject throws (S3 transient, network)Logged at warn with { verificationId, uploadId, err }. failed++. Row left intact → retried next run.
softDeleteUpload throwsSame — logged, failed++, row stays deletedAt === null → retried next run.
Row already cleanedNever seen — filtered out by the store’s deletedAt === null predicate.
A run that hits 50 failures and 450 successes returns { scanned: 500, deleted: 450, failed: 50, alreadyDeleted: 0 } and the 50 failures simply reappear tomorrow. alreadyDeleted is reserved for future use and is currently always 0 (the store skips them rather than returning them).

The manual trigger

POST /admin/retention/run lets a SUPER admin enqueue a one-shot run on demand — for backlog draining or operational testing. It enqueues the same handler the scheduler uses and returns 202 Accepted (the work happens out-of-band in the worker; the response carries no result counts). MODERATOR and FINANCE are forbidden because the operation is destructive. When the worker / Redis isn’t wired (dev, unit tests), a no-op enqueuer is injected and the endpoint still returns 202 — mirroring the Phase 5 push-enqueuer fallback pattern.

What is and isn’t removed

ArtifactFate
Object in DO Spaces (fileKey)Deleted physically.
uploads rowSoft-deleted (deletedAt = now) — kept for audit / quota signal.
tenancy_verifications rowKept entirely — the decision history (who approved/rejected, when, why) is audit data and is never touched by retention.
So after cleanup you can still answer “was this user’s tenancy approved, by whom, and when?” — you just can’t retrieve the original document.

Deferred decision

Open question (deferred): should the scheduled retention worker write a SYSTEM-actor row to the admin audit log on each run (e.g. { action: 'retention.run', scanned, deleted, failed })? Today the run is observable only via worker logs. The manual trigger writes a normal admin-actor audit row, but the autonomous daily run does not. Adding a SYSTEM-actor audit row would give a queryable trail of automated deletions — flagged here for a future decision, not implemented in Phase 6.

See also

  • Trigger retention run — the SUPER-only manual kick.
  • Approve tenancy / Reject tenancy — where reviewedAt (the clock start) is set.
  • Upload flow — how the document reached private storage.
  • apps/worker/src/jobs/retention-cleanup.ts — the job handler (source of truth).
  • apps/worker/src/jobs/retention-cleanup.queue.ts — the BullMQ scheduling wiring.