--- kind: task slug: workspace-soft-delete-and-audit title: Workspace soft-delete (archive/restore) and append-only audit log plan_slug: multitenant-saas-hardening epic_slug: tenant-lifecycle status: ready priority: P1 tenant_id: global owner: unassigned cursor_todo_id: null updated_at: "2026-06-01" --- # Task summary Two related changes shipped together because they share the "we need a paper trail" motivation: soft-delete for workspaces (and the major tenant-scoped tables) and an append-only `audit_log` that records who did what. ## Description ### Soft-delete Add `archived_at timestamptz null` to: - `workspaces` - `objects` - `markdown_backlog_items` For each, add a partial index `where archived_at is null` on the same columns currently indexed (so the "active rows" filter stays fast). **Read-side convention**: every workspace-scoped tRPC procedure adds `archived_at IS NULL` to its `where` clause by default. Add an `includeArchived: boolean` optional input to list-procedures that opt-in to showing archived rows (settings → archive view). **Write-side**: - `workspaces.archive({ workspaceSlug })` — owner only. Sets `archived_at`. Cascades to a *background job* that flips `archived_at` on all `objects` and `markdown_backlog_items` for that workspace. (For now, do it inline in the same transaction; revisit if it ever blocks.) - `workspaces.restore({ workspaceSlug })` — owner only. Sets `archived_at = null` and cascades the unset. Don't hard-delete anything via the UI yet. Hard-delete is a separate task and a separate set of risks. ### Audit log New table `audit_log`: - `id` uuid pk - `workspace_id` uuid not null (indexed) - `actor_user_id` uuid null (null = system actor, e.g. markdown importer) - `action` varchar not null (e.g. `object.create`, `object.update`, `member.invite`, `workspace.archive`) - `target_type` varchar not null (e.g. `object`, `workspace`, `workspace_member`) - `target_id` uuid null (nullable because some actions don't target a single row) - `metadata` jsonb null (small structured payload — keep it small, don't dump full row state here) - `created_at` timestamptz default now (indexed `(workspace_id, created_at desc)`) **Write path**: a tiny helper `recordAudit(db, { workspaceId, actorUserId, action, targetType, targetId, metadata })`. Call from every mutation procedure. Don't auto-instrument via Drizzle middleware — be explicit so it's grep-able which mutations are audited and which aren't. **Read view**: minimal — `apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx` (new route) with a paginated table. Owner-only. Keep the UI dumb; this is a debugging surface, not a product feature. ### Anti-goals - Don't try to write to the audit log from inside a non-procedure context (cron, importer) without an explicit `actor_user_id = null` or a synthetic "system" user. The point of the column is "who did this, for accountability" — fudging it defeats the purpose. - Don't add row-level retention policies in this task. The table can grow; we'll partition or roll up later. ## Subtasks - [ ] Add `archived_at` to the 3 tables + partial indexes. - [ ] Add `audit_log` schema. - [ ] Generate and commit migration. - [ ] Add `archive` and `restore` procedures to the workspaces router. - [ ] Implement `recordAudit` helper and call from every mutation in `objects`, `workspaces`, `invites`, and the markdown backlog import path. - [ ] Add `archived_at IS NULL` to every existing list-procedure (audit existing routers). - [ ] Add `/[workspaceSlug]/settings/audit` page (owner-only, paginated). ## Owner or assignee Unassigned ## Status ready ## Estimation L ## Acceptance criteria - [ ] Archived workspace stops appearing in the workspace switcher. - [ ] Restoring an archived workspace makes its objects visible again. - [ ] At least one `audit_log` row is written for every mutation in `objects` and `workspaces`. - [ ] Audit view renders paginated rows scoped to the current workspace. ## Links to related Epic / Plan - Epic: `./Epic-tenant-lifecycle.md` - Plan: `../Plan-multitenant-saas-hardening.md`