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/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", 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); }); 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; } 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/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 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"); });