diff --git a/docs/codebase-index.md b/docs/codebase-index.md index ab7f891614..873cd20bd3 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -959,7 +959,7 @@ due time, the note's id and that phone's endpoint in `alert_reminder_times`, nev Administrator source intake also records admission events in `document_admission_events`. -`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `document_publication_approvals`, `document_corpus_access_state`, `document_corpus_access_snapshots`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `clinical_quality_feedback_triage`, `clinical_quality_feedback_triage_events`, `medication_records`, `differential_records`, `source_review_events`, `user_favourites`, `user_favourite_sets`, `user_preferences`, `work_admin_paperwork`, `work_backups`, `work_booking_courses`, `work_course_bookings`, `work_hospitals`, `work_hospital_teams`, `work_role_grants`, `alert_reminder_times`, `alert_brief_sent`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs`, `on_call_entries`, `cme_years`, `cme_requirements`, `cme_routines`, `cme_entries`, `cme_allocations`, `cme_evidence`, `cme_year_snapshots`, `cme_year_amendments`, `cme_plan_goals`, `cme_plan_goal_carries`, `cme_entry_goals`, `cme_training_periods`, `cme_training_milestones`, `cme_missed_sessions`, `cme_entry_drafts`, `calendar_feed_tokens`, `on_call_shift_imports`, `on_call_shifts`, `on_call_services`, `on_call_service_sites`, `on_call_service_members`, `on_call_service_invitations`, `on_call_service_entries`, `on_call_service_reports`, `on_call_service_orientation`, `site_content_publications`, `site_content_reconciliation_plans`, `site_content_public_records`, `site_content_sync_state`, `site_content_sync_events`, `site_content_sync_event_plans`, `site_content_sync_worker_invocations`, `site_content_releases`, `site_content_release_records`, `site_content_release_receipts` +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_evidence_revisions`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `document_publication_approvals`, `document_corpus_access_state`, `document_corpus_access_snapshots`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `clinical_quality_feedback_triage`, `clinical_quality_feedback_triage_events`, `medication_records`, `differential_records`, `source_review_events`, `user_favourites`, `user_favourite_sets`, `user_preferences`, `work_admin_paperwork`, `work_backups`, `work_booking_courses`, `work_course_bookings`, `work_hospitals`, `work_hospital_teams`, `work_role_grants`, `alert_reminder_times`, `alert_brief_sent`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs`, `on_call_entries`, `cme_years`, `cme_requirements`, `cme_routines`, `cme_entries`, `cme_allocations`, `cme_evidence`, `cme_year_snapshots`, `cme_year_amendments`, `cme_plan_goals`, `cme_plan_goal_carries`, `cme_entry_goals`, `cme_training_periods`, `cme_training_milestones`, `cme_missed_sessions`, `cme_entry_drafts`, `calendar_feed_tokens`, `on_call_shift_imports`, `on_call_shifts`, `on_call_services`, `on_call_service_sites`, `on_call_service_members`, `on_call_service_invitations`, `on_call_service_entries`, `on_call_service_reports`, `on_call_service_orientation`, `site_content_publications`, `site_content_reconciliation_plans`, `site_content_public_records`, `site_content_sync_state`, `site_content_sync_events`, `site_content_sync_event_plans`, `site_content_sync_worker_invocations`, `site_content_releases`, `site_content_release_records`, `site_content_release_receipts` Team modes (Roster, Admin, Teaching): `admin_leave_balances`, `admin_settings`, `extra_time_records`, `on_call_service_member_events`, `registrar_assessments`, `registrar_training_milestones`, `roster_assignments`, `roster_calendar_links`, `roster_change_agreements`, `roster_changes`, `roster_draft_assignments`, `roster_drafts`, `roster_leave`, `roster_member_roles`, `roster_open_post_alerts`, `roster_open_post_keys`, `roster_open_shifts`, `roster_publication_protections`, `roster_publication_seen`, `roster_publications`, `roster_rotation_preferences`, `roster_rotation_rounds`, `roster_shift_codes`, `roster_staffing_needs`, `roster_swaps`, `roster_team_settings`, `roster_unavailability`, `teaching_attendance`, `teaching_audit_events`, `teaching_calendar_optins`, `teaching_checkin_claims`, `teaching_collection_sections`, `teaching_collections`, `teaching_display_links`, `teaching_feedback_answers`, `teaching_feedback_replied`, `teaching_group_members`, `teaching_groups`, `teaching_member_roles`, `teaching_notice_reads`, `teaching_notices`, `teaching_occurrences`, `teaching_readiness`, `teaching_resource_saves`, `teaching_resources`, `teaching_series`, `teaching_supervision_entries`, `teaching_supervision_notes`, `teaching_supervision_pairings`, `teaching_team_settings`, `teaching_week_adds`, `web_push_subscriptions`. diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index cc0e669797..bdb8e7cc94 100644 --- a/supabase/applied-migration-hashes.json +++ b/supabase/applied-migration-hashes.json @@ -1,7 +1,7 @@ { "note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.", "generator": "scripts/check-migration-immutability.mjs", - "sealed_at": "2026-10-18T08:41:00.862Z", + "sealed_at": "2026-10-18T09:59:47.045Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -324,6 +324,7 @@ "20261017175448_scope_ingestion_claim_atomically.sql": "af192755dd24aa8a6e6146013e1b7abd1b2954a53410c301dedfcb7576c88eba", "20261018053558_recheck_ingestion_claim_scope_after_job_lock.sql": "a427b8aacce70579ad26bd701adb65912a345ab78d54e5b34386bbd05d261b6c", "20261018062736_recheck_legacy_ingestion_claim_document_after_job_lock.sql": "39f08a91ed4c15a8aff0dff80341242604101886de873c5cadafa65e9b92e3c8", - "20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql": "f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac" + "20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql": "f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac", + "20261018095804_rag_cache_scope_evidence_revision.sql": "0a3c6845cda4d8feb22c54dbf176c7ece61da0333c4a75edb0664b3046a37994" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index fb59a19c60..6c0aa4b3f7 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,8 +1,8 @@ { - "generated_at": "2026-10-18T06:37:18.983Z", + "generated_at": "2026-10-18T10:29:05.818Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "ddba5299f794d7be628fe0e9a85a4f7c4c4218bd29854c9e209a25a3c24909e5", + "schema_sha256": "deeee8f86fe40c6e07c7ff56e5c97a85cab409112af961eda4403b9f4384dadf", "replay_seconds": 14, "snapshot": { "views": [ @@ -2979,6 +2979,34 @@ "rls_forced": false, "rls_enabled": true }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=arwdDxtm/postgres" + ], + "name": "document_evidence_revisions", + "columns": [ + { + "name": "document_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "revision", + "type": "bigint", + "default": "0", + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, { "acl": [ "postgres=arwdDxtm/postgres", @@ -14709,6 +14737,12 @@ "table": "document_embedding_fields", "def_hash": "7224a35b50c37a7394210ca930e4e99f" }, + { + "def": "CREATE UNIQUE INDEX document_evidence_revisions_pkey ON public.document_evidence_revisions USING btree (document_id)", + "name": "document_evidence_revisions_pkey", + "table": "document_evidence_revisions", + "def_hash": "2615588a76c4e5926413aec57fd7d419" + }, { "def": "CREATE INDEX document_images_created_at_idx ON public.document_images USING btree (created_at)", "name": "document_images_created_at_idx", @@ -18188,6 +18222,21 @@ "name": "set_document_embedding_field_content_hash", "table": "document_embedding_fields" }, + { + "def": "CREATE TRIGGER document_images_evidence_delete AFTER DELETE ON public.document_images REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_images_evidence_delete", + "table": "document_images" + }, + { + "def": "CREATE TRIGGER document_images_evidence_insert AFTER INSERT ON public.document_images REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_images_evidence_insert", + "table": "document_images" + }, + { + "def": "CREATE TRIGGER document_images_evidence_update AFTER UPDATE ON public.document_images REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_images_evidence_update", + "table": "document_images" + }, { "def": "CREATE TRIGGER document_index_units_updated_at BEFORE UPDATE ON public.document_index_units FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "document_index_units_updated_at", @@ -18213,6 +18262,21 @@ "name": "document_sections_updated_at", "table": "document_sections" }, + { + "def": "CREATE TRIGGER document_table_facts_evidence_delete AFTER DELETE ON public.document_table_facts REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_table_facts_evidence_delete", + "table": "document_table_facts" + }, + { + "def": "CREATE TRIGGER document_table_facts_evidence_insert AFTER INSERT ON public.document_table_facts REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_table_facts_evidence_insert", + "table": "document_table_facts" + }, + { + "def": "CREATE TRIGGER document_table_facts_evidence_update AFTER UPDATE ON public.document_table_facts REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_table_facts_evidence_update", + "table": "document_table_facts" + }, { "def": "CREATE TRIGGER document_title_words_enforce_public_scope BEFORE INSERT OR UPDATE ON public.document_title_words FOR EACH ROW EXECUTE FUNCTION public.enforce_document_title_word_scope()", "name": "document_title_words_enforce_public_scope", @@ -18620,6 +18684,13 @@ "def_hash": "f02e0c8d223bb2621468b958df75f1f1", "signature": "public.bind_public_source_upload_authority(jsonb)" }, + { + "acl": [ + "postgres=X/postgres" + ], + "def_hash": "e91b4cce6223c04f8e34780d31eb6aa9", + "signature": "public.bump_document_evidence_revisions(uuid[])" + }, { "acl": [ "postgres=X/postgres", @@ -19086,6 +19157,13 @@ "def_hash": "37f32cc93a66d9ce45bdcecf263ace70", "signature": "public.detect_legacy_ivfflat_indexes()" }, + { + "acl": [ + "postgres=X/postgres" + ], + "def_hash": "58f5ef7258095fc953849fdb45a832d1", + "signature": "public.document_evidence_rows_changed()" + }, { "acl": [ "postgres=X/postgres", @@ -19724,7 +19802,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "6495e84c303f7bfb857743ff77c613ee", + "def_hash": "6e2e9461e611d13573b2e1aea26f0d5e", "signature": "public.rag_cache_scope_revision_v1(uuid,boolean,uuid[])" }, { @@ -22154,6 +22232,16 @@ "name": "document_embedding_fields_source_chunk_id_fkey", "table": "document_embedding_fields" }, + { + "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", + "name": "document_evidence_revisions_document_id_fkey", + "table": "document_evidence_revisions" + }, + { + "def": "PRIMARY KEY (document_id)", + "name": "document_evidence_revisions_pkey", + "table": "document_evidence_revisions" + }, { "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", "name": "document_images_document_id_fkey", diff --git a/supabase/migrations/20261018095804_rag_cache_scope_evidence_revision.sql b/supabase/migrations/20261018095804_rag_cache_scope_evidence_revision.sql new file mode 100644 index 0000000000..9ca9970047 --- /dev/null +++ b/supabase/migrations/20261018095804_rag_cache_scope_evidence_revision.sql @@ -0,0 +1,91 @@ +-- DRAFT. Merge only inside the owner's approved database-change window, and only after +-- 20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql (it replaces that +-- migration's rag_cache_scope_revision_v1). Owner approval: 17:55 AWST 2026-10-18, relayed by Grok Bot. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- Cached answers and searches were keyed by a revision that hashed only document rows, so an +-- edit to a table fact or image (threshold, action, caption, relevance, searchability, ...) +-- left the revision unchanged and stale evidence could be served from cache. Each document now +-- carries an evidence counter, bumped in the writing transaction by statement-level triggers +-- whenever a retrieval-relevant fact or image row is inserted, deleted or changed. The scope +-- revision stays a single pass over documents plus one keyed join: no per-request scan of +-- facts or images. +create table public.document_evidence_revisions ( + document_id uuid primary key references public.documents(id) on delete cascade, + revision bigint not null default 0 +); +alter table public.document_evidence_revisions owner to postgres; +alter table public.document_evidence_revisions enable row level security; +revoke all on table public.document_evidence_revisions from public, anon, authenticated; +grant all on table public.document_evidence_revisions to service_role; + +-- Bumps once per touched document per statement. Documents removed in the same statement +-- (cascade from public.documents) are skipped; their rows leave the scope revision anyway. +create function public.bump_document_evidence_revisions(p_document_ids uuid[]) returns void +language sql security definer set search_path = '' as $$ + insert into public.document_evidence_revisions as r(document_id, revision) + select d.id, 1 + from public.documents d + where d.id = any(p_document_ids) + order by d.id + on conflict (document_id) do update set revision = r.revision + 1 +$$; +alter function public.bump_document_evidence_revisions(uuid[]) owner to postgres; +revoke all on function public.bump_document_evidence_revisions(uuid[]) from public, anon, authenticated; + +-- Retrieval-relevant means every stored column except created_at (and generated search_tsv, +-- which follows its sources). A no-op UPDATE does not bump the counter. +create function public.document_evidence_rows_changed() returns trigger +language plpgsql security definer set search_path = '' as $$ +begin + if tg_op = 'INSERT' then + perform public.bump_document_evidence_revisions(array(select distinct n.document_id from new_rows n)); + elsif tg_op = 'DELETE' then + perform public.bump_document_evidence_revisions(array(select distinct o.document_id from old_rows o)); + else + perform public.bump_document_evidence_revisions(array( + select distinct x.document_id from ( + select o.document_id as old_document_id, n.document_id as new_document_id + from old_rows o join new_rows n on n.id = o.id + where (pg_catalog.to_jsonb(o) - array['created_at','search_tsv']) + is distinct from (pg_catalog.to_jsonb(n) - array['created_at','search_tsv']) + ) changed + cross join lateral (values (changed.old_document_id), (changed.new_document_id)) x(document_id) + )); + end if; + return null; +end $$; +alter function public.document_evidence_rows_changed() owner to postgres; +revoke all on function public.document_evidence_rows_changed() from public, anon, authenticated; + +create trigger document_table_facts_evidence_insert after insert on public.document_table_facts + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_update after update on public.document_table_facts + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_delete after delete on public.document_table_facts + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_insert after insert on public.document_images + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_update after update on public.document_images + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_delete after delete on public.document_images + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); + +-- Same signature, digest shape and grants; each document's row hash now also carries its +-- evidence counter. +create or replace function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id, + coalesce(r.revision, 0))::text) as row_hash + from public.documents d + left join public.document_evidence_revisions r on r.document_id = d.id + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 8e6db93a47..56a7525fb7 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -35492,3 +35492,96 @@ end $$; alter function public.teaching_depth_command(uuid,uuid,text,jsonb) owner to postgres; revoke all on function public.teaching_depth_command(uuid,uuid,text,jsonb) from public, anon, authenticated; grant execute on function public.teaching_depth_command(uuid,uuid,text,jsonb) to service_role; + +-- Projection: 20261018095804_rag_cache_scope_evidence_revision.sql +-- DRAFT. Merge only inside the owner's approved database-change window, and only after +-- 20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql (it replaces that +-- migration's rag_cache_scope_revision_v1). Owner approval: 17:55 AWST 2026-10-18, relayed by Grok Bot. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- Cached answers and searches were keyed by a revision that hashed only document rows, so an +-- edit to a table fact or image (threshold, action, caption, relevance, searchability, ...) +-- left the revision unchanged and stale evidence could be served from cache. Each document now +-- carries an evidence counter, bumped in the writing transaction by statement-level triggers +-- whenever a retrieval-relevant fact or image row is inserted, deleted or changed. The scope +-- revision stays a single pass over documents plus one keyed join: no per-request scan of +-- facts or images. +create table public.document_evidence_revisions ( + document_id uuid primary key references public.documents(id) on delete cascade, + revision bigint not null default 0 +); +alter table public.document_evidence_revisions owner to postgres; +alter table public.document_evidence_revisions enable row level security; +revoke all on table public.document_evidence_revisions from public, anon, authenticated; +grant all on table public.document_evidence_revisions to service_role; + +-- Bumps once per touched document per statement. Documents removed in the same statement +-- (cascade from public.documents) are skipped; their rows leave the scope revision anyway. +create function public.bump_document_evidence_revisions(p_document_ids uuid[]) returns void +language sql security definer set search_path = '' as $$ + insert into public.document_evidence_revisions as r(document_id, revision) + select d.id, 1 + from public.documents d + where d.id = any(p_document_ids) + order by d.id + on conflict (document_id) do update set revision = r.revision + 1 +$$; +alter function public.bump_document_evidence_revisions(uuid[]) owner to postgres; +revoke all on function public.bump_document_evidence_revisions(uuid[]) from public, anon, authenticated; + +-- Retrieval-relevant means every stored column except created_at (and generated search_tsv, +-- which follows its sources). A no-op UPDATE does not bump the counter. +create function public.document_evidence_rows_changed() returns trigger +language plpgsql security definer set search_path = '' as $$ +begin + if tg_op = 'INSERT' then + perform public.bump_document_evidence_revisions(array(select distinct n.document_id from new_rows n)); + elsif tg_op = 'DELETE' then + perform public.bump_document_evidence_revisions(array(select distinct o.document_id from old_rows o)); + else + perform public.bump_document_evidence_revisions(array( + select distinct x.document_id from ( + select o.document_id as old_document_id, n.document_id as new_document_id + from old_rows o join new_rows n on n.id = o.id + where (pg_catalog.to_jsonb(o) - array['created_at','search_tsv']) + is distinct from (pg_catalog.to_jsonb(n) - array['created_at','search_tsv']) + ) changed + cross join lateral (values (changed.old_document_id), (changed.new_document_id)) x(document_id) + )); + end if; + return null; +end $$; +alter function public.document_evidence_rows_changed() owner to postgres; +revoke all on function public.document_evidence_rows_changed() from public, anon, authenticated; + +create trigger document_table_facts_evidence_insert after insert on public.document_table_facts + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_update after update on public.document_table_facts + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_delete after delete on public.document_table_facts + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_insert after insert on public.document_images + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_update after update on public.document_images + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_delete after delete on public.document_images + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); + +-- Same signature, digest shape and grants; each document's row hash now also carries its +-- evidence counter. +create or replace function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id, + coalesce(r.revision, 0))::text) as row_hash + from public.documents d + left join public.document_evidence_revisions r on r.document_id = d.id + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; diff --git a/tests/rag-cache-governance-revocation.test.ts b/tests/rag-cache-governance-revocation.test.ts index a968663723..22208e4c2b 100644 --- a/tests/rag-cache-governance-revocation.test.ts +++ b/tests/rag-cache-governance-revocation.test.ts @@ -84,6 +84,9 @@ function installStorage() { owner_id: ownerId as string | null, status: "indexed", updated_at: "2026-10-16T00:00:00.000Z", + // Models public.document_evidence_revisions: bumped by the DB triggers when a table fact or + // image of this document changes (tests/sql/rag-cache-scope-evidence-revision.sql proves it). + evidence_revision: 0, metadata: { indexed_at: "2026-10-15T00:00:00.000Z", index_generation_id: "generation-1", @@ -264,6 +267,28 @@ describe("durable source-governance cache fence (F13)", () => { }, ); + it.each(["table fact threshold", "image caption"])( + "rejects retained local and shared entries after a %s edit that leaves the document row unchanged", + async () => { + const { cache, state, document } = await setup(); + await seedBoth(cache, state); + const start = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + expect(await cache.getCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).not.toBeNull(); + // Another replica edits evidence only: updated_at, status and metadata stay as they were. + document.evidence_revision += 1; + const after = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + expect(after).not.toBe(start); + expect(await cache.getCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toBeNull(); + expect(await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getSharedCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toMatchObject({ kind: "miss" }); + }, + ); + it("keeps ordinary current-source hits and keeps the fresh-metadata refusal control", async () => { const { cache, state } = await setup(); await seedBoth(cache, state); diff --git a/tests/sql/rag-cache-scope-evidence-revision.sql b/tests/sql/rag-cache-scope-evidence-revision.sql new file mode 100644 index 0000000000..424fe99bf8 --- /dev/null +++ b/tests/sql/rag-cache-scope-evidence-revision.sql @@ -0,0 +1,73 @@ +-- Disposable database only. Proves table-fact and image edits change the RAG cache scope revision. +\set ON_ERROR_STOP on +begin; +set local statement_timeout = '60s'; +create function pg_temp.assert_true(ok boolean, message text) returns void +language plpgsql as $$ begin + if ok is distinct from true then raise exception '%', message; end if; +end $$; +create function pg_temp.rev() returns text language sql as $$ + select public.rag_cache_scope_revision_v1('11111111-1111-4111-8111-111111111111'::uuid, false, null) +$$; +create function pg_temp.other_rev() returns text language sql as $$ + select public.rag_cache_scope_revision_v1('11111111-1111-4111-8111-111111111111'::uuid, false, + array['00000000-0000-4000-8000-0000000000e2']::uuid[]) +$$; +select pg_temp.assert_true(not exists (select 1 from public.documents), 'requires an empty disposable database'); +insert into auth.users(id) values ('11111111-1111-4111-8111-111111111111'); +insert into public.documents(id, owner_id, title, file_name, file_type, storage_path, status, metadata) +values + ('00000000-0000-4000-8000-0000000000e1', '11111111-1111-4111-8111-111111111111', + 'Synthetic evidence', 'fixture.pdf', 'application/pdf', 'synthetic/evidence-1', 'indexed', '{}'), + ('00000000-0000-4000-8000-0000000000e2', '11111111-1111-4111-8111-111111111111', + 'Synthetic other', 'other.pdf', 'application/pdf', 'synthetic/evidence-2', 'indexed', '{}'); + +create temp table seen(step text primary key, rev text not null, other text not null); +insert into seen values ('start', pg_temp.rev(), pg_temp.other_rev()); + +insert into public.document_table_facts(id, owner_id, document_id, table_title, clinical_parameter, threshold_value, action) +values ('00000000-0000-4000-8000-0000000000f1', '11111111-1111-4111-8111-111111111111', + '00000000-0000-4000-8000-0000000000e1', 'Synthetic table', 'synthetic level', '> 1', 'review'); +insert into seen values ('fact insert', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'fact insert') <> (select rev from seen where step = 'start'), + 'inserting a table fact must change the revision'); + +update public.document_table_facts set threshold_value = '> 2' where id = '00000000-0000-4000-8000-0000000000f1'; +insert into seen values ('fact threshold', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'fact threshold') <> (select rev from seen where step = 'fact insert'), + 'editing a fact threshold must change the revision'); + +update public.document_table_facts set action = action where id = '00000000-0000-4000-8000-0000000000f1'; +select pg_temp.assert_true(pg_temp.rev() = (select rev from seen where step = 'fact threshold'), + 'a no-op fact update must not change the revision'); + +insert into public.document_images(id, document_id, storage_path, caption) +values ('00000000-0000-4000-8000-0000000000a1', '00000000-0000-4000-8000-0000000000e1', 'synthetic/image-1.png', 'Synthetic caption'); +insert into seen values ('image insert', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'image insert') <> (select rev from seen where step = 'fact threshold'), + 'inserting an image must change the revision'); + +update public.document_images set caption = 'Edited synthetic caption' where id = '00000000-0000-4000-8000-0000000000a1'; +insert into seen values ('image caption', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'image caption') <> (select rev from seen where step = 'image insert'), + 'editing an image caption must change the revision'); + +update public.document_images set clinical_relevance_score = 0.9, searchable = false +where id = '00000000-0000-4000-8000-0000000000a1'; +insert into seen values ('image relevance', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'image relevance') <> (select rev from seen where step = 'image caption'), + 'editing image relevance or searchability must change the revision'); + +delete from public.document_table_facts where id = '00000000-0000-4000-8000-0000000000f1'; +insert into seen values ('fact delete', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'fact delete') <> (select rev from seen where step = 'image relevance'), + 'deleting a table fact must change the revision'); + +select pg_temp.assert_true((select count(distinct other) = 1 from seen), + 'evidence edits on one document must not change another document''s scoped revision'); + +-- Deleting the document cascades its facts and images; the evidence triggers must not block it. +delete from public.documents where id = '00000000-0000-4000-8000-0000000000e1'; +select pg_temp.assert_true(not exists (select 1 from public.document_evidence_revisions + where document_id = '00000000-0000-4000-8000-0000000000e1'), 'the evidence counter is removed with its document'); +rollback;