Files
solopm-server/plans/12-retention.md
T

3.7 KiB

12 — Soft Deletes, Retention & Culling

Deletes in SoloPM are soft (deleted_at) so mistakes are recoverable. The culling job is the only code path that hard-deletes, and it is responsible for cascades and disk files — which is why DB-level ON DELETE CASCADE is deliberately absent.

Lifecycle

  • Soft-deleted rows are invisible to every normal query (deleted_at IS NULL filters, 01-architecture.md) and leave the search index immediately (10-search.md)
  • Rows older than CULL_AFTER_DAYS (default 30) since deleted_at are hard-deleted by the cull job
  • Restore before cull = clearing deleted_at (no UI in v1; document the SQL in ops notes)

Cull job

  • Runs as a cull job scheduled daily (03-domain-events.md) + CLI solopm cull [--dry-run] + POST /admin/cull?dry_run=
  • Dry-run mode: full traversal, returns/logs the would-delete report {resource_type: count} + file list, deletes nothing — required before trusting the job
  • Deletion order respects FK dependencies (children first)
  • Cascade scope per culled resource:
    • project → its epics, issues (and their cascade), labels, members, favorites, dependencies (both directions), wiki pages, links, attachments (+files), webhooks + deliveries, receipts
    • issue → its comments (and their cascade), issue_labels, watchers, blocker edges (both directions), sub-issue links (children's parent_issue_id → NULL), links, attachments (+files)
    • epic → member issues' epic_id/epic_rank → NULL (issues survive)
    • comment → its links, attachments (+files)
    • label → its issue_labels pivots
    • wiki page → row only
    • attachment → row and the file on disk — file deletion and row deletion happen together; a failed file unlink (other than not-exists) aborts that row's cull and is logged
  • User culling (soft-deleted users past retention):
    • Reassign authored content to the zombie user (id 2): issues.creator_id/assignee_id, comments.author_id, epics.creator_id, labels.created_by, attachments.uploader_id, links.created_by, wiki created_by/updated_by, project creator_id
    • Precondition (already enforced at delete time): not sole owner of any live project
    • Delete their favorites, watches, notifications, oauth_accounts, api_tokens, sessions
    • audit_log.initiated_by keeps the original user id — audit is immutable; UI renders unknown ids as "Deleted User"
  • Non-soft-delete retention in the same job:
    • notifications older than 90 days → hard delete
    • events processed > 30 days ago → hard delete
    • jobs done/dead > 30 days ago → hard delete
    • incoming_webhook_receipts > 90 days → hard delete
    • outgoing_webhook_deliveries > 90 days → hard delete
    • audit_logkept forever in v1 (17-decisions.md)
    • expired sessions (scs handles) and revoked/expired api_tokens > 30 days → hard delete
  • The job logs a summary line per run (counts per resource type, duration, dry-run flag) and records a cull job row with the report in payload

Safety checklist

  • Dry-run output reviewed before first real run in any environment
  • File deletions restricted to paths under UPLOAD_DIR matching the stored relative path (no traversal)
  • Whole-project cull wrapped in one transaction per project (files unlinked after commit; unlink failures logged for manual sweep)
  • Integration tests: cull a project → zero orphan rows (assert per table) and zero orphan files; cull a user → zombie owns their content, audit rows untouched