-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table. -- -- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup -- run (on-demand or scheduled). The catalog is operational metadata for -- the /admin/backups UI (size, row counts, storage URI, status). The -- audit chain stays on paliad.system_audit_log — this table is the -- richer-shape duplicate that the UI lists from without parsing JSON. -- -- INSERT/UPDATE happen only through the Go service path (BackupRunner) -- under the migration-runner role, so we don't add a write RLS policy -- for end users. SELECT is admin-only, mirroring system_audit_log. -- -- Idempotent: CREATE TABLE / INDEX / POLICY all guarded. SELECT set_config( 'paliad.audit_reason', 'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)', true); CREATE TABLE IF NOT EXISTS paliad.backups ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')), status text NOT NULL CHECK (status IN ('running', 'done', 'failed')), -- requested_by is NULL for kind='scheduled' (no human caller). requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL, -- requested_by_email is captured at write time so the row survives -- a subsequent user deletion. For scheduled runs we write a sentinel -- like 'system@paliad' (no real user attached). requested_by_email text NOT NULL, -- audit_id back-references the system_audit_log row written before -- the artifact is generated. Nullable so a catalog row can still be -- INSERTed if the audit write itself fails (defense-in-depth). audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL, -- storage_uri is populated when status flips to 'done'. Resolves -- through the Go-side ArtifactStore interface ('file://...' for -- LocalDiskStore today; future stores get their own URI scheme). storage_uri text, size_bytes bigint, row_counts jsonb NOT NULL DEFAULT '{}'::jsonb, sheet_count int, warnings jsonb NOT NULL DEFAULT '[]'::jsonb, -- error is NULL unless status='failed'. Free-form, captured from -- the Go-side error.Error(). error text, started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, -- deleted_at marks artifacts the lifecycle cleanup removed from -- storage (Slice B). The catalog row itself stays forever — it's -- part of the audit chain. NULL means "still on disk". deleted_at timestamptz ); -- Read patterns: -- - "show me recent backups" — started_at DESC -- - "find last successful scheduled backup today" — kind + status + started_at CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx ON paliad.backups (started_at DESC); CREATE INDEX IF NOT EXISTS backups_kind_status_idx ON paliad.backups (kind, status); ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY; -- Admin-only read. INSERT/UPDATE/DELETE happen via the Go service path -- under the migration-runner role (no end-user write surface). DROP POLICY IF EXISTS backups_select_admin ON paliad.backups; CREATE POLICY backups_select_admin ON paliad.backups FOR SELECT USING ( EXISTS ( SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin' ) ); COMMENT ON TABLE paliad.backups IS 'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.'; COMMENT ON COLUMN paliad.backups.requested_by_email IS 'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.'; COMMENT ON COLUMN paliad.backups.storage_uri IS 'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.'; COMMENT ON COLUMN paliad.backups.deleted_at IS 'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';