diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 2545d2b24..647d6e1a8 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -1,18 +1,15 @@
"use client";
-import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import dynamic from "next/dynamic";
import {
AlertCircle,
Bell,
BookOpen,
- CheckCircle2,
ChevronDown,
ChevronRight,
CircleUserRound,
Clock3,
- Copy,
ExternalLink,
FileImage,
FileText,
@@ -21,7 +18,6 @@ import {
HelpCircle,
Heart,
Keyboard,
- Layers,
ListChecks,
Loader2,
LogOut,
@@ -29,7 +25,6 @@ import {
LockKeyhole,
Palette,
PanelTop,
- Plus,
Quote,
RefreshCw,
Search,
@@ -48,66 +43,37 @@ import {
import {
type CSSProperties,
type FormEvent,
- type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
-import { AccessibleTable } from "@/components/AccessibleTable";
-import {
- DocumentOrganizationBadges,
- documentDisplayTitle,
- documentOrganizationProfile,
-} from "@/components/DocumentOrganizationBadges";
-import { DocumentTagCloud } from "@/components/DocumentTagCloud";
-import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions";
+import { type DocumentDeleteResult } from "@/components/DocumentManagementActions";
import { useDismissableLayer } from "@/components/use-dismissable-layer";
-import { formatCompactCitationLabel } from "@/lib/citations";
import { extractSafetyFindings } from "@/lib/clinical-safety";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
import { isLocalNoAuthMode } from "@/lib/env";
import {
appBackdrop,
answerSurface,
- chatMicroAction,
- clinicalDivider,
cn,
- EmptyState,
- fieldControlPlain,
fieldControlWithIcon,
fieldIcon,
floatingControl,
- iconTilePremium,
- metadataPill,
- panelSubtle,
primaryControl,
- SourceProvenance,
- SourceStatusBadge,
- sourceCard,
- subtleStatusPill,
- tableCard,
- tableCardHeader,
- tableMicroActionRow,
textMuted,
- toneDanger,
- toneInfo,
- toneNeutral,
toneSuccess,
toneWarning,
} from "@/components/ui-primitives";
import { useAuthSession } from "@/lib/supabase/client";
-import { SafeBoldText } from "@/components/SafeBoldText";
import { Sheet } from "@/components/ui/sheet";
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface";
import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results";
-import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions";
import { AuthPanel } from "@/components/clinical-dashboard/auth-panel";
import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed";
import { useTheme } from "@/components/clinical-dashboard/use-theme";
-import { StatusBadge } from "@/components/clinical-dashboard/badges";
import {
type SidebarIdentity,
deriveSidebarIdentity,
@@ -129,42 +95,22 @@ import {
import {
GuideDialog,
GuideTrigger,
- SectionHeading,
UtilityDrawer,
} from "@/components/clinical-dashboard/dashboard-shell";
import {
- cleanDisplayTitle,
sanitizeAnswerDisplayText,
sanitizeDisplayText,
} from "@/components/clinical-dashboard/display-text";
import {
NaturalLanguageAnswer,
ScopeAndGovernanceNotice,
- SourceImage,
UserQuestionBubble,
} from "@/components/clinical-dashboard/answer-content";
import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status";
-import {
- AnswerFeedbackPanel,
- AnswerSafetyNotice,
- AnswerSupportSummaryCard,
- answerHasCentralTable,
- answerSupportPriority,
- ClinicalNotesChecklistPanel,
- clinicalNotesCount,
- clinicalNotesDisplayCountForAnswer,
- compactEvidenceSummary,
- type EvidenceTabName,
- simpleClinicalTableProps,
- evidenceMapRowsFromRenderModel,
- evidenceTabCount,
- evidenceTabOrder,
- QuoteCards,
- SafetyFindingsListContent,
-} from "@/components/clinical-dashboard/evidence-panels";
+import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels";
import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context";
-import { emptyStates, errorCopy } from "@/lib/ui-copy";
+import { errorCopy } from "@/lib/ui-copy";
import { applicationsLauncherItemCount } from "@/components/applications-launcher-page";
import {
DrawerGroupLabel,
@@ -198,7 +144,7 @@ const DocumentDrawer = dynamic(
);
import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";
-import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance";
+import { isWeakRelevance } from "@/components/clinical-dashboard/relevance";
import {
answerPayloadIsUsable,
isRetryableError,
@@ -236,20 +182,15 @@ import {
maxStoredAnswerTurns,
savePersistedAnswerThread,
} from "@/lib/answer-thread-storage";
-import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy";
-import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer";
+import { buildAnswerRenderModel } from "@/lib/answer-render-policy";
import {
frontendSourceGovernanceWarnings,
groupSourceGovernanceWarnings,
type SourceGovernanceWarning,
} from "@/lib/source-governance";
-import { smartEvidenceTags } from "@/lib/evidence-tags";
import {
- tagSearchText,
type SmartDocumentTag,
type SmartDocumentTagFacet,
- type SmartDocumentTagTier,
- type SmartDocumentTagQualityIssueKind,
} from "@/lib/document-tags";
import type {
ClinicalDocument,
@@ -263,16 +204,13 @@ import type {
RelatedDocument,
SearchResult,
SearchScopeSummary,
- VisualEvidenceCard,
ClinicalQueryMode,
DocumentLabel,
- DocumentLabelType,
} from "@/lib/types";
import type { SearchScopeFilters } from "@/lib/search-scope";
import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
import {
createQuoteFollowUp,
- type AnswerEvidenceMapRow,
type AnswerViewMode,
shouldPollForUpdates,
} from "@/lib/ward-output";
@@ -487,367 +425,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string
function normalizeNavigationHash(hash: string) {
return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search";
}
-
-function compactClinicalTableCaption(item: VisualEvidenceCard) {
- const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table";
- const cleaned = sourceTextForCompactDisplay(raw)
- .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "")
- .replace(/\b(?:page|p\.)\s*\d+\b/gi, "")
- .replace(/\s{2,}/g, " ")
- .trim();
- const caption = cleaned || "Clinical table";
- return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`;
-}
-
-function visualEvidenceHeader(item: VisualEvidenceCard) {
- const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · ");
- const titleText = sourceTextForCompactDisplay(titleSource).trim();
- const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim();
- const normalizedTitle = titleText.toLowerCase();
- const normalizedCaption = captionText.toLowerCase();
- const isDuplicateCaption =
- Boolean(normalizedCaption) &&
- (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle);
- return {
- title: titleText || captionText || "Visual evidence",
- caption: isDuplicateCaption ? null : captionText,
- };
-}
-
-function VisualEvidenceStrip({
- evidence,
- collapsed = false,
- embedded = false,
-}: {
- evidence: VisualEvidenceCard[];
- collapsed?: boolean;
- embedded?: boolean;
-}) {
- function looksLikeTableText(value?: string | null) {
- return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3);
- }
-
- if (collapsed) {
- return (
-
- );
- }
-
- const content = (
- <>
-
- {evidence.length === 0 ? (
-
- ) : (
-
- {evidence.map((item) => {
- const tableMarkdown = item.accessibleTableMarkdown?.trim()
- ? item.accessibleTableMarkdown
- : looksLikeTableText(item.tableTextSnippet)
- ? item.tableTextSnippet
- : null;
- const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length);
- const tableCaption = compactClinicalTableCaption(item);
- const sourceHeader = visualEvidenceHeader(item);
- const displayLabels = smartEvidenceTags(
- item.labels,
- [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet]
- .filter(Boolean)
- .join(" "),
- );
- return (
-
-
-
-
-
- {!hasStructuredTable ? {sourceHeader.title}
: null}
- {!hasStructuredTable && sourceHeader.caption ? {sourceHeader.caption}
: null}
-
- {!hasStructuredTable && item.tableTextSnippet ? (
-
- {sourceTextForCompactDisplay(item.tableTextSnippet)}
-
- ) : null}
- {displayLabels.length ? (
-
- {displayLabels.map((label) => (
-
- {label}
-
- ))}
-
- ) : null}
-
-
-
- {formatCompactCitationLabel(item)}
-
-
- {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"}
-
- {item.image_type && (
-
- {item.image_type.replaceAll("_", " ")}
-
- )}
- {!hasStructuredTable ? : null}
-
-
- Open source
-
-
-
- );
- })}
-
- )}
- >
- );
-
- if (embedded) return {content}
;
-
- return (
-
- );
-}
-
-const evidenceTabIconMap: Record = {
- Claims: CheckCircle2,
- Quotes: Quote,
- Tables: ListChecks,
- Images: FileImage,
- Gaps: AlertCircle,
-};
-
-function supportDotClass(supportLevel: string) {
- const normalized = supportLevel.toLowerCase();
- if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]";
- if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) {
- return "bg-[color:var(--warning)]";
- }
- return "bg-[color:var(--clinical-accent)]";
-}
-
-function supportLabel(supportLevel: string) {
- const normalized = supportLevel.toLowerCase();
- if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported";
- if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby"))
- return "Partial";
- return "Direct";
-}
-
-function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) {
- if (rows.length) return rows.slice(0, 6);
- return renderModel.primarySources.slice(0, 6).map((source, index) => ({
- id: source.id,
- section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`,
- detail: source.snippet || source.reason || "Open source passage to review the cited evidence.",
- supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength,
- citationCount: 1,
- sourceStatus:
- source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`,
- bestSourceLabel: source.label,
- bestLinkedPassage: source.snippet || source.reason,
- href: source.href,
- }));
-}
-
-function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) {
- const claimRows = claimRowsForEvidencePanel(rows, renderModel);
- const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length;
- const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length;
-
- if (!claimRows.length) {
- return ;
- }
-
- return (
-
-
-
-
Claims checked
-
-
-
- Direct
-
-
-
- Partial
-
-
-
- Unsupported
-
-
-
-
- {directCount} direct · {partialCount} partial
-
-
-
-
- {claimRows.map((row, index) => (
-
-
-
-
- {row.section}
-
- {row.detail || row.bestLinkedPassage || row.bestSourceLabel}
-
-
-
-
- ))}
-
-
- );
-}
-
-function EvidenceGapsPanel({ warnings }: { warnings: string[] }) {
- if (!warnings.length) {
- return (
-
- );
- }
-
- return (
-
- {warnings.map((warning, index) => (
-
-
-
-
Gap {index + 1}
-
{warning}
-
-
- ))}
-
- );
-}
-
-function MobileEvidenceTabPanel({
- tab,
- renderModel,
- visualEvidence,
- answerEvidenceMapRows,
- copiedQuotes,
- onCopyQuotes,
- onFollowUpQuote,
- onScopeDocument,
-}: {
- tab: EvidenceTabName;
- renderModel: AnswerRenderModel;
- visualEvidence: VisualEvidenceCard[];
- answerEvidenceMapRows: AnswerEvidenceMapRow[];
- copiedQuotes: boolean;
- onCopyQuotes: () => void;
- onFollowUpQuote?: (quote: QuoteCard) => void;
- onScopeDocument: (documentId: string) => void;
-}) {
- if (tab === "Claims") {
- return ;
- }
-
- if (tab === "Tables") {
- const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length);
- return tableEvidence.length ? (
-
- {tableEvidence.slice(0, 4).map((item, index) => (
-
-
-
-
-
-
- {compactClinicalTableCaption(item)}
-
-
- Table {index + 1} · p.{item.page_number ?? "n/a"}
-
-
-
-
-
-
- ))}
-
- ) : (
-
- );
- }
-
- if (tab === "Images") {
- return visualEvidence.length ? (
-
- ) : (
-
- );
- }
-
- if (tab === "Quotes") {
- return (
-
- );
- }
-
- return ;
-}
-
/**
* A completed Q&A exchange kept on screen after a newer answer arrives, so
* Answer mode reads as a conversation thread instead of replacing each result.
diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx
index c1b2835a3..68072f029 100644
--- a/src/components/clinical-dashboard/favourites-hub.tsx
+++ b/src/components/clinical-dashboard/favourites-hub.tsx
@@ -22,7 +22,6 @@ import {
favouriteItems,
favouriteSets,
favouriteTabs,
- favouriteTypeCount,
type FavouriteItem,
type FavouriteSet,
type FavouriteTabId,
diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx
index 2f84e2134..5dcca9e54 100644
--- a/src/components/clinical-dashboard/visual-evidence.tsx
+++ b/src/components/clinical-dashboard/visual-evidence.tsx
@@ -29,7 +29,6 @@ import {
evidenceTabOrder,
QuoteCards,
simpleClinicalTableProps,
- VerificationWorkspace,
} from "@/components/clinical-dashboard/evidence-panels";
import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance";
import {
diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx
index ec53f934c..d07f9b17f 100644
--- a/src/components/services/services-navigator-page.tsx
+++ b/src/components/services/services-navigator-page.tsx
@@ -435,8 +435,10 @@ export function ServicesNavigatorPage() {
const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: initialQuery }));
const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery;
const registry = useRegistryRecords("service");
- const searchableRecords =
- registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords;
+ const searchableRecords = useMemo(
+ () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords),
+ [registry.records, registry.status],
+ );
const matches = useMemo(() => {
const ranked = rankServiceRecords(searchableRecords, query);
return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords;
diff --git a/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql
new file mode 100644
index 000000000..21e0c281d
--- /dev/null
+++ b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql
@@ -0,0 +1,202 @@
+-- Reconcile live index drift reported by search_schema_health() on 2026-07-05.
+-- Creates canonical retrieval-support indexes that are missing on live (or only
+-- present under legacy names) and teaches search_schema_health() to accept
+-- verified functional equivalents so replays do not false-fail mid-rollout.
+
+set search_path = public, extensions, pg_catalog;
+
+create index if not exists documents_title_trgm_idx
+ on public.documents using gin (lower(coalesce(title, '') || ' ' || coalesce(file_name, '')) gin_trgm_ops);
+
+create index if not exists document_chunks_content_trgm_idx
+ on public.document_chunks using gin (lower(coalesce(section_heading, '') || ' ' || coalesce(content, '')) gin_trgm_ops);
+
+create index if not exists document_labels_label_trgm_idx
+ on public.document_labels using gin (lower(label) gin_trgm_ops);
+
+create index if not exists document_summaries_summary_trgm_idx
+ on public.document_summaries using gin (lower(summary) gin_trgm_ops);
+
+create index if not exists document_table_facts_owner_document_page_idx
+ on public.document_table_facts(owner_id, document_id, page_number);
+
+create index if not exists document_index_units_owner_chunk_type_idx
+ on public.document_index_units(owner_id, source_chunk_id, unit_type)
+ where source_chunk_id is not null;
+
+create index if not exists document_pages_document_idx
+ on public.document_pages(document_id, page_number);
+
+create index if not exists document_sections_document_idx
+ on public.document_sections(document_id, section_index);
+
+create index if not exists rag_retrieval_logs_owner_created_idx
+ on public.rag_retrieval_logs(owner_id, created_at desc);
+
+create index if not exists rag_retrieval_logs_miss_idx
+ on public.rag_retrieval_logs(is_miss, created_at desc)
+ where is_miss = true;
+
+create or replace function public.search_schema_health()
+returns jsonb
+language plpgsql
+stable
+security definer
+set search_path = public, extensions, pg_catalog, pg_temp
+as $$
+declare
+ missing text[] := array[]::text[];
+ vector_type_oid oid;
+ vector_schema text;
+ index_name text;
+ legacy_ivfflat_indexes text[];
+ zero_vec extensions.vector(1536);
+ probe_text text := 'schema health probe zzznomatch';
+ hybrid_rpcs text[] := array[
+ 'match_document_chunks_hybrid',
+ 'match_document_index_units_hybrid',
+ 'match_document_embedding_fields_hybrid',
+ 'match_document_memory_cards_hybrid'
+ ];
+ rpc_name text;
+ required_indexes constant text[] := array[
+ 'documents_title_trgm_idx',
+ 'document_chunks_content_trgm_idx',
+ 'document_labels_label_trgm_idx',
+ 'document_summaries_summary_trgm_idx',
+ 'document_chunks_embedding_hnsw_idx',
+ 'document_embedding_fields_embedding_hnsw_idx',
+ 'document_memory_cards_embedding_hnsw_idx',
+ 'documents_indexed_owner_title_idx',
+ 'document_table_facts_owner_document_page_idx',
+ 'document_embedding_fields_owner_chunk_idx',
+ 'document_index_units_owner_chunk_type_idx',
+ 'document_table_facts_source_image_idx',
+ 'document_pages_document_idx',
+ 'document_sections_document_idx',
+ 'document_chunks_document_idx',
+ 'document_memory_cards_document_idx',
+ 'document_embedding_fields_document_idx',
+ 'document_table_facts_document_idx',
+ 'document_index_units_document_idx',
+ 'rag_retrieval_logs_owner_created_idx',
+ 'rag_retrieval_logs_miss_idx',
+ 'rag_retrieval_logs_strategy_idx'
+ ];
+ -- Verified live equivalents: same table/column intent, different migration-era name.
+ index_aliases constant jsonb := jsonb_build_object(
+ 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'),
+ 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'),
+ 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'),
+ 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'),
+ 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'),
+ 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx')
+ );
+begin
+ select t.oid, n.nspname
+ into vector_type_oid, vector_schema
+ from pg_type t
+ join pg_namespace n on n.oid = t.typnamespace
+ where t.typname = 'vector'
+ and n.nspname = 'extensions'
+ limit 1;
+
+ if vector_type_oid is null then
+ missing := array_append(missing, 'extensions.vector_type');
+ end if;
+
+ if to_regprocedure('public.match_document_chunks(extensions.vector, integer, double precision, uuid, uuid)') is null then
+ missing := array_append(missing, 'match_document_chunks.extensions_vector_signature');
+ end if;
+ if to_regprocedure('public.match_document_chunks_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then
+ missing := array_append(missing, 'match_document_chunks_hybrid.extensions_vector_signature');
+ end if;
+ if to_regprocedure('public.match_document_chunks_text(text, integer, uuid[], uuid)') is null then
+ missing := array_append(missing, 'match_document_chunks_text.signature');
+ end if;
+ if to_regprocedure('public.match_document_lookup_chunks_text(text, uuid[], integer, uuid)') is null then
+ missing := array_append(missing, 'match_document_lookup_chunks_text.signature');
+ end if;
+ if to_regprocedure('public.match_document_memory_cards_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then
+ missing := array_append(missing, 'match_document_memory_cards_hybrid.extensions_vector_signature');
+ end if;
+ if to_regprocedure('public.match_document_memory_cards_hybrid_v2(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then
+ missing := array_append(missing, 'match_document_memory_cards_hybrid_v2.extensions_vector_signature');
+ end if;
+ if to_regprocedure('public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then
+ missing := array_append(missing, 'match_document_index_units_hybrid.extensions_vector_signature');
+ end if;
+ if to_regprocedure('public.match_document_embedding_fields_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then
+ missing := array_append(missing, 'match_document_embedding_fields_hybrid.extensions_vector_signature');
+ end if;
+ if to_regprocedure('public.match_documents_for_query(text, integer, uuid)') is null then
+ missing := array_append(missing, 'match_documents_for_query.signature');
+ end if;
+ if to_regprocedure('public.match_document_table_facts_text(text, integer, uuid[], uuid)') is null then
+ missing := array_append(missing, 'match_document_table_facts_text.signature');
+ end if;
+ if to_regprocedure('public.explain_retrieval_rpc(text, text, integer, uuid, uuid[], boolean)') is null then
+ missing := array_append(missing, 'explain_retrieval_rpc.signature');
+ end if;
+ if to_regclass('public.rag_retrieval_logs') is null then
+ missing := array_append(missing, 'rag_retrieval_logs.table');
+ end if;
+
+ foreach index_name in array required_indexes loop
+ if not exists (
+ select 1
+ from pg_class c
+ join pg_namespace ns on ns.oid = c.relnamespace
+ where ns.nspname = 'public'
+ and c.relname = index_name
+ and c.relkind = 'i'
+ )
+ and not (
+ index_aliases ? index_name
+ and exists (
+ select 1
+ from pg_class c
+ join pg_namespace ns on ns.oid = c.relnamespace
+ where ns.nspname = 'public'
+ and c.relkind = 'i'
+ and c.relname in (
+ select jsonb_array_elements_text(index_aliases -> index_name)
+ )
+ )
+ ) then
+ missing := array_append(missing, index_name);
+ end if;
+ end loop;
+
+ if vector_type_oid is not null then
+ zero_vec := (select ('[' || string_agg('0', ',') || ']') from generate_series(1, 1536))::extensions.vector(1536);
+ foreach rpc_name in array hybrid_rpcs loop
+ begin
+ execute format(
+ 'select 1 from public.%I($1, $2, 1, 0.1, null::uuid[], null::uuid) limit 1',
+ rpc_name
+ ) using zero_vec, probe_text;
+ exception
+ when undefined_function then
+ missing := array_append(missing, rpc_name || '.execution_signature');
+ when others then
+ missing := array_append(missing, rpc_name || '.execution:' || SQLSTATE);
+ end;
+ end loop;
+ end if;
+
+ select public.detect_legacy_ivfflat_indexes() into legacy_ivfflat_indexes;
+
+ return jsonb_build_object(
+ 'ok', cardinality(missing) = 0,
+ 'missing', missing,
+ 'vector_extension_schema', vector_schema,
+ 'legacy_ivfflat_indexes', coalesce(legacy_ivfflat_indexes, array[]::text[]),
+ 'deferred_hnsw_indexes', array[]::text[],
+ 'checked_at', now()
+ );
+end;
+$$;
+
+revoke execute on function public.search_schema_health() from public, anon, authenticated;
+grant execute on function public.search_schema_health() to service_role;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 63657b132..16ce2230c 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -2396,6 +2396,15 @@ declare
'rag_retrieval_logs_miss_idx',
'rag_retrieval_logs_strategy_idx'
];
+ -- Verified live equivalents: same table/column intent, different migration-era name.
+ index_aliases constant jsonb := jsonb_build_object(
+ 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'),
+ 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'),
+ 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'),
+ 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'),
+ 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'),
+ 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx')
+ );
begin
select t.oid, n.nspname
into vector_type_oid, vector_schema
@@ -2454,6 +2463,19 @@ begin
where ns.nspname = 'public'
and c.relname = index_name
and c.relkind = 'i'
+ )
+ and not (
+ index_aliases ? index_name
+ and exists (
+ select 1
+ from pg_class c
+ join pg_namespace ns on ns.oid = c.relnamespace
+ where ns.nspname = 'public'
+ and c.relkind = 'i'
+ and c.relname in (
+ select jsonb_array_elements_text(index_aliases -> index_name)
+ )
+ )
) then
missing := array_append(missing, index_name);
end if;
diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts
new file mode 100644
index 000000000..0801c4bd9
--- /dev/null
+++ b/tests/public-access-deep.test.ts
@@ -0,0 +1,225 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const publicDocumentId = "11111111-1111-4111-8111-111111111111";
+
+beforeEach(() => {
+ vi.resetModules();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.unstubAllEnvs();
+});
+
+describe("public access deep checks", () => {
+ it("rejects unauthenticated access to operator-only routes", async () => {
+ vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} }));
+ const auth = {
+ AuthenticationError: class AuthenticationError extends Error {},
+ requireAuthenticatedUser: vi.fn(async () => {
+ throw new auth.AuthenticationError();
+ }),
+ unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }),
+ };
+ vi.doMock("@/lib/supabase/admin", () => ({
+ createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })),
+ }));
+ vi.doMock("@/lib/supabase/auth", () => auth);
+
+ const cases = [
+ {
+ routePath: "../src/app/api/jobs/route",
+ handler: "GET" as const,
+ request: new Request("http://localhost/api/jobs"),
+ },
+ {
+ routePath: "../src/app/api/ingestion/jobs/route",
+ handler: "GET" as const,
+ request: new Request("http://localhost/api/ingestion/jobs"),
+ },
+ {
+ routePath: "../src/app/api/ingestion/batches/route",
+ handler: "GET" as const,
+ request: new Request("http://localhost/api/ingestion/batches"),
+ },
+ {
+ routePath: "../src/app/api/documents/bulk/route",
+ handler: "POST" as const,
+ request: new Request("http://localhost/api/documents/bulk", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "delete", documentIds: [publicDocumentId] }),
+ }),
+ },
+ {
+ routePath: "../src/app/api/documents/[id]/summarize/route",
+ handler: "POST" as const,
+ request: new Request(`http://localhost/api/documents/${publicDocumentId}/summarize`, { method: "POST" }),
+ params: { id: publicDocumentId },
+ },
+ {
+ routePath: "../src/app/api/eval-cases/route",
+ handler: "POST" as const,
+ request: new Request("http://localhost/api/eval-cases", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ query: "What monitoring is needed?",
+ rating: "good",
+ answer: "Monitor FBC.",
+ queryMode: "auto",
+ queryClass: "table_threshold",
+ }),
+ }),
+ },
+ ] as const;
+
+ for (const testCase of cases) {
+ vi.resetModules();
+ vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} }));
+ vi.doMock("@/lib/supabase/admin", () => ({
+ createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })),
+ }));
+ vi.doMock("@/lib/supabase/auth", () => auth);
+ const mod = await import(testCase.routePath);
+ const handler = mod[testCase.handler];
+ const response = await handler(
+ testCase.request,
+ "params" in testCase ? { params: Promise.resolve(testCase.params) } : undefined,
+ );
+ expect(response.status, testCase.routePath).toBe(401);
+ }
+ });
+
+ it("does not expose secret values from the health endpoint", async () => {
+ vi.doMock("@/lib/env", () => ({
+ env: {
+ NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co",
+ SUPABASE_SERVICE_ROLE_KEY: "super-secret-service-role-key",
+ OPENAI_API_KEY: "sk-super-secret-openai-key",
+ },
+ isDemoMode: () => false,
+ }));
+ const { GET } = await import("../src/app/api/health/route");
+ const response = await GET(new Request("https://clinical.example/api/health?deep=1"));
+ const body = await response.json();
+ const serialized = JSON.stringify(body);
+
+ expect(serialized).not.toContain("super-secret-service-role-key");
+ expect(serialized).not.toContain("sk-super-secret-openai-key");
+ expect(body.checks.supabaseConfig).toBe("ok");
+ expect(body.checks.openaiConfig).toBe("ok");
+ });
+
+});
+
+describe("production anonymous retrieval scope", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.unstubAllEnvs();
+ });
+
+ it("fails closed in production when anonymous global retrieval omits owner scope", async () => {
+ vi.doUnmock("@/lib/rag");
+ vi.stubEnv("NODE_ENV", "production");
+ vi.doMock("@/lib/env", () => ({
+ env: {
+ OPENAI_API_KEY: "sk-test",
+ RAG_PROVIDER_MODE: "offline",
+ RAG_SEARCH_CACHE_TTL_MS: 0,
+ RAG_ANSWER_CACHE_TTL_MS: 0,
+ },
+ isDemoMode: () => false,
+ isLocalNoAuthMode: () => false,
+ requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }),
+ }));
+ vi.doMock("@/lib/supabase/admin", () => ({
+ createAdminClient: vi.fn(() => ({
+ from: vi.fn(() => ({
+ select: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ is: vi.fn(() => ({
+ order: vi.fn(() => ({
+ limit: vi.fn(async () => ({ data: [], error: null })),
+ })),
+ })),
+ })),
+ })),
+ })),
+ rpc: vi.fn(async () => ({ data: [], error: null })),
+ })),
+ }));
+
+ const { searchChunksWithTelemetry } = await import("../src/lib/rag");
+
+ await expect(
+ searchChunksWithTelemetry({
+ query: "clozapine monitoring",
+ allowGlobalSearch: true,
+ }),
+ ).rejects.toThrow(/ownerId|tenant/i);
+ });
+});
+
+describe("test-runtime anonymous retrieval scope", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.unstubAllEnvs();
+ });
+
+ it("allows anonymous global retrieval in test runtime where owner scope stays permissive", async () => {
+ vi.doUnmock("@/lib/rag");
+ vi.doMock("@/lib/env", () => ({
+ env: {
+ OPENAI_API_KEY: undefined,
+ RAG_PROVIDER_MODE: "offline",
+ RAG_SEARCH_CACHE_TTL_MS: 0,
+ RAG_ANSWER_CACHE_TTL_MS: 0,
+ },
+ isDemoMode: () => false,
+ isLocalNoAuthMode: () => false,
+ requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }),
+ }));
+ vi.doMock("@/lib/supabase/admin", () => ({
+ createAdminClient: vi.fn(() => ({
+ from: vi.fn(() => ({
+ select: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ is: vi.fn(() => ({
+ order: vi.fn(() => ({
+ limit: vi.fn(async () => ({ data: [], error: null })),
+ })),
+ in: vi.fn(() => ({
+ order: vi.fn(() => ({
+ limit: vi.fn(async () => ({ data: [], error: null })),
+ })),
+ })),
+ })),
+ })),
+ })),
+ })),
+ rpc: vi.fn(async () => ({ data: [], error: null })),
+ })),
+ }));
+
+ const { searchChunksWithTelemetry } = await import("../src/lib/rag");
+ const result = await searchChunksWithTelemetry({
+ query: "clozapine monitoring",
+ allowGlobalSearch: true,
+ });
+
+ expect(result.results).toEqual([]);
+ expect(result.telemetry).toBeDefined();
+ });
+});
diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts
index 68cd98773..6dd16d6c9 100644
--- a/tests/supabase-schema.test.ts
+++ b/tests/supabase-schema.test.ts
@@ -80,6 +80,10 @@ const registryCatalogPayloadMigration = readFileSync(
new URL("../supabase/migrations/20260705030000_registry_catalog_payload.sql", import.meta.url),
"utf8",
).replace(/\s+/g, " ");
+const searchHealthIndexesMigration = readFileSync(
+ new URL("../supabase/migrations/20260705180000_reconcile_search_health_indexes.sql", import.meta.url),
+ "utf8",
+).replace(/\s+/g, " ");
const ragQueriesRetentionMigration = readFileSync(
new URL("../supabase/migrations/20260629060603_rag_queries_retention.sql", import.meta.url),
"utf8",
@@ -739,6 +743,17 @@ describe("Supabase schema Data API grants", () => {
"add column if not exists catalog_payload jsonb not null default '{}'::jsonb",
);
});
+
+ it("reconciles search_schema_health index drift with canonical creates and live aliases", () => {
+ expect(searchHealthIndexesMigration).toContain("create index if not exists documents_title_trgm_idx");
+ expect(searchHealthIndexesMigration).toContain("create index if not exists document_labels_label_trgm_idx");
+ expect(searchHealthIndexesMigration).toContain("create index if not exists rag_retrieval_logs_miss_idx");
+ expect(searchHealthIndexesMigration).toContain("index_aliases constant jsonb := jsonb_build_object(");
+ expect(searchHealthIndexesMigration).toContain("'documents_title_search_tsv_idx'");
+ expect(searchHealthIndexesMigration).toContain("'document_pages_document_id_page_number_key'");
+ expect(schema).toContain("index_aliases constant jsonb := jsonb_build_object(");
+ expect(schema).toContain("jsonb_array_elements_text(index_aliases -> index_name)");
+ });
});
describe("RC9 — lexical text path must not fabricate a cosine similarity", () => {