Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions scripts/audit-public-document-approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ async function main() {
const approvalIds = documents
.map((document) => String(document.metadata?.publication_approval_id ?? ""))
.filter(Boolean);
const approvals = new Map<string, { document_id: string; manifest_digest: string; decision: string }>();
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);
Expand All @@ -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
);
});

Expand Down
26 changes: 22 additions & 4 deletions scripts/promote-public-documents-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`);
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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;
Expand Down
114 changes: 106 additions & 8 deletions scripts/sql/verify-publication-approval.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,74 @@ 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'),
('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')
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');

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
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')),
('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(
'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
Expand All @@ -51,6 +102,51 @@ 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
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)
Expand Down Expand Up @@ -82,15 +178,17 @@ 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',
'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
'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
Expand Down
1 change: 1 addition & 0 deletions src/lib/publication-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
)
Expand Down
7 changes: 7 additions & 0 deletions src/lib/supabase/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,7 @@ export type Database = {
id: string;
manifest_digest: string;
reason: string;
reviewed_state_digest: string | null;
};
Insert: {
approved_at?: string;
Expand All @@ -911,6 +912,7 @@ export type Database = {
id?: string;
manifest_digest: string;
reason: string;
reviewed_state_digest?: string | null;
};
Update: {
approved_at?: string;
Expand All @@ -922,6 +924,7 @@ export type Database = {
id?: string;
manifest_digest?: string;
reason?: string;
reviewed_state_digest?: string | null;
};
Relationships: [];
};
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 39 additions & 5 deletions supabase/drift-manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"generated_at": "2026-07-22T03:09:35.765Z",
"generated_at": "2026-07-22T11:50:53.425Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127",
"schema_sha256": "4dfef34464628457daf82681fdf0c1e7a6beebd20fb24c84780cb4e4cce51fc7",
"replay_seconds": 16,
"schema_sha256": "d0cd017793cb2ffeb5e50462377c06d0f574fe6b6a295da9f32af404076b245b",
"replay_seconds": 15,
"snapshot": {
"views": [
{
Expand Down Expand Up @@ -1897,6 +1897,14 @@
"identity": "",
"not_null": true,
"generated": ""
},
{
"name": "reviewed_state_digest",
"type": "text",
"default": null,
"identity": "",
"not_null": false,
"generated": ""
}
],
"reloptions": null,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -6788,6 +6801,14 @@
"def_hash": "143cc43d9a00f140f3da8f9c054db74a",
"signature": "public.document_label_metadata(uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
"def_hash": "e5ae9b56178a6a13e5528ead1ae0339a",
"signature": "public.document_publication_state_digest(uuid,uuid)"
},
{
"acl": [
"postgres=X/postgres",
Expand Down Expand Up @@ -6848,7 +6869,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
"def_hash": "2fef16fbe1d1e07658b5ce051f6e7d7a",
"def_hash": "248e7a470031632101db7d22b1dbd006",
"signature": "public.guard_document_publication_transition()"
},
{
Expand Down Expand Up @@ -7104,7 +7125,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
"def_hash": "406d621ed9ad3b052b9282e7e980813a",
"def_hash": "072da246344dd56a204b715a35c5a015",
"signature": "public.publish_approved_documents(jsonb,text,integer)"
},
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading