Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@ on:
pull_request:
branches: ["**"]
workflow_dispatch:
schedule:
- cron: "0 18 * * 0"

permissions:
contents: read
Expand DownExpand Up@@ -61,3 +63,32 @@ jobs:

- name: Chromium UI smoke
run: npm run test:e2e:chromium

release-browser-matrix:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: npm

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build
env:
NEXT_PUBLIC_SUPABASE_URL: https://sjrfecxgysukkwxsowpy.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: placeholder-ci-anon-key

- name: Install Playwright browsers
run: npx playwright install --with-deps

- name: Full browser UI matrix
run: npm run test:e2e
8 changes: 8 additions & 0 deletions docs/clinical-governance.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,3 +36,11 @@ Use the `.github/pull_request_template.md` clinical governance section for any c
- Confirm unknown or outdated source metadata is treated conservatively.
- Confirm demo/synthetic content remains separated from real clinical sources.
- Confirm clinical decision-support behavior changes have deployment classification and TGA SaMD impact reviewed before production use.

## Verification Records

### RLS & access scoping — 2026-06-28
- Supabase **security advisors: 0 findings** for `Clinical KB Database` (`sjrfecxgysukkwxsowpy`). The linter specifically flags missing RLS / insecure policies, so a clean run confirms RLS is enabled and policy-covered across `public` tables.
- Supabase **performance advisors: INFO only** — unused indexes (expected on a low-traffic database; do not drop pre-launch) and one auth connection-strategy tip (switch to percentage-based allocation when scaling instance size).
- **Application-layer cross-owner denial** (service-role routes enforce `owner_id` scoping in code) is covered by `tests/private-access-routes.test.ts` and `tests/private-rag-access.test.ts` (unowned document detail/signed-url/rename rejected; listing and search scoped to the authenticated owner).
- **Follow-up:** add a live DB-level RLS integration test that connects as two real authenticated users via the publishable (anon) key and asserts owner B cannot read owner A's rows. This needs a seeded test project/harness and is tracked as a remaining item.
6 changes: 3 additions & 3 deletions docs/process-hardening.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ This document turns the current process review into phased, durable repo practic
- `npm run verify:cheap` is the default broad local gate for source/config/test changes: lint, typecheck, and unit tests.
- `npm run verify:ui` is the default UI gate: Chromium Playwright smoke, stress, and accessibility media checks.
- `npm run verify:release` is the release-confidence gate: lint, typecheck, unit tests, build, and the full Playwright browser project set.
- CI now installs Chromium and runs the Chromium UI gate after build.
- CI now installs Chromium and runs the Chromium UI gate after build on all branches; a gated release-browser job runs the full Playwright browser matrix on `main`, `release/*`, manual dispatch, and the weekly schedule.
- `tests/ui-accessibility.spec.ts` covers reduced-motion and forced-colors dashboard usability so those modes are no longer only reviewed by inspection.
- `tests/ui-tools.spec.ts` covers the `/applications` launcher at mobile and desktop sizes.
- `AGENTS.md` now points future agents to these gates and to this document.
Expand DownExpand Up@@ -36,13 +36,13 @@ This document turns the current process review into phased, durable repo practic
- `npm run check:runtime` is the strict runtime gate and is now part of `npm run verify:cheap`, `npm run verify:ui`, and `npm run verify:release`; it fails outside Node 24.x or npm 11.x when run through npm.
- CI runs `npm run check:runtime` after dependency install so branch verification cannot silently drift away from Node 24.
- `npm run check:edge:functions` is the Deno type gate for the Supabase `indexing-v3-agent` Edge Function.
- Decide whether CI should run all Playwright browser projects on protected branches, release branches, or a scheduled workflow instead of every push.
- Tune the full-browser CI cadence if release branches or weekly schedules prove too slow or too sparse.
- Add explicit review ownership for clinical source governance, outdated-source handling, incident review, and decommission decisions.
- Record production-readiness outcomes in release notes whenever clinical workflow, source governance, privacy, or deployment assumptions change.

## Known limits

- Chromium UI coverage is active in CI now; Firefox and WebKit remain available through `npm run test:e2e` and `npm run verify:release`.
- Chromium UI coverage is active in CI on all branches; Firefox and WebKit run in the gated release-browser CI job and remain available locally through `npm run test:e2e` and `npm run verify:release`.
- The new accessibility media smoke verifies usability and layout in reduced-motion and forced-colors modes; it is not a full WCAG audit.
- The format gate intentionally ignores `.tmp-visual/` and `scratch/`; those folders are local investigation output, not release source.
- Process scripts do not commit, push, deploy, mutate Supabase data, or run dependency updates.
14 changes: 10 additions & 4 deletions docs/supabase-migration-reconciliation.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# Supabase Migration Reconciliation

Last reviewed: 2026-06-27
Last reviewed: 2026-06-28

Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`)

Expand All@@ -12,20 +12,26 @@ Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`)

## Verified Applied

These versions are safe to mark applied in Supabase migration history:
These previously local-only versions have been verified in the live project history:

- `20260625033425` - `document_strict_gate_status` exists, `repair_strict_enrichment_gate_batch(integer)` exists, service role can read/execute, and anon cannot read/execute.
- `20260625033944` - `complete_strict_enrichment_job(uuid, uuid, text, text, text)` exists, service role can execute, and anon cannot execute.
- `20260626000000` - duplicate index `ingestion_job_stages_doc_idx` is absent and canonical index `ingestion_job_stages_document_started_idx` exists.
- `20260626020000` - retrieval RPC performance migration is present in remote history.
- `20260626030000` - document organisation profile label constraint migration is present in remote history.
- `20260627000000` - deliberately applied as a no-op deferral for retrieval HNSW `ef_search`; hosted migrations cannot set this function GUC for this project, and the live vector RPC bodies already use session-local `set_config('hnsw.ef_search', '100', true)` where relevant.
- `20260628000000` - atomic document index generation commit RPC and committed-generation retrieval filters are present and verified in live.
- `20260628135727` - explicit `invoke_indexing_v3_agent(integer)` execute grant hardening is present and verified in live.

## Skipped
## Current Status

All other local-only migrations from `supabase migration list --linked` remain unrepaired until they are individually verified. This includes older search/retrieval/API-rate-limit migrations and the current `20260626020000` retrieval RPC performance migration.
As of this review, `npx supabase migration list --linked` shows no local-only migrations for `sjrfecxgysukkwxsowpy`. Remote migration history is aligned through `20260628135727`.

## Verification Commands

```powershell
npx supabase migration list --linked
npx supabase db advisors --linked
npx supabase db query --linked "select to_regclass('public.document_strict_gate_status') as gate_view, to_regprocedure('public.repair_strict_enrichment_gate_batch(integer)') as repair_rpc, to_regprocedure('public.complete_strict_enrichment_job(uuid, uuid, text, text, text)') as complete_rpc, to_regclass('public.ingestion_job_stages_doc_idx') as duplicate_index, to_regclass('public.ingestion_job_stages_document_started_idx') as canonical_stage_index;"
npx supabase db query --linked "select to_regprocedure('public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb)') as commit_generation_rpc, has_function_privilege('anon', 'public.invoke_indexing_v3_agent(integer)', 'execute') as anon_can_invoke_indexing_v3_agent, has_function_privilege('service_role', 'public.invoke_indexing_v3_agent(integer)', 'execute') as service_role_can_invoke_indexing_v3_agent;"
```
8 changes: 6 additions & 2 deletions src/app/api/answer/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export async function POST(request: Request) {
if (scope.documentIds?.length === 0) {
return NextResponse.json({
answer:
"The selected filters did not match any indexed documents, so I cannot generate a source-backed answer for that scope.",
"The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.",
grounded: false,
confidence: "unsupported",
citations: [],
Expand All@@ -97,12 +97,16 @@ export async function POST(request: Request) {
relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null,
});
if (hasDangerSourceGovernanceWarning(warnings)) {
// Build the refusal explicitly — never spread ...answer here, or the original
// (refused) sources/smartPanel/smartApiPlan would still reach the client and
// defeat the refusal. Keep only the safe "unsupported" contract fields, matching
// the empty-scope branch above.
return NextResponse.json({
...answer,
answer: sourceGovernanceRefusalAnswer,
grounded: false,
confidence: "unsupported",
citations: [],
sources: [],
scope: { ...scope, queryMode: body.queryMode },
sourceGovernanceWarnings: warnings,
});
Expand Down
16 changes: 10 additions & 6 deletions src/app/api/answer/stream/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,7 @@ import {
sourceGovernanceWarnings,
} from "@/lib/source-governance";
import { createAdminClient } from "@/lib/supabase/admin";
import { requireAuthenticatedUser } from "@/lib/supabase/auth";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";

export const runtime = "nodejs";

Expand DownExpand Up@@ -64,7 +64,9 @@ function streamErrorPayload(error: unknown) {
if (error instanceof Error) {
return {
message: "Answer generation failed. Retry with a narrower question.",
status: 503,
// Match the non-streaming /api/answer route, which returns 500 for a
// generic answer-generation failure.
status: 500,
details: { code: error.name },
};
}
Expand DownExpand Up@@ -107,7 +109,7 @@ function streamAnswer(body: AnswerBody, ownerId?: string) {
if (scope?.documentIds?.length === 0) {
send("final", {
answer:
"The selected filters did not match any indexed documents, so I cannot generate a source-backed answer for that scope.",
"The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.",
grounded: false,
confidence: "unsupported",
citations: [],
Expand DownExpand Up@@ -159,12 +161,14 @@ function streamAnswer(body: AnswerBody, ownerId?: string) {
relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null,
});
if (hasDangerSourceGovernanceWarning(warnings)) {
// Explicit refusal payload — do not spread ...answer (see /api/answer):
// the refused sources/smartPanel/smartApiPlan must not reach the client.
send("final", {
...answer,
answer: sourceGovernanceRefusalAnswer,
grounded: false,
confidence: "unsupported",
citations: [],
sources: [],
scope: scope ? { ...scope, queryMode: body.queryMode } : undefined,
sourceGovernanceWarnings: warnings,
});
Expand DownExpand Up@@ -213,8 +217,8 @@ export async function POST(request: Request) {

return streamAnswer(body, user.id);
} catch (error) {
if (error instanceof Error && error.name === "AuthenticationError") {
return Response.json({ error: "Authentication required." }, { status: 401 });
if (error instanceof AuthenticationError) {
return unauthorizedResponse(error);
}
if (error instanceof z.ZodError) {
return jsonError(error, 400);
Expand Down
11 changes: 8 additions & 3 deletions src/app/api/eval-cases/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,12 @@ import { normalizedClinicalSearchTokens } from "@/lib/clinical-search";
import { clinicalQueryModeSchema } from "@/lib/clinical-query-mode";
import { env, isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy";
import {
normalizedQueryTextForStorage,
queryDerivedTokensForStorage,
queryPrivacyMetadata,
queryTextForStorage,
} from "@/lib/query-privacy";
import { searchScopeFiltersSchema } from "@/lib/search-scope";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
Expand DownExpand Up@@ -117,7 +122,7 @@ export async function POST(request: Request) {

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const normalizedQuery = parsed.data.query.toLowerCase().replace(/\s+/g, " ").trim();
const normalizedQuery = normalizedQueryTextForStorage(parsed.data.query);
const sourceChunkIds = uniqueUuidValues(parsed.data.sourceChunkIds);
const citedChunkIds = uniqueUuidValues(parsed.data.citedChunkIds);
const sourceFiles = uniqueValues(parsed.data.sourceFiles);
Expand DownExpand Up@@ -149,7 +154,7 @@ export async function POST(request: Request) {
miss_reason: missReason,
expected_document_id: expectedDocumentId,
expected_chunk_id: expectedChunkId,
candidate_aliases: normalizedClinicalSearchTokens(parsed.data.query).slice(0, 12),
candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(parsed.data.query).slice(0, 12)),
promoted_eval_case: true,
promoted_at: new Date().toISOString(),
metadata: {
Expand Down
11 changes: 8 additions & 3 deletions src/app/api/search/interaction/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,12 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { normalizedClinicalSearchTokens } from "@/lib/clinical-search";
import { isDemoMode } from "@/lib/env";
import { normalizeQueryText, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy";
import {
normalizedQueryTextForStorage,
queryDerivedTokensForStorage,
queryPrivacyMetadata,
queryTextForStorage,
} from "@/lib/query-privacy";
import { createAdminClient } from "@/lib/supabase/admin";
import * as serverAuth from "@/lib/supabase/auth";

Expand DownExpand Up@@ -76,14 +81,14 @@ export async function POST(request: Request) {
await supabase.from("rag_query_misses").insert({
owner_id: user.id,
query: queryTextForStorage(body.query),
normalized_query: normalizeQueryText(body.query),
normalized_query: normalizedQueryTextForStorage(body.query),
query_class: body.queryClass ?? null,
clicked_document_id: clickedDocumentId,
clicked_chunk_id: clickedChunkId,
top_files: safeFileName ? [safeFileName] : [],
top_chunk_ids: clickedChunkId ? [clickedChunkId] : [],
miss_reason: "clicked_result",
candidate_aliases: normalizedClinicalSearchTokens(body.query).slice(0, 10),
candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(body.query).slice(0, 10)),
candidate_labels: safeTitle
? [
{
Expand Down
48 changes: 29 additions & 19 deletions src/app/api/search/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,12 @@ import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit
import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode";
import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope";
import { sourceGovernanceWarnings } from "@/lib/source-governance";
import { normalizeQueryText, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy";
import {
normalizedQueryTextForStorage,
queryDerivedTokensForStorage,
queryPrivacyMetadata,
queryTextForStorage,
} from "@/lib/query-privacy";
import type { ChunkImage, ClinicalSourceMetadata, SearchResult } from "@/lib/types";

export const runtime = "nodejs";
Expand DownExpand Up@@ -86,43 +91,48 @@ function buildDocumentMatchesFromResults(results: SearchResult[], limit: number)
file_name: string;
bestPages: number[];
bestChunkIds: string[];
imageCount: number;
tableCount: number;
// Track unique image ids: the same image is often hydrated onto several of
// a document's chunks, so summing per-chunk counts would inflate the badge.
imageIds: Set<string>;
tableImageIds: Set<string>;
score: number;
}
>();
for (const result of results) {
const current = grouped.get(result.document_id);
const score = result.hybrid_score ?? result.similarity;
const page = result.page_number ?? null;
const clinicalImages = result.images?.filter((image) => isClinicalImageEvidence(image)) ?? [];
const tableCount = clinicalImages.filter((image) => image.source_kind === "table_crop").length;
const imageCount = clinicalImages.length;
let current = grouped.get(result.document_id);
if (!current) {
grouped.set(result.document_id, {
current = {
document_id: result.document_id,
title: result.title,
file_name: result.file_name,
bestPages: page ? [page] : [],
bestChunkIds: [result.id],
imageCount,
tableCount,
bestPages: [],
bestChunkIds: [],
imageIds: new Set<string>(),
tableImageIds: new Set<string>(),
score,
});
continue;
};
grouped.set(result.document_id, current);
}
current.score = Math.max(current.score, score);
if (page && !current.bestPages.includes(page)) current.bestPages.push(page);
if (!current.bestChunkIds.includes(result.id)) current.bestChunkIds.push(result.id);
current.imageCount += imageCount;
current.tableCount += tableCount;
for (const image of clinicalImages) {
if (!image.id) continue;
current.imageIds.add(image.id);
if (image.source_kind === "table_crop") current.tableImageIds.add(image.id);
}
}

return Array.from(grouped.values())
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((document) => ({
.map(({ imageIds, tableImageIds, ...document }) => ({
...document,
imageCount: imageIds.size,
tableCount: tableImageIds.size,
labels: [],
summarySnippet: null,
matchReason: `Matched ${document.bestChunkIds.length} indexed passage${
Expand DownExpand Up@@ -337,7 +347,7 @@ function candidatePromotions(query: string, results: SearchResult[]) {
confidence: label.confidence,
}));
return {
aliases: Array.from(new Set(queryTerms)).slice(0, 10),
aliases: queryDerivedTokensForStorage(Array.from(new Set(queryTerms)).slice(0, 10)),
labels: topLabels,
};
}
Expand DownExpand Up@@ -370,7 +380,7 @@ function logWeakSearch(args: {
.insert({
owner_id: args.ownerId,
query: queryTextForStorage(args.query),
normalized_query: normalizeQueryText(args.query),
normalized_query: normalizedQueryTextForStorage(args.query),
query_class: args.queryClass,
route: args.route ?? null,
retrieval_strategy: args.retrievalStrategy ?? null,
Expand DownExpand Up@@ -478,7 +488,7 @@ function logRetrievalDiagnostics(args: {
await args.supabase.from("rag_retrieval_logs").insert({
owner_id: args.ownerId,
query: queryTextForStorage(args.query),
normalized_query: normalizeQueryText(args.query),
normalized_query: normalizedQueryTextForStorage(args.query),
query_class: (args.telemetry.query_class as string) ?? null,
retrieval_strategy: (args.telemetry.retrieval_strategy as string) ?? null,
candidate_count: args.results.length,
Expand Down
Loading