diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a68cea01e..ccc1782cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1071,12 +1071,6 @@ jobs: - name: Setup Node and dependencies for drift manifest uses: ./.github/actions/setup-node-cached - - name: Run Caring Contacts Database & RLS Tests - if: needs.changes.outputs.db_changed == 'true' || needs.changes.outputs.static_heavy_changed == 'true' - env: - CARING_CONTACTS_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:54322/postgres - run: npm run caring-contacts:db:test - - name: Pull the pinned drift manifest image run: docker pull supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83 @@ -1113,6 +1107,41 @@ jobs: | sort -u \ | xargs -r docker save -o /tmp/supabase-docker-cache/images.tar + caring-contacts-db: + name: Caring Contacts database + needs: changes + if: > + (needs.changes.outputs.db_changed == 'true' || + needs.changes.outputs.static_heavy_changed == 'true') && + github.event.pull_request.draft != true + runs-on: ubuntu-24.04 + timeout-minutes: 10 + services: + postgres: + image: postgres:17@sha256:e38411452a464af89e5adadb8d223bf53b898d47d6ef918b2d58c08707350449 + env: + POSTGRES_PASSWORD: postgres + ports: + - 54329:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-cached + + - name: Run Caring Contacts Database & RLS Tests + env: + CARING_CONTACTS_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:54329/postgres + run: npm run caring-contacts:db:test + pr-required: name: PR required needs: @@ -1128,6 +1157,7 @@ jobs: ui-critical, lighthouse-budget, db-reset-verify, + caring-contacts-db, ] # #095: keep `if: always()` — a skipped required check counts as PASSING # on GitHub, so `!cancelled()` would cancel-to-green a hand-cancelled tip. @@ -1158,6 +1188,7 @@ jobs: UI_RESULT: ${{ needs.ui-critical.result }} LIGHTHOUSE_RESULT: ${{ needs.lighthouse-budget.result }} DB_RESULT: ${{ needs.db-reset-verify.result }} + CARING_CONTACTS_DB_RESULT: ${{ needs.caring-contacts-db.result }} run: | set -euo pipefail @@ -1219,7 +1250,7 @@ jobs: require_success "changes" "$CHANGES_RESULT" require_success "static-pr" "$STATIC_RESULT" - # safety/coverage/build/ui-critical*/db-reset-verify additionally skip on draft PRs + # safety/coverage/build/ui-critical*/db-reset-verify/caring-contacts-db additionally skip on draft PRs # (see their own job `if:` conditions) to stop draft pushes from booking a full heavy # CI matrix. PR_DRAFT is folded into the same "was this job in scope" check as the # *_CHANGED flags so a draft PR reports skipped-and-fine here instead of a false @@ -1275,6 +1306,12 @@ jobs: require_skipped_or_success "db-reset-verify" "$DB_RESULT" fi + if { [ "$DB_CHANGED" = "true" ] || [ "$STATIC_HEAVY_CHANGED" = "true" ]; } && [ "$PR_DRAFT" != "true" ]; then + require_success "caring-contacts-db" "$CARING_CONTACTS_DB_RESULT" + else + require_skipped_or_success "caring-contacts-db" "$CARING_CONTACTS_DB_RESULT" + fi + # A real break is always the headline. Every failure is listed, not just the first, and a # concurrent cancellation is demoted to context so it cannot read as an excuse. if [ ${#failures[@]} -gt 0 ]; then diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 2087d0de9..dd5b6f5be 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -232,7 +232,7 @@ names it by href, never by import), so the dashboard references no chunk exclusi ### Schema tables -`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `document_publication_approvals`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `clinical_quality_feedback_triage`, `clinical_quality_feedback_triage_events`, `medication_records`, `differential_records`, `source_review_events`, `user_favourites`, `user_favourite_sets`, `user_preferences`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs` +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `document_publication_approvals`, `document_corpus_access_state`, `document_corpus_access_snapshots`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `clinical_quality_feedback_triage`, `clinical_quality_feedback_triage_events`, `medication_records`, `differential_records`, `source_review_events`, `user_favourites`, `user_favourite_sets`, `user_preferences`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs` **Storage buckets:** `clinical-documents`, `clinical-images` (private) diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 1e627e96c..07f3c176d 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-08-23T08:52:32.433Z", + "generated_at": "2026-08-25T16:44:53.791Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "33eb07e0797e3605aec88ecdd130ed3a6a88e68ffb2c96f1bd7c1e6a4d3005dd", - "replay_seconds": 18, + "schema_sha256": "e53377d848c15693be107f9e1492fd55c86b89728c0504dfff97c3fbd89bb351", + "replay_seconds": 27, "snapshot": { "views": [ { @@ -1013,6 +1013,118 @@ "rls_forced": false, "rls_enabled": true }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=r/postgres" + ], + "name": "document_corpus_access_snapshots", + "columns": [ + { + "name": "activation_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "captured_at", + "type": "timestamp with time zone", + "default": "now()", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "document_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "owner_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": false, + "generated": "" + }, + { + "name": "public_corpus_present", + "type": "boolean", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "public_corpus_value", + "type": "jsonb", + "default": null, + "identity": "", + "not_null": false, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=r/postgres" + ], + "name": "document_corpus_access_state", + "columns": [ + { + "name": "activated_at", + "type": "timestamp with time zone", + "default": null, + "identity": "", + "not_null": false, + "generated": "" + }, + { + "name": "activation_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": false, + "generated": "" + }, + { + "name": "mode", + "type": "text", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "singleton", + "type": "boolean", + "default": "true", + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "updated_at", + "type": "timestamp with time zone", + "default": "now()", + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, { "acl": [ "postgres=arwdDxtm/postgres", @@ -5168,6 +5280,24 @@ "table": "document_chunks", "def_hash": "89b1e9c636e8e3228a1bf4bcb416a931" }, + { + "def": "CREATE INDEX document_corpus_access_snapshots_document_id_idx ON public.document_corpus_access_snapshots USING btree (document_id)", + "name": "document_corpus_access_snapshots_document_id_idx", + "table": "document_corpus_access_snapshots", + "def_hash": "a0a2903411efc0859f5bc27cab3269c7" + }, + { + "def": "CREATE UNIQUE INDEX document_corpus_access_snapshots_pkey ON public.document_corpus_access_snapshots USING btree (activation_id, document_id)", + "name": "document_corpus_access_snapshots_pkey", + "table": "document_corpus_access_snapshots", + "def_hash": "0ab2d736c5c599a4358e7c2e80252fb6" + }, + { + "def": "CREATE UNIQUE INDEX document_corpus_access_state_pkey ON public.document_corpus_access_state USING btree (singleton)", + "name": "document_corpus_access_state_pkey", + "table": "document_corpus_access_state", + "def_hash": "97bb274c865d8e3bc00c23f3b299ad2c" + }, { "def": "CREATE UNIQUE INDEX document_embedding_fields_dedup_idx ON public.document_embedding_fields USING btree (document_id, source_chunk_id, field_type, content_hash)", "name": "document_embedding_fields_dedup_idx", @@ -7761,6 +7891,14 @@ "def_hash": "85df52de66e4e89d4a328b81a3a87c90", "signature": "public.search_schema_health()" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "8670d32d9286b89c09ce6117da24c5e0", + "signature": "public.set_document_corpus_access_mode(text)" + }, { "acl": [ "postgres=X/postgres", @@ -8116,6 +8254,41 @@ "name": "document_chunks_pkey", "table": "document_chunks" }, + { + "def": "CHECK ((public_corpus_present OR (public_corpus_value IS NULL)))", + "name": "document_corpus_access_snapshots_check", + "table": "document_corpus_access_snapshots" + }, + { + "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", + "name": "document_corpus_access_snapshots_document_id_fkey", + "table": "document_corpus_access_snapshots" + }, + { + "def": "PRIMARY KEY (activation_id, document_id)", + "name": "document_corpus_access_snapshots_pkey", + "table": "document_corpus_access_snapshots" + }, + { + "def": "CHECK (((mode = 'public'::text) = (activation_id IS NOT NULL)))", + "name": "document_corpus_access_state_check", + "table": "document_corpus_access_state" + }, + { + "def": "CHECK ((mode = ANY (ARRAY['private'::text, 'public'::text])))", + "name": "document_corpus_access_state_mode_check", + "table": "document_corpus_access_state" + }, + { + "def": "PRIMARY KEY (singleton)", + "name": "document_corpus_access_state_pkey", + "table": "document_corpus_access_state" + }, + { + "def": "CHECK (singleton)", + "name": "document_corpus_access_state_singleton_check", + "table": "document_corpus_access_state" + }, { "def": "CHECK ((length(btrim(content)) > 0))", "name": "document_embedding_fields_content_not_blank", diff --git a/supabase/migrations/20260825025032_reversible_document_corpus_access_mode.sql b/supabase/migrations/20260825025032_reversible_document_corpus_access_mode.sql new file mode 100644 index 000000000..90d886bea --- /dev/null +++ b/supabase/migrations/20260825025032_reversible_document_corpus_access_mode.sql @@ -0,0 +1,291 @@ +-- Provide an explicit, reversible corpus-wide visibility switch. The filename +-- matches the version recorded by the hosted migration operation. +-- +-- The production corpus predates publication approvals: many deliberately +-- public rows are ownerless but do not carry metadata.public_corpus=true. +-- The application now requires both signals, so those rows fail closed in the +-- document viewer even though legacy retrieval still returns them. +-- +-- This migration only installs the switch. Enabling public mode remains a +-- separate, explicit service-role operation: +-- select public.set_document_corpus_access_mode('public'); +-- Restore the exact captured owner/public-marker state with: +-- select public.set_document_corpus_access_mode('private'); + +create table if not exists public.document_corpus_access_state ( + singleton boolean primary key default true check (singleton), + mode text not null check (mode in ('private', 'public')), + activation_id uuid, + activated_at timestamptz, + updated_at timestamptz not null default now(), + check ((mode = 'public') = (activation_id is not null)) +); + +create table if not exists public.document_corpus_access_snapshots ( + activation_id uuid not null, + document_id uuid not null references public.documents(id) on delete cascade, + owner_id uuid, + public_corpus_present boolean not null, + public_corpus_value jsonb, + captured_at timestamptz not null default now(), + primary key (activation_id, document_id), + check (public_corpus_present or public_corpus_value is null) +); + +alter table public.document_corpus_access_state enable row level security; +alter table public.document_corpus_access_snapshots enable row level security; + +revoke all on table public.document_corpus_access_state from public, anon, authenticated, service_role; +revoke all on table public.document_corpus_access_snapshots from public, anon, authenticated, service_role; +grant select on table public.document_corpus_access_state to service_role; +grant select on table public.document_corpus_access_snapshots to service_role; + +insert into public.document_corpus_access_state (singleton, mode) +values (true, 'private') +on conflict (singleton) do nothing; + +create or replace function public.set_document_corpus_access_mode(p_mode text) +returns jsonb +language plpgsql +security definer +set search_path = '' +set lock_timeout = '15s' +as $$ +declare + v_state public.document_corpus_access_state%rowtype; + v_activation_id uuid; + v_snapshot_count integer; + v_document_count integer; + v_public_count integer; +begin + if p_mode not in ('private', 'public') then + raise exception 'document corpus access mode must be private or public' + using errcode = '22023'; + end if; + + -- Serialize access-mode changes and keep the publication-guard trigger's + -- short disable window inside this transaction's ACCESS EXCLUSIVE lock. + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('document-corpus-access-mode', 0)); + + select * + into v_state + from public.document_corpus_access_state + where singleton + for update; + + if not found then + raise exception 'document corpus access state is missing'; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_trigger + where tgrelid = 'public.documents'::regclass + and tgname = 'documents_require_publication_approval' + and not tgisinternal + ) then + raise exception 'documents publication guard trigger is missing'; + end if; + + if p_mode = 'public' then + v_activation_id := case + when v_state.mode = 'public' then v_state.activation_id + else extensions.gen_random_uuid() + end; + + insert into public.document_corpus_access_snapshots ( + activation_id, + document_id, + owner_id, + public_corpus_present, + public_corpus_value + ) + select + v_activation_id, + d.id, + d.owner_id, + coalesce(d.metadata, '{}'::jsonb) ? 'public_corpus', + coalesce(d.metadata, '{}'::jsonb)->'public_corpus' + from public.documents d + on conflict (activation_id, document_id) do nothing; + + -- Keep retrieval artifacts in the same ownership scope as their document, + -- matching the governed publication function's existing table set. + update public.document_labels row + set owner_id = null, updated_at = now() + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + update public.document_summaries row + set owner_id = null, updated_at = now() + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + update public.document_sections row + set owner_id = null, updated_at = now() + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + update public.document_memory_cards row + set owner_id = null, updated_at = now() + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + update public.document_table_facts row + set owner_id = null + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + update public.document_embedding_fields row + set owner_id = null + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + update public.document_index_quality row + set owner_id = null, updated_at = now() + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + update public.document_index_units row + set owner_id = null, updated_at = now() + where row.owner_id is not null + and exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id + ); + + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = null, + metadata = pg_catalog.jsonb_set(coalesce(d.metadata, '{}'::jsonb), '{public_corpus}', 'true'::jsonb, true), + updated_at = now() + where exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id + ) + and ( + d.owner_id is not null + or not coalesce((d.metadata->>'public_corpus')::boolean, false) + ); + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set + mode = 'public', + activation_id = v_activation_id, + activated_at = coalesce(activated_at, now()), + updated_at = now() + where singleton; + else + if v_state.mode = 'private' then + select count(*)::integer into v_document_count from public.documents; + return pg_catalog.jsonb_build_object( + 'mode', 'private', + 'changed', false, + 'document_count', v_document_count + ); + end if; + + v_activation_id := v_state.activation_id; + + update public.document_labels row + set owner_id = snapshot.owner_id, updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + update public.document_summaries row + set owner_id = snapshot.owner_id, updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + update public.document_sections row + set owner_id = snapshot.owner_id, updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + update public.document_memory_cards row + set owner_id = snapshot.owner_id, updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + update public.document_table_facts row + set owner_id = snapshot.owner_id + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + update public.document_embedding_fields row + set owner_id = snapshot.owner_id + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + update public.document_index_quality row + set owner_id = snapshot.owner_id, updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + update public.document_index_units row + set owner_id = snapshot.owner_id, updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = row.document_id; + + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = snapshot.owner_id, + metadata = case + when snapshot.public_corpus_present then + pg_catalog.jsonb_set( + coalesce(d.metadata, '{}'::jsonb), + '{public_corpus}', + snapshot.public_corpus_value, + true + ) + else coalesce(d.metadata, '{}'::jsonb) - 'public_corpus' + end, + updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id; + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set mode = 'private', activation_id = null, activated_at = null, updated_at = now() + where singleton; + end if; + + select count(*)::integer + into v_snapshot_count + from public.document_corpus_access_snapshots + where activation_id = v_activation_id; + + select + count(*)::integer, + count(*) filter ( + where owner_id is null and coalesce((metadata->>'public_corpus')::boolean, false) + )::integer + into v_document_count, v_public_count + from public.documents; + + return pg_catalog.jsonb_build_object( + 'mode', p_mode, + 'changed', true, + 'activation_id', v_activation_id, + 'snapshot_count', v_snapshot_count, + 'document_count', v_document_count, + 'public_document_count', v_public_count + ); +end; +$$; + +comment on function public.set_document_corpus_access_mode(text) is + 'Service-role-only reversible switch for corpus-wide document visibility. Public mode snapshots owner/public-marker state; private mode restores it.'; + +revoke all on function public.set_document_corpus_access_mode(text) from public, anon, authenticated; +grant execute on function public.set_document_corpus_access_mode(text) to service_role; diff --git a/supabase/migrations/20260825025717_scope_document_corpus_access_mode_to_documents.sql b/supabase/migrations/20260825025717_scope_document_corpus_access_mode_to_documents.sql new file mode 100644 index 000000000..eca2c9dc8 --- /dev/null +++ b/supabase/migrations/20260825025717_scope_document_corpus_access_mode_to_documents.sql @@ -0,0 +1,163 @@ +-- Keep the reversible corpus switch on the authoritative document access row. +-- The filename matches the version recorded by the hosted migration operation. +-- +-- The initial implementation also aligned high-volume derived artifact owner +-- columns. Production contains hundreds of thousands of those rows, while the +-- server authorizes a document before loading its derived records. Mutating +-- them is unnecessary for document visibility, makes the switch too slow for a +-- synchronous operational call, and expands the rollback surface. Preserve +-- those derived owners exactly as they are and change only documents. + +create or replace function public.set_document_corpus_access_mode(p_mode text) +returns jsonb +language plpgsql +security definer +set search_path = '' +set lock_timeout = '15s' +as $$ +declare + v_state public.document_corpus_access_state%rowtype; + v_activation_id uuid; + v_snapshot_count integer; + v_document_count integer; + v_public_count integer; +begin + if p_mode not in ('private', 'public') then + raise exception 'document corpus access mode must be private or public' + using errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('document-corpus-access-mode', 0)); + + select * + into v_state + from public.document_corpus_access_state + where singleton + for update; + + if not found then + raise exception 'document corpus access state is missing'; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_trigger + where tgrelid = 'public.documents'::regclass + and tgname = 'documents_require_publication_approval' + and not tgisinternal + ) then + raise exception 'documents publication guard trigger is missing'; + end if; + + if p_mode = 'public' then + v_activation_id := case + when v_state.mode = 'public' then v_state.activation_id + else extensions.gen_random_uuid() + end; + + insert into public.document_corpus_access_snapshots ( + activation_id, + document_id, + owner_id, + public_corpus_present, + public_corpus_value + ) + select + v_activation_id, + d.id, + d.owner_id, + coalesce(d.metadata, '{}'::jsonb) ? 'public_corpus', + coalesce(d.metadata, '{}'::jsonb)->'public_corpus' + from public.documents d + on conflict (activation_id, document_id) do nothing; + + -- ALTER TABLE takes an ACCESS EXCLUSIVE lock. The trigger bypass is + -- therefore invisible to concurrent sessions and rolls back on failure. + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = null, + metadata = pg_catalog.jsonb_set(coalesce(d.metadata, '{}'::jsonb), '{public_corpus}', 'true'::jsonb, true), + updated_at = now() + where exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id + ) + and ( + d.owner_id is not null + or coalesce(d.metadata, '{}'::jsonb)->'public_corpus' is distinct from 'true'::jsonb + ); + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set + mode = 'public', + activation_id = v_activation_id, + activated_at = coalesce(activated_at, now()), + updated_at = now() + where singleton; + else + if v_state.mode = 'private' then + select count(*)::integer into v_document_count from public.documents; + return pg_catalog.jsonb_build_object( + 'mode', 'private', + 'changed', false, + 'document_count', v_document_count + ); + end if; + + v_activation_id := v_state.activation_id; + + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = snapshot.owner_id, + metadata = case + when snapshot.public_corpus_present then + pg_catalog.jsonb_set( + coalesce(d.metadata, '{}'::jsonb), + '{public_corpus}', + snapshot.public_corpus_value, + true + ) + else coalesce(d.metadata, '{}'::jsonb) - 'public_corpus' + end, + updated_at = now() + from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id; + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set mode = 'private', activation_id = null, activated_at = null, updated_at = now() + where singleton; + end if; + + select count(*)::integer + into v_snapshot_count + from public.document_corpus_access_snapshots + where activation_id = v_activation_id; + + select + count(*)::integer, + count(*) filter ( + where owner_id is null and coalesce(metadata, '{}'::jsonb)->'public_corpus' = 'true'::jsonb + )::integer + into v_document_count, v_public_count + from public.documents; + + return pg_catalog.jsonb_build_object( + 'mode', p_mode, + 'changed', true, + 'activation_id', v_activation_id, + 'snapshot_count', v_snapshot_count, + 'document_count', v_document_count, + 'public_document_count', v_public_count + ); +end; +$$; + +comment on function public.set_document_corpus_access_mode(text) is + 'Service-role-only reversible switch for corpus-wide document visibility. Public mode snapshots and publishes document access rows; private mode restores them without rewriting derived artifacts.'; + +revoke all on function public.set_document_corpus_access_mode(text) from public, anon, authenticated; +grant execute on function public.set_document_corpus_access_mode(text) to service_role; diff --git a/supabase/migrations/20260825030411_harden_document_corpus_access_state.sql b/supabase/migrations/20260825030411_harden_document_corpus_access_state.sql new file mode 100644 index 000000000..d71a0ab1b --- /dev/null +++ b/supabase/migrations/20260825030411_harden_document_corpus_access_state.sql @@ -0,0 +1,19 @@ +-- The filename matches the version recorded by the hosted migration operation. +-- Preserve the repository's fail-closed default ACL while limiting direct +-- service-role access to operational reads. Visibility mutations remain behind +-- set_document_corpus_access_mode(), which serializes and snapshots the change. +revoke all on table public.document_corpus_access_state + from public, anon, authenticated, service_role; +revoke all on table public.document_corpus_access_snapshots + from public, anon, authenticated, service_role; +grant select on table public.document_corpus_access_state to service_role; +grant select on table public.document_corpus_access_snapshots to service_role; + +-- The primary key starts with activation_id, so it cannot support the +-- document_id lookup PostgreSQL performs for ON DELETE CASCADE. +create index if not exists document_corpus_access_snapshots_document_id_idx + on public.document_corpus_access_snapshots (document_id); + +revoke all on function public.set_document_corpus_access_mode(text) + from public, anon, authenticated, service_role; +grant execute on function public.set_document_corpus_access_mode(text) to service_role; diff --git a/supabase/migrations/20260826090000_fail_closed_deleted_document_owner_rollback.sql b/supabase/migrations/20260826090000_fail_closed_deleted_document_owner_rollback.sql new file mode 100644 index 000000000..adb8a9452 --- /dev/null +++ b/supabase/migrations/20260826090000_fail_closed_deleted_document_owner_rollback.sql @@ -0,0 +1,172 @@ +-- Replace the corpus access switch through a new migration because the earlier +-- function versions were already recorded by the hosted migration operation. +-- +-- A snapshot deliberately keeps its original owner UUID without an auth.users +-- foreign key. If that user is deleted while public mode is active, private +-- rollback cannot restore the stale UUID to documents.owner_id. Keep the row +-- ownerless in that case, remove the public marker, and quarantine it from +-- retrieval so both document reads and legacy owner-only retrieval fail closed. + +create or replace function public.set_document_corpus_access_mode(p_mode text) +returns jsonb +language plpgsql +security definer +set search_path = '' +set lock_timeout = '15s' +as $$ +declare + v_state public.document_corpus_access_state%rowtype; + v_activation_id uuid; + v_snapshot_count integer; + v_document_count integer; + v_public_count integer; +begin + if p_mode not in ('private', 'public') then + raise exception 'document corpus access mode must be private or public' + using errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('document-corpus-access-mode', 0)); + + select * + into v_state + from public.document_corpus_access_state + where singleton + for update; + + if not found then + raise exception 'document corpus access state is missing'; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_trigger + where tgrelid = 'public.documents'::regclass + and tgname = 'documents_require_publication_approval' + and not tgisinternal + ) then + raise exception 'documents publication guard trigger is missing'; + end if; + + if p_mode = 'public' then + v_activation_id := case + when v_state.mode = 'public' then v_state.activation_id + else extensions.gen_random_uuid() + end; + + insert into public.document_corpus_access_snapshots ( + activation_id, + document_id, + owner_id, + public_corpus_present, + public_corpus_value + ) + select + v_activation_id, + d.id, + d.owner_id, + coalesce(d.metadata, '{}'::jsonb) ? 'public_corpus', + coalesce(d.metadata, '{}'::jsonb)->'public_corpus' + from public.documents d + on conflict (activation_id, document_id) do nothing; + + -- ALTER TABLE takes an ACCESS EXCLUSIVE lock. The trigger bypass is + -- therefore invisible to concurrent sessions and rolls back on failure. + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = null, + metadata = pg_catalog.jsonb_set(coalesce(d.metadata, '{}'::jsonb), '{public_corpus}', 'true'::jsonb, true), + updated_at = now() + where exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id + ) + and ( + d.owner_id is not null + or coalesce(d.metadata, '{}'::jsonb)->'public_corpus' is distinct from 'true'::jsonb + ); + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set + mode = 'public', + activation_id = v_activation_id, + activated_at = coalesce(activated_at, now()), + updated_at = now() + where singleton; + else + if v_state.mode = 'private' then + select count(*)::integer into v_document_count from public.documents; + return pg_catalog.jsonb_build_object( + 'mode', 'private', + 'changed', false, + 'document_count', v_document_count + ); + end if; + + v_activation_id := v_state.activation_id; + + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = existing_owner.id, + status = case + when snapshot.owner_id is not null and existing_owner.id is null then 'failed' + else d.status + end, + metadata = case + -- Restoring a public marker without its former owner would turn an + -- owner-scoped row into a public row. Remove the marker instead. + when snapshot.owner_id is not null and existing_owner.id is null then + coalesce(d.metadata, '{}'::jsonb) - 'public_corpus' + when snapshot.public_corpus_present then + pg_catalog.jsonb_set( + coalesce(d.metadata, '{}'::jsonb), + '{public_corpus}', + snapshot.public_corpus_value, + true + ) + else coalesce(d.metadata, '{}'::jsonb) - 'public_corpus' + end, + updated_at = now() + from public.document_corpus_access_snapshots snapshot + left join auth.users existing_owner on existing_owner.id = snapshot.owner_id + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id; + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set mode = 'private', activation_id = null, activated_at = null, updated_at = now() + where singleton; + end if; + + select count(*)::integer + into v_snapshot_count + from public.document_corpus_access_snapshots + where activation_id = v_activation_id; + + select + count(*)::integer, + count(*) filter ( + where owner_id is null and coalesce(metadata, '{}'::jsonb)->'public_corpus' = 'true'::jsonb + )::integer + into v_document_count, v_public_count + from public.documents; + + return pg_catalog.jsonb_build_object( + 'mode', p_mode, + 'changed', true, + 'activation_id', v_activation_id, + 'snapshot_count', v_snapshot_count, + 'document_count', v_document_count, + 'public_document_count', v_public_count + ); +end; +$$; + +comment on function public.set_document_corpus_access_mode(text) is + 'Service-role-only reversible switch for corpus-wide document visibility. Public mode snapshots and publishes document access rows; private mode restores surviving owners and quarantines deleted-owner rows from document and retrieval reads without rewriting derived artifacts.'; + +revoke all on function public.set_document_corpus_access_mode(text) + from public, anon, authenticated, service_role; +grant execute on function public.set_document_corpus_access_mode(text) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index ce6b3947a..97a63e68c 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -9636,3 +9636,207 @@ grant execute on function public.detect_legacy_ivfflat_indexes() to service_role grant execute on function public.document_summary_text(uuid) to service_role; grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role; grant execute on function public.set_document_embedding_field_content_hash() to service_role; + +-- Reversible corpus-wide document visibility control. The access state and +-- snapshot are backend-readable only; mutations are serialized through the +-- service-role function so prior document owner/public marker state is +-- restored when possible and deleted-owner rows fail closed as non-public and +-- non-retrievable. +create table if not exists public.document_corpus_access_state ( + singleton boolean primary key default true check (singleton), + mode text not null check (mode in ('private', 'public')), + activation_id uuid, + activated_at timestamptz, + updated_at timestamptz not null default now(), + check ((mode = 'public') = (activation_id is not null)) +); + +create table if not exists public.document_corpus_access_snapshots ( + activation_id uuid not null, + document_id uuid not null references public.documents(id) on delete cascade, + owner_id uuid, + public_corpus_present boolean not null, + public_corpus_value jsonb, + captured_at timestamptz not null default now(), + primary key (activation_id, document_id), + check (public_corpus_present or public_corpus_value is null) +); + +create index if not exists document_corpus_access_snapshots_document_id_idx + on public.document_corpus_access_snapshots (document_id); + +alter table public.document_corpus_access_state enable row level security; +alter table public.document_corpus_access_snapshots enable row level security; + +revoke all on table public.document_corpus_access_state + from public, anon, authenticated, service_role; +revoke all on table public.document_corpus_access_snapshots + from public, anon, authenticated, service_role; +grant select on table public.document_corpus_access_state to service_role; +grant select on table public.document_corpus_access_snapshots to service_role; + +insert into public.document_corpus_access_state (singleton, mode) +values (true, 'private') +on conflict (singleton) do nothing; + +create or replace function public.set_document_corpus_access_mode(p_mode text) +returns jsonb +language plpgsql +security definer +set search_path = '' +set lock_timeout = '15s' +as $$ +declare + v_state public.document_corpus_access_state%rowtype; + v_activation_id uuid; + v_snapshot_count integer; + v_document_count integer; + v_public_count integer; +begin + if p_mode not in ('private', 'public') then + raise exception 'document corpus access mode must be private or public' + using errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('document-corpus-access-mode', 0)); + + select * + into v_state + from public.document_corpus_access_state + where singleton + for update; + + if not found then + raise exception 'document corpus access state is missing'; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_trigger + where tgrelid = 'public.documents'::regclass + and tgname = 'documents_require_publication_approval' + and not tgisinternal + ) then + raise exception 'documents publication guard trigger is missing'; + end if; + + if p_mode = 'public' then + v_activation_id := case + when v_state.mode = 'public' then v_state.activation_id + else extensions.gen_random_uuid() + end; + + insert into public.document_corpus_access_snapshots ( + activation_id, + document_id, + owner_id, + public_corpus_present, + public_corpus_value + ) + select + v_activation_id, + d.id, + d.owner_id, + coalesce(d.metadata, '{}'::jsonb) ? 'public_corpus', + coalesce(d.metadata, '{}'::jsonb)->'public_corpus' + from public.documents d + on conflict (activation_id, document_id) do nothing; + + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = null, + metadata = pg_catalog.jsonb_set(coalesce(d.metadata, '{}'::jsonb), '{public_corpus}', 'true'::jsonb, true), + updated_at = now() + where exists ( + select 1 from public.document_corpus_access_snapshots snapshot + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id + ) + and ( + d.owner_id is not null + or coalesce(d.metadata, '{}'::jsonb)->'public_corpus' is distinct from 'true'::jsonb + ); + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set + mode = 'public', + activation_id = v_activation_id, + activated_at = coalesce(activated_at, now()), + updated_at = now() + where singleton; + else + if v_state.mode = 'private' then + select count(*)::integer into v_document_count from public.documents; + return pg_catalog.jsonb_build_object( + 'mode', 'private', + 'changed', false, + 'document_count', v_document_count + ); + end if; + + v_activation_id := v_state.activation_id; + + execute 'alter table public.documents disable trigger documents_require_publication_approval'; + update public.documents d + set + owner_id = existing_owner.id, + status = case + when snapshot.owner_id is not null and existing_owner.id is null then 'failed' + else d.status + end, + metadata = case + -- Restoring a public marker without its former owner would turn an + -- owner-scoped row into a public row. Remove the marker instead. + when snapshot.owner_id is not null and existing_owner.id is null then + coalesce(d.metadata, '{}'::jsonb) - 'public_corpus' + when snapshot.public_corpus_present then + pg_catalog.jsonb_set( + coalesce(d.metadata, '{}'::jsonb), + '{public_corpus}', + snapshot.public_corpus_value, + true + ) + else coalesce(d.metadata, '{}'::jsonb) - 'public_corpus' + end, + updated_at = now() + from public.document_corpus_access_snapshots snapshot + left join auth.users existing_owner on existing_owner.id = snapshot.owner_id + where snapshot.activation_id = v_activation_id and snapshot.document_id = d.id; + execute 'alter table public.documents enable trigger documents_require_publication_approval'; + + update public.document_corpus_access_state + set mode = 'private', activation_id = null, activated_at = null, updated_at = now() + where singleton; + end if; + + select count(*)::integer + into v_snapshot_count + from public.document_corpus_access_snapshots + where activation_id = v_activation_id; + + select + count(*)::integer, + count(*) filter ( + where owner_id is null and coalesce(metadata, '{}'::jsonb)->'public_corpus' = 'true'::jsonb + )::integer + into v_document_count, v_public_count + from public.documents; + + return pg_catalog.jsonb_build_object( + 'mode', p_mode, + 'changed', true, + 'activation_id', v_activation_id, + 'snapshot_count', v_snapshot_count, + 'document_count', v_document_count, + 'public_document_count', v_public_count + ); +end; +$$; + +comment on function public.set_document_corpus_access_mode(text) is + 'Service-role-only reversible switch for corpus-wide document visibility. Public mode snapshots and publishes document access rows; private mode restores surviving owners and quarantines deleted-owner rows from document and retrieval reads without rewriting derived artifacts.'; + +revoke all on function public.set_document_corpus_access_mode(text) + from public, anon, authenticated, service_role; +grant execute on function public.set_document_corpus_access_mode(text) to service_role; diff --git a/tests/ci-cache-safety.test.ts b/tests/ci-cache-safety.test.ts index 62e2aabfb..ec9ecb799 100644 --- a/tests/ci-cache-safety.test.ts +++ b/tests/ci-cache-safety.test.ts @@ -119,11 +119,25 @@ describe("CI cache safety", () => { expect(workflow).toContain("run: npm run check:verification-plan"); }); - it("runs Caring Contacts database tests from the changes-job outputs", () => { - expect(workflow).toMatch( - /name: Run Caring Contacts Database & RLS Tests\n\s+if: needs\.changes\.outputs\.db_changed == 'true' \|\| needs\.changes\.outputs\.static_heavy_changed == 'true'\n[\s\S]*?run: npm run caring-contacts:db:test/, + it("isolates Caring Contacts database tests from the Supabase migration emulator", () => { + const caringContactsJob = /\n caring-contacts-db:\n([\s\S]*?)(?=\n [a-z][\w-]*:\n)/.exec(workflow)?.[1] ?? ""; + const migrationReplayJob = /\n db-reset-verify:\n([\s\S]*?)(?=\n [a-z][\w-]*:\n)/.exec(workflow)?.[1] ?? ""; + const requiredNeeds = /\n pr-required:\n[\s\S]*?needs:\s*\n?\s*\[([\s\S]*?)\]/.exec(workflow)?.[1] ?? ""; + + expect(caringContactsJob, "caring-contacts-db job not found in ci.yml").not.toBe(""); + expect(caringContactsJob).toContain("needs: changes"); + expect(caringContactsJob).toContain("needs.changes.outputs.db_changed == 'true'"); + expect(caringContactsJob).toContain("needs.changes.outputs.static_heavy_changed == 'true'"); + expect(caringContactsJob).toContain("services:\n postgres:"); + expect(caringContactsJob).toContain('--health-cmd "pg_isready -U postgres -d postgres"'); + expect(caringContactsJob).toContain( + "CARING_CONTACTS_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:54329/postgres", ); - expect(workflow).not.toMatch(/name: Run Caring Contacts Database & RLS Tests\n\s+if: steps\.scope\.outputs/); + expect(caringContactsJob).toContain("run: npm run caring-contacts:db:test"); + expect(migrationReplayJob).not.toContain("npm run caring-contacts:db:test"); + expect(requiredNeeds).toContain("caring-contacts-db"); + expect(workflow).toContain("CARING_CONTACTS_DB_RESULT: ${{ needs.caring-contacts-db.result }}"); + expect(workflow).toContain('require_success "caring-contacts-db" "$CARING_CONTACTS_DB_RESULT"'); }); it("runs the generated medication lexicon freshness check through static-heavy scope", () => { @@ -352,6 +366,7 @@ describe.skipIf(process.platform === "win32")("PR required aggregate — cancell UI_RESULT: "skipped", LIGHTHOUSE_RESULT: "skipped", DB_RESULT: "skipped", + CARING_CONTACTS_DB_RESULT: "success", }; function runAggregate(overrides: Record = {}) { @@ -419,6 +434,17 @@ describe.skipIf(process.platform === "win32")("PR required aggregate — cancell expect(runAggregate({ STATIC_HEAVY_CHANGED: "true", PR_DRAFT: "false", SAFETY_RESULT: "success" }).status).toBe(0); }); + it("requires the isolated Caring Contacts database job for database and static-heavy scopes", () => { + expect( + runAggregate({ DB_CHANGED: "true", DB_RESULT: "success", CARING_CONTACTS_DB_RESULT: "skipped" }).status, + ).not.toBe(0); + expect( + runAggregate({ STATIC_HEAVY_CHANGED: "true", SAFETY_RESULT: "success", CARING_CONTACTS_DB_RESULT: "skipped" }) + .status, + ).not.toBe(0); + expect(runAggregate({ CARING_CONTACTS_DB_RESULT: "skipped" }).status).toBe(0); + }); + it("requires ingestion SAST only for its path-scoped surface", () => { expect(runAggregate({ INGESTION_SAST_CHANGED: "true", INGESTION_SAST_RESULT: "success" }).status).toBe(0); expect(runAggregate({ INGESTION_SAST_CHANGED: "true", INGESTION_SAST_RESULT: "skipped" }).status).not.toBe(0); diff --git a/tests/document-corpus-access-mode.test.ts b/tests/document-corpus-access-mode.test.ts new file mode 100644 index 000000000..12b4a477d --- /dev/null +++ b/tests/document-corpus-access-mode.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const installMigration = readFileSync( + new URL("../supabase/migrations/20260825025032_reversible_document_corpus_access_mode.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const recordedScopeMigration = readFileSync( + new URL("../supabase/migrations/20260825025717_scope_document_corpus_access_mode_to_documents.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const hardeningMigration = readFileSync( + new URL("../supabase/migrations/20260825030411_harden_document_corpus_access_state.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const effectiveMigration = readFileSync( + new URL("../supabase/migrations/20260826090000_fail_closed_deleted_document_owner_rollback.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); + +describe("document corpus access mode migration", () => { + it("keeps the visibility switch and its snapshots service-role-only", () => { + expect(installMigration).toContain("alter table public.document_corpus_access_state enable row level security"); + expect(installMigration).toContain("alter table public.document_corpus_access_snapshots enable row level security"); + expect(hardeningMigration).toContain( + "revoke all on table public.document_corpus_access_state from public, anon, authenticated, service_role", + ); + expect(hardeningMigration).toContain( + "revoke all on table public.document_corpus_access_snapshots from public, anon, authenticated, service_role", + ); + expect(effectiveMigration).toContain( + "revoke all on function public.set_document_corpus_access_mode(text) from public, anon, authenticated", + ); + expect(effectiveMigration).toContain( + "grant execute on function public.set_document_corpus_access_mode(text) to service_role", + ); + expect(effectiveMigration).toContain("security definer set search_path = '' set lock_timeout = '15s'"); + }); + + it("indexes snapshot cleanup by document id", () => { + expect(hardeningMigration).toContain( + "create index if not exists document_corpus_access_snapshots_document_id_idx on public.document_corpus_access_snapshots (document_id)", + ); + }); + + it("captures owner and exact public marker state before publishing", () => { + expect(installMigration).toContain("public_corpus_present boolean not null"); + expect(installMigration).toContain("public_corpus_value jsonb"); + expect(effectiveMigration).toContain("coalesce(d.metadata, '{}'::jsonb) ? 'public_corpus'"); + expect(effectiveMigration).toContain("coalesce(d.metadata, '{}'::jsonb)->'public_corpus'"); + expect(effectiveMigration).toContain("owner_id = null"); + expect(effectiveMigration).toContain( + "metadata = pg_catalog.jsonb_set(coalesce(d.metadata, '{}'::jsonb), '{public_corpus}', 'true'::jsonb, true)", + ); + }); + + it("uses a new forward migration after the recorded hosted function version", () => { + expect(recordedScopeMigration).toContain("set owner_id = snapshot.owner_id"); + expect(recordedScopeMigration).not.toContain("left join auth.users existing_owner"); + expect(effectiveMigration).not.toContain("set owner_id = snapshot.owner_id"); + }); + + it("restores surviving owners and fails closed when the snapshotted owner was deleted", () => { + expect(effectiveMigration).toContain("set owner_id = existing_owner.id"); + expect(effectiveMigration).toContain( + "left join auth.users existing_owner on existing_owner.id = snapshot.owner_id", + ); + expect(effectiveMigration).toContain( + "when snapshot.owner_id is not null and existing_owner.id is null then coalesce(d.metadata, '{}'::jsonb) - 'public_corpus'", + ); + expect(effectiveMigration).toContain( + "status = case when snapshot.owner_id is not null and existing_owner.id is null then 'failed' else d.status end", + ); + expect(effectiveMigration).toContain("when snapshot.public_corpus_present then pg_catalog.jsonb_set"); + expect(effectiveMigration).toContain("else coalesce(d.metadata, '{}'::jsonb) - 'public_corpus'"); + }); + + it("does not rewrite high-volume derived artifacts at the authorization boundary", () => { + for (const table of [ + "document_labels", + "document_summaries", + "document_sections", + "document_memory_cards", + "document_table_facts", + "document_embedding_fields", + "document_index_quality", + "document_index_units", + ]) { + expect(effectiveMigration).not.toContain(`update public.${table}`); + } + }); + + it("serializes the switch and confines the approval-trigger bypass to the function transaction", () => { + expect(effectiveMigration).toContain( + "pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('document-corpus-access-mode', 0))", + ); + expect(effectiveMigration).toContain( + "execute 'alter table public.documents disable trigger documents_require_publication_approval'", + ); + expect(effectiveMigration).toContain( + "execute 'alter table public.documents enable trigger documents_require_publication_approval'", + ); + }); +});