From 659e417a08ffd733bca671a47c8c32672922731a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:21:55 +0800 Subject: [PATCH 1/3] fix(db): bind publication approval to reviewed state --- scripts/audit-public-document-approvals.ts | 12 +- scripts/promote-public-documents-batch.ts | 26 +- scripts/sql/verify-publication-approval.sql | 44 ++- src/lib/publication-manifest.ts | 1 + src/lib/supabase/database.types.ts | 7 + supabase/drift-manifest.json | 44 ++- ...publication_approval_to_reviewed_state.sql | 368 ++++++++++++++++++ supabase/schema.sql | 120 +++++- tests/publication-manifest.test.ts | 17 + tests/supabase-schema.test.ts | 43 ++ 10 files changed, 660 insertions(+), 22 deletions(-) create mode 100644 supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql diff --git a/scripts/audit-public-document-approvals.ts b/scripts/audit-public-document-approvals.ts index dee9e6346a..49b1d978fb 100644 --- a/scripts/audit-public-document-approvals.ts +++ b/scripts/audit-public-document-approvals.ts @@ -28,11 +28,14 @@ async function main() { const approvalIds = documents .map((document) => String(document.metadata?.publication_approval_id ?? "")) .filter(Boolean); - const approvals = new Map(); + const approvals = new Map< + string, + { document_id: string; manifest_digest: string; reviewed_state_digest: string | null; decision: string } + >(); for (let index = 0; index < approvalIds.length; index += PAGE_SIZE) { const { data, error } = await supabase .from("document_publication_approvals") - .select("id, document_id, manifest_digest, decision") + .select("id, document_id, manifest_digest, reviewed_state_digest, decision") .in("id", approvalIds.slice(index, index + PAGE_SIZE)); if (error) throw new Error(error.message); for (const approval of data ?? []) approvals.set(approval.id, approval); @@ -41,12 +44,15 @@ async function main() { const missing = documents.filter((document) => { const approvalId = String(document.metadata?.publication_approval_id ?? ""); const digest = String(document.metadata?.publication_manifest_digest ?? ""); + const reviewedStateDigest = String(document.metadata?.publication_reviewed_state_digest ?? ""); const approval = approvals.get(approvalId); return ( !approval || approval.document_id !== document.id || approval.decision !== "approved" || - approval.manifest_digest !== digest + approval.manifest_digest !== digest || + !approval.reviewed_state_digest || + approval.reviewed_state_digest !== reviewedStateDigest ); }); diff --git a/scripts/promote-public-documents-batch.ts b/scripts/promote-public-documents-batch.ts index 29b487dc80..761d8ca736 100644 --- a/scripts/promote-public-documents-batch.ts +++ b/scripts/promote-public-documents-batch.ts @@ -35,6 +35,16 @@ async function main() { if (!document) validationErrors.push(`${entry.documentId}: not found`); else if (document.owner_id !== entry.expectedOwnerId) validationErrors.push(`${entry.documentId}: owner changed`); else if (document.status !== "indexed") validationErrors.push(`${entry.documentId}: status is ${document.status}`); + else { + const { data: currentStateDigest, error: digestError } = await supabase.rpc("document_publication_state_digest", { + p_document_id: entry.documentId, + p_expected_owner_id: entry.expectedOwnerId, + }); + if (digestError) throw new Error(digestError.message); + if (currentStateDigest !== entry.expectedStateDigest) { + validationErrors.push(`${entry.documentId}: reviewed content/state digest changed`); + } + } } if (validationErrors.length > 0) { throw new Error(`Publication manifest validation failed:\n${validationErrors.join("\n")}`); @@ -67,19 +77,22 @@ async function main() { const { data: existingApprovals, error: existingApprovalError } = await supabase .from("document_publication_approvals") - .select("document_id, expected_prior_owner_id, decision, manifest_digest") + .select("document_id, expected_prior_owner_id, decision, manifest_digest, reviewed_state_digest") .eq("manifest_digest", digest) .in("document_id", ids); if (existingApprovalError) throw new Error(existingApprovalError.message); const existing = new Set( (existingApprovals ?? []).map( (approval) => - `${approval.document_id}:${approval.expected_prior_owner_id}:${approval.decision}:${approval.manifest_digest}`, + `${approval.document_id}:${approval.expected_prior_owner_id}:${approval.decision}:${approval.manifest_digest}:${approval.reviewed_state_digest}`, ), ); const approvals = manifest.documents .filter( - (document) => !existing.has(`${document.documentId}:${document.expectedOwnerId}:${document.decision}:${digest}`), + (document) => + !existing.has( + `${document.documentId}:${document.expectedOwnerId}:${document.decision}:${digest}:${document.expectedStateDigest}`, + ), ) .map((document) => ({ document_id: document.documentId, @@ -89,6 +102,7 @@ async function main() { reason: manifest.reason, evidence_references: manifest.evidenceReferences, manifest_digest: digest, + reviewed_state_digest: document.expectedStateDigest, })); if (approvals.length > 0) { const { error: approvalError } = await supabase.from("document_publication_approvals").insert(approvals); @@ -97,7 +111,11 @@ async function main() { const approvedDocuments = manifest.documents .filter((document) => document.decision === "approved") - .map((document) => ({ document_id: document.documentId, expected_owner_id: document.expectedOwnerId })); + .map((document) => ({ + document_id: document.documentId, + expected_owner_id: document.expectedOwnerId, + expected_state_digest: document.expectedStateDigest, + })); if (approvedDocuments.length === 0) { console.log("[public-documents:promote] decisions recorded; no documents were approved for publication."); return; diff --git a/scripts/sql/verify-publication-approval.sql b/scripts/sql/verify-publication-approval.sql index 64692bf008..90c49e1929 100644 --- a/scripts/sql/verify-publication-approval.sql +++ b/scripts/sql/verify-publication-approval.sql @@ -11,23 +11,27 @@ values ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Approved fixture', 'approved.pdf', 'application/pdf', 'fixtures/approved.pdf', 'indexed'), ('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Private fixture', 'private.pdf', 'application/pdf', 'fixtures/private.pdf', 'indexed'), ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Quarantine fixture', 'quarantine.pdf', 'application/pdf', 'fixtures/quarantine.pdf', 'indexed'), - ('10000000-0000-4000-8000-000000000004', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Unapproved fixture', 'unapproved.pdf', 'application/pdf', 'fixtures/unapproved.pdf', 'indexed'); + ('10000000-0000-4000-8000-000000000004', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Unapproved fixture', 'unapproved.pdf', 'application/pdf', 'fixtures/unapproved.pdf', 'indexed'), + ('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Post-review mutation fixture', 'changed.pdf', 'application/pdf', 'fixtures/changed.pdf', 'indexed'); insert into public.document_labels (document_id, owner_id, label, label_type, source) values ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'publication fixture', 'custom', 'manual'); insert into public.document_publication_approvals ( - document_id, expected_prior_owner_id, approving_operator_id, decision, reason, evidence_references, manifest_digest + document_id, expected_prior_owner_id, approving_operator_id, decision, reason, evidence_references, + manifest_digest, reviewed_state_digest ) values - ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Approved publication fixture.', array['fixture:approved'], repeat('a', 64)), - ('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'keep_private', 'Private publication fixture.', array['fixture:private'], repeat('a', 64)), - ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'quarantine', 'Quarantine publication fixture.', array['fixture:quarantine'], repeat('a', 64)); + ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Approved publication fixture.', array['fixture:approved'], repeat('a', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')), + ('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'keep_private', 'Private publication fixture.', array['fixture:private'], repeat('a', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')), + ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'quarantine', 'Quarantine publication fixture.', array['fixture:quarantine'], repeat('a', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')), + ('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Mutation protection fixture.', array['fixture:changed'], repeat('b', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')); select public.publish_approved_documents( jsonb_build_array(jsonb_build_object( 'document_id', '10000000-0000-4000-8000-000000000001', - 'expected_owner_id', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + 'expected_owner_id', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'expected_state_digest', public.document_publication_state_digest('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') )), repeat('a', 64), 1 @@ -51,6 +55,28 @@ begin and owner_id is null ) then raise exception 'private or quarantine publication fixture was published'; end if; + update public.documents + set title = 'Changed after approval' + where id = '10000000-0000-4000-8000-000000000006'; + begin + perform public.publish_approved_documents( + jsonb_build_array(jsonb_build_object( + 'document_id', '10000000-0000-4000-8000-000000000006', + 'expected_owner_id', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'expected_state_digest', ( + select reviewed_state_digest from public.document_publication_approvals + where document_id = '10000000-0000-4000-8000-000000000006' + ) + )), + repeat('b', 64), + 1 + ); + raise exception 'post-review document mutation unexpectedly published'; + exception when others then + if sqlerrm = 'post-review document mutation unexpectedly published' then raise; end if; + if sqlerrm not like 'publication document % changed after review' then raise; end if; + end; + begin update public.documents set owner_id = null, metadata = metadata || jsonb_build_object('public_corpus', true) @@ -82,7 +108,8 @@ begin begin insert into public.document_publication_approvals ( - document_id, expected_prior_owner_id, approving_operator_id, decision, reason, evidence_references, manifest_digest + document_id, expected_prior_owner_id, approving_operator_id, decision, reason, evidence_references, + manifest_digest, reviewed_state_digest ) values ( '10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', @@ -90,7 +117,8 @@ begin 'quarantine', 'Contradictory decision fixture.', array['fixture:contradictory'], - repeat('a', 64) + repeat('a', 64), + repeat('c', 64) ); raise exception 'contradictory publication approval unexpectedly succeeded'; exception when unique_violation then diff --git a/src/lib/publication-manifest.ts b/src/lib/publication-manifest.ts index f42381e246..4ece735c22 100644 --- a/src/lib/publication-manifest.ts +++ b/src/lib/publication-manifest.ts @@ -14,6 +14,7 @@ const publicationManifestSchema = z z.object({ documentId: z.string().uuid(), expectedOwnerId: z.string().uuid(), + expectedStateDigest: z.string().regex(/^[0-9a-f]{64}$/), decision: publicationDecisionSchema, }), ) diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 99b68a3280..58e20c4de4 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -900,6 +900,7 @@ export type Database = { id: string; manifest_digest: string; reason: string; + reviewed_state_digest: string | null; }; Insert: { approved_at?: string; @@ -911,6 +912,7 @@ export type Database = { id?: string; manifest_digest: string; reason: string; + reviewed_state_digest?: string | null; }; Update: { approved_at?: string; @@ -922,6 +924,7 @@ export type Database = { id?: string; manifest_digest?: string; reason?: string; + reviewed_state_digest?: string | null; }; Relationships: []; }; @@ -2840,6 +2843,10 @@ export type Database = { Args: { owner_filter: string; row_owner_id: string | null; include_public?: boolean }; Returns: boolean; }; + document_publication_state_digest: { + Args: { p_document_id: string; p_expected_owner_id: string }; + Returns: string; + }; publish_approved_documents: { Args: { p_documents: Json; diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 06d57a3b13..9a39242b70 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-22T03:09:35.765Z", + "generated_at": "2026-07-22T11:03:47.977Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "4dfef34464628457daf82681fdf0c1e7a6beebd20fb24c84780cb4e4cce51fc7", - "replay_seconds": 16, + "schema_sha256": "7e39786e6be70516c9a50b53192d33a49b82f189a8c3013b0f5ed2da346a1575", + "replay_seconds": 19, "snapshot": { "views": [ { @@ -1897,6 +1897,14 @@ "identity": "", "not_null": true, "generated": "" + }, + { + "name": "reviewed_state_digest", + "type": "text", + "default": null, + "identity": "", + "not_null": false, + "generated": "" } ], "reloptions": null, @@ -6524,6 +6532,11 @@ "name": "document_publication_approvals_immutable", "table": "document_publication_approvals" }, + { + "def": "CREATE TRIGGER document_publication_approvals_require_state_digest BEFORE INSERT ON public.document_publication_approvals FOR EACH ROW EXECUTE FUNCTION public.require_document_publication_approval_state_digest()", + "name": "document_publication_approvals_require_state_digest", + "table": "document_publication_approvals" + }, { "def": "CREATE TRIGGER document_sections_updated_at BEFORE UPDATE ON public.document_sections FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "document_sections_updated_at", @@ -6788,6 +6801,14 @@ "def_hash": "143cc43d9a00f140f3da8f9c054db74a", "signature": "public.document_label_metadata(uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "d415a919cde34ea955dfbcea162c6289", + "signature": "public.document_publication_state_digest(uuid,uuid)" + }, { "acl": [ "postgres=X/postgres", @@ -6848,7 +6869,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "2fef16fbe1d1e07658b5ce051f6e7d7a", + "def_hash": "03cd491adfb5390a52a5339cc96190a1", "signature": "public.guard_document_publication_transition()" }, { @@ -7104,7 +7125,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "406d621ed9ad3b052b9282e7e980813a", + "def_hash": "ae1bcb442fe7ee0536ad161e7226c505", "signature": "public.publish_approved_documents(jsonb,text,integer)" }, { @@ -7179,6 +7200,14 @@ "def_hash": "baa8c0bda37cc561026baecdf1cca1c2", "signature": "public.request_indexing_v3_enrichment(uuid,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "a574c83b93f318a8ca10867dfc86941c", + "signature": "public.require_document_publication_approval_state_digest()" + }, { "acl": [ "postgres=X/postgres", @@ -7731,6 +7760,11 @@ "name": "document_publication_approvals_reason_check", "table": "document_publication_approvals" }, + { + "def": "CHECK (((reviewed_state_digest IS NULL) OR (reviewed_state_digest ~ '^[0-9a-f]{64}$'::text)))", + "name": "document_publication_approvals_reviewed_state_digest_format", + "table": "document_publication_approvals" + }, { "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", "name": "document_sections_document_id_fkey", diff --git a/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql b/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql new file mode 100644 index 0000000000..66986c71bc --- /dev/null +++ b/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql @@ -0,0 +1,368 @@ +-- Bind every new publication decision to the exact document and committed +-- artifact state that the operator reviewed. Historical approvals remain +-- readable but cannot be used for a new publication because they have no +-- reviewed_state_digest. + +alter table public.document_publication_approvals + add column if not exists reviewed_state_digest text; + +alter table public.document_publication_approvals + drop constraint if exists document_publication_approvals_reviewed_state_digest_format; +alter table public.document_publication_approvals + add constraint document_publication_approvals_reviewed_state_digest_format + check (reviewed_state_digest is null or reviewed_state_digest ~ '^[0-9a-f]{64}$'); + +create or replace function public.document_publication_state_digest( + p_document_id uuid, + p_expected_owner_id uuid +) +returns text +language sql +stable +security definer +set search_path = '' +as $$ + select encode( + extensions.digest( + convert_to( + jsonb_build_object( + 'document', to_jsonb(d) - array['owner_id', 'created_at', 'updated_at', 'search_tsv', 'title_search_tsv'], + 'pages', coalesce(( + select jsonb_agg(to_jsonb(p) - array['created_at', 'updated_at'] order by p.page_number, p.id) + from public.document_pages p where p.document_id = d.id + ), '[]'::jsonb), + 'images', coalesce(( + select jsonb_agg(to_jsonb(i) - array['created_at', 'updated_at'] order by i.page_number nulls last, i.id) + from public.document_images i + where i.document_id = d.id + and public.is_committed_document_generation(i.index_generation_id, d.index_generation_id) + ), '[]'::jsonb), + 'labels', coalesce(( + select jsonb_agg(to_jsonb(l) - array['owner_id', 'created_at', 'updated_at'] order by l.id) + from public.document_labels l where l.document_id = d.id + ), '[]'::jsonb), + 'summaries', coalesce(( + select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.id) + from public.document_summaries s where s.document_id = d.id + ), '[]'::jsonb), + 'sections', coalesce(( + select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.section_index, s.id) + from public.document_sections s + where s.document_id = d.id + and public.is_committed_artifact_generation( + coalesce(s.artifact_generation_id, s.index_generation_id), + d.metadata + ) + ), '[]'::jsonb), + 'memory_cards', coalesce(( + select jsonb_agg( + to_jsonb(m) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] + order by m.id + ) + from public.document_memory_cards m + where m.document_id = d.id + and public.is_committed_artifact_generation( + coalesce(m.artifact_generation_id, m.index_generation_id), + d.metadata + ) + ), '[]'::jsonb), + 'chunks', coalesce(( + select jsonb_agg(to_jsonb(c) - array['embedding', 'search_tsv', 'created_at'] order by c.chunk_index, c.id) + from public.document_chunks c + where c.document_id = d.id + and public.is_committed_document_generation(c.index_generation_id, d.index_generation_id) + ), '[]'::jsonb), + 'table_facts', coalesce(( + select jsonb_agg(to_jsonb(f) - array['owner_id', 'search_tsv', 'created_at'] order by f.id) + from public.document_table_facts f + where f.document_id = d.id + and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id) + ), '[]'::jsonb), + 'embedding_fields', coalesce(( + select jsonb_agg( + to_jsonb(f) - array['owner_id', 'embedding', 'search_tsv', 'created_at'] + order by f.id + ) + from public.document_embedding_fields f + where f.document_id = d.id + and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id) + ), '[]'::jsonb), + 'index_quality', coalesce(( + select to_jsonb(q) - array['owner_id', 'updated_at'] + from public.document_index_quality q where q.document_id = d.id + ), '{}'::jsonb), + 'index_units', coalesce(( + select jsonb_agg( + to_jsonb(u) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] + order by u.id + ) + from public.document_index_units u + where u.document_id = d.id + and public.is_committed_artifact_generation( + coalesce(u.artifact_generation_id, u.index_generation_id), + d.metadata + ) + ), '[]'::jsonb) + )::text, + 'UTF8' + ), + 'sha256' + ), + 'hex' + ) + from public.documents d + where d.id = p_document_id + and d.owner_id = p_expected_owner_id; +$$; + +revoke all on function public.document_publication_state_digest(uuid, uuid) from public, anon, authenticated; +grant execute on function public.document_publication_state_digest(uuid, uuid) to service_role; + +create or replace function public.require_document_publication_approval_state_digest() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if new.reviewed_state_digest is null then + raise exception 'publication approval requires a reviewed content/state digest'; + end if; + return new; +end; +$$; + +revoke all on function public.require_document_publication_approval_state_digest() from public, anon, authenticated; + +drop trigger if exists document_publication_approvals_require_state_digest on public.document_publication_approvals; +create trigger document_publication_approvals_require_state_digest +before insert on public.document_publication_approvals +for each row execute function public.require_document_publication_approval_state_digest(); + +create or replace function public.guard_document_publication_transition() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + v_approval_id uuid; + v_manifest_digest text; + v_reviewed_state_digest text; + v_current_state_digest text; +begin + if tg_op = 'INSERT' then + if new.owner_id is null then + raise exception 'public documents must be created as owned rows before approved publication'; + end if; + return new; + end if; + + if old.owner_id is not null and new.owner_id is null then + begin + v_approval_id := nullif(new.metadata->>'publication_approval_id', '')::uuid; + exception when invalid_text_representation then + raise exception 'public document transition has an invalid publication approval id'; + end; + v_manifest_digest := lower(coalesce(new.metadata->>'publication_manifest_digest', '')); + v_reviewed_state_digest := lower(coalesce(new.metadata->>'publication_reviewed_state_digest', '')); + + if v_approval_id is null + or v_manifest_digest !~ '^[0-9a-f]{64}$' + or v_reviewed_state_digest !~ '^[0-9a-f]{64}$' then + raise exception 'public document transition requires publication approval evidence'; + end if; + + if not exists ( + select 1 + from public.document_publication_approvals approval + where approval.id = v_approval_id + and approval.document_id = old.id + and approval.expected_prior_owner_id = old.owner_id + and approval.decision = 'approved' + and approval.manifest_digest = v_manifest_digest + and approval.reviewed_state_digest = v_reviewed_state_digest + ) then + raise exception 'public document transition approval does not match the reviewed document state'; + end if; + + perform 1 from public.document_pages where document_id = old.id for update; + perform 1 from public.document_images where document_id = old.id for update; + perform 1 from public.document_labels where document_id = old.id for update; + perform 1 from public.document_summaries where document_id = old.id for update; + perform 1 from public.document_sections where document_id = old.id for update; + perform 1 from public.document_memory_cards where document_id = old.id for update; + perform 1 from public.document_chunks where document_id = old.id for update; + perform 1 from public.document_table_facts where document_id = old.id for update; + perform 1 from public.document_embedding_fields where document_id = old.id for update; + perform 1 from public.document_index_quality where document_id = old.id for update; + perform 1 from public.document_index_units where document_id = old.id for update; + + v_current_state_digest := public.document_publication_state_digest(old.id, old.owner_id); + if v_current_state_digest is distinct from v_reviewed_state_digest then + raise exception 'public document transition content changed after review'; + end if; + end if; + return new; +end; +$$; + +revoke all on function public.guard_document_publication_transition() from public, anon, authenticated; + +create or replace function public.publish_approved_documents( + p_documents jsonb, + p_manifest_digest text, + p_expected_count integer +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_entry jsonb; + v_document public.documents%rowtype; + v_document_id uuid; + v_expected_owner_id uuid; + v_expected_state_digest text; + v_current_state_digest text; + v_approval_id uuid; + v_manifest_digest text := lower(trim(coalesce(p_manifest_digest, ''))); + v_count integer; + v_results jsonb := '[]'::jsonb; +begin + if jsonb_typeof(p_documents) is distinct from 'array' then + raise exception 'publication documents must be a JSON array'; + end if; + if v_manifest_digest !~ '^[0-9a-f]{64}$' then + raise exception 'publication manifest digest must be a lowercase SHA-256 value'; + end if; + + v_count := jsonb_array_length(p_documents); + if p_expected_count is null or p_expected_count < 1 or p_expected_count <> v_count then + raise exception 'publication expected count % does not match manifest count %', p_expected_count, v_count; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_documents) entry + group by entry->>'document_id' + having count(*) > 1 + ) then + raise exception 'publication manifest contains duplicate document ids'; + end if; + + for v_entry in select value from jsonb_array_elements(p_documents) + loop + begin + v_document_id := nullif(v_entry->>'document_id', '')::uuid; + v_expected_owner_id := nullif(v_entry->>'expected_owner_id', '')::uuid; + exception when invalid_text_representation then + raise exception 'publication manifest contains an invalid document or owner id'; + end; + v_expected_state_digest := lower(coalesce(v_entry->>'expected_state_digest', '')); + if v_document_id is null or v_expected_owner_id is null then + raise exception 'publication manifest requires document_id and expected_owner_id'; + end if; + if v_expected_state_digest !~ '^[0-9a-f]{64}$' then + raise exception 'publication manifest requires expected_state_digest'; + end if; + + -- This row lock serializes title/metadata/status updates, committed index + -- generation swaps, and new child rows (their foreign-key checks take a + -- conflicting key-share lock). Existing child rows are locked below before + -- the state digest is recomputed. + select * into v_document + from public.documents + where id = v_document_id + for update; + if not found then + raise exception 'publication document % was not found', v_document_id; + end if; + if v_document.owner_id is distinct from v_expected_owner_id then + raise exception 'publication document % owner changed from the manifest expectation', v_document_id; + end if; + if v_document.status <> 'indexed' then + raise exception 'publication document % is not indexed', v_document_id; + end if; + + perform 1 from public.document_pages where document_id = v_document_id for update; + perform 1 from public.document_images where document_id = v_document_id for update; + perform 1 from public.document_labels where document_id = v_document_id for update; + perform 1 from public.document_summaries where document_id = v_document_id for update; + perform 1 from public.document_sections where document_id = v_document_id for update; + perform 1 from public.document_memory_cards where document_id = v_document_id for update; + perform 1 from public.document_chunks where document_id = v_document_id for update; + perform 1 from public.document_table_facts where document_id = v_document_id for update; + perform 1 from public.document_embedding_fields where document_id = v_document_id for update; + perform 1 from public.document_index_quality where document_id = v_document_id for update; + perform 1 from public.document_index_units where document_id = v_document_id for update; + + select approval.id into v_approval_id + from public.document_publication_approvals approval + where approval.document_id = v_document_id + and approval.expected_prior_owner_id = v_expected_owner_id + and approval.decision = 'approved' + and approval.manifest_digest = v_manifest_digest + and approval.reviewed_state_digest = v_expected_state_digest + order by approval.approved_at desc, approval.id desc + limit 1; + if v_approval_id is null then + raise exception 'publication document % lacks matching approved evidence', v_document_id; + end if; + + if exists ( + select 1 from public.document_labels where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_summaries where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_sections where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_memory_cards where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_table_facts where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_embedding_fields where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_index_quality where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + union all select 1 from public.document_index_units where document_id = v_document_id and owner_id is distinct from v_expected_owner_id + ) then + raise exception 'publication document % has mismatched artifact ownership', v_document_id; + end if; + + v_current_state_digest := public.document_publication_state_digest(v_document_id, v_expected_owner_id); + if v_current_state_digest is distinct from v_expected_state_digest then + raise exception 'publication document % changed after review', v_document_id; + end if; + + update public.document_labels set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_summaries set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_sections set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_memory_cards set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_table_facts set owner_id = null where document_id = v_document_id; + update public.document_embedding_fields set owner_id = null where document_id = v_document_id; + update public.document_index_quality set owner_id = null, updated_at = now() where document_id = v_document_id; + update public.document_index_units set owner_id = null, updated_at = now() where document_id = v_document_id; + + update public.documents + set owner_id = null, + metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object( + 'public_corpus', true, + 'publication_approval_id', v_approval_id, + 'publication_manifest_digest', v_manifest_digest, + 'publication_reviewed_state_digest', v_expected_state_digest, + 'published_at', now() + ), + updated_at = now() + where id = v_document_id; + + v_results := v_results || jsonb_build_array(jsonb_build_object( + 'document_id', v_document_id, + 'previous_owner_id', v_expected_owner_id, + 'approval_id', v_approval_id, + 'reviewed_state_digest', v_expected_state_digest, + 'outcome', 'published' + )); + end loop; + + return jsonb_build_object( + 'manifest_digest', v_manifest_digest, + 'published_count', v_count, + 'documents', v_results + ); +end; +$$; + +revoke all on function public.publish_approved_documents(jsonb, text, integer) from public, anon, authenticated; +grant execute on function public.publish_approved_documents(jsonb, text, integer) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index d0a97ae47b..7e6c338ef6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -7816,7 +7816,10 @@ create table if not exists public.document_publication_approvals ( reason text not null check (char_length(trim(reason)) between 3 and 2000), evidence_references text[] not null check (cardinality(evidence_references) > 0), manifest_digest text not null check (manifest_digest ~ '^[0-9a-f]{64}$'), + reviewed_state_digest text, approved_at timestamptz not null default now(), + constraint document_publication_approvals_reviewed_state_digest_format + check (reviewed_state_digest is null or reviewed_state_digest ~ '^[0-9a-f]{64}$'), unique (document_id, expected_prior_owner_id, manifest_digest) ); @@ -7848,6 +7851,67 @@ create trigger document_publication_approvals_immutable before update or delete on public.document_publication_approvals for each row execute function public.prevent_document_publication_approval_mutation(); +create or replace function public.document_publication_state_digest( + p_document_id uuid, + p_expected_owner_id uuid +) +returns text +language sql +stable +security definer +set search_path = '' +as $$ + select encode( + extensions.digest( + convert_to( + jsonb_build_object( + 'document', to_jsonb(d) - array['owner_id', 'created_at', 'updated_at', 'search_tsv', 'title_search_tsv'], + 'pages', coalesce((select jsonb_agg(to_jsonb(p) - array['created_at', 'updated_at'] order by p.page_number, p.id) from public.document_pages p where p.document_id = d.id), '[]'::jsonb), + 'images', coalesce((select jsonb_agg(to_jsonb(i) - array['created_at', 'updated_at'] order by i.page_number nulls last, i.id) from public.document_images i where i.document_id = d.id and public.is_committed_document_generation(i.index_generation_id, d.index_generation_id)), '[]'::jsonb), + 'labels', coalesce((select jsonb_agg(to_jsonb(l) - array['owner_id', 'created_at', 'updated_at'] order by l.id) from public.document_labels l where l.document_id = d.id), '[]'::jsonb), + 'summaries', coalesce((select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.id) from public.document_summaries s where s.document_id = d.id), '[]'::jsonb), + 'sections', coalesce((select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.section_index, s.id) from public.document_sections s where s.document_id = d.id and public.is_committed_artifact_generation(coalesce(s.artifact_generation_id, s.index_generation_id), d.metadata)), '[]'::jsonb), + 'memory_cards', coalesce((select jsonb_agg(to_jsonb(m) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] order by m.id) from public.document_memory_cards m where m.document_id = d.id and public.is_committed_artifact_generation(coalesce(m.artifact_generation_id, m.index_generation_id), d.metadata)), '[]'::jsonb), + 'chunks', coalesce((select jsonb_agg(to_jsonb(c) - array['embedding', 'search_tsv', 'created_at'] order by c.chunk_index, c.id) from public.document_chunks c where c.document_id = d.id and public.is_committed_document_generation(c.index_generation_id, d.index_generation_id)), '[]'::jsonb), + 'table_facts', coalesce((select jsonb_agg(to_jsonb(f) - array['owner_id', 'search_tsv', 'created_at'] order by f.id) from public.document_table_facts f where f.document_id = d.id and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id)), '[]'::jsonb), + 'embedding_fields', coalesce((select jsonb_agg(to_jsonb(f) - array['owner_id', 'embedding', 'search_tsv', 'created_at'] order by f.id) from public.document_embedding_fields f where f.document_id = d.id and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id)), '[]'::jsonb), + 'index_quality', coalesce((select to_jsonb(q) - array['owner_id', 'updated_at'] from public.document_index_quality q where q.document_id = d.id), '{}'::jsonb), + 'index_units', coalesce((select jsonb_agg(to_jsonb(u) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] order by u.id) from public.document_index_units u where u.document_id = d.id and public.is_committed_artifact_generation(coalesce(u.artifact_generation_id, u.index_generation_id), d.metadata)), '[]'::jsonb) + )::text, + 'UTF8' + ), + 'sha256' + ), + 'hex' + ) + from public.documents d + where d.id = p_document_id + and d.owner_id = p_expected_owner_id; +$$; + +revoke all on function public.document_publication_state_digest(uuid, uuid) from public, anon, authenticated; +grant execute on function public.document_publication_state_digest(uuid, uuid) to service_role; + +create or replace function public.require_document_publication_approval_state_digest() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if new.reviewed_state_digest is null then + raise exception 'publication approval requires a reviewed content/state digest'; + end if; + return new; +end; +$$; + +revoke all on function public.require_document_publication_approval_state_digest() from public, anon, authenticated; + +drop trigger if exists document_publication_approvals_require_state_digest on public.document_publication_approvals; +create trigger document_publication_approvals_require_state_digest +before insert on public.document_publication_approvals +for each row execute function public.require_document_publication_approval_state_digest(); + create or replace function public.guard_document_publication_transition() returns trigger language plpgsql @@ -7856,6 +7920,8 @@ as $$ declare v_approval_id uuid; v_manifest_digest text; + v_reviewed_state_digest text; + v_current_state_digest text; begin if tg_op = 'INSERT' then if new.owner_id is null then @@ -7871,8 +7937,11 @@ begin raise exception 'public document transition has an invalid publication approval id'; end; v_manifest_digest := lower(coalesce(new.metadata->>'publication_manifest_digest', '')); + v_reviewed_state_digest := lower(coalesce(new.metadata->>'publication_reviewed_state_digest', '')); - if v_approval_id is null or v_manifest_digest !~ '^[0-9a-f]{64}$' then + if v_approval_id is null + or v_manifest_digest !~ '^[0-9a-f]{64}$' + or v_reviewed_state_digest !~ '^[0-9a-f]{64}$' then raise exception 'public document transition requires publication approval evidence'; end if; @@ -7884,8 +7953,26 @@ begin and approval.expected_prior_owner_id = old.owner_id and approval.decision = 'approved' and approval.manifest_digest = v_manifest_digest + and approval.reviewed_state_digest = v_reviewed_state_digest ) then - raise exception 'public document transition approval does not match the document, prior owner, decision, and manifest'; + raise exception 'public document transition approval does not match the reviewed document state'; + end if; + + perform 1 from public.document_pages where document_id = old.id for update; + perform 1 from public.document_images where document_id = old.id for update; + perform 1 from public.document_labels where document_id = old.id for update; + perform 1 from public.document_summaries where document_id = old.id for update; + perform 1 from public.document_sections where document_id = old.id for update; + perform 1 from public.document_memory_cards where document_id = old.id for update; + perform 1 from public.document_chunks where document_id = old.id for update; + perform 1 from public.document_table_facts where document_id = old.id for update; + perform 1 from public.document_embedding_fields where document_id = old.id for update; + perform 1 from public.document_index_quality where document_id = old.id for update; + perform 1 from public.document_index_units where document_id = old.id for update; + + v_current_state_digest := public.document_publication_state_digest(old.id, old.owner_id); + if v_current_state_digest is distinct from v_reviewed_state_digest then + raise exception 'public document transition content changed after review'; end if; end if; return new; @@ -7914,6 +8001,8 @@ declare v_document public.documents%rowtype; v_document_id uuid; v_expected_owner_id uuid; + v_expected_state_digest text; + v_current_state_digest text; v_approval_id uuid; v_manifest_digest text := lower(trim(coalesce(p_manifest_digest, ''))); v_count integer; @@ -7947,10 +8036,17 @@ begin exception when invalid_text_representation then raise exception 'publication manifest contains an invalid document or owner id'; end; + v_expected_state_digest := lower(coalesce(v_entry->>'expected_state_digest', '')); if v_document_id is null or v_expected_owner_id is null then raise exception 'publication manifest requires document_id and expected_owner_id'; end if; + if v_expected_state_digest !~ '^[0-9a-f]{64}$' then + raise exception 'publication manifest requires expected_state_digest'; + end if; + -- The parent lock serializes document/generation changes and conflicts with + -- FK key-share locks taken by new child rows. Existing artifact rows are + -- locked below before the canonical state digest is recomputed. select * into v_document from public.documents where id = v_document_id @@ -7965,12 +8061,25 @@ begin raise exception 'publication document % is not indexed', v_document_id; end if; + perform 1 from public.document_pages where document_id = v_document_id for update; + perform 1 from public.document_images where document_id = v_document_id for update; + perform 1 from public.document_labels where document_id = v_document_id for update; + perform 1 from public.document_summaries where document_id = v_document_id for update; + perform 1 from public.document_sections where document_id = v_document_id for update; + perform 1 from public.document_memory_cards where document_id = v_document_id for update; + perform 1 from public.document_chunks where document_id = v_document_id for update; + perform 1 from public.document_table_facts where document_id = v_document_id for update; + perform 1 from public.document_embedding_fields where document_id = v_document_id for update; + perform 1 from public.document_index_quality where document_id = v_document_id for update; + perform 1 from public.document_index_units where document_id = v_document_id for update; + select approval.id into v_approval_id from public.document_publication_approvals approval where approval.document_id = v_document_id and approval.expected_prior_owner_id = v_expected_owner_id and approval.decision = 'approved' and approval.manifest_digest = v_manifest_digest + and approval.reviewed_state_digest = v_expected_state_digest order by approval.approved_at desc, approval.id desc limit 1; if v_approval_id is null then @@ -7990,6 +8099,11 @@ begin raise exception 'publication document % has mismatched artifact ownership', v_document_id; end if; + v_current_state_digest := public.document_publication_state_digest(v_document_id, v_expected_owner_id); + if v_current_state_digest is distinct from v_expected_state_digest then + raise exception 'publication document % changed after review', v_document_id; + end if; + update public.document_labels set owner_id = null, updated_at = now() where document_id = v_document_id; update public.document_summaries set owner_id = null, updated_at = now() where document_id = v_document_id; update public.document_sections set owner_id = null, updated_at = now() where document_id = v_document_id; @@ -8005,6 +8119,7 @@ begin 'public_corpus', true, 'publication_approval_id', v_approval_id, 'publication_manifest_digest', v_manifest_digest, + 'publication_reviewed_state_digest', v_expected_state_digest, 'published_at', now() ), updated_at = now() @@ -8014,6 +8129,7 @@ begin 'document_id', v_document_id, 'previous_owner_id', v_expected_owner_id, 'approval_id', v_approval_id, + 'reviewed_state_digest', v_expected_state_digest, 'outcome', 'published' )); end loop; diff --git a/tests/publication-manifest.test.ts b/tests/publication-manifest.test.ts index caa779834a..08d2c253dc 100644 --- a/tests/publication-manifest.test.ts +++ b/tests/publication-manifest.test.ts @@ -15,6 +15,7 @@ const manifest = { { documentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", expectedOwnerId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + expectedStateDigest: "d".repeat(64), decision: "approved", }, ], @@ -70,4 +71,20 @@ describe("publication manifests", () => { ).toBe(decision); } }); + + it("requires a canonical reviewed-state digest for every decision", () => { + const withoutDigest = { + documentId: manifest.documents[0].documentId, + expectedOwnerId: manifest.documents[0].expectedOwnerId, + decision: manifest.documents[0].decision, + }; + expect(() => parsePublicationManifest(JSON.stringify({ ...manifest, documents: [withoutDigest] }))).toThrow( + /expectedStateDigest/, + ); + expect(() => + parsePublicationManifest( + JSON.stringify({ ...manifest, documents: [{ ...manifest.documents[0], expectedStateDigest: "ABC" }] }), + ), + ).toThrow(/expectedStateDigest/); + }); }); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 897268f8b3..eed1e863af 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -120,6 +120,10 @@ const publicationApprovalMigration = readFileSync( new URL("../supabase/migrations/20260717131000_guard_document_publication_approval.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const publicationReviewedStateMigration = readFileSync( + new URL("../supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); const deleteDocumentIfIdleMigration = readFileSync( new URL("../supabase/migrations/20260717132000_delete_document_if_idle.sql", import.meta.url), "utf8", @@ -1178,6 +1182,45 @@ describe("Supabase Preview replay guards", () => { } }); + it("binds publication approval to canonical reviewed content under the document lock", () => { + for (const sql of [schema, publicationReviewedStateMigration]) { + expect(sql).toContain("reviewed_state_digest"); + expect(sql).toContain("create or replace function public.document_publication_state_digest("); + expect(sql).toContain("publication approval requires a reviewed content/state digest"); + expect(sql).toContain("v_current_state_digest := public.document_publication_state_digest("); + expect(sql).toContain("publication document % changed after review"); + expect(sql).toContain("'publication_reviewed_state_digest', v_expected_state_digest"); + + const functionStart = sql.indexOf("create or replace function public.publish_approved_documents("); + const functionBody = sql.slice(functionStart, sql.indexOf("$$;", functionStart)); + for (const table of [ + "document_pages", + "document_images", + "document_labels", + "document_summaries", + "document_sections", + "document_memory_cards", + "document_chunks", + "document_table_facts", + "document_embedding_fields", + "document_index_quality", + "document_index_units", + ]) { + expect(functionBody).toContain(`perform 1 from public.${table} where document_id = v_document_id for update;`); + } + expect(functionBody.indexOf("for update;")).toBeLessThan( + functionBody.indexOf("v_current_state_digest := public.document_publication_state_digest("), + ); + + const guardStart = sql.indexOf("create or replace function public.guard_document_publication_transition("); + const guardBody = sql.slice(guardStart, sql.indexOf("$$;", guardStart)); + expect(guardBody).toContain("perform 1 from public.document_chunks where document_id = old.id for update;"); + expect(guardBody.indexOf("for update;")).toBeLessThan( + guardBody.indexOf("v_current_state_digest := public.document_publication_state_digest("), + ); + } + }); + it("serializes permanent deletion against ingestion job creation", () => { for (const sql of [schema, deleteDocumentIfIdleMigration]) { const functionStart = sql.indexOf("create or replace function public.delete_document_if_idle("); From 202c10eeb034c6384825fac3bccdfdeac5cb28bb Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:47:48 +0800 Subject: [PATCH 2/3] fix(db): align approval digest with served artifacts --- scripts/sql/verify-publication-approval.sql | 44 ++++++++++++++++++- supabase/drift-manifest.json | 8 ++-- ...publication_approval_to_reviewed_state.sql | 34 +++++++------- supabase/schema.sql | 14 +++--- tests/supabase-schema.test.ts | 13 ++++++ 5 files changed, 86 insertions(+), 27 deletions(-) diff --git a/scripts/sql/verify-publication-approval.sql b/scripts/sql/verify-publication-approval.sql index 90c49e1929..62caa4b171 100644 --- a/scripts/sql/verify-publication-approval.sql +++ b/scripts/sql/verify-publication-approval.sql @@ -12,7 +12,49 @@ values ('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Private fixture', 'private.pdf', 'application/pdf', 'fixtures/private.pdf', 'indexed'), ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Quarantine fixture', 'quarantine.pdf', 'application/pdf', 'fixtures/quarantine.pdf', 'indexed'), ('10000000-0000-4000-8000-000000000004', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Unapproved fixture', 'unapproved.pdf', 'application/pdf', 'fixtures/unapproved.pdf', 'indexed'), - ('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Post-review mutation fixture', 'changed.pdf', 'application/pdf', 'fixtures/changed.pdf', 'indexed'); + ('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Post-review mutation fixture', 'changed.pdf', 'application/pdf', 'fixtures/changed.pdf', 'indexed'), + ('10000000-0000-4000-8000-000000000007', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Generation filter fixture', 'generation.pdf', 'application/pdf', 'fixtures/generation.pdf', 'indexed'); + +update public.documents +set metadata = jsonb_build_object('index_generation_id', '20000000-0000-4000-8000-000000000001') +where id = '10000000-0000-4000-8000-000000000007'; + +-- Retrieval treats metadata as authoritative for table facts. Keep the typed +-- field deliberately stale to prove the reviewed-state digest follows the row +-- that can actually be served rather than silently excluding it. +insert into public.document_table_facts ( + id, owner_id, document_id, row_label, action, index_generation_id, metadata +) +values ( + '30000000-0000-4000-8000-000000000001', + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + '10000000-0000-4000-8000-000000000007', + 'Metadata-committed row', + 'Before review', + '20000000-0000-4000-8000-000000000002', + jsonb_build_object('index_generation_id', '20000000-0000-4000-8000-000000000001') +); + +do $$ +declare + before_digest text; + after_digest text; +begin + before_digest := public.document_publication_state_digest( + '10000000-0000-4000-8000-000000000007', + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + ); + update public.document_table_facts + set action = 'Changed after review' + where id = '30000000-0000-4000-8000-000000000001'; + after_digest := public.document_publication_state_digest( + '10000000-0000-4000-8000-000000000007', + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + ); + if before_digest = after_digest then + raise exception 'metadata-committed table fact was omitted from publication digest'; + end if; +end $$; insert into public.document_labels (document_id, owner_id, label, label_type, source) values ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'publication fixture', 'custom', 'manual'); diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 9a39242b70..2b81d08829 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-22T11:03:47.977Z", + "generated_at": "2026-07-22T11:34:03.185Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "7e39786e6be70516c9a50b53192d33a49b82f189a8c3013b0f5ed2da346a1575", - "replay_seconds": 19, + "schema_sha256": "55fc2f30011ec65d00781ad08e4afb68061f26576974db442b8c7f0f7d5c06cf", + "replay_seconds": 16, "snapshot": { "views": [ { @@ -6806,7 +6806,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "d415a919cde34ea955dfbcea162c6289", + "def_hash": "e5ae9b56178a6a13e5528ead1ae0339a", "signature": "public.document_publication_state_digest(uuid,uuid)" }, { diff --git a/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql b/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql index 66986c71bc..223d985328 100644 --- a/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql +++ b/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql @@ -35,7 +35,10 @@ as $$ select jsonb_agg(to_jsonb(i) - array['created_at', 'updated_at'] order by i.page_number nulls last, i.id) from public.document_images i where i.document_id = d.id - and public.is_committed_document_generation(i.index_generation_id, d.index_generation_id) + and ( + nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null + or public.is_committed_artifact_generation(i.metadata, d.metadata) + ) ), '[]'::jsonb), 'labels', coalesce(( select jsonb_agg(to_jsonb(l) - array['owner_id', 'created_at', 'updated_at'] order by l.id) @@ -49,10 +52,7 @@ as $$ select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.section_index, s.id) from public.document_sections s where s.document_id = d.id - and public.is_committed_artifact_generation( - coalesce(s.artifact_generation_id, s.index_generation_id), - d.metadata - ) + and public.is_committed_artifact_generation(s.metadata, d.metadata) ), '[]'::jsonb), 'memory_cards', coalesce(( select jsonb_agg( @@ -61,22 +61,29 @@ as $$ ) from public.document_memory_cards m where m.document_id = d.id - and public.is_committed_artifact_generation( - coalesce(m.artifact_generation_id, m.index_generation_id), - d.metadata + and ( + nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null + or public.is_committed_artifact_generation(m.metadata, d.metadata) ) ), '[]'::jsonb), 'chunks', coalesce(( select jsonb_agg(to_jsonb(c) - array['embedding', 'search_tsv', 'created_at'] order by c.chunk_index, c.id) from public.document_chunks c where c.document_id = d.id - and public.is_committed_document_generation(c.index_generation_id, d.index_generation_id) + and ( + public.is_committed_document_generation(c.index_generation_id, d.index_generation_id) + or nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null + or public.is_committed_artifact_generation(c.metadata, d.metadata) + ) ), '[]'::jsonb), 'table_facts', coalesce(( select jsonb_agg(to_jsonb(f) - array['owner_id', 'search_tsv', 'created_at'] order by f.id) from public.document_table_facts f where f.document_id = d.id - and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id) + and ( + nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null + or public.is_committed_artifact_generation(f.metadata, d.metadata) + ) ), '[]'::jsonb), 'embedding_fields', coalesce(( select jsonb_agg( @@ -85,7 +92,7 @@ as $$ ) from public.document_embedding_fields f where f.document_id = d.id - and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id) + and public.is_committed_artifact_generation(f.metadata, d.metadata) ), '[]'::jsonb), 'index_quality', coalesce(( select to_jsonb(q) - array['owner_id', 'updated_at'] @@ -98,10 +105,7 @@ as $$ ) from public.document_index_units u where u.document_id = d.id - and public.is_committed_artifact_generation( - coalesce(u.artifact_generation_id, u.index_generation_id), - d.metadata - ) + and public.is_committed_artifact_generation(u.metadata, d.metadata) ), '[]'::jsonb) )::text, 'UTF8' diff --git a/supabase/schema.sql b/supabase/schema.sql index 7e6c338ef6..606843c9c9 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -7867,16 +7867,16 @@ as $$ jsonb_build_object( 'document', to_jsonb(d) - array['owner_id', 'created_at', 'updated_at', 'search_tsv', 'title_search_tsv'], 'pages', coalesce((select jsonb_agg(to_jsonb(p) - array['created_at', 'updated_at'] order by p.page_number, p.id) from public.document_pages p where p.document_id = d.id), '[]'::jsonb), - 'images', coalesce((select jsonb_agg(to_jsonb(i) - array['created_at', 'updated_at'] order by i.page_number nulls last, i.id) from public.document_images i where i.document_id = d.id and public.is_committed_document_generation(i.index_generation_id, d.index_generation_id)), '[]'::jsonb), + 'images', coalesce((select jsonb_agg(to_jsonb(i) - array['created_at', 'updated_at'] order by i.page_number nulls last, i.id) from public.document_images i where i.document_id = d.id and (nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null or public.is_committed_artifact_generation(i.metadata, d.metadata))), '[]'::jsonb), 'labels', coalesce((select jsonb_agg(to_jsonb(l) - array['owner_id', 'created_at', 'updated_at'] order by l.id) from public.document_labels l where l.document_id = d.id), '[]'::jsonb), 'summaries', coalesce((select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.id) from public.document_summaries s where s.document_id = d.id), '[]'::jsonb), - 'sections', coalesce((select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.section_index, s.id) from public.document_sections s where s.document_id = d.id and public.is_committed_artifact_generation(coalesce(s.artifact_generation_id, s.index_generation_id), d.metadata)), '[]'::jsonb), - 'memory_cards', coalesce((select jsonb_agg(to_jsonb(m) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] order by m.id) from public.document_memory_cards m where m.document_id = d.id and public.is_committed_artifact_generation(coalesce(m.artifact_generation_id, m.index_generation_id), d.metadata)), '[]'::jsonb), - 'chunks', coalesce((select jsonb_agg(to_jsonb(c) - array['embedding', 'search_tsv', 'created_at'] order by c.chunk_index, c.id) from public.document_chunks c where c.document_id = d.id and public.is_committed_document_generation(c.index_generation_id, d.index_generation_id)), '[]'::jsonb), - 'table_facts', coalesce((select jsonb_agg(to_jsonb(f) - array['owner_id', 'search_tsv', 'created_at'] order by f.id) from public.document_table_facts f where f.document_id = d.id and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id)), '[]'::jsonb), - 'embedding_fields', coalesce((select jsonb_agg(to_jsonb(f) - array['owner_id', 'embedding', 'search_tsv', 'created_at'] order by f.id) from public.document_embedding_fields f where f.document_id = d.id and public.is_committed_document_generation(f.index_generation_id, d.index_generation_id)), '[]'::jsonb), + 'sections', coalesce((select jsonb_agg(to_jsonb(s) - array['owner_id', 'created_at', 'updated_at'] order by s.section_index, s.id) from public.document_sections s where s.document_id = d.id and public.is_committed_artifact_generation(s.metadata, d.metadata)), '[]'::jsonb), + 'memory_cards', coalesce((select jsonb_agg(to_jsonb(m) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] order by m.id) from public.document_memory_cards m where m.document_id = d.id and (nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null or public.is_committed_artifact_generation(m.metadata, d.metadata))), '[]'::jsonb), + 'chunks', coalesce((select jsonb_agg(to_jsonb(c) - array['embedding', 'search_tsv', 'created_at'] order by c.chunk_index, c.id) from public.document_chunks c where c.document_id = d.id and (public.is_committed_document_generation(c.index_generation_id, d.index_generation_id) or nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null or public.is_committed_artifact_generation(c.metadata, d.metadata))), '[]'::jsonb), + 'table_facts', coalesce((select jsonb_agg(to_jsonb(f) - array['owner_id', 'search_tsv', 'created_at'] order by f.id) from public.document_table_facts f where f.document_id = d.id and (nullif(coalesce(d.metadata, '{}'::jsonb)->>'index_generation_id', '') is null or public.is_committed_artifact_generation(f.metadata, d.metadata))), '[]'::jsonb), + 'embedding_fields', coalesce((select jsonb_agg(to_jsonb(f) - array['owner_id', 'embedding', 'search_tsv', 'created_at'] order by f.id) from public.document_embedding_fields f where f.document_id = d.id and public.is_committed_artifact_generation(f.metadata, d.metadata)), '[]'::jsonb), 'index_quality', coalesce((select to_jsonb(q) - array['owner_id', 'updated_at'] from public.document_index_quality q where q.document_id = d.id), '{}'::jsonb), - 'index_units', coalesce((select jsonb_agg(to_jsonb(u) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] order by u.id) from public.document_index_units u where u.document_id = d.id and public.is_committed_artifact_generation(coalesce(u.artifact_generation_id, u.index_generation_id), d.metadata)), '[]'::jsonb) + 'index_units', coalesce((select jsonb_agg(to_jsonb(u) - array['owner_id', 'embedding', 'search_tsv', 'created_at', 'updated_at'] order by u.id) from public.document_index_units u where u.document_id = d.id and public.is_committed_artifact_generation(u.metadata, d.metadata)), '[]'::jsonb) )::text, 'UTF8' ), diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index eed1e863af..5a6266e3a2 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1191,6 +1191,19 @@ describe("Supabase Preview replay guards", () => { expect(sql).toContain("publication document % changed after review"); expect(sql).toContain("'publication_reviewed_state_digest', v_expected_state_digest"); + const digestStart = sql.indexOf("create or replace function public.document_publication_state_digest("); + const digestBody = sql.slice(digestStart, sql.indexOf("$$;", digestStart)); + for (const tableAlias of ["i", "s", "m", "f", "u"]) { + expect(digestBody).toContain(`public.is_committed_artifact_generation(${tableAlias}.metadata, d.metadata)`); + } + expect(digestBody).toContain( + "public.is_committed_document_generation(c.index_generation_id, d.index_generation_id)", + ); + expect(digestBody).toContain("public.is_committed_artifact_generation(c.metadata, d.metadata)"); + expect(digestBody).not.toContain( + "public.is_committed_document_generation(f.index_generation_id, d.index_generation_id)", + ); + const functionStart = sql.indexOf("create or replace function public.publish_approved_documents("); const functionBody = sql.slice(functionStart, sql.indexOf("$$;", functionStart)); for (const table of [ From 79dadbc46e5694ad7ea2232cdc14329632d40943 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:57:15 +0800 Subject: [PATCH 3/3] fix(db): block publication during active ingestion --- scripts/sql/verify-publication-approval.sql | 32 +++++++++++++++- supabase/drift-manifest.json | 10 ++--- ...publication_approval_to_reviewed_state.sql | 38 +++++++++++++++++++ supabase/schema.sql | 38 +++++++++++++++++++ tests/supabase-schema.test.ts | 8 ++++ 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/scripts/sql/verify-publication-approval.sql b/scripts/sql/verify-publication-approval.sql index 62caa4b171..fc4c444ead 100644 --- a/scripts/sql/verify-publication-approval.sql +++ b/scripts/sql/verify-publication-approval.sql @@ -13,7 +13,8 @@ values ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Quarantine fixture', 'quarantine.pdf', 'application/pdf', 'fixtures/quarantine.pdf', 'indexed'), ('10000000-0000-4000-8000-000000000004', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Unapproved fixture', 'unapproved.pdf', 'application/pdf', 'fixtures/unapproved.pdf', 'indexed'), ('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Post-review mutation fixture', 'changed.pdf', 'application/pdf', 'fixtures/changed.pdf', 'indexed'), - ('10000000-0000-4000-8000-000000000007', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Generation filter fixture', 'generation.pdf', 'application/pdf', 'fixtures/generation.pdf', 'indexed'); + ('10000000-0000-4000-8000-000000000007', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Generation filter fixture', 'generation.pdf', 'application/pdf', 'fixtures/generation.pdf', 'indexed'), + ('10000000-0000-4000-8000-000000000008', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Active ingestion fixture', 'active.pdf', 'application/pdf', 'fixtures/active.pdf', 'indexed'); update public.documents set metadata = jsonb_build_object('index_generation_id', '20000000-0000-4000-8000-000000000001') @@ -59,6 +60,9 @@ end $$; insert into public.document_labels (document_id, owner_id, label, label_type, source) values ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'publication fixture', 'custom', 'manual'); +insert into public.ingestion_jobs (document_id, status, stage) +values ('10000000-0000-4000-8000-000000000008', 'pending', 'queued'); + insert into public.document_publication_approvals ( document_id, expected_prior_owner_id, approving_operator_id, decision, reason, evidence_references, manifest_digest, reviewed_state_digest @@ -67,7 +71,8 @@ values ('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Approved publication fixture.', array['fixture:approved'], repeat('a', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000001', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')), ('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'keep_private', 'Private publication fixture.', array['fixture:private'], repeat('a', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000002', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')), ('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'quarantine', 'Quarantine publication fixture.', array['fixture:quarantine'], repeat('a', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000003', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')), - ('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Mutation protection fixture.', array['fixture:changed'], repeat('b', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')); + ('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Mutation protection fixture.', array['fixture:changed'], repeat('b', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000006', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')), + ('10000000-0000-4000-8000-000000000008', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'approved', 'Active ingestion protection fixture.', array['fixture:active'], repeat('c', 64), public.document_publication_state_digest('10000000-0000-4000-8000-000000000008', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')); select public.publish_approved_documents( jsonb_build_array(jsonb_build_object( @@ -119,6 +124,29 @@ begin if sqlerrm not like 'publication document % changed after review' then raise; end if; end; + begin + perform public.publish_approved_documents( + jsonb_build_array(jsonb_build_object( + 'document_id', '10000000-0000-4000-8000-000000000008', + 'expected_owner_id', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'expected_state_digest', ( + select reviewed_state_digest from public.document_publication_approvals + where document_id = '10000000-0000-4000-8000-000000000008' + ) + )), + repeat('c', 64), + 1 + ); + raise exception 'active ingestion fixture unexpectedly published'; + exception when others then + if sqlerrm = 'active ingestion fixture unexpectedly published' then raise; end if; + if sqlerrm not like 'publication document % has active ingestion work' then raise; end if; + end; + if exists ( + select 1 from public.documents + where id = '10000000-0000-4000-8000-000000000008' and owner_id is null + ) then raise exception 'active ingestion fixture became public'; end if; + begin update public.documents set owner_id = null, metadata = metadata || jsonb_build_object('public_corpus', true) diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 2b81d08829..93fc78f202 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-22T11:34:03.185Z", + "generated_at": "2026-07-22T11:50:53.425Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "55fc2f30011ec65d00781ad08e4afb68061f26576974db442b8c7f0f7d5c06cf", - "replay_seconds": 16, + "schema_sha256": "d0cd017793cb2ffeb5e50462377c06d0f574fe6b6a295da9f32af404076b245b", + "replay_seconds": 15, "snapshot": { "views": [ { @@ -6869,7 +6869,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "03cd491adfb5390a52a5339cc96190a1", + "def_hash": "248e7a470031632101db7d22b1dbd006", "signature": "public.guard_document_publication_transition()" }, { @@ -7125,7 +7125,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "ae1bcb442fe7ee0536ad161e7226c505", + "def_hash": "072da246344dd56a204b715a35c5a015", "signature": "public.publish_approved_documents(jsonb,text,integer)" }, { diff --git a/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql b/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql index 223d985328..6e45e4226d 100644 --- a/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql +++ b/supabase/migrations/20260722190000_bind_publication_approval_to_reviewed_state.sql @@ -200,6 +200,25 @@ begin perform 1 from public.document_index_quality where document_id = old.id for update; perform 1 from public.document_index_units where document_id = old.id for update; + begin + perform 1 from public.ingestion_jobs where document_id = old.id for update nowait; + perform 1 from public.indexing_v3_agent_jobs where document_id = old.id for update nowait; + exception when lock_not_available then + raise exception 'public document transition has active ingestion work'; + end; + if exists ( + select 1 from public.ingestion_jobs + where document_id = old.id and status in ('pending', 'processing') + ) or exists ( + select 1 from public.indexing_v3_agent_jobs + where document_id = old.id + and status not in ('completed', 'needs_enrichment_artifacts') + and enrichment_status in ('pending', 'failed', 'processing') + and attempt_count < max_attempts + ) then + raise exception 'public document transition has active ingestion work'; + end if; + v_current_state_digest := public.document_publication_state_digest(old.id, old.owner_id); if v_current_state_digest is distinct from v_reviewed_state_digest then raise exception 'public document transition content changed after review'; @@ -299,6 +318,25 @@ begin perform 1 from public.document_index_quality where document_id = v_document_id for update; perform 1 from public.document_index_units where document_id = v_document_id for update; + begin + perform 1 from public.ingestion_jobs where document_id = v_document_id for update nowait; + perform 1 from public.indexing_v3_agent_jobs where document_id = v_document_id for update nowait; + exception when lock_not_available then + raise exception 'publication document % has active ingestion work', v_document_id; + end; + if exists ( + select 1 from public.ingestion_jobs + where document_id = v_document_id and status in ('pending', 'processing') + ) or exists ( + select 1 from public.indexing_v3_agent_jobs + where document_id = v_document_id + and status not in ('completed', 'needs_enrichment_artifacts') + and enrichment_status in ('pending', 'failed', 'processing') + and attempt_count < max_attempts + ) then + raise exception 'publication document % has active ingestion work', v_document_id; + end if; + select approval.id into v_approval_id from public.document_publication_approvals approval where approval.document_id = v_document_id diff --git a/supabase/schema.sql b/supabase/schema.sql index 606843c9c9..4bf043c541 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -7970,6 +7970,25 @@ begin perform 1 from public.document_index_quality where document_id = old.id for update; perform 1 from public.document_index_units where document_id = old.id for update; + begin + perform 1 from public.ingestion_jobs where document_id = old.id for update nowait; + perform 1 from public.indexing_v3_agent_jobs where document_id = old.id for update nowait; + exception when lock_not_available then + raise exception 'public document transition has active ingestion work'; + end; + if exists ( + select 1 from public.ingestion_jobs + where document_id = old.id and status in ('pending', 'processing') + ) or exists ( + select 1 from public.indexing_v3_agent_jobs + where document_id = old.id + and status not in ('completed', 'needs_enrichment_artifacts') + and enrichment_status in ('pending', 'failed', 'processing') + and attempt_count < max_attempts + ) then + raise exception 'public document transition has active ingestion work'; + end if; + v_current_state_digest := public.document_publication_state_digest(old.id, old.owner_id); if v_current_state_digest is distinct from v_reviewed_state_digest then raise exception 'public document transition content changed after review'; @@ -8073,6 +8092,25 @@ begin perform 1 from public.document_index_quality where document_id = v_document_id for update; perform 1 from public.document_index_units where document_id = v_document_id for update; + begin + perform 1 from public.ingestion_jobs where document_id = v_document_id for update nowait; + perform 1 from public.indexing_v3_agent_jobs where document_id = v_document_id for update nowait; + exception when lock_not_available then + raise exception 'publication document % has active ingestion work', v_document_id; + end; + if exists ( + select 1 from public.ingestion_jobs + where document_id = v_document_id and status in ('pending', 'processing') + ) or exists ( + select 1 from public.indexing_v3_agent_jobs + where document_id = v_document_id + and status not in ('completed', 'needs_enrichment_artifacts') + and enrichment_status in ('pending', 'failed', 'processing') + and attempt_count < max_attempts + ) then + raise exception 'publication document % has active ingestion work', v_document_id; + end if; + select approval.id into v_approval_id from public.document_publication_approvals approval where approval.document_id = v_document_id diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 5a6266e3a2..3cbbbfad5a 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1221,6 +1221,12 @@ describe("Supabase Preview replay guards", () => { ]) { expect(functionBody).toContain(`perform 1 from public.${table} where document_id = v_document_id for update;`); } + for (const table of ["ingestion_jobs", "indexing_v3_agent_jobs"]) { + expect(functionBody).toContain( + `perform 1 from public.${table} where document_id = v_document_id for update nowait;`, + ); + } + expect(functionBody).toContain("publication document % has active ingestion work"); expect(functionBody.indexOf("for update;")).toBeLessThan( functionBody.indexOf("v_current_state_digest := public.document_publication_state_digest("), ); @@ -1228,6 +1234,8 @@ describe("Supabase Preview replay guards", () => { const guardStart = sql.indexOf("create or replace function public.guard_document_publication_transition("); const guardBody = sql.slice(guardStart, sql.indexOf("$$;", guardStart)); expect(guardBody).toContain("perform 1 from public.document_chunks where document_id = old.id for update;"); + expect(guardBody).toContain("perform 1 from public.ingestion_jobs where document_id = old.id for update nowait;"); + expect(guardBody).toContain("public document transition has active ingestion work"); expect(guardBody.indexOf("for update;")).toBeLessThan( guardBody.indexOf("v_current_state_digest := public.document_publication_state_digest("), );