-- Personal report persistence for self-hosted PostgreSQL (staging). -- Mirrors supabase/migrations/20260806010000_personal_reports.sql -- one-to-one in table shape, constraints, RLS and grants. -- -- Ownership model: rows are owned by auth.users(id) (the business-auth -- mirror kept in sync by identity.sync_user_to_business_auth). Normal -- application sessions connect as app_runtime (member of authenticated) and -- set local role authenticated; they may select/delete only their own rows -- through RLS plus the explicit owner grants below, and can never -- insert/update (generation and status writes are performed exclusively -- through service_role, which has BYPASSRLS and full table privileges). -- admin_runtime has no direct access to report bodies (least privilege); -- server-side generation runs through service_role, which admin_runtime may -- SET ROLE to. -- -- Idempotency: unique (user_id, request_id) is the primary lock; replay of a -- known requestId requires the same request_fingerprint (a sha256 of the -- caller's request intent), so a different payload under the same requestId -- surfaces as request_conflict instead of silently overwriting. Failed -- retries are not implicitly upserted here; callers either reuse the failed -- record via a new requestId or surface the stable failure. -- -- No birth details, report bodies, model prompts or exception stacks are ever -- written to logs, index columns or audit events; failure_code is a stable -- enum shared with frontend/src/lib/personal-report-service.ts and -- scripts/personal_report_contract.py. create table if not exists public.personal_reports ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, session_id uuid, chart_profile_id uuid, request_id uuid not null, request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'), report_type text not null check (report_type in ('personal_full', 'personal_thematic')), status text not null check (status in ('generating', 'ready', 'failed')), schema_version text not null check (schema_version = 'report_document.v1'), presentation_mode text not null check (presentation_mode in ('default', 'research')), requested_themes text[] not null default '{}'::text[], report_document jsonb, calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'), evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'), skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'), skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'), failure_code text check (failure_code in ( 'profile_incomplete', 'birth_time_not_usable', 'report_generation_in_progress', 'report_rate_limited', 'calculation_unavailable', 'model_unavailable', 'report_schema_invalid', 'report_guard_rejected', 'report_not_found' )), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), completed_at timestamptz, check ((status = 'ready') = (report_document is not null)), check ((status = 'ready') = (completed_at is not null)), check ((status = 'ready') = (calculation_hash is not null)), check ((status = 'ready') = (evidence_hash is not null)), check ((status = 'failed') = (failure_code is not null)), unique (user_id, request_id) ); create index if not exists personal_reports_user_created_idx on public.personal_reports (user_id, created_at desc); -- One in-flight generation per user, enforced by the database so a second -- request cannot start while the first is still generating. create unique index if not exists personal_reports_one_generating_per_user on public.personal_reports (user_id) where status = 'generating'; alter table public.personal_reports enable row level security; revoke all on table public.personal_reports from public, anon, authenticated, service_role; revoke all on table public.personal_reports from app_runtime, admin_runtime, migration_runner, backup_reader; drop policy if exists personal_reports_select_own on public.personal_reports; create policy personal_reports_select_own on public.personal_reports for select to authenticated using (auth.uid() = user_id); drop policy if exists personal_reports_delete_own on public.personal_reports; create policy personal_reports_delete_own on public.personal_reports for delete to authenticated using (auth.uid() = user_id); -- Normal users can never insert or update rows: creating a generating record -- and moving it to ready/failed are server-side operations only. RLS -- policies alone do not grant table privileges, so the owner read/delete -- grants below are required for the policies to be reachable. grant select, delete on table public.personal_reports to authenticated; -- admin_runtime intentionally has no direct access to report bodies (least -- privilege); server-side generation runs through service_role, which -- admin_runtime may SET ROLE to. grant select, insert, update, delete on table public.personal_reports to service_role;