diff --git a/.env.example b/.env.example
index 62cee8871..8892fc581 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,4 @@
-# Supabase project values for the live "Clinical KB Database" project.
+# Supabase project values for the live "Clinical KB Database" project.
# Keep service role keys server-only; never prefix them with NEXT_PUBLIC_ or
# import them into client components.
NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co
@@ -6,9 +6,14 @@ SUPABASE_PROJECT_REF=sjrfecxgysukkwxsowpy
SUPABASE_PROJECT_NAME=Clinical KB Database
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
+# Direct Postgres connection string for scripts/migrations that need SQL access.
+# Server-only; never expose in client code or NEXT_PUBLIC_ vars.
+SUPABASE_DB_URL=postgresql://postgres:password@db.sjrfecxgysukkwxsowpy.supabase.co:5432/postgres
# Edge Function only (indexing-v3-agent cron auth). Not read by Next.js env.ts.
# Set in Supabase Edge Function secrets / deployment env, not in the browser bundle.
INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret
+# Secret required for /api/health?deep=1 Supabase probe (x-health-deep-token header).
+HEALTH_DEEP_PROBE_SECRET=your-long-random-health-deep-probe-secret
# Local-only no-auth mode (development only).
# Enable one or both of these to load real data without per-request browser auth:
diff --git a/docs/process-hardening.md b/docs/process-hardening.md
index 48c530e9b..38ea80008 100644
--- a/docs/process-hardening.md
+++ b/docs/process-hardening.md
@@ -95,6 +95,15 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went
- Auth server is capped at 10 absolute DB connections (Supabase advisor); switch to percentage-based allocation in the dashboard before scaling instance size (not settable via SQL/MCP).
- `storage_cleanup_jobs` live indexes drifted from `supabase/schema.sql`: live carries legacy auto-names (`storage_cleanup_jobs_document_id_idx`, `storage_cleanup_jobs_owner_id_idx`, a non-partial `storage_cleanup_jobs_status_created_idx`) that the hardening defs superseded. Migration `20260703030000_reconcile_storage_cleanup_jobs_indexes` is **prepared but NOT applied** — it drops the legacy names and (re)creates the intended named/partial indexes to match schema.sql. Functional-not-broken (the document_id FK is covered), so apply to live only with explicit approval. `20260703000000`/`010000` are also absent from live `schema_migrations` and will self-heal on the next `supabase db push`.
+## Live database drift reconciliation (2026-07-05)
+
+- **RESOLVED:** `indexing_v3_agent_jobs` table + `claim_indexing_v3_agent_jobs` + `update_indexing_v3_agent_job_status` were recorded as applied but absent on live. Migration `20260705220000_reconcile_live_database_drift` idempotently re-applied them; live verified post-push.
+- **RESOLVED:** `match_document_embedding_fields_text` codified with service_role-only execute.
+- **RESOLVED:** `rag_visual_eval_*` tables codified with service_role-only RLS.
+- **RESOLVED:** Live-only `20260705133000_tighten_search_document_chunks_owner_scope` mirrored in `schema.sql`.
+- **Edge function follow-up:** deploy `indexing-v3-agent` after merge so JSONB status RPC parsing is live.
+- **Operator-only:** publishable key rotation (`docs/archive/operator-decisions-2026-07-04.md`).
+
## PR merge gate: tiered CI + required checks (2026-07-02)
- CI is now two parallel PR jobs instead of one serial 6-7 minute job: `verify` (runtime alignment, edge typecheck, CI-safe production readiness, lint, typecheck, unit tests with coverage gate, build — ~3 min) and `ui-smoke` (Chromium Playwright smoke against its own dev server — ~4.5 min). Wall-clock PR feedback drops to the slower of the two, and a flaky smoke rerun no longer repeats lint/typecheck/tests/build.
diff --git a/docs/source-review-priority-2026-07-02.md b/docs/source-review-priority-2026-07-02.md
index 4c17926e3..29f73d510 100644
--- a/docs/source-review-priority-2026-07-02.md
+++ b/docs/source-review-priority-2026-07-02.md
@@ -8,7 +8,7 @@ ceiling 0.2) to 0.5398 in the afternoon run. The jump is not corpus decay: the r
review-flagged sources are no longer buried and the metric now reports the true corpus state surfacing in
golden-case top results. The bounded debt acceptance in
`docs/release-source-metadata-debt-2026-06-30.json` was re-accepted at a 0.6 ceiling (expiry unchanged,
-2026-07-31) on the condition that the documents below are clinically reviewed first.
+2026-08-31) on the condition that the documents below are clinically reviewed first.
Corpus context (live DB, 2026-07-02): 2,065 indexed documents — 1,397 current/locally_reviewed, 481
`review_due`, 132 `unknown` status, 130 `unverified` validation, 0 outdated, 0 poor-extraction.
diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md
index 37ba5dbd3..f900e103d 100644
--- a/docs/supabase-migration-reconciliation.md
+++ b/docs/supabase-migration-reconciliation.md
@@ -1,6 +1,6 @@
# Supabase Migration Reconciliation
-Last reviewed: 2026-07-04
+Last reviewed: 2026-07-05
Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`)
@@ -27,7 +27,15 @@ These previously local-only versions were verified in the live project history b
## Current Status (July 2026)
-The repo now includes additional July 2026 migrations beyond the June checkpoint above, including:
+Migration `20260705220000_reconcile_live_database_drift.sql` codifies live-only drift discovered 2026-07-05:
+
+- `indexing_v3_agent_jobs` table and claim/update RPCs (recorded as applied in history but absent on live at inspection time)
+- `match_document_embedding_fields_text` RPC with service-role-only execute grants (was present on live with anon/auth execute)
+- `rag_visual_eval_cases` / `rag_visual_eval_runs` tables with service-role-only RLS (were present on live without RLS)
+
+`supabase/schema.sql` has been reconciled to match. Apply the migration through the normal linked workflow when ready; do not use raw dashboard SQL for retrieval RPCs.
+
+The repo also includes additional July 2026 migrations beyond the June checkpoint above, including:
- Retrieval RPC codification and hybrid execution smoke (`20260701140631`, related July 1 fixes)
- Legacy vector index drops and `search_schema_health()` reconciliation (`20260702014803`, `20260702021604`)
diff --git a/package.json b/package.json
index c38c76383..febd34b50 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"visual:backfill": "tsx scripts/backfill-visual-intelligence.ts",
"backfill:text-normalization": "tsx scripts/backfill-text-normalization.ts",
"check:supabase-project": "tsx scripts/check-supabase-project.ts",
+ "check:retrieval-owner": "tsx scripts/check-retrieval-owner-migration.ts",
"check:indexing": "tsx scripts/check-indexing.ts",
"check:type-scale": "node scripts/check-type-scale.mjs",
"recover:ingestion": "tsx scripts/recover-ingestion-queue.ts",
@@ -68,6 +69,7 @@
"reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts",
"supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts",
"promote:query-misses": "tsx scripts/promote-query-misses.ts",
+ "promote:public-documents": "tsx scripts/promote-public-documents.ts",
"eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts",
"eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts",
"eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts",
@@ -108,7 +110,6 @@
"pdfjs-dist": "^6.1.200",
"pdfkit": "^0.19.1",
"postcss": "^8.5.15",
- "postgres": "3.4.9",
"react": "19.2.7",
"react-dom": "19.2.7",
"zod": "^4.4.3"
diff --git a/scripts/apply-governance-dashboard.mjs b/scripts/apply-governance-dashboard.mjs
new file mode 100644
index 000000000..0957e5dc5
--- /dev/null
+++ b/scripts/apply-governance-dashboard.mjs
@@ -0,0 +1,135 @@
+import fs from "node:fs";
+
+const path = "src/components/ClinicalDashboard.tsx";
+let s = fs.readFileSync(path, "utf8");
+
+if (!s.includes("SourceReviewQueuePanel")) {
+ s = s.replace(
+ 'import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";',
+ 'import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";\nimport { SourceReviewQueuePanel } from "@/components/clinical-dashboard/source-review-queue-panel";',
+ );
+}
+
+if (!s.includes("search-request-token")) {
+ s = s.replace(
+ `import {
+ frontendSourceGovernanceWarnings,
+ groupSourceGovernanceWarnings,
+ type SourceGovernanceWarning,
+} from "@/lib/source-governance";`,
+ `import {
+ frontendSourceGovernanceWarnings,
+ groupSourceGovernanceWarnings,
+ serializeSourceGovernanceWarning,
+ type SourceGovernanceWarning,
+} from "@/lib/source-governance";
+import {
+ invalidateSearchRequests,
+ isLatestSearchRequest,
+ type SearchRequestToken,
+} from "@/lib/search-request-token";`,
+ );
+}
+
+s = s.replace(
+ "const searchRequestSeqRef = useRef(0);",
+ `const searchRequestSeqRef = useRef(0);
+
+ function invalidateInFlightSearchRequests() {
+ searchRequestSeqRef.current = invalidateSearchRequests(searchRequestSeqRef.current);
+ }`,
+);
+
+s = s.replace(
+ "const requestId = ++searchRequestSeqRef.current;",
+ `const requestId = invalidateSearchRequests(searchRequestSeqRef.current);
+ searchRequestSeqRef.current = requestId;`,
+);
+
+s = s.replace(
+ /if \(requestId === searchRequestSeqRef\.current\) setAnswerProgress\(message\);/g,
+ "if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) setAnswerProgress(message);",
+);
+
+s = s.replace(
+ /if \(requestId === searchRequestSeqRef\.current\) \{/g,
+ "if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {",
+);
+
+s = s.replace(
+ "function crossModeSearch(mode: AppModeId, crossQuery: string) {\n modeChangeFromUiRef.current = true;",
+ "function crossModeSearch(mode: AppModeId, crossQuery: string) {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;",
+);
+
+s = s.replace(
+ "function selectSearchMode(mode: AppModeId) {\n modeChangeFromUiRef.current = true;",
+ "function selectSearchMode(mode: AppModeId) {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;",
+);
+
+s = s.replace(
+ "function startNewChat() {\n modeChangeFromUiRef.current = true;",
+ "function startNewChat() {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;",
+);
+
+s = s.replace(
+ "sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message),",
+ "sourceGovernanceWarnings: sourceGovernanceWarnings.map(serializeSourceGovernanceWarning),",
+);
+
+s = s.replace(
+ ` ")) {
+ s = s.replace(
+ ` />
+
+
+ `,
+ ` />
+
+
+
+ `,
+ );
+}
+
+const shortcutIdx = s.indexOf("async function runDocumentSearchShortcut");
+if (shortcutIdx !== -1) {
+ const canRunIdx = s.indexOf(" if (!canRunSearch) {", shortcutIdx);
+ const fnEnd = s.indexOf("\n function handleTagSearch", canRunIdx);
+ const block = s.slice(canRunIdx, fnEnd);
+ if (!block.includes("invalidateInFlightSearchRequests()")) {
+ const newBlock = block
+ .replace(
+ " setQuery(trimmedSearchText);",
+ " invalidateInFlightSearchRequests();\n setQuery(trimmedSearchText);",
+ )
+ .replace(
+ " if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);\n\n try {",
+ " if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);\n\n const requestId = invalidateSearchRequests(searchRequestSeqRef.current);\n searchRequestSeqRef.current = requestId;\n\n try {",
+ )
+ .replace(
+ " applySearchResult(payload);",
+ " if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n applySearchResult(payload);\n }",
+ )
+ .replace(
+ ' setError(requestError instanceof Error ? requestError.message : "Document search failed");',
+ ' if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n setError(requestError instanceof Error ? requestError.message : "Document search failed");\n }',
+ )
+ .replace(
+ " setLoading(false);\n setAnswerProgress(null);",
+ " if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n setLoading(false);\n setAnswerProgress(null);\n }",
+ );
+ s = s.slice(0, canRunIdx) + newBlock + s.slice(fnEnd);
+ }
+}
+
+fs.writeFileSync(path, s);
+console.log("ClinicalDashboard governance edits applied");
diff --git a/scripts/capture-support-chip-screenshots.ts b/scripts/capture-support-chip-screenshots.ts
new file mode 100644
index 000000000..2b8247c9b
--- /dev/null
+++ b/scripts/capture-support-chip-screenshots.ts
@@ -0,0 +1,137 @@
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { chromium } from "playwright-core";
+
+import { getPlaywrightBaseUrl } from "./playwright-base-url";
+import { demoAnswer, demoDocuments } from "../src/lib/demo-data";
+
+const outputDir = process.env.SCREENSHOT_DIR ?? join(process.cwd(), "artifacts", "screenshots");
+mkdirSync(outputDir, { recursive: true });
+
+const question = "What clozapine monitoring items are shown in the table image?";
+
+const readySetupChecks = [
+ { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." },
+ { id: "project", label: "Clinical KB Database target", status: "ready", detail: "Test Supabase project ready." },
+ { id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Test schema ready." },
+ { id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Test search schema ready." },
+ { id: "openai", label: "OpenAI API key available", status: "ready", detail: "Test OpenAI ready." },
+ { id: "worker", label: "npm run worker running", status: "unknown", detail: "Worker not required for UI smoke." },
+];
+
+function answerStreamBody(payload: unknown) {
+ return [
+ `event: progress\ndata: ${JSON.stringify({ stage: "retrieving", message: "Searching indexed documents." })}`,
+ `event: final\ndata: ${JSON.stringify(payload)}`,
+ "",
+ ].join("\n\n");
+}
+
+async function mockDemoApi(page: import("playwright-core").Page, baseUrl: string) {
+ await page.route(/\/api\/local-project-id$/, async (route) => {
+ await route.fulfill({
+ json: {
+ appName: "Clinical KB",
+ projectId: "test-project",
+ identityPath: "/api/local-project-id",
+ localServer: {
+ currentUrl: baseUrl,
+ currentPort: Number(new URL(baseUrl).port || 4298),
+ projectPortStart: 4298,
+ projectPortEnd: 53210,
+ safeLocalOrigin: true,
+ requestOrigin: null,
+ requestReferer: null,
+ unsafeLocalCaller: null,
+ },
+ },
+ });
+ });
+ await page.route("**/api/setup-status**", async (route) => {
+ await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } });
+ });
+ await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => {
+ await route.fulfill({
+ json: {
+ documents: demoDocuments,
+ demoMode: true,
+ pagination: {
+ limit: 150,
+ offset: 0,
+ total: demoDocuments.length,
+ nextOffset: demoDocuments.length,
+ hasMore: false,
+ },
+ },
+ });
+ });
+ await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => {
+ const body = route.request().postDataJSON() as {
+ query?: string;
+ documentId?: string;
+ documentIds?: string[];
+ };
+ const payload = demoAnswer(body?.query ?? question, body?.documentId, body?.documentIds);
+ const pathname = new URL(route.request().url()).pathname;
+ if (pathname.endsWith("/stream")) {
+ await route.fulfill({
+ body: answerStreamBody(payload),
+ contentType: "text/event-stream; charset=utf-8",
+ headers: { "Cache-Control": "no-cache, no-transform" },
+ });
+ return;
+ }
+ await route.fulfill({ json: payload });
+ });
+}
+
+async function main() {
+ const baseUrl = getPlaywrightBaseUrl();
+ const browser = await chromium.launch();
+ const page = await browser.newPage({ viewport: { width: 390, height: 820 } });
+ await mockDemoApi(page, baseUrl);
+ await page.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" });
+ await page.waitForLoadState("networkidle").catch(() => undefined);
+
+ const questionInput = page
+ .locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible')
+ .first();
+ await questionInput.fill(question);
+ await page.locator('[aria-label="Generate source-backed answer"]:visible').first().click();
+
+ await page.getByTestId("plain-answer-response").waitFor({ state: "visible", timeout: 30_000 });
+ await page.getByTestId("answer-follow-up-suggestions").waitFor({ state: "visible", timeout: 30_000 });
+ await page.getByTestId("answer-support-action-row").waitFor({ state: "attached", timeout: 30_000 });
+
+ const mainContent = page.locator("#main-content");
+
+ await mainContent.evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ await page.waitForTimeout(500);
+ const expandedPath = join(outputDir, "support-chips-expanded-at-bottom.png");
+ await page.screenshot({ path: expandedPath, fullPage: false });
+
+ await mainContent.evaluate((element) => {
+ element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 180);
+ });
+ await page
+ .waitForFunction(() => {
+ const row = document.querySelector('[data-testid="answer-support-action-row"]');
+ return row?.getAttribute("data-collapsed") === "true";
+ }, { timeout: 10_000 })
+ .catch(() => undefined);
+ await page.waitForTimeout(500);
+ const collapsedPath = join(outputDir, "support-chips-collapsed-above-composer.png");
+ await page.screenshot({ path: collapsedPath, fullPage: false });
+
+ const collapsed = await page.getByTestId("answer-support-action-row").getAttribute("data-collapsed");
+ console.log(JSON.stringify({ outputDir, collapsed, expandedPath, collapsedPath, baseUrl }, null, 2));
+
+ await browser.close();
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/scripts/check-document-label-governance.ts b/scripts/check-document-label-governance.ts
index c418edf4c..a6615f06f 100644
--- a/scripts/check-document-label-governance.ts
+++ b/scripts/check-document-label-governance.ts
@@ -175,7 +175,7 @@ async function main() {
console.log(`Low confidence generated labels: ${report.analytics.lowConfidence}`);
console.log(`Quality warnings: ${report.analytics.qualityIssues.length}`);
console.log(`Blocking quality issues: ${report.analytics.blockingQualityIssues.length}`);
- console.log(`Missing gold-label rows: ${report.analytics.missingGoldLabels.length}`);
+ console.log(`Missing gold-label rows (advisory): ${report.analytics.missingGoldLabels.length}`);
console.log(
`Relevance checks: ${report.relevanceChecks.filter((check) => check.passed).length}/${report.relevanceChecks.length} passed`,
);
diff --git a/scripts/check-retrieval-owner-migration.ts b/scripts/check-retrieval-owner-migration.ts
new file mode 100644
index 000000000..927d38148
--- /dev/null
+++ b/scripts/check-retrieval-owner-migration.ts
@@ -0,0 +1,75 @@
+import { loadEnvConfig } from "@next/env";
+
+loadEnvConfig(process.cwd());
+
+async function tryRpc(
+ supabase: Awaited>,
+ name: string,
+ args: Record,
+) {
+ const { data, error } = await supabase.rpc(name as never, args as never);
+ return {
+ data,
+ error: error?.message ?? null,
+ code: (error as { code?: string } | null)?.code ?? null,
+ };
+}
+
+async function main() {
+ const { createAdminClient } = await import("../src/lib/supabase/admin");
+ const supabase = createAdminClient();
+
+ console.log("Checking live Supabase: Clinical KB Database (sjrfecxgysukkwxsowpy)\n");
+
+ const health = await tryRpc(supabase, "search_schema_health", {});
+ console.log("search_schema_health:", JSON.stringify(health, null, 2));
+
+ const helper = await tryRpc(supabase, "retrieval_owner_matches", {
+ owner_filter: "00000000-0000-0000-0000-000000000000",
+ row_owner_id: null,
+ });
+ console.log("\nretrieval_owner_matches (sentinel + null owner):", JSON.stringify(helper, null, 2));
+
+ const { count: publicDocs, error: publicErr } = await supabase
+ .from("documents")
+ .select("id", { count: "exact", head: true })
+ .is("owner_id", null)
+ .eq("status", "indexed");
+ console.log("\nindexed public documents:", JSON.stringify({ count: publicDocs, error: publicErr?.message ?? null }));
+
+ const sentinelSearch = await tryRpc(supabase, "match_document_chunks_text", {
+ query_text: "monitoring",
+ match_count: 5,
+ document_filters: null,
+ owner_filter: "00000000-0000-0000-0000-000000000000",
+ });
+ console.log(
+ "\nmatch_document_chunks_text (public sentinel):",
+ JSON.stringify(
+ {
+ resultCount: Array.isArray(sentinelSearch.data) ? sentinelSearch.data.length : null,
+ error: sentinelSearch.error,
+ code: sentinelSearch.code,
+ },
+ null,
+ 2,
+ ),
+ );
+
+ console.log("\n=== VERDICT ===");
+ if (helper.code === "PGRST202" || helper.error?.includes("Could not find the function")) {
+ console.log("NOT APPLIED — retrieval_owner_matches is missing on live Supabase.");
+ process.exit(1);
+ }
+ if (helper.data === true && Array.isArray(sentinelSearch.data)) {
+ console.log("APPLIED — helper exists and hybrid RPC accepts the public sentinel.");
+ process.exit(0);
+ }
+ console.log("UNCLEAR — review the output above.");
+ process.exit(1);
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/scripts/promote-public-documents.ts b/scripts/promote-public-documents.ts
new file mode 100644
index 000000000..2621eec57
--- /dev/null
+++ b/scripts/promote-public-documents.ts
@@ -0,0 +1,191 @@
+import { loadEnvConfig } from "@next/env";
+
+loadEnvConfig(process.cwd());
+
+type PromoteArgs = {
+ ownerId?: string;
+ apply: boolean;
+ limit?: number;
+};
+
+const PROMOTABLE_VALIDATION_STATUSES = ["locally_reviewed", "approved"] as const;
+
+const RELATED_TABLES = [
+ "document_labels",
+ "document_summaries",
+ "document_sections",
+ "document_memory_cards",
+ "document_table_facts",
+ "document_embedding_fields",
+ "document_index_quality",
+ "document_index_units",
+] as const;
+
+async function loadAdminClient() {
+ const { createAdminClient } = await import("@/lib/supabase/admin");
+ return createAdminClient();
+}
+
+function parseArgs(argv: string[]): PromoteArgs {
+ const args: PromoteArgs = {
+ ownerId: process.env.PUBLIC_WORKSPACE_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID,
+ apply: false,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (token === "--owner-id") {
+ args.ownerId = argv[index + 1];
+ index += 1;
+ continue;
+ }
+ if (token === "--apply") {
+ args.apply = true;
+ continue;
+ }
+ if (token === "--limit") {
+ args.limit = Number(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ throw new Error(`Unknown argument: ${token}`);
+ }
+
+ return args;
+}
+
+type CandidateDocument = {
+ id: string;
+ title: string | null;
+ file_name: string | null;
+ owner_id: string | null;
+ metadata: Record | null;
+};
+
+async function fetchCandidates(
+ supabase: Awaited>,
+ ownerId?: string,
+ limit?: number,
+) {
+ const candidates: CandidateDocument[] = [];
+ const pageSize = 200;
+
+ for (let offset = 0; ; offset += pageSize) {
+ let query = supabase
+ .from("documents")
+ .select("id,title,file_name,owner_id,metadata")
+ .eq("status", "indexed")
+ .not("owner_id", "is", null)
+ .in("metadata->>clinical_validation_status", [...PROMOTABLE_VALIDATION_STATUSES])
+ .order("updated_at", { ascending: false })
+ .range(offset, offset + pageSize - 1);
+
+ if (ownerId) query = query.eq("owner_id", ownerId);
+
+ const { data, error } = await query;
+ if (error) throw new Error(error.message);
+ const rows = (data ?? []) as CandidateDocument[];
+ candidates.push(...rows);
+ if (rows.length < pageSize) break;
+ if (limit && candidates.length >= limit) break;
+ }
+
+ return limit ? candidates.slice(0, limit) : candidates;
+}
+
+async function countPublic(supabase: Awaited>) {
+ const { count, error } = await supabase
+ .from("documents")
+ .select("id", { count: "exact", head: true })
+ .eq("status", "indexed")
+ .is("owner_id", null);
+
+ if (error) throw new Error(error.message);
+ return count ?? 0;
+}
+
+async function clearOwnerOnRelatedRows(
+ supabase: Awaited>,
+ table: (typeof RELATED_TABLES)[number],
+ documentIds: string[],
+) {
+ const { error } = await supabase.from(table).update({ owner_id: null }).in("document_id", documentIds);
+ if (error) throw new Error(`${table}: ${error.message}`);
+}
+
+async function promoteBatch(
+ supabase: Awaited>,
+ batch: CandidateDocument[],
+) {
+ const now = new Date().toISOString();
+ for (const document of batch) {
+ const metadata = {
+ ...(document.metadata ?? {}),
+ public_corpus: true,
+ };
+ const { error } = await supabase
+ .from("documents")
+ .update({ owner_id: null, metadata, updated_at: now })
+ .eq("id", document.id);
+ if (error) throw new Error(`documents/${document.id}: ${error.message}`);
+ }
+
+ const documentIds = batch.map((document) => document.id);
+ for (const table of RELATED_TABLES) {
+ await clearOwnerOnRelatedRows(supabase, table, documentIds);
+ }
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const supabase = await loadAdminClient();
+
+ const [candidates, publicBefore] = await Promise.all([
+ fetchCandidates(supabase, args.ownerId, args.limit),
+ countPublic(supabase),
+ ]);
+
+ console.log("[public-documents:promote] indexed public documents (before):", publicBefore);
+ console.log(
+ `[public-documents:promote] promotion candidates${args.ownerId ? ` for owner ${args.ownerId}` : ""}:`,
+ candidates.length,
+ );
+
+ if (candidates.length > 0) {
+ console.log("[public-documents:promote] sample candidates:");
+ for (const document of candidates.slice(0, 8)) {
+ console.log(
+ ` - ${document.title ?? document.file_name ?? document.id} (${document.metadata?.clinical_validation_status ?? "unknown"})`,
+ );
+ }
+ if (candidates.length > 8) console.log(` ... and ${candidates.length - 8} more`);
+ }
+
+ if (!args.apply) {
+ console.log("\nDry run only. Re-run with --apply to promote these documents to the public corpus.");
+ return;
+ }
+
+ if (candidates.length === 0) {
+ console.log("\nNothing to promote.");
+ return;
+ }
+
+ const batchSize = 25;
+ let promoted = 0;
+ for (let offset = 0; offset < candidates.length; offset += batchSize) {
+ const batch = candidates.slice(offset, offset + batchSize);
+ await promoteBatch(supabase, batch);
+ promoted += batch.length;
+ console.log(`[public-documents:promote] promoted ${promoted}/${candidates.length}`);
+ }
+
+ const publicAfter = await countPublic(supabase);
+ console.log("\n[public-documents:promote] indexed public documents (after):", publicAfter);
+ console.log(`[public-documents:promote] promoted ${promoted} document(s) to owner_id IS NULL.`);
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});
diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts
index 8d4f3b2a9..0084e60cd 100644
--- a/src/app/api/answer/route.ts
+++ b/src/app/api/answer/route.ts
@@ -4,7 +4,7 @@ import { demoAnswer } from "@/lib/demo-data";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { answerQuestionWithScope } from "@/lib/rag";
import { jsonError, PublicApiError } from "@/lib/http";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { publicAccessContext } from "@/lib/public-api-access";
import { classifyRagQuery } from "@/lib/clinical-search";
import { buildSmartRagApiPlan } from "@/lib/smart-rag-api";
@@ -70,7 +70,7 @@ export async function POST(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "answer",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit);
diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts
index 4741c2287..ef4fb0e54 100644
--- a/src/app/api/answer/stream/route.ts
+++ b/src/app/api/answer/stream/route.ts
@@ -2,7 +2,7 @@ import { z } from "zod";
import { demoAnswer } from "@/lib/demo-data";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { PublicApiError, jsonError } from "@/lib/http";
-import { consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit";
import { publicAccessContext } from "@/lib/public-api-access";
import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag";
import { classifyRagQuery } from "@/lib/clinical-search";
@@ -232,7 +232,7 @@ export async function POST(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "answer",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) return rateLimitStream(rateLimit);
diff --git a/src/app/api/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts
index c94551a28..6e0df0589 100644
--- a/src/app/api/documents/bulk/route.ts
+++ b/src/app/api/documents/bulk/route.ts
@@ -121,10 +121,10 @@ export async function POST(request: Request) {
try {
if (isDemoMode()) return NextResponse.json({ error: "Bulk edits are unavailable in demo mode." }, { status: 400 });
- const parsed = await parseJsonBody(request, bulkMetadataSchema, "Bulk edit payload is invalid.");
-
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
+
+ const parsed = await parseJsonBody(request, bulkMetadataSchema, "Bulk edit payload is invalid.");
const ids = Array.from(new Set(parsed.documentIds));
const { data: documents, error: documentsError } = await supabase
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index f70d0264b..521ef2737 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -1,24 +1,34 @@
+import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-// Liveness/readiness probe for load balancers and uptime monitors. It reports
-// whether the server is configured to operate for real (Supabase + OpenAI present)
-// and, with ?deep=1, whether Supabase is actually reachable. The payload exposes only
-// boolean configuration presence and operational status — never secret values.
+function allowDeepHealthProbe(request: Request): boolean {
+ const secret = env.HEALTH_DEEP_PROBE_SECRET;
+ if (!secret) return false;
+ const token = request.headers.get("x-health-deep-token");
+ if (!token) return false;
+ const expected = Buffer.from(secret);
+ const received = Buffer.from(token);
+ if (expected.length !== received.length) return false;
+ return timingSafeEqual(expected, received);
+}
+
export async function GET(request: Request) {
const deep = new URL(request.url).searchParams.get("deep") === "1";
const supabaseConfigured = Boolean(env.NEXT_PUBLIC_SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY);
- const checks: Record = {
+ const checks: Record = {
supabaseConfig: supabaseConfigured ? "ok" : "missing",
openaiConfig: env.OPENAI_API_KEY ? "ok" : "missing",
};
if (deep) {
- if (supabaseConfigured && !isDemoMode()) {
+ if (!allowDeepHealthProbe(request)) {
+ checks.supabase = "unauthorized";
+ } else if (supabaseConfigured && !isDemoMode()) {
try {
const [{ createAdminClient }, { probeSupabaseHealth }] = await Promise.all([
import("@/lib/supabase/admin"),
@@ -34,7 +44,8 @@ export async function GET(request: Request) {
}
}
- const ready = !Object.values(checks).includes("missing") && !Object.values(checks).includes("error");
+ const ready =
+ !Object.values(checks).some((value) => value === "missing" || value === "error" || value === "unauthorized");
return NextResponse.json(
{
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
index a0474e14d..ea99d7aa3 100644
--- a/src/app/api/search/route.ts
+++ b/src/app/api/search/route.ts
@@ -14,7 +14,7 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api";
import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider";
import { createAdminClient } from "@/lib/supabase/admin";
import * as serverAuth from "@/lib/supabase/auth";
-import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { publicAccessContext } from "@/lib/public-api-access";
import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode";
import { parseJsonBody } from "@/lib/validation/body";
@@ -893,7 +893,7 @@ export async function POST(request: Request) {
supabase,
subject: access.rateLimitSubject,
bucket: "search",
- allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse(
diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts
index bf0551c94..081568601 100644
--- a/src/app/api/setup-status/route.ts
+++ b/src/app/api/setup-status/route.ts
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard";
import { createAdminClient } from "@/lib/supabase/admin";
-import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health";
import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project";
@@ -52,6 +51,10 @@ function check(id: SetupCheckId, label: string, status: SetupCheckStatus, detail
return { id, label, status, detail };
}
+function projectSetupCheckStatus(status: ReturnType["status"]) {
+ return status === "ready" || status === "warning" ? "ready" : "needs_setup";
+}
+
async function readSupabaseAvailability(supabase: AdminClient | null) {
if (!requiredSupabaseEnvPresent || !supabaseProjectCanBeQueried || !supabase) return null;
const now = Date.now();
@@ -297,7 +300,7 @@ async function buildSetupStatusPayload(): Promise {
check(
"project",
"Clinical KB Database target",
- supabaseProjectCheck.status === "ready" ? "ready" : "needs_setup",
+ projectSetupCheckStatus(supabaseProjectCheck.status),
formatSupabaseProjectCheck(supabaseProjectCheck),
),
check(
@@ -354,7 +357,7 @@ async function buildSetupStatusPayload(): Promise {
check(
"project",
"Clinical KB Database target",
- supabaseProjectCheck.status === "ready" ? "ready" : "needs_setup",
+ projectSetupCheckStatus(supabaseProjectCheck.status),
formatSupabaseProjectCheck(supabaseProjectCheck),
),
schema,
@@ -409,27 +412,11 @@ async function readSetupStatusPayload() {
}
}
-async function requireProductionSetupStatusAuth(request: Request) {
+export async function GET(request: Request) {
const identity = localProjectRequestIdentityPayload(request);
- if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) {
- return identity;
+ if (!identity.localServer.safeLocalOrigin) {
+ return unsafeLocalProjectResponse(identity);
}
- await requireAuthenticatedUser(request, createAdminClient());
- return identity;
-}
-export async function GET(request: Request) {
- try {
- const identity = await requireProductionSetupStatusAuth(request);
- if (!identity.localServer.safeLocalOrigin) {
- return unsafeLocalProjectResponse(identity);
- }
-
- return setupStatusResponse(await readSetupStatusPayload());
- } catch (error) {
- if (error instanceof AuthenticationError) {
- return unauthorizedResponse(error);
- }
- throw error;
- }
+ return setupStatusResponse(await readSetupStatusPayload());
}
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 2545d2b24..d05570158 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,38 @@ 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 { isDeployedClinicalKb } from "@/lib/deployed-app";
+import { isLocalNoAuthMode, publicUploadsEnabled } 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 +96,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 +145,8 @@ const DocumentDrawer = dynamic(
);
import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";
-import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance";
+import { SourceReviewQueuePanel } from "@/components/clinical-dashboard/source-review-queue-panel";
+import { isWeakRelevance } from "@/components/clinical-dashboard/relevance";
import {
answerPayloadIsUsable,
isRetryableError,
@@ -236,20 +184,21 @@ 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,
+ serializeSourceGovernanceWarning,
type SourceGovernanceWarning,
} from "@/lib/source-governance";
-import { smartEvidenceTags } from "@/lib/evidence-tags";
import {
- tagSearchText,
+ invalidateSearchRequests,
+ isLatestSearchRequest,
+ type SearchRequestToken,
+} from "@/lib/search-request-token";
+import {
type SmartDocumentTag,
type SmartDocumentTagFacet,
- type SmartDocumentTagTier,
- type SmartDocumentTagQualityIssueKind,
} from "@/lib/document-tags";
import type {
ClinicalDocument,
@@ -263,16 +212,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 +433,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.
@@ -2174,7 +1759,11 @@ export function ClinicalDashboard({
process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks);
const canUsePrivateApis =
localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated");
- const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis;
+ const canUploadDocuments =
+ canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis);
+ const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady;
+ const canRunSearch =
+ explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch;
const closeDashboardTransientSurfaces = useCallback(
(except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => {
if (except !== "guide") setGuideOpen(false);
@@ -2355,20 +1944,25 @@ export function ClinicalDashboard({
const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null);
if (!setupResponse) {
- setApiUnavailable(true);
- setSetupWarning("The local API is unavailable.");
- return;
- }
-
- if (setupResponse.ok) {
+ if (isDeployedClinicalKb()) {
+ setSetupWarning("Setup status could not be loaded. You can still try search.");
+ } else {
+ setApiUnavailable(true);
+ setSetupWarning("The local API is unavailable.");
+ return;
+ }
+ } else if (setupResponse.ok) {
const payload = (await setupResponse.json()) as SetupStatusPayload;
setSetupChecks(payload.checks ?? fallbackSetupChecks);
nextDemoMode = Boolean(payload.demoMode);
routeIndexingActive = Boolean(payload.indexingActive);
routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.pollAfterMs);
if (nextDemoMode) setDemoMode(true);
+ } else if (isDeployedClinicalKb()) {
+ setSetupWarning("Setup status could not be loaded. You can still try search.");
} else {
setApiUnavailable(true);
+ return;
}
}
@@ -2922,11 +2516,13 @@ export function ClinicalDashboard({
function searchNetworkFailure(label: string) {
const offline = typeof navigator !== "undefined" && !navigator.onLine;
- const localOrigin = typeof window !== "undefined" ? window.location.origin : "the local Clinical KB server";
+ const origin = typeof window !== "undefined" ? window.location.origin : "Clinical KB";
return makeSearchError(
offline
? `${label} could not run because the browser is offline.`
- : `${label} could not reach Clinical KB at ${localOrigin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`,
+ : isDeployedClinicalKb()
+ ? `${label} could not reach Clinical KB at ${origin}. Check your connection and try again shortly.`
+ : `${label} could not reach Clinical KB at ${origin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`,
undefined,
true,
);
@@ -3061,7 +2657,11 @@ export function ClinicalDashboard({
// resolve out of order; only the latest request may commit answer/sources/
// error/loading state, or a stale response would display one query's answer
// under another query's composer text.
- const searchRequestSeqRef = useRef(0);
+ const searchRequestSeqRef = useRef(0);
+
+ function invalidateInFlightSearchRequests() {
+ searchRequestSeqRef.current = invalidateSearchRequests(searchRequestSeqRef.current);
+ }
function applySearchResult(payload: SearchResultModePayload, displayQuery?: string) {
if (payload.kind === "documents") {
@@ -3125,7 +2725,8 @@ export function ClinicalDashboard({
// library, whose single `document_labels.in()` request produces an
// over-long PostgREST URL that fails on large corpora. Corpus search runs
// unscoped (like Documents); users opt into label filters explicitly.
- const requestId = ++searchRequestSeqRef.current;
+ const requestId = invalidateSearchRequests(searchRequestSeqRef.current);
+ searchRequestSeqRef.current = requestId;
setSearchMode(targetMode);
// Answer mode keeps the composer as the draft source until a successful
@@ -3179,7 +2780,7 @@ export function ClinicalDashboard({
// must also be discarded once a newer search takes over, or a slow stale
// request repaints the progress banner under the newer query.
const onProgress = (message: string | null) => {
- if (requestId === searchRequestSeqRef.current) setAnswerProgress(message);
+ if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) setAnswerProgress(message);
};
setLoading(true);
setError(null);
@@ -3253,7 +2854,7 @@ export function ClinicalDashboard({
}
// M10: discard a stale response — a newer search owns the UI state.
- if (requestId === searchRequestSeqRef.current) {
+ if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {
applySearchResult(successfulPayload, trimmedQuery);
if (successfulPayload.kind === "answer") {
// The composer is a draft box in a conversation: clear it so the
@@ -3272,11 +2873,11 @@ export function ClinicalDashboard({
}
}
} catch (requestError) {
- if (requestId === searchRequestSeqRef.current) {
+ if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {
setError(requestError instanceof Error ? requestError.message : "Search failed");
}
} finally {
- if (requestId === searchRequestSeqRef.current) {
+ if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {
setLoading(false);
setAnswerProgress(null);
}
@@ -3344,6 +2945,7 @@ export function ClinicalDashboard({
}
function crossModeSearch(mode: AppModeId, crossQuery: string) {
+ invalidateInFlightSearchRequests();
modeChangeFromUiRef.current = true;
if (mode === "differentials") clearDifferentialModeResultState();
setCommandScopes([]);
@@ -3403,7 +3005,7 @@ export function ClinicalDashboard({
sourceChunkIds,
citedChunkIds,
sourceFiles,
- sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message),
+ sourceGovernanceWarnings: sourceGovernanceWarnings.map(serializeSourceGovernanceWarning),
unverifiedNumericTokens: answer.unverifiedNumericTokens ?? [],
}),
});
@@ -3479,6 +3081,7 @@ export function ClinicalDashboard({
return;
}
+ invalidateInFlightSearchRequests();
setQuery(trimmedSearchText);
setSearchMode(targetMode);
setModeSearchSubmitted(true);
@@ -3496,17 +3099,26 @@ export function ClinicalDashboard({
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);
+ const requestId = invalidateSearchRequests(searchRequestSeqRef.current);
+ searchRequestSeqRef.current = requestId;
+
try {
const shortcutQueryMode = appModeQueryMode(targetMode, queryMode);
const payload = await runWithRetries(() =>
requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode),
);
- applySearchResult(payload);
+ if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {
+ applySearchResult(payload);
+ }
} catch (requestError) {
- setError(requestError instanceof Error ? requestError.message : "Document search failed");
+ if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {
+ setError(requestError instanceof Error ? requestError.message : "Document search failed");
+ }
} finally {
- setLoading(false);
- setAnswerProgress(null);
+ if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {
+ setLoading(false);
+ setAnswerProgress(null);
+ }
}
}
@@ -3595,6 +3207,7 @@ export function ClinicalDashboard({
}
function selectSearchMode(mode: AppModeId) {
+ invalidateInFlightSearchRequests();
modeChangeFromUiRef.current = true;
if (mode === "differentials") clearDifferentialModeResultState();
setQuery("");
@@ -3638,6 +3251,7 @@ export function ClinicalDashboard({
}
function startNewChat() {
+ invalidateInFlightSearchRequests();
modeChangeFromUiRef.current = true;
const href = appModeHomeHref("answer", { focus: true });
setQuery("");
@@ -4005,21 +3619,21 @@ export function ClinicalDashboard({
);
- const showAuthPanel = !clientDemoMode && !canUsePrivateApis;
- const showDegradedNotice = !isOnline || apiUnavailable;
+ const showAuthPanel = false;
+ const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch);
const hasMobileBottomSearch = searchMode !== "answer";
const showDesktopHomeComposer =
- !loading &&
!error &&
- ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) ||
- (searchMode === "documents" &&
- activeModeResultKind === "documents" &&
- documentMatches.length === 0 &&
- !modeSearchSubmitted) ||
- (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) ||
- (activeModeResultKind === "differentials" && !modeSearchSubmitted) ||
+ (activeModeResultKind === "tools" ||
activeModeResultKind === "favourites" ||
- activeModeResultKind === "tools");
+ (!loading &&
+ ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) ||
+ (searchMode === "documents" &&
+ activeModeResultKind === "documents" &&
+ documentMatches.length === 0 &&
+ !modeSearchSubmitted) ||
+ (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) ||
+ (activeModeResultKind === "differentials" && !modeSearchSubmitted))));
const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined;
// Favourites and Tools are content-rich hubs: they share the centred hero but
// stay top-aligned so their lists start in a stable position.
@@ -4044,14 +3658,18 @@ export function ClinicalDashboard({
summary={
!isOnline
? "Your browser is offline. Existing content may remain visible, but private search and uploads need network access."
- : "The local API did not respond. Check the app server and setup status before retrying."
+ : isDeployedClinicalKb()
+ ? "The app could not reach its API. Try again in a moment."
+ : "The local API did not respond. Check the app server and setup status before retrying."
}
mobileSummary={!isOnline ? "Offline" : "API unavailable"}
>
{!isOnline
? "Reconnect before uploading documents, refreshing source URLs, or generating answers."
- : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
+ : isDeployedClinicalKb()
+ ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly."
+ : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
);
@@ -4079,7 +3697,7 @@ export function ClinicalDashboard({
{
id: "upload",
label: "Upload",
- summary: uploadReadOnlyMode || !canUsePrivateApis ? "Locked" : "Ready",
+ summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready",
panelId: "dashboard-upload-section",
icon: UploadCloud,
},
@@ -4659,7 +4277,7 @@ export function ClinicalDashboard({
@@ -4704,6 +4322,7 @@ export function ClinicalDashboard({
onReindex={reindexDocument}
onEnrich={enrichDocument}
/>
+
diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx
index ff148551d..eefa782d3 100644
--- a/src/components/clinical-dashboard/answer-content.tsx
+++ b/src/components/clinical-dashboard/answer-content.tsx
@@ -23,6 +23,7 @@ import {
chatMicroAction,
cn,
sourceCapsule,
+ statusDotDanger,
statusDotMuted,
statusDotReady,
statusDotReview,
@@ -35,9 +36,16 @@ import {
comparableAnswerText,
sanitizeAnswerDisplayText,
} from "@/components/clinical-dashboard/display-text";
+import { SourcePreviewPopover } from "@/components/clinical-dashboard/source-preview-popover";
import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet";
import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache";
-import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata";
+import {
+ extractionQualityLabel,
+ normalizeSourceMetadata,
+ sourceStatusLabel,
+ sourceStatusNeedsAttention,
+ validationStatusShortLabel,
+} from "@/lib/source-metadata";
import { clinicalProseUsefulness } from "@/lib/source-text-sanitizer";
import {
frontendSourceGovernanceWarnings,
@@ -260,8 +268,15 @@ function sourceCapsuleText({
export function sourceStatusDotClass(metadata: ReturnType | null | undefined) {
if (!metadata) return statusDotMuted;
+ if (metadata.document_status === "outdated" || metadata.extraction_quality === "poor") return statusDotDanger;
+ if (
+ metadata.document_status === "review_due" ||
+ metadata.clinical_validation_status === "unverified" ||
+ metadata.extraction_quality === "partial"
+ ) {
+ return statusDotReview;
+ }
if (metadata.document_status === "current") return statusDotReady;
- if (metadata.document_status === "review_due" || metadata.document_status === "outdated") return statusDotReview;
return statusDotMuted;
}
@@ -282,7 +297,14 @@ function sourceBadgeLabel(index: number) {
}
function sourceBadgeToneClass(metadata: ReturnType, index: number) {
- if (metadata.document_status === "review_due" || metadata.document_status === "outdated") {
+ if (metadata.document_status === "outdated" || metadata.extraction_quality === "poor") {
+ return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
+ }
+ if (
+ metadata.document_status === "review_due" ||
+ metadata.clinical_validation_status === "unverified" ||
+ metadata.extraction_quality === "partial"
+ ) {
return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]";
}
if (index === 0) {
@@ -302,6 +324,10 @@ function sourceSupportLabel(source: CapsulePreviewSource, index: number) {
function sourceStatusShortLabel(metadata: ReturnType) {
if (metadata.document_status === "review_due") return "Review due";
if (metadata.document_status === "outdated") return "Outdated";
+ if (metadata.clinical_validation_status === "unverified") return validationStatusShortLabel(metadata);
+ if (metadata.extraction_quality === "partial" || metadata.extraction_quality === "poor") {
+ return extractionQualityLabel(metadata);
+ }
if (metadata.document_status === "current") return "Current";
return sourceStatusLabel(metadata);
}
@@ -380,9 +406,10 @@ function SourcePreviewContent({
showHeader?: boolean;
}) {
const primaryPreviewSource = previewSources[0] ?? null;
- const reviewDueSource = previewSources.find(
- (source) => source.metadata.document_status === "review_due" || source.metadata.document_status === "outdated",
- );
+ const attentionSource = previewSources.find((source) => sourceStatusNeedsAttention(source.metadata));
+ const attentionIsDanger =
+ attentionSource?.metadata.document_status === "outdated" ||
+ attentionSource?.metadata.extraction_quality === "poor";
return (
<>
@@ -447,8 +474,11 @@ function SourcePreviewContent({
@@ -500,12 +530,16 @@ function SourcePreviewContent({
- {reviewDueSource ? : }
- {reviewDueSource
- ? `${sourceBadgeLabel(previewSources.indexOf(reviewDueSource))} review due`
+ {attentionSource ? : }
+ {attentionSource
+ ? `${sourceBadgeLabel(previewSources.indexOf(attentionSource))} ${sourceStatusShortLabel(attentionSource.metadata)}`
: "Sources current"}
{primaryPreviewSource ? (
@@ -579,6 +613,7 @@ export function NaturalLanguageAnswer({
ref={sourceCapsuleRef}
className={cn(sourceCapsule, "w-fit")}
aria-label="Open answer sources"
+ aria-haspopup="dialog"
aria-expanded={sourcePreviewOpen}
onClick={() => {
if (canOpenSourcePreview) setSourcePreviewOpen((current) => !current);
@@ -594,7 +629,12 @@ export function NaturalLanguageAnswer({
) : (
capsuleText
)}
- {canOpenSourcePreview ? : null}
+ {canOpenSourcePreview ? (
+
+ ) : null}
);
@@ -658,10 +698,11 @@ export function NaturalLanguageAnswer({
) : null}
{sourceCapsuleButton}
- {sourcePreviewOpen && canOpenSourcePreview && !usePreviewSheet ? (
- setSourcePreviewOpen(false)}
+ anchorRef={sourceCapsuleRef}
>
-
+
) : null}
) {
if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null;
const message = authUnavailable
- ? "Private medication search is waiting for sign-in."
+ ? isDeployedClinicalKb()
+ ? "Sign in to search your private medication library."
+ : "Private medication search is waiting for sign-in."
: apiUnavailable
- ? "Medication search is using the local mockup while the API is unavailable."
+ ? isDeployedClinicalKb()
+ ? "Medication search is temporarily unavailable. Try again shortly."
+ : "Medication search is using the local mockup while the API is unavailable."
: setupWarning || "Medication search setup is still warming up.";
return (
diff --git a/src/components/clinical-dashboard/source-preview-popover.tsx b/src/components/clinical-dashboard/source-preview-popover.tsx
new file mode 100644
index 000000000..113798415
--- /dev/null
+++ b/src/components/clinical-dashboard/source-preview-popover.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode, type RefObject } from "react";
+
+interface SourcePreviewPopoverProps {
+ open: boolean;
+ onClose: () => void;
+ anchorRef: RefObject;
+ children: ReactNode;
+}
+
+/**
+ * Controlled popover for the NaturalLanguageAnswer source capsule preview.
+ * Renders inline (flow-position) below the anchor, matching the pre-refactor
+ * style. Closes on click-outside or Escape.
+ */
+export function SourcePreviewPopover({ open, onClose, children }: SourcePreviewPopoverProps) {
+ const popoverRef = useRef
(null);
+
+ useEffect(() => {
+ if (!open) return;
+
+ function handlePointerDown(event: PointerEvent) {
+ if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") onClose();
+ }
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/clinical-dashboard/source-review-queue-panel.tsx b/src/components/clinical-dashboard/source-review-queue-panel.tsx
new file mode 100644
index 000000000..cf99f4593
--- /dev/null
+++ b/src/components/clinical-dashboard/source-review-queue-panel.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import Link from "next/link";
+import { ShieldAlert } from "lucide-react";
+
+import { sourceResultHref } from "@/components/clinical-dashboard/source-actions";
+import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text";
+import {
+ cn,
+ panelSubtle,
+ SourceProvenance,
+ SourceStatusBadge,
+ textMuted,
+ toneDanger,
+ toneWarning,
+} from "@/components/ui-primitives";
+import { normalizeSourceMetadata, sourceStatusNeedsAttention } from "@/lib/source-metadata";
+import type { SearchResult } from "@/lib/types";
+
+export function SourceReviewQueuePanel({ sources }: { sources: SearchResult[] }) {
+ const reviewItems = sources.filter((source) => sourceStatusNeedsAttention(normalizeSourceMetadata(source.source_metadata)));
+
+ if (reviewItems.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+
Source review queue
+
+ {reviewItems.length} matched source{reviewItems.length === 1 ? "" : "s"} need clinical review before use.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx
index e899fe11e..35b9a9d14 100644
--- a/src/components/forms/form-detail-page.tsx
+++ b/src/components/forms/form-detail-page.tsx
@@ -94,6 +94,7 @@ function sourceToneClass(form: FormRecord) {
const status = form.source?.status?.toLowerCase() ?? "";
if (form.verification?.locallyVerified || status.includes("checked") || status.includes("verified"))
return toneSuccess;
+ if (status.includes("outdated") || status.includes("invalid") || status.includes("withdrawn")) return toneDanger;
if (status.includes("required") || status.includes("review")) return toneWarning;
return toneNeutral;
}
diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx
index ec53f934c..0db3e2a93 100644
--- a/src/components/services/services-navigator-page.tsx
+++ b/src/components/services/services-navigator-page.tsx
@@ -436,7 +436,7 @@ export function ServicesNavigatorPage() {
const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery;
const registry = useRegistryRecords("service");
const searchableRecords =
- registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords;
+ registry.status === "ready" ? registry.records : serviceRecords;
const matches = useMemo(() => {
const ranked = rankServiceRecords(searchableRecords, query);
return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords;
diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx
index 083dfc7ff..d9f482b19 100644
--- a/src/components/ui-primitives.tsx
+++ b/src/components/ui-primitives.tsx
@@ -30,9 +30,9 @@ export const controlBase =
"inline-flex min-h-tap items-center justify-center gap-2 rounded-lg text-sm font-semibold transition active:translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none";
export const primaryControl = `${controlBase} bg-[color:var(--command)] px-5 text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] hover:bg-[color:var(--command-hover)] hover:shadow-[var(--shadow-hover)]`;
export const floatingControl =
- "inline-flex min-h-tap items-center justify-center gap-2 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none";
+ "inline-flex min-h-tap items-center justify-center gap-2 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition active:translate-y-px hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none";
export const toolbarButton =
- "grid h-tap w-tap shrink-0 place-items-center rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none";
+ "grid h-tap w-tap shrink-0 place-items-center rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] shadow-[var(--shadow-inset)] transition active:translate-y-px hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none";
export const eyebrowText = "text-2xs font-semibold uppercase leading-4 tracking-[0.06em] text-[color:var(--text-soft)]";
export const fieldLabel = `mb-1.5 block ${eyebrowText}`;
export const fieldControl =
@@ -72,9 +72,9 @@ export const chatAnswerText =
export const chatActionRow =
"flex min-h-tap flex-wrap items-center gap-1.5 text-xs font-semibold text-[color:var(--text-heading)] sm:min-h-8";
export const chatMicroAction =
- "inline-flex min-h-tap min-w-tap items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50";
+ "inline-flex min-h-tap min-w-tap items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition active:translate-y-px hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50";
export const sourceCapsule =
- "source-capsule-hover focus-ring-premium inline-flex min-h-tap items-center gap-1.5 rounded-full border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-xs font-semibold text-[color:var(--clinical-accent)] transition hover:border-[color:var(--clinical-accent)]";
+ "source-capsule-hover focus-ring-premium inline-flex min-h-tap items-center gap-1.5 rounded-full border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-xs font-semibold text-[color:var(--clinical-accent)] transition active:translate-y-px hover:border-[color:var(--clinical-accent)]";
export const evidenceRow =
"flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-left shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
export const clinicalNotesRow =
@@ -84,9 +84,9 @@ export const chatComposerShell =
export const chatComposerInput =
"min-h-tap min-w-0 flex-1 bg-transparent px-2 text-base font-medium text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-soft)]";
export const chatComposerIconButton =
- "grid h-tap w-tap shrink-0 place-items-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50";
+ "grid h-tap w-tap shrink-0 place-items-center rounded-full text-[color:var(--text-muted)] transition active:translate-y-px hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50";
export const chatSendButton =
- "grid h-tap w-tap shrink-0 place-items-center rounded-full bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)] transition hover:bg-[color:var(--clinical-accent-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50";
+ "grid h-tap w-tap shrink-0 place-items-center rounded-full bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)] transition active:translate-y-px hover:bg-[color:var(--clinical-accent-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50";
export const tableCard =
"overflow-hidden rounded-lg border border-[color:var(--border)]/80 bg-[color:var(--surface)] shadow-[0_6px_16px_rgb(15_27_45_/_4%)]";
export const tableCardHeader =
@@ -100,6 +100,7 @@ export const sidebarToolTile =
export const statusDotBase = "inline-block h-2 w-2 shrink-0 rounded-full";
export const statusDotReady = `${statusDotBase} bg-[color:var(--success)]`;
export const statusDotReview = `${statusDotBase} bg-[color:var(--warning)]`;
+export const statusDotDanger = `${statusDotBase} bg-[color:var(--danger)]`;
export const statusDotMuted = `${statusDotBase} bg-[color:var(--text-soft)]`;
export const toneSuccess =
diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts
index ed8e43576..05b98c327 100644
--- a/src/lib/answer-render-policy.ts
+++ b/src/lib/answer-render-policy.ts
@@ -1,4 +1,5 @@
import { citationFromResult, citationIdentity, documentCitationHref, formatCitationLabel } from "@/lib/citations";
+import { clipboardProvenanceLine, normalizeSourceMetadata } from "@/lib/source-metadata";
import type {
BestSourceRecommendation,
Citation,
@@ -517,10 +518,15 @@ export function formatAnswerRenderCopyText(args: {
warnings: string[];
}) {
const sourceLines = args.primarySources.length
- ? args.primarySources.map(
- (source, index) =>
+ ? args.primarySources.map((source, index) => {
+ const provenance = source.sourceMetadata
+ ? clipboardProvenanceLine(normalizeSourceMetadata(source.sourceMetadata))
+ : null;
+ return [
`${index + 1}. ${source.label} | ${describeSourceStrengthForCopy(source.sourceStrength)} | ${source.href}`,
- )
+ ...(provenance ? [` Provenance: ${provenance}`] : []),
+ ].join("\n");
+ })
: ["No policy-approved sources were attached."];
const warningLines = args.warnings.length ? args.warnings.map((warning) => `- ${warning}`) : ["- None"];
diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts
index 2a19a21ba..593cd9cfb 100644
--- a/src/lib/api-rate-limit.ts
+++ b/src/lib/api-rate-limit.ts
@@ -1,10 +1,23 @@
import { NextResponse } from "next/server";
+import { isLocalNoAuthMode } from "@/lib/env";
import { PublicApiError } from "@/lib/http";
import type { RateLimitSubject } from "@/lib/public-api-access";
import type { createAdminClient } from "@/lib/supabase/admin";
+/** Prefer durable RPC rate limits; fall back to per-instance memory when the DB function is unavailable. */
+export function allowRateLimitInMemoryFallbackOnUnavailable() {
+ return isLocalNoAuthMode() || process.env.NODE_ENV === "production";
+}
+
export type ApiRateLimitBucket =
- "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry";
+ | "answer"
+ | "search"
+ | "document_read"
+ | "document_upload"
+ | "document_summarize"
+ | "document_reindex"
+ | "bulk_reindex"
+ | "registry";
export type ApiRateLimitResult = {
limited: boolean;
@@ -17,6 +30,8 @@ export type ApiRateLimitResult = {
const apiRateLimitDefaults = {
answer: { limit: 30, windowSeconds: 60 },
search: { limit: 240, windowSeconds: 60 },
+ document_read: { limit: 180, windowSeconds: 60 },
+ document_upload: { limit: 12, windowSeconds: 60 },
document_summarize: { limit: 12, windowSeconds: 60 },
document_reindex: { limit: 6, windowSeconds: 60 },
bulk_reindex: { limit: 2, windowSeconds: 60 },
@@ -26,6 +41,8 @@ const apiRateLimitDefaults = {
const anonymousApiRateLimitDefaults: Partial> = {
answer: { limit: 6, windowSeconds: 60 },
search: { limit: 60, windowSeconds: 60 },
+ document_read: { limit: 45, windowSeconds: 60 },
+ document_upload: { limit: 3, windowSeconds: 60 },
};
type SupabaseAdmin = ReturnType;
diff --git a/src/lib/deployed-app.ts b/src/lib/deployed-app.ts
new file mode 100644
index 000000000..82bb8f3a7
--- /dev/null
+++ b/src/lib/deployed-app.ts
@@ -0,0 +1,3 @@
+export function isDeployedClinicalKb() {
+ return process.env.NODE_ENV === "production";
+}
diff --git a/src/lib/document-label-governance.ts b/src/lib/document-label-governance.ts
index 3b9bb4288..371385d49 100644
--- a/src/lib/document-label-governance.ts
+++ b/src/lib/document-label-governance.ts
@@ -383,7 +383,6 @@ export function buildDocumentLabelGovernanceReport(documents: LabelGovernanceDoc
relevanceChecks,
passed:
analytics.blockingQualityIssues.length === 0 &&
- analytics.missingGoldLabels.length === 0 &&
relevanceChecks.every((check) => check.passed),
};
}
diff --git a/src/lib/env.ts b/src/lib/env.ts
index ac5d8dbf5..b17748051 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -7,10 +7,14 @@ const envSchema = z.object({
SUPABASE_PROJECT_REF: z.string().optional(),
SUPABASE_PROJECT_NAME: z.string().optional(),
SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),
+ SUPABASE_DB_URL: z.string().url().optional(),
+ HEALTH_DEEP_PROBE_SECRET: z.string().min(16).optional(),
NEXT_PUBLIC_LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"),
LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"),
LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(),
LOCAL_NO_AUTH_OWNER_ID: z.string().optional(),
+ PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(),
+ NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(),
OPENAI_API_KEY: z.string().optional(),
OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"),
// Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding
@@ -192,3 +196,11 @@ export function isLocalNoAuthMode() {
return process.env.NODE_ENV !== "production" && (publicNoAuth || serverNoAuth);
}
+
+export function publicWorkspaceOwnerId() {
+ return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null;
+}
+
+export function publicUploadsEnabled() {
+ return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true";
+}
diff --git a/src/lib/rag.ts b/src/lib/rag.ts
index 6adea390f..992f541ca 100644
--- a/src/lib/rag.ts
+++ b/src/lib/rag.ts
@@ -2055,9 +2055,9 @@ function assertGlobalSearchAllowed(args: SearchChunksArgs) {
}
}
-function ownerScopeForDocumentFilteredRetrieval(ownerId: string | undefined, documentIds: string[] | undefined) {
+function ownerScopeForDocumentFilteredRetrieval(ownerId: string | undefined, allowGlobalSearch?: boolean) {
if (ownerId) return requireOwnerScope(ownerId);
- if (documentIds?.length) return undefined;
+ if (allowGlobalSearch) return undefined;
return requireOwnerScope(ownerId);
}
@@ -2186,6 +2186,7 @@ async function searchTextChunkCandidates(args: {
queryVariants: string[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
}) {
const runChunkText = async (queryText: string, matchCount: number) => {
@@ -2193,7 +2194,7 @@ async function searchTextChunkCandidates(args: {
query_text: queryText,
match_count: matchCount,
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.allowGlobalSearch),
});
return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]);
};
@@ -2346,7 +2347,7 @@ async function fetchBestDocumentLookupChunks(args: {
query_text: args.query,
document_filters: args.documentIds ?? undefined,
match_count: Math.max(args.limit * 3, 24),
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.allowGlobalSearch),
});
if (!rpcError && rpcChunks?.length) {
const ranked = (rpcChunks as DocumentLookupChunkRow[])
@@ -2793,6 +2794,7 @@ async function searchTableFactCandidates(args: {
queryVariants?: string[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
}) {
const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice(
@@ -2805,7 +2807,7 @@ async function searchTableFactCandidates(args: {
query_text: variant,
match_count: index === 0 ? args.matchCount : Math.min(args.matchCount, 24),
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.allowGlobalSearch),
});
if (error || !data?.length) return [] as TableFactRpcRow[];
return data as TableFactRpcRow[];
@@ -2844,6 +2846,7 @@ async function searchEmbeddingFieldCandidates(args: {
queryEmbedding: number[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
}) {
@@ -2853,7 +2856,7 @@ async function searchEmbeddingFieldCandidates(args: {
match_count: args.matchCount,
min_similarity: 0.12,
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.allowGlobalSearch),
});
if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
@@ -2894,6 +2897,7 @@ async function searchIndexUnitCandidates(args: {
queryEmbedding: number[];
ownerId?: string;
documentIds?: string[];
+ allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
}) {
@@ -2903,7 +2907,7 @@ async function searchIndexUnitCandidates(args: {
match_count: args.matchCount,
min_similarity: 0.1,
document_filters: args.documentIds ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.allowGlobalSearch),
});
if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
@@ -5512,6 +5516,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryVariants,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: textCandidateCount,
});
telemetry.text_candidate_count = textData.length;
@@ -5610,6 +5615,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryVariants,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
});
const tableFactLatencyMs = Date.now() - tableFactStartedAt;
@@ -5790,6 +5796,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
telemetry,
});
@@ -5803,6 +5810,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
+ allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 64),
telemetry,
});
@@ -5816,7 +5824,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
match_count: candidateCount,
min_similarity: minSimilarity,
document_filters: documentFilterList ?? undefined,
- owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList),
+ owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.allowGlobalSearch),
});
return { data, error, latencyMs: Date.now() - startedAt };
})(),
@@ -5910,7 +5918,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
document_filter: documentFilter ?? undefined,
owner_filter: ownerScopeForDocumentFilteredRetrieval(
args.ownerId,
- documentFilter ? [documentFilter] : undefined,
+ documentFilter ? undefined : args.allowGlobalSearch,
),
});
diff --git a/src/lib/search-request-token.ts b/src/lib/search-request-token.ts
new file mode 100644
index 000000000..daa6b4358
--- /dev/null
+++ b/src/lib/search-request-token.ts
@@ -0,0 +1,13 @@
+export type SearchRequestToken = number;
+
+export function nextSearchRequestToken(current: SearchRequestToken): SearchRequestToken {
+ return current + 1;
+}
+
+export function invalidateSearchRequests(current: SearchRequestToken): SearchRequestToken {
+ return nextSearchRequestToken(current);
+}
+
+export function isLatestSearchRequest(requestId: SearchRequestToken, latest: SearchRequestToken): boolean {
+ return requestId === latest;
+}
diff --git a/src/lib/source-governance.ts b/src/lib/source-governance.ts
index d9ede0a20..5a7740e6a 100644
--- a/src/lib/source-governance.ts
+++ b/src/lib/source-governance.ts
@@ -52,10 +52,16 @@ export function isDangerSourceGovernanceMessage(message: string) {
const frontendVisibleWarningCodes = new Set([
"outdated_source",
+ "review_due_source",
+ "unverified_source",
"poor_extraction",
"weak_evidence",
]);
+export function serializeSourceGovernanceWarning(warning: SourceGovernanceWarning) {
+ return `${warning.code}:${warning.severity}:${warning.message}`;
+}
+
function isLocalMetadataText(value: string) {
return /\b(?:wa|western australia|perth|north metropolitan|east metropolitan|south metropolitan|health service)\b/i.test(
value,
diff --git a/src/lib/source-metadata.ts b/src/lib/source-metadata.ts
index 337b246ca..e16b22f9e 100644
--- a/src/lib/source-metadata.ts
+++ b/src/lib/source-metadata.ts
@@ -58,6 +58,24 @@ export function validationStatusLabel(metadata?: ClinicalSourceMetadata | null)
return "Not locally validated";
}
+export function validationStatusShortLabel(metadata?: ClinicalSourceMetadata | null) {
+ const status = metadata?.clinical_validation_status ?? "unverified";
+ if (status === "approved") return "Approved";
+ if (status === "locally_reviewed") return "Locally reviewed";
+ return "Unverified";
+}
+
+export function sourceStatusNeedsAttention(metadata?: ClinicalSourceMetadata | null) {
+ const source = metadata ?? normalizeSourceMetadata(null);
+ return (
+ source.document_status === "review_due" ||
+ source.document_status === "outdated" ||
+ source.clinical_validation_status === "unverified" ||
+ source.extraction_quality === "poor" ||
+ source.extraction_quality === "partial"
+ );
+}
+
export function extractionQualityLabel(metadata?: ClinicalSourceMetadata | null) {
const status = metadata?.extraction_quality ?? "unknown";
if (status === "good") return "Good extraction";
diff --git a/supabase/functions/indexing-v3-agent/index.ts b/supabase/functions/indexing-v3-agent/index.ts
index aa66e9354..c55ba7caa 100644
--- a/supabase/functions/indexing-v3-agent/index.ts
+++ b/supabase/functions/indexing-v3-agent/index.ts
@@ -1882,7 +1882,7 @@ async function updateAgentJobStatus(
error: string | null = null,
nextRunAt: string | null = null,
): Promise {
- const rows = await sql>`
+ const rows = await sql`
select *
from public.update_indexing_v3_agent_job_status(
${job.document_id}::uuid,
@@ -1891,7 +1891,8 @@ async function updateAgentJobStatus(
${nextRunAt}::timestamptz
)
`;
- if (!rows[0]?.ok) {
+ const result = parseJobStatusRpcResult(rows[0], "update_indexing_v3_agent_job_status");
+ if (!result?.ok) {
throw new Error(`Failed to update indexing_v3_agent_jobs status to ${status} for document ${job.document_id}`);
}
}
diff --git a/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql
new file mode 100644
index 000000000..d93f69a91
--- /dev/null
+++ b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql
@@ -0,0 +1,73 @@
+-- Codify live migration tighten_search_document_chunks_owner_scope (20260705133000).
+-- Fail closed: null p_owner_id may only search public documents (owner_id is null).
+
+create or replace function public.search_document_chunks(
+ p_document_id uuid,
+ p_query text,
+ match_count integer default 20,
+ p_owner_id uuid default null
+)
+returns table (
+ id uuid,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ image_ids uuid[],
+ text_rank real,
+ trigram_score real
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with normalized as (
+ select
+ websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv,
+ lower(trim(coalesce(p_query, ''))) as query_text
+ ),
+ tokens as (
+ select distinct token
+ from normalized,
+ lateral regexp_split_to_table(normalized.query_text, '\s+') as token
+ where length(token) >= 3
+ )
+ select
+ c.id,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.image_ids,
+ ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join normalized
+ where c.document_id = p_document_id
+ and d.status = 'indexed'
+ and (
+ (p_owner_id is null and d.owner_id is null)
+ or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id))
+ )
+ and (
+ c.search_tsv @@ normalized.query_tsv
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%'
+ or exists (
+ select 1
+ from tokens t
+ where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%'
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token
+ )
+ )
+ order by
+ ts_rank_cd(c.search_tsv, normalized.query_tsv) desc,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc,
+ c.chunk_index asc
+ limit least(greatest(match_count, 1), 80);
+$$;
+
+revoke execute on function public.search_document_chunks(uuid, text, integer, uuid) from public, anon, authenticated;
+revoke execute on function public.search_document_chunks(uuid, text, integer, uuid) from public, anon, authenticated;
+grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role;
diff --git a/supabase/migrations/20260705220000_reconcile_live_database_drift.sql b/supabase/migrations/20260705220000_reconcile_live_database_drift.sql
new file mode 100644
index 000000000..b5c3202b6
--- /dev/null
+++ b/supabase/migrations/20260705220000_reconcile_live_database_drift.sql
@@ -0,0 +1,264 @@
+-- Reconcile live database drift discovered 2026-07-05:
+-- 1. indexing_v3_agent_jobs table + claim/update RPCs recorded as applied but absent on live
+-- 2. match_document_embedding_fields_text exists on live with anon/auth execute (codify + lock down)
+-- 3. rag_visual_eval_* tables exist on live without RLS (codify + enable service_role-only RLS)
+
+set search_path = public, extensions, pg_catalog;
+
+create table if not exists public.indexing_v3_agent_jobs (
+ id uuid primary key default gen_random_uuid(),
+ document_id uuid not null references public.documents(id) on delete cascade,
+ status text not null default 'pending'
+ check (status in ('pending', 'processing', 'completed', 'failed', 'needs_enrichment_artifacts')),
+ enrichment_status text not null default 'pending'
+ check (enrichment_status in ('pending', 'processing', 'completed', 'failed', 'needs_enrichment_artifacts')),
+ attempt_count integer not null default 0,
+ max_attempts integer not null default 3,
+ locked_by text,
+ locked_at timestamptz,
+ next_run_at timestamptz,
+ version text not null default 'visual-core-v3',
+ last_error text,
+ metadata jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create unique index if not exists indexing_v3_agent_jobs_document_id_idx
+ on public.indexing_v3_agent_jobs(document_id);
+
+create index if not exists indexing_v3_agent_jobs_claim_idx
+ on public.indexing_v3_agent_jobs(status, enrichment_status, next_run_at, id)
+ where status not in ('completed', 'needs_enrichment_artifacts');
+
+create index if not exists indexing_v3_agent_jobs_locked_at_idx
+ on public.indexing_v3_agent_jobs(locked_at)
+ where status = 'processing';
+
+alter table public.indexing_v3_agent_jobs enable row level security;
+
+drop policy if exists "indexing v3 agent jobs service role all" on public.indexing_v3_agent_jobs;
+create policy "indexing v3 agent jobs service role all"
+ on public.indexing_v3_agent_jobs
+ for all to service_role
+ using (true)
+ with check (true);
+
+grant select, insert, update, delete
+ on table public.indexing_v3_agent_jobs to service_role;
+
+insert into public.indexing_v3_agent_jobs (
+ document_id, status, enrichment_status, attempt_count, max_attempts,
+ locked_by, locked_at, next_run_at, version, last_error, metadata, created_at, updated_at
+)
+select
+ d.id,
+ case
+ when coalesce(d.metadata->>'indexing_v3_agent_status', 'pending') in
+ ('completed', 'needs_enrichment_artifacts', 'failed')
+ then coalesce(d.metadata->>'indexing_v3_agent_status', 'pending')
+ when coalesce(d.metadata->>'indexing_v3_agent_status', 'pending') in ('deferred', 'retry_pending')
+ then 'pending'
+ when coalesce(d.metadata->>'indexing_v3_agent_status', '') = 'processing'
+ and (
+ nullif(d.metadata->>'indexing_v3_agent_locked_at', '') is null
+ or (d.metadata->>'indexing_v3_agent_locked_at')::timestamptz < now() - interval '2 hours'
+ )
+ then 'pending'
+ else 'pending'
+ end,
+ coalesce(d.metadata->>'enrichment_status', 'pending'),
+ case when coalesce(d.metadata->>'indexing_v3_agent_attempt_count', '') ~ '^[0-9]+$'
+ then (d.metadata->>'indexing_v3_agent_attempt_count')::integer else 0 end,
+ greatest(case when coalesce(d.metadata->>'indexing_v3_agent_max_attempts', '') ~ '^[0-9]+$'
+ then (d.metadata->>'indexing_v3_agent_max_attempts')::integer else 3 end, 1),
+ nullif(d.metadata->>'indexing_v3_agent_locked_by', ''),
+ case when coalesce(d.metadata->>'indexing_v3_agent_locked_at', '') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
+ then (d.metadata->>'indexing_v3_agent_locked_at')::timestamptz else null end,
+ case when coalesce(d.metadata->>'indexing_v3_agent_next_run_at', '') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
+ then (d.metadata->>'indexing_v3_agent_next_run_at')::timestamptz else null end,
+ coalesce(nullif(d.metadata->>'indexing_v3_agent_version', ''), 'visual-core-v3'),
+ nullif(d.metadata->>'indexing_v3_agent_last_error', ''),
+ '{}'::jsonb,
+ coalesce(d.created_at, now()),
+ coalesce(d.updated_at, now())
+from public.documents d
+where d.metadata ? 'indexing_v3_agent_status'
+on conflict (document_id) do nothing;
+
+create or replace function public.claim_indexing_v3_agent_jobs(
+ p_worker_id text,
+ p_claim_limit integer default 1,
+ p_stale_after_minutes integer default 45
+)
+returns table (
+ id uuid, document_id uuid, batch_id uuid, status text, stage text, progress integer,
+ error_message text, attempt_count integer, max_attempts integer, locked_at timestamptz,
+ locked_by text, documents jsonb
+)
+language plpgsql
+set search_path = public, extensions, pg_temp
+as $$
+begin
+ insert into public.indexing_v3_agent_jobs (
+ document_id, status, enrichment_status, next_run_at, version, metadata, created_at, updated_at
+ )
+ select d.id, 'pending', coalesce(d.metadata->>'enrichment_status', 'pending'),
+ case when coalesce(d.metadata->>'indexing_v3_agent_next_run_at', '') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
+ then (d.metadata->>'indexing_v3_agent_next_run_at')::timestamptz else null end,
+ coalesce(nullif(d.metadata->>'indexing_v3_agent_version', ''), 'visual-core-v3'),
+ '{}'::jsonb, coalesce(d.created_at, now()), now()
+ from public.documents d
+ where d.status = 'indexed'
+ and d.metadata ? 'indexing_v3_agent_status'
+ and coalesce(d.metadata->>'indexing_v3_agent_status', 'pending')
+ not in ('completed', 'needs_enrichment_artifacts')
+ on conflict (document_id) do nothing;
+
+ return query
+ with eligible_jobs as (
+ select j.id, j.document_id, j.attempt_count, j.max_attempts
+ from public.indexing_v3_agent_jobs j
+ join public.documents d on d.id = j.document_id and d.status = 'indexed'
+ where j.status not in ('completed', 'needs_enrichment_artifacts')
+ and j.enrichment_status in ('pending', 'failed', 'processing')
+ and j.attempt_count < j.max_attempts
+ and coalesce(j.next_run_at, now()) <= now()
+ and (j.status <> 'processing' or j.locked_at is null
+ or j.locked_at < now() - make_interval(mins => p_stale_after_minutes))
+ order by coalesce(j.next_run_at, j.updated_at), j.id
+ limit greatest(p_claim_limit, 1)
+ for update of j skip locked
+ ),
+ claimed_jobs as (
+ update public.indexing_v3_agent_jobs j
+ set status = 'processing', enrichment_status = 'processing', locked_by = p_worker_id,
+ locked_at = now(), attempt_count = e.attempt_count + 1, last_error = null,
+ next_run_at = null, updated_at = now()
+ from eligible_jobs e where j.id = e.id returning j.*
+ ),
+ patched_documents as (
+ update public.documents d
+ set metadata = jsonb_strip_nulls(
+ (coalesce(d.metadata, '{}'::jsonb) - 'indexing_v3_agent_next_run_at' - 'indexing_v3_agent_last_error')
+ || jsonb_build_object(
+ 'indexing_v3_agent_status', 'processing',
+ 'indexing_v3_agent_version', cj.version,
+ 'indexing_v3_agent_locked_by', p_worker_id,
+ 'indexing_v3_agent_locked_at', cj.locked_at,
+ 'indexing_v3_agent_attempt_count', cj.attempt_count,
+ 'indexing_v3_agent_max_attempts', cj.max_attempts,
+ 'indexing_v3_agent_updated_at', now(),
+ 'enrichment_status', 'processing'
+ )
+ ), updated_at = now()
+ from claimed_jobs cj
+ where d.id = cj.document_id and d.status = 'indexed'
+ returning d.*, cj.id as job_id, cj.attempt_count as job_attempt_count,
+ cj.max_attempts as job_max_attempts, cj.locked_at as job_locked_at
+ )
+ select pd.job_id, pd.id, pd.import_batch_id, 'processing'::text, 'v3 enrichment claimed'::text,
+ 95::integer, null::text, pd.job_attempt_count, pd.job_max_attempts, pd.job_locked_at,
+ p_worker_id,
+ to_jsonb(pd.*) - 'job_id' - 'job_attempt_count' - 'job_max_attempts' - 'job_locked_at'
+ from patched_documents pd;
+end;
+$$;
+
+revoke execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) from public, anon, authenticated;
+grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role;
+
+create or replace function public.update_indexing_v3_agent_job_status(
+ p_document_id uuid, p_status text, p_error text default null, p_next_run_at timestamptz default null
+)
+returns jsonb
+language plpgsql
+set search_path = public, extensions, pg_temp
+as $$
+declare v_job_id uuid;
+begin
+ if p_status not in ('pending', 'completed', 'failed', 'needs_enrichment_artifacts') then
+ raise exception 'invalid status %', p_status;
+ end if;
+ update public.indexing_v3_agent_jobs
+ set status = p_status,
+ enrichment_status = case
+ when p_status = 'completed' then 'completed'
+ when p_status = 'failed' then 'failed'
+ when p_status = 'needs_enrichment_artifacts' then 'needs_enrichment_artifacts'
+ else enrichment_status end,
+ last_error = p_error,
+ next_run_at = case when p_status = 'pending' then coalesce(p_next_run_at, now()) else null end,
+ locked_by = null, locked_at = null, updated_at = now()
+ where document_id = p_document_id
+ returning id into v_job_id;
+ return jsonb_build_object('ok', v_job_id is not null, 'job_id', v_job_id,
+ 'document_id', p_document_id, 'status', p_status);
+end;
+$$;
+
+revoke execute on function public.update_indexing_v3_agent_job_status(uuid, text, text, timestamptz) from public, anon, authenticated;
+grant execute on function public.update_indexing_v3_agent_job_status(uuid, text, text, timestamptz) to service_role;
+
+create or replace function public.match_document_embedding_fields_text(
+ query_text text, match_count integer default 16, min_text_rank double precision default 0.0,
+ document_filters uuid[] default null, owner_filter uuid default null
+)
+returns table (
+ id uuid, document_id uuid, source_chunk_id uuid, field_type text, content text, text_rank double precision
+)
+language sql stable set search_path = public, extensions, pg_temp
+as $$
+ with q as (select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq),
+ ranked as (
+ select f.id, f.document_id, f.source_chunk_id, f.field_type, f.content,
+ ts_rank_cd(f.search_tsv, q.tsq)::double precision as text_rank
+ from public.document_embedding_fields f
+ join public.documents d on d.id = f.document_id
+ cross join q
+ where f.source_chunk_id is not null
+ and (document_filters is null or f.document_id = any(document_filters))
+ and (owner_filter is null or d.owner_id = owner_filter)
+ and d.status = 'indexed' and f.search_tsv @@ q.tsq
+ )
+ select * from ranked where text_rank >= min_text_rank
+ order by text_rank desc, id limit match_count;
+$$;
+
+revoke execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) from public, anon, authenticated;
+grant execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) to service_role;
+
+create table if not exists public.rag_visual_eval_cases (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid references auth.users(id) on delete set null,
+ document_id uuid references public.documents(id) on delete set null,
+ case_name text not null, query text not null,
+ expected_unit_types text[] not null default '{}'::text[],
+ expected_terms text[] not null default '{}'::text[],
+ expected_image_type text, active boolean not null default true,
+ metadata jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists rag_visual_eval_cases_doc_idx on public.rag_visual_eval_cases(document_id, active);
+create index if not exists rag_visual_eval_cases_owner_id_idx on public.rag_visual_eval_cases(owner_id);
+
+create table if not exists public.rag_visual_eval_runs (
+ id uuid primary key default gen_random_uuid(),
+ case_id uuid not null references public.rag_visual_eval_cases(id) on delete cascade,
+ document_id uuid references public.documents(id) on delete set null,
+ passed boolean not null, top_hit boolean not null, matched_count integer not null default 0,
+ hit_payload jsonb not null default '{}'::jsonb, run_metadata jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now()
+);
+create index if not exists rag_visual_eval_runs_case_id_idx on public.rag_visual_eval_runs(case_id);
+create index if not exists rag_visual_eval_runs_document_id_idx on public.rag_visual_eval_runs(document_id);
+
+alter table public.rag_visual_eval_cases enable row level security;
+alter table public.rag_visual_eval_runs enable row level security;
+drop policy if exists "rag visual eval cases service role all" on public.rag_visual_eval_cases;
+create policy "rag visual eval cases service role all" on public.rag_visual_eval_cases for all to service_role using (true) with check (true);
+drop policy if exists "rag visual eval runs service role all" on public.rag_visual_eval_runs;
+create policy "rag visual eval runs service role all" on public.rag_visual_eval_runs for all to service_role using (true) with check (true);
+grant select, insert, update, delete on table public.rag_visual_eval_cases to service_role;
+grant select, insert, update, delete on table public.rag_visual_eval_runs to service_role;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 16ce2230c..c0bc5966e 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -2847,6 +2847,73 @@ $$;
revoke execute on function public.match_document_lookup_chunks_text(text, uuid[], integer, uuid) from public, anon, authenticated;
grant execute on function public.match_document_lookup_chunks_text(text, uuid[], integer, uuid) to service_role;
+create or replace function public.search_document_chunks(
+ p_document_id uuid,
+ p_query text,
+ match_count integer default 20,
+ p_owner_id uuid default null
+)
+returns table (
+ id uuid,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ image_ids uuid[],
+ text_rank real,
+ trigram_score real
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with normalized as (
+ select
+ websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv,
+ lower(trim(coalesce(p_query, ''))) as query_text
+ ),
+ tokens as (
+ select distinct token
+ from normalized,
+ lateral regexp_split_to_table(normalized.query_text, '\s+') as token
+ where length(token) >= 3
+ )
+ select
+ c.id,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.image_ids,
+ ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join normalized
+ where c.document_id = p_document_id
+ and d.status = 'indexed'
+ and (
+ (p_owner_id is null and d.owner_id is null)
+ or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id))
+ )
+ and (
+ c.search_tsv @@ normalized.query_tsv
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%'
+ or exists (
+ select 1
+ from tokens t
+ where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%'
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token
+ )
+ )
+ order by
+ ts_rank_cd(c.search_tsv, normalized.query_tsv) desc,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc,
+ c.chunk_index asc
+ limit least(greatest(match_count, 1), 80);
+$$;
+
create or replace function public.get_related_document_metadata(
document_ids uuid[],
owner_filter uuid default null
@@ -3077,6 +3144,31 @@ as $$
limit match_count;
$$;
+create or replace function public.match_document_embedding_fields_text(
+ query_text text, match_count integer default 16, min_text_rank double precision default 0.0,
+ document_filters uuid[] default null, owner_filter uuid default null
+)
+returns table (
+ id uuid, document_id uuid, source_chunk_id uuid, field_type text, content text, text_rank double precision
+)
+language sql stable set search_path = public, extensions, pg_temp
+as $$
+ with q as (select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq),
+ ranked as (
+ select f.id, f.document_id, f.source_chunk_id, f.field_type, f.content,
+ ts_rank_cd(f.search_tsv, q.tsq)::double precision as text_rank
+ from public.document_embedding_fields f
+ join public.documents d on d.id = f.document_id
+ cross join q
+ where f.source_chunk_id is not null
+ and (document_filters is null or f.document_id = any(document_filters))
+ and (owner_filter is null or d.owner_id = owner_filter)
+ and d.status = 'indexed' and f.search_tsv @@ q.tsq
+ )
+ select * from ranked where text_rank >= min_text_rank
+ order by text_rank desc, id limit match_count;
+$$;
+
create or replace view public.document_strict_gate_status
with (security_invoker = true)
as
@@ -3677,6 +3769,10 @@ grant usage, select on all sequences in schema public to service_role;
grant execute on all functions in schema public to service_role;
revoke execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) from public, anon, authenticated;
grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role;
+revoke execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) from public, anon, authenticated;
+grant execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) to service_role;
+revoke execute on function public.search_document_chunks(uuid, text, integer, uuid) from public, anon, authenticated;
+grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role;
revoke execute on function public.invoke_indexing_v3_agent(integer) from public, anon, authenticated;
grant execute on function public.invoke_indexing_v3_agent(integer) to service_role;
@@ -4139,6 +4235,41 @@ comment on index public.documents_indexing_v3_agent_claim_idx is
comment on table public.indexing_v3_agent_jobs is
'Dedicated worker-state table for the v3 indexing / enrichment agent. Replaces JSONB state in documents.metadata. claim_indexing_v3_agent_jobs uses SKIP LOCKED here; update_indexing_v3_agent_job_status completes/fails a job. See migration 20260702190000 for transition notes.';
+create table if not exists public.rag_visual_eval_cases (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid references auth.users(id) on delete set null,
+ document_id uuid references public.documents(id) on delete set null,
+ case_name text not null, query text not null,
+ expected_unit_types text[] not null default '{}'::text[],
+ expected_terms text[] not null default '{}'::text[],
+ expected_image_type text, active boolean not null default true,
+ metadata jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists rag_visual_eval_cases_doc_idx on public.rag_visual_eval_cases(document_id, active);
+create index if not exists rag_visual_eval_cases_owner_id_idx on public.rag_visual_eval_cases(owner_id);
+
+create table if not exists public.rag_visual_eval_runs (
+ id uuid primary key default gen_random_uuid(),
+ case_id uuid not null references public.rag_visual_eval_cases(id) on delete cascade,
+ document_id uuid references public.documents(id) on delete set null,
+ passed boolean not null, top_hit boolean not null, matched_count integer not null default 0,
+ hit_payload jsonb not null default '{}'::jsonb, run_metadata jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now()
+);
+create index if not exists rag_visual_eval_runs_case_id_idx on public.rag_visual_eval_runs(case_id);
+create index if not exists rag_visual_eval_runs_document_id_idx on public.rag_visual_eval_runs(document_id);
+
+alter table public.rag_visual_eval_cases enable row level security;
+alter table public.rag_visual_eval_runs enable row level security;
+drop policy if exists "rag visual eval cases service role all" on public.rag_visual_eval_cases;
+create policy "rag visual eval cases service role all" on public.rag_visual_eval_cases for all to service_role using (true) with check (true);
+drop policy if exists "rag visual eval runs service role all" on public.rag_visual_eval_runs;
+create policy "rag visual eval runs service role all" on public.rag_visual_eval_runs for all to service_role using (true) with check (true);
+grant select, insert, update, delete on table public.rag_visual_eval_cases to service_role;
+grant select, insert, update, delete on table public.rag_visual_eval_runs to service_role;
+
-- Curated clinical registry backing the Services and Forms modes: structured
-- records (contacts, eligibility, referral pathways, criteria) for real WA
-- entities, seeded from reviewed fixtures and linkable to verifying source
diff --git a/tests/answer-render-policy.test.ts b/tests/answer-render-policy.test.ts
index 5cf6f2a00..d2ba7ac3c 100644
--- a/tests/answer-render-policy.test.ts
+++ b/tests/answer-render-policy.test.ts
@@ -155,6 +155,24 @@ describe("answer render policy", () => {
const model = buildAnswerRenderModel(
answer({
confidence: "medium",
+ sources: [
+ source({
+ source_metadata: {
+ source_title: "Current local source",
+ publisher: "WA Health",
+ jurisdiction: "Australia/WA",
+ version: null,
+ publication_date: null,
+ review_date: "2026-01-01T00:00:00.000Z",
+ uploaded_at: null,
+ indexed_at: null,
+ uploaded_by: null,
+ document_status: "current",
+ clinical_validation_status: "locally_reviewed",
+ extraction_quality: "good",
+ },
+ }),
+ ],
quoteCards: [quote(), quote({ quote: "another exact quote" })],
visualEvidence: [visual(), visual({ id: "image-2", image_id: "img-2" })],
relatedDocuments: [related(), related({ document_id: "related-doc-2", title: "Second related" })],
@@ -169,6 +187,7 @@ describe("answer render policy", () => {
expect(model.relatedDocuments).toHaveLength(0);
expect(model.evidenceRows).toHaveLength(1);
expect(model.copyText).toContain("Verify against linked source documents");
+ expect(model.copyText).toContain("Provenance:");
});
it("deduplicates high-confidence evidence channels and caps optional blocks", () => {
diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts
new file mode 100644
index 000000000..c25bb36c5
--- /dev/null
+++ b/tests/api-rate-limit-fallback.test.ts
@@ -0,0 +1,23 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+});
+
+describe("allowRateLimitInMemoryFallbackOnUnavailable", () => {
+ it("enables fallback for production deployments", async () => {
+ vi.stubEnv("NODE_ENV", "production");
+ const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit");
+ expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true);
+ });
+
+ it("enables fallback for local no-auth development", async () => {
+ vi.stubEnv("NODE_ENV", "development");
+ vi.doMock("@/lib/env", () => ({
+ isLocalNoAuthMode: () => true,
+ }));
+ const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit");
+ expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true);
+ });
+});
diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts
new file mode 100644
index 000000000..9903fb20e
--- /dev/null
+++ b/tests/public-access-deep.test.ts
@@ -0,0 +1,226 @@
+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({ documentIds: [publicDocumentId], metadata: { sourceStatus: "current" } }),
+ }),
+ },
+ {
+ 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",
+ HEALTH_DEEP_PROBE_SECRET: undefined,
+ },
+ 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");
+ expect(body.checks.supabase).toBe("unauthorized");
+ });
+
+});
+
+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",
+ }),
+ ).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/setup-status-route.test.ts b/tests/setup-status-route.test.ts
index 170aac7f5..9a3a99f87 100644
--- a/tests/setup-status-route.test.ts
+++ b/tests/setup-status-route.test.ts
@@ -7,10 +7,13 @@ afterEach(() => {
});
describe("/api/setup-status", () => {
- it("requires auth for non-local production requests before returning setup posture", async () => {
+ it("returns setup posture for anonymous production requests without exposing secret values", async () => {
vi.stubEnv("NODE_ENV", "production");
- const getUser = vi.fn();
- const createAdminClient = vi.fn(() => ({ auth: { getUser } }));
+ const from = vi.fn(async () => ({ error: null, data: [], count: 0 }));
+ const createAdminClient = vi.fn(() => ({
+ from,
+ rpc: vi.fn(),
+ }));
vi.doMock("@/lib/env", () => ({
env: {
NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co",
@@ -24,16 +27,72 @@ describe("/api/setup-status", () => {
isLocalNoAuthMode: () => false,
}));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient }));
+ vi.doMock("@/lib/supabase/health", () => ({
+ probeSupabaseHealth: vi.fn(async () => ({ ok: true })),
+ isSupabaseUnavailableError: () => false,
+ formatSupabaseUnavailableError: (error: unknown) => String(error),
+ }));
+ vi.doMock("@/lib/supabase/project", () => ({
+ checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }),
+ formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.",
+ }));
+ const { GET } = await import("../src/app/api/setup-status/route");
+
+ const response = await GET(new Request("https://clinical.example/api/setup-status"));
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body).toMatchObject({
+ demoMode: false,
+ checks: expect.arrayContaining([
+ expect.objectContaining({ id: "env" }),
+ expect.objectContaining({ id: "openai" }),
+ ]),
+ });
+ expect(JSON.stringify(body)).not.toContain("service-role-key");
+ expect(JSON.stringify(body)).not.toContain("openai-key");
+ });
+
+ it("treats project warning status as ready when the URL ref matches", async () => {
+ vi.stubEnv("NODE_ENV", "production");
+ const from = vi.fn(async () => ({ error: null, data: [], count: 0 }));
+ const createAdminClient = vi.fn(() => ({
+ from,
+ rpc: vi.fn(),
+ }));
+ vi.doMock("@/lib/env", () => ({
+ env: {
+ NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co",
+ SUPABASE_SERVICE_ROLE_KEY: "service-role-key",
+ OPENAI_API_KEY: "openai-key",
+ SUPABASE_DOCUMENT_BUCKET: "clinical-documents",
+ SUPABASE_IMAGE_BUCKET: "clinical-images",
+ WORKER_POLL_MS: 1500,
+ },
+ isDemoMode: () => false,
+ isLocalNoAuthMode: () => false,
+ }));
+ vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient }));
+ vi.doMock("@/lib/supabase/health", () => ({
+ probeSupabaseHealth: vi.fn(async () => ({ ok: true })),
+ isSupabaseUnavailableError: () => false,
+ formatSupabaseUnavailableError: (error: unknown) => String(error),
+ }));
+ vi.doMock("@/lib/supabase/project", () => ({
+ checkSupabaseProjectConfig: () => ({
+ status: "warning",
+ detail: 'Set SUPABASE_PROJECT_NAME="Clinical KB Database" in .env.local.',
+ }),
+ formatSupabaseProjectCheck: () => 'Set SUPABASE_PROJECT_NAME="Clinical KB Database" in .env.local.',
+ }));
const { GET } = await import("../src/app/api/setup-status/route");
const response = await GET(new Request("https://clinical.example/api/setup-status"));
const body = await response.json();
- expect(response.status).toBe(401);
- expect(body).toEqual({ error: "Authentication required." });
- expect(JSON.stringify(body)).not.toContain("OPENAI");
- expect(JSON.stringify(body)).not.toContain("Supabase");
- expect(createAdminClient).toHaveBeenCalledTimes(1);
- expect(getUser).not.toHaveBeenCalled();
+ expect(response.status).toBe(200);
+ expect(body.checks).toEqual(
+ expect.arrayContaining([expect.objectContaining({ id: "project", status: "ready" })]),
+ );
});
});
diff --git a/tests/source-governance.test.ts b/tests/source-governance.test.ts
index ba23e5d12..3472a216b 100644
--- a/tests/source-governance.test.ts
+++ b/tests/source-governance.test.ts
@@ -150,7 +150,7 @@ describe("source governance warnings", () => {
expect(hasDangerSourceGovernanceWarning(warnings)).toBe(false);
});
- it("keeps routine review metadata notes out of frontend-visible governance warnings", () => {
+ it("surfaces review_due and unverified warnings to the frontend", () => {
const warnings = sourceGovernanceWarnings({
results: [
result({
@@ -183,7 +183,9 @@ describe("source governance warnings", () => {
expect(warnings.map((warning) => warning.code)).toEqual(
expect.arrayContaining(["review_due_source", "unverified_source"]),
);
- expect(frontendSourceGovernanceWarnings(warnings)).toEqual([]);
+ expect(frontendSourceGovernanceWarnings(warnings).map((warning) => warning.code)).toEqual(
+ expect.arrayContaining(["review_due_source", "unverified_source"]),
+ );
});
it("surfaces only clinically material warnings to the frontend", () => {
@@ -204,8 +206,7 @@ describe("source governance warnings", () => {
const visibleCodes = frontendSourceGovernanceWarnings(warnings).map((warning) => warning.code);
expect(visibleCodes).toEqual(expect.arrayContaining(["weak_evidence", "outdated_source", "poor_extraction"]));
- expect(visibleCodes).not.toContain("review_due_source");
- expect(visibleCodes).not.toContain("unverified_source");
+ expect(visibleCodes).toContain("unverified_source");
expect(visibleCodes).not.toContain("non_local_source");
});
diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts
index 6dd16d6c9..2cb988dfe 100644
--- a/tests/supabase-schema.test.ts
+++ b/tests/supabase-schema.test.ts
@@ -96,6 +96,14 @@ const ragRetrievalLogsRetentionMigration = readFileSync(
new URL("../supabase/migrations/20260702120000_rag_retrieval_logs_retention.sql", import.meta.url),
"utf8",
).replace(/\s+/g, " ");
+const liveDatabaseDriftMigration = readFileSync(
+ new URL("../supabase/migrations/20260705220000_reconcile_live_database_drift.sql", import.meta.url),
+ "utf8",
+).replace(/\s+/g, " ");
+const searchDocumentChunksOwnerScopeMigration = readFileSync(
+ new URL("../supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql", import.meta.url),
+ "utf8",
+).replace(/\s+/g, " ");
function extractTextChunkFunction(sql: string) {
const start = sql.indexOf("function public.match_document_chunks_text");
@@ -744,6 +752,22 @@ describe("Supabase schema Data API grants", () => {
);
});
+ it("reconciles live database drift for embedding-field text RPC and rag visual eval tables", () => {
+ for (const sql of [schema, liveDatabaseDriftMigration]) {
+ expect(sql).toContain("create or replace function public.match_document_embedding_fields_text");
+ expect(sql).toContain("create table if not exists public.rag_visual_eval_cases");
+ expect(sql).toContain("create table if not exists public.rag_visual_eval_runs");
+ expect(sql).toContain('create policy "rag visual eval cases service role all"');
+ expect(sql).toContain('create policy "rag visual eval runs service role all"');
+ expect(sql).toContain(
+ "revoke execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) from public, anon, authenticated",
+ );
+ expect(sql).toContain(
+ "grant execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) to service_role",
+ );
+ }
+ });
+
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");
@@ -754,6 +778,16 @@ describe("Supabase schema Data API grants", () => {
expect(schema).toContain("index_aliases constant jsonb := jsonb_build_object(");
expect(schema).toContain("jsonb_array_elements_text(index_aliases -> index_name)");
});
+
+ it("mirrors tightened search_document_chunks owner scope in schema and migration", () => {
+ expect(searchDocumentChunksOwnerScopeMigration).toContain(
+ "(p_owner_id is null and d.owner_id is null)",
+ );
+ expect(schema).toContain("create or replace function public.search_document_chunks(");
+ expect(schema).toContain(
+ "revoke execute on function public.search_document_chunks(uuid, text, integer, uuid) from public, anon, authenticated",
+ );
+ });
});
describe("RC9 — lexical text path must not fabricate a cosine similarity", () => {