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 attenancy_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:- Computes
cutoff = now − 30 days(built from the injectedClock, never a raw wall-clock read). - Asks the store for due rows:
reviewStatus ∈ { APPROVED, REJECTED }ANDreviewedAt < cutoffANDuploads.deletedAt === null, bounded to 500 rows per run. - For each row, in a per-row
try/catch:storage.deleteObject(fileKey)thenretentionStore.softDeleteUpload(uploadId, now). - Logs a run summary:
{ scanned, deleted, failed, cutoff }.
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.
deleteObjectresolves whether or not the object exists — noNoSuchKeyerror. - Already-cleaned rows are skipped at query time.
listDueForCleanupfilters onuploads.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-rowtry/catch and never fails the whole batch on a single bad row:
| Failure | Handling |
|---|---|
deleteObject throws (S3 transient, network) | Logged at warn with { verificationId, uploadId, err }. failed++. Row left intact → retried next run. |
softDeleteUpload throws | Same — logged, failed++, row stays deletedAt === null → retried next run. |
| Row already cleaned | Never seen — filtered out by the store’s deletedAt === null predicate. |
{ 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
| Artifact | Fate |
|---|---|
Object in DO Spaces (fileKey) | Deleted physically. |
uploads row | Soft-deleted (deletedAt = now) — kept for audit / quota signal. |
tenancy_verifications row | Kept entirely — the decision history (who approved/rejected, when, why) is audit data and is never touched by retention. |
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.