From a3a907263dc64950db0486dbd9c259e2ba779262 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 09:40:15 +0000 Subject: [PATCH 1/5] fix(governance): surface the unvalidated-source caveat in the answer UI Since 20260706120000 promoted every indexed document into the public anonymous corpus regardless of clinical_validation_status, and new uploads default to "unverified", answers can be grounded on never-locally-validated guidelines. The unverified_source warning was generated server-side but filtered out of the frontend-visible set, so no caveat ever reached the answer UI. Make it frontend-visible (still non-blocking; review_due stays a hidden routine note). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011WXsrao2p3qYRuf7P9pkEY --- src/lib/source-governance.ts | 4 ++++ tests/source-governance.test.ts | 12 ++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/source-governance.ts b/src/lib/source-governance.ts index d9ede0a20..90777719f 100644 --- a/src/lib/source-governance.ts +++ b/src/lib/source-governance.ts @@ -54,6 +54,10 @@ const frontendVisibleWarningCodes = new Set([ "outdated_source", "poor_extraction", "weak_evidence", + // Since the public-corpus promotion (all indexed documents are anonymously + // searchable regardless of clinical_validation_status), "not locally + // validated" is a clinically material caveat, not routine review metadata. + "unverified_source", ]); function isLocalMetadataText(value: string) { diff --git a/tests/source-governance.test.ts b/tests/source-governance.test.ts index ba23e5d12..69625a1f9 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("keeps review-due notes hidden while surfacing the unvalidated-source caveat", () => { const warnings = sourceGovernanceWarnings({ results: [ result({ @@ -183,7 +183,10 @@ describe("source governance warnings", () => { expect(warnings.map((warning) => warning.code)).toEqual( expect.arrayContaining(["review_due_source", "unverified_source"]), ); - expect(frontendSourceGovernanceWarnings(warnings)).toEqual([]); + // With the whole indexed corpus promoted public regardless of validation + // status, "not locally validated" must stay visible; review-due remains a + // routine metadata note. + expect(frontendSourceGovernanceWarnings(warnings).map((warning) => warning.code)).toEqual(["unverified_source"]); }); it("surfaces only clinically material warnings to the frontend", () => { @@ -203,9 +206,10 @@ 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).toEqual( + expect.arrayContaining(["weak_evidence", "outdated_source", "poor_extraction", "unverified_source"]), + ); expect(visibleCodes).not.toContain("review_due_source"); - expect(visibleCodes).not.toContain("unverified_source"); expect(visibleCodes).not.toContain("non_local_source"); }); From 869b38fa0f7f9cb548381460f5f15c439d62a5e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 09:40:15 +0000 Subject: [PATCH 2/5] fix(search): clear loading when executeSearch bails on !canRunSearch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This early-return fires after searchRequestSeqRef is bumped, which gates a superseded in-flight request's finally block out of resetting loading, so the answer skeleton could stay on screen indefinitely — the residual case of the #316 skeleton-hang class. Mirror the sibling early-returns. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011WXsrao2p3qYRuf7P9pkEY --- src/components/ClinicalDashboard.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d44906295..3a4563820 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2729,6 +2729,11 @@ export function ClinicalDashboard({ return; } if (!canRunSearch) { + // requestId was already bumped above, so a superseded in-flight request's + // finally block can no longer reset loading — reset it here or the answer + // skeleton can stay on screen indefinitely. + setLoading(false); + setAnswerProgress(null); setError(errorCopy.searchSetupNotReady); return; } From 310c779e8e58977e466b61d73a4f03794f806710 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 09:40:30 +0000 Subject: [PATCH 3/5] fix(db): honor the public-owner sentinel in match_document_embedding_fields_text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codification in 20260705230000 kept the legacy (owner_filter is null or d.owner_id = owner_filter) predicate, so this RPC ignores retrieval_owner_matches: the anonymous sentinel would match zero rows and a real owner id would exclude public documents. Latent (only the _hybrid variant is wired into the app) but wrong the moment it is used. Migration is prepared, NOT applied to live — tracked in docs/process-hardening.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011WXsrao2p3qYRuf7P9pkEY --- docs/process-hardening.md | 1 + ...x_embedding_fields_text_owner_sentinel.sql | 40 +++++++++++++++++++ supabase/schema.sql | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260706130000_fix_embedding_fields_text_owner_sentinel.sql diff --git a/docs/process-hardening.md b/docs/process-hardening.md index f6a53a4ee..380ec8cd0 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -99,6 +99,7 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **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 `20260705230000_reconcile_live_database_drift` idempotently re-applied them; live verified post-push. - **RESOLVED:** `match_document_embedding_fields_text` codified with service_role-only execute. +- **Follow-up (2026-07-06):** the codified `match_document_embedding_fields_text` kept the legacy `(owner_filter is null or d.owner_id = owner_filter)` predicate instead of `retrieval_owner_matches`, so it ignores the public-owner sentinel (anonymous sentinel would match zero rows; a real owner id excludes public docs). Migration `20260706130000_fix_embedding_fields_text_owner_sentinel` recreates it with the shared predicate — **prepared but NOT applied to live**; latent until the `_text` RPC is wired into app code, so apply with the next approved push. - **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. diff --git a/supabase/migrations/20260706130000_fix_embedding_fields_text_owner_sentinel.sql b/supabase/migrations/20260706130000_fix_embedding_fields_text_owner_sentinel.sql new file mode 100644 index 000000000..5862b2beb --- /dev/null +++ b/supabase/migrations/20260706130000_fix_embedding_fields_text_owner_sentinel.sql @@ -0,0 +1,40 @@ +-- match_document_embedding_fields_text (codified from live drift in +-- 20260705230000) kept the legacy `(owner_filter is null or ...)` predicate, +-- so it ignores the public-owner sentinel every other retrieval function +-- honours via retrieval_owner_matches (20260705210000). Latent today (the app +-- only calls the _hybrid variant) but wrong the moment this RPC is wired in: +-- the sentinel would match zero rows and a real owner id would exclude public +-- documents. Recreate it with the shared owner predicate. +set search_path = public, extensions, pg_temp; + +-- Replay guard: drop first in case the live/preview OUT signature drifted, +-- which makes `create or replace` fail during migration replay. +drop function if exists public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid); + +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 public.retrieval_owner_matches(owner_filter, d.owner_id) + 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; diff --git a/supabase/schema.sql b/supabase/schema.sql index 5da342f1e..e01272b39 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -3198,7 +3198,7 @@ as $$ 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 public.retrieval_owner_matches(owner_filter, d.owner_id) and d.status = 'indexed' and f.search_tsv @@ q.tsq ) select * from ranked where text_rank >= min_text_rank From 71e0ab091baec771ff71220acb4da8681ded77a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 09:40:31 +0000 Subject: [PATCH 4/5] fix(scripts): stop treating missing IPv6 as a busy port; allow Chromium executable override canListenOnHost resolved false on any listen error, so in IPv6-less containers EAFNOSUPPORT on ::1/:: disqualified every port and both npm run dev and the Playwright runner died with 'No free port found'. Treat an unsupported address family as unable to hold the port. Also let PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH point the chromium project at a preinstalled browser for sandboxes that block browser downloads; default behavior is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011WXsrao2p3qYRuf7P9pkEY --- playwright.config.ts | 9 ++++++++- scripts/dev-free-port.mjs | 6 +++++- scripts/run-playwright.mjs | 6 +++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index b858b9525..8bbd2c5db 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,10 @@ import { getPlaywrightBaseUrl } from "./scripts/playwright-base-url"; const baseURL = getPlaywrightBaseUrl(); +// Sandboxed CI/cloud containers often ship a preinstalled Chromium and block +// browser downloads; point this at that binary instead of the managed one. +const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + export default defineConfig({ testDir: "./tests", testMatch: /.*ui-(smoke|stress|accessibility|tools|tools-task-directory|overlap)\.spec\.ts/, @@ -22,7 +26,10 @@ export default defineConfig({ projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), + }, }, { name: "firefox", diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs index ee86c9802..fc635ad0d 100644 --- a/scripts/dev-free-port.mjs +++ b/scripts/dev-free-port.mjs @@ -63,7 +63,11 @@ function removePortArgs(args) { function canListenOnHost(port, host) { return new Promise((resolve) => { const server = net.createServer(); - server.once("error", () => resolve(false)); + server.once("error", (error) => { + // An unsupported address family (e.g. no IPv6 in the container) cannot + // hold the port, so it must not disqualify it as busy. + resolve(error.code === "EAFNOSUPPORT" || error.code === "EADDRNOTAVAIL"); + }); server.once("listening", () => { server.close(() => resolve(true)); }); diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 6d1c6d5af..99e6013e8 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -26,7 +26,11 @@ function sleep(ms) { function canListenOnHost(port, host) { return new Promise((resolve) => { const server = net.createServer(); - server.once("error", () => resolve(false)); + server.once("error", (error) => { + // An unsupported address family (e.g. no IPv6 in the container) cannot + // hold the port, so it must not disqualify it as busy. + resolve(error.code === "EAFNOSUPPORT" || error.code === "EADDRNOTAVAIL"); + }); server.once("listening", () => server.close(() => resolve(true))); server.listen(port, host); }); From 6dbd5b476a1b141c482ff848e93f9fefebfff220 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 09:40:31 +0000 Subject: [PATCH 5/5] chore(deps): bump eslint-config-next from 16.2.9 to 16.2.10 Aligns the lingering 16.2.9 pin with next 16.2.10 (bumped in #311). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011WXsrao2p3qYRuf7P9pkEY --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe6466f70..6e1b67e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", - "eslint-config-next": "16.2.9", + "eslint-config-next": "16.2.10", "playwright": "^1.61.1", "prettier": "^3.9.4", "tailwindcss": "^4.3.1", @@ -1783,9 +1783,9 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.9.tgz", - "integrity": "sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", + "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5003,13 +5003,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.9.tgz", - "integrity": "sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz", + "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.9", + "@next/eslint-plugin-next": "16.2.10", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/package.json b/package.json index 803beb690..3081a74f8 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", - "eslint-config-next": "16.2.9", + "eslint-config-next": "16.2.10", "playwright": "^1.61.1", "prettier": "^3.9.4", "tailwindcss": "^4.3.1",