Phase 10: two-phase document retention with grace window and admin recovery UI
A daily retention job that flag-then-hard-deletes documents, chats, and tabular reviews past their retention horizon. A 7-day grace window between flag and deletion lets admins recover items before bytes are gone, and an `/admin/retention` tab makes that recovery visible.
Backend (490f8271, migration 0017, +510/-19 across 10 files). Migration adds pending_deletion_at (timestamptz) to documents, chats, and tabular_reviews, and retention_grace_days (default 7) to org_settings. Partial indexes cover each pending_deletion_at IS NOT NULL case.
lib/retention.ts runs two phases daily. Phase 1 (flagPastRetention): rows whose age exceeds the effective retention horizon are stamped with pending_deletion_at = now(). Retention horizon resolves workspace-first (workspaces.retention_days), falling through to org_settings.retention_days; 0 means keep forever. Phase 2 (hardDeleteAged): rows that have sat flagged past the grace window are hard-deleted and their storage objects are unlinked through the storage facade. Both phases write audit events - retention.flag and retention.hard_delete - with row counts. The job is disabled in dev with RETENTION_DISABLED=true.
User-facing list endpoints - single documents, project docs, chats, project chats, tabular review list - are updated to exclude pending_deletion_at IS NOT NULL rows. Flagged items disappear from the UI immediately, before hard delete.
Admin UI (c91262ac, +420/-24 across 10 files). New admin endpoints: GET /admin/retention/pending returns every currently-flagged row across documents/chats/reviews with created-at, flagged-at, and days-since-flag. POST /admin/retention/restore clears pending_deletion_at on a specific row. POST /admin/retention/run kicks the job immediately. The frontend /admin/retention tab renders the pending list and surfaces restore controls per row.
The same commit also drops projects.shared_with and tabular_reviews.shared_with via migration 0018. These JSONB columns were kept after Phase 3 as a one-cycle rollback safety net; no application code reads or writes them any more. Their GIN indexes drop implicitly with DROP COLUMN. API responses still surface shared_with: string[] derived from the members tables.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?