diff --git a/docs/branch-review-records/209b08a93bea5954ac3af3034d10c056a50f6b4dde3771c74ad6bebb83b4ea44.record.md b/docs/branch-review-records/209b08a93bea5954ac3af3034d10c056a50f6b4dde3771c74ad6bebb83b4ea44.record.md new file mode 100644 index 0000000000..d9517618f0 --- /dev/null +++ b/docs/branch-review-records/209b08a93bea5954ac3af3034d10c056a50f6b4dde3771c74ad6bebb83b4ea44.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/issues-reconcile-2026-08-19 | b393cdd530e81d6bfbdc15c495d870b57c73f01b | Serialized reconcile of 21 queued outstanding-issues inbox requests (PR #2168) | approved — documentation-only; canonical diff equals the recorded reconciliation transaction | check:outstanding-issues passed (392 rows, 57 open); check:ledger-write-discipline passed b400b138f8c1..HEAD; format:changed clean; inbox 0 pending / 391 applied | diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index b0a2d84cde..f957d2aa1b 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -182,7 +182,8 @@ export async function POST(request: Request) { } if (error instanceof Error) { const fallbackBody = body; - const fallbackReason = fallbackBody ? nonProductionSupabaseDemoFallbackReason(error) : null; + const fallbackReason = + fallbackBody && process.env.NODE_ENV !== "production" ? nonProductionSupabaseDemoFallbackReason(error) : null; if (fallbackBody && fallbackReason) { return NextResponse.json( { ...buildDemoAnswerPayload(fallbackBody, fallbackReason), interactionId }, diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index 25fd4e50ff..fbc0f66eac 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -126,13 +126,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s const fetchRecord = async () => { const { data, error } = await supabase .from("differential_records") - .select("*") + .select( + "id,owner_id,kind,slug,title,subtitle,status,clinical_hinge,tags,payload,source,source_status,validation_status,last_reviewed_at,review_due_at,created_at,updated_at", + ) .eq("owner_id", access.ownerId) .eq("kind", kind) .eq("slug", normalizedSlug) .maybeSingle(); if (error) throw new Error(error.message); - return (data as DifferentialRecordRow | null) ?? null; + return (data as unknown as DifferentialRecordRow | null) ?? null; }; let row = await fetchRecord(); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index d254358d6a..595f12b316 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -6,6 +6,7 @@ import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { buildStorageCleanupJobUpdate } from "@/lib/ingestion"; import { invalidateRagCachesForDocumentMutation } from "@/lib/rag/rag"; +import { clearCachedSignedUrlsForDocument } from "@/lib/signed-url-cache"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { writeAuditLog } from "@/lib/audit"; @@ -238,6 +239,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i if (ledgerWarning) cleanup.storageWarnings.push(ledgerWarning); invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); + clearCachedSignedUrlsForDocument(id); await writeAuditLog(supabase, { ownerId: user.id, action: "document_delete", diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index b610378a9a..4e426215e0 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -220,29 +220,62 @@ export async function GET(request: Request) { } const ownedIds = [...ownedDocumentIds]; - const emptyResult = () => Promise.resolve({ data: [], error: null }); - const [ownedLabelsResult, publicLabelsResult, ownedSummariesResult, publicSummariesResult] = await Promise.all([ - ownedIds.length - ? supabase.from("document_labels").select(LABEL_LIST_COLUMNS).in("document_id", ownedIds) - : emptyResult(), - publicDocumentIds.length - ? supabase.from("document_labels").select(PUBLIC_LABEL_LIST_COLUMNS).in("document_id", publicDocumentIds) - : emptyResult(), - ownedIds.length - ? supabase.from("document_summaries").select(SUMMARY_LIST_COLUMNS).in("document_id", ownedIds) - : emptyResult(), - publicDocumentIds.length - ? supabase.from("document_summaries").select(PUBLIC_SUMMARY_LIST_COLUMNS).in("document_id", publicDocumentIds) - : emptyResult(), + const ownedLabelsPromises = []; + for (let i = 0; i < ownedIds.length; i += 100) { + ownedLabelsPromises.push( + supabase + .from("document_labels") + .select(LABEL_LIST_COLUMNS) + .in("document_id", ownedIds.slice(i, i + 100)), + ); + } + const publicLabelsPromises = []; + for (let i = 0; i < publicDocumentIds.length; i += 100) { + publicLabelsPromises.push( + supabase + .from("document_labels") + .select(PUBLIC_LABEL_LIST_COLUMNS) + .in("document_id", publicDocumentIds.slice(i, i + 100)), + ); + } + const ownedSummariesPromises = []; + for (let i = 0; i < ownedIds.length; i += 100) { + ownedSummariesPromises.push( + supabase + .from("document_summaries") + .select(SUMMARY_LIST_COLUMNS) + .in("document_id", ownedIds.slice(i, i + 100)), + ); + } + const publicSummariesPromises = []; + for (let i = 0; i < publicDocumentIds.length; i += 100) { + publicSummariesPromises.push( + supabase + .from("document_summaries") + .select(PUBLIC_SUMMARY_LIST_COLUMNS) + .in("document_id", publicDocumentIds.slice(i, i + 100)), + ); + } + + const [ownedLabelsResults, publicLabelsResults, ownedSummariesResults, publicSummariesResults] = await Promise.all([ + Promise.all(ownedLabelsPromises), + Promise.all(publicLabelsPromises), + Promise.all(ownedSummariesPromises), + Promise.all(publicSummariesPromises), ]); - for (const result of [ownedLabelsResult, publicLabelsResult, ownedSummariesResult, publicSummariesResult]) { - if (result.error) throw new Error(result.error.message); + for (const res of [ + ...ownedLabelsResults, + ...publicLabelsResults, + ...ownedSummariesResults, + ...publicSummariesResults, + ]) { + if (res.error) throw new Error(res.error.message); } const labelsByDocument = new Map(); const labelRows = parseListRows( - [...(ownedLabelsResult.data ?? []), ...(publicLabelsResult.data ?? [])], + [...ownedLabelsResults.flatMap((res) => res.data ?? []), ...publicLabelsResults.flatMap((res) => res.data ?? [])], labelListRowSchema, ); for (const label of labelRows) { @@ -253,7 +286,10 @@ export async function GET(request: Request) { labelsByDocument.set(label.document_id, existing); } const summaryRows = parseListRows( - [...(ownedSummariesResult.data ?? []), ...(publicSummariesResult.data ?? [])], + [ + ...ownedSummariesResults.flatMap((res) => res.data ?? []), + ...publicSummariesResults.flatMap((res) => res.data ?? []), + ], summaryListRowSchema, ); const summariesByDocument = new Map( diff --git a/src/components/clinical-dashboard/auth-panel.tsx b/src/components/clinical-dashboard/auth-panel.tsx index 06b3627ed1..f90245a9bb 100644 --- a/src/components/clinical-dashboard/auth-panel.tsx +++ b/src/components/clinical-dashboard/auth-panel.tsx @@ -44,7 +44,7 @@ const authEmailChangeEvent = "clinical-kb-auth-email-change"; function getAuthEmailSnapshot() { if (typeof window === "undefined") return ""; try { - return window.localStorage.getItem(AUTH_EMAIL_STORAGE_KEY) ?? ""; + return window.sessionStorage.getItem(AUTH_EMAIL_STORAGE_KEY) ?? ""; } catch { return ""; } diff --git a/src/components/favourites/favourites-storage.ts b/src/components/favourites/favourites-storage.ts index ccac51a82d..787386510f 100644 --- a/src/components/favourites/favourites-storage.ts +++ b/src/components/favourites/favourites-storage.ts @@ -58,6 +58,8 @@ export function subscribeFavouritesStorage(listener: () => void): () => void { }; } +const FAVOURITES_TTL_MS = 90 * 24 * 60 * 60 * 1000; + export function loadFavouriteLastOpened(): Record { if (typeof window === "undefined") { return getDefaultInitialTimestamps(); @@ -69,7 +71,14 @@ export function loadFavouriteLastOpened(): Record { if (raw) { const parsed = JSON.parse(raw); if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - const result: Record = { ...getDefaultInitialTimestamps(), ...parsed }; + const now = Date.now(); + const pruned: Record = {}; + for (const [key, ts] of Object.entries(parsed)) { + if (typeof ts === "number" && Number.isFinite(ts) && now - ts < FAVOURITES_TTL_MS) { + pruned[key] = ts; + } + } + const result: Record = { ...getDefaultInitialTimestamps(), ...pruned }; inMemoryLastOpened = result; return result; } diff --git a/src/lib/answer-telemetry.ts b/src/lib/answer-telemetry.ts index e227db3d34..287264ff38 100644 --- a/src/lib/answer-telemetry.ts +++ b/src/lib/answer-telemetry.ts @@ -148,3 +148,22 @@ export async function logAnswerDiagnostics(args: { } } } + +export const DEFAULT_RETRIEVAL_LOG_RETENTION_DAYS = 90; + +export async function pruneExpiredRetrievalLogs( + supabase: ReturnType, + retentionDays: number = DEFAULT_RETRIEVAL_LOG_RETENTION_DAYS, +): Promise<{ deletedCount: number | null; error: Error | null }> { + try { + const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); + const { count, error } = await supabase + .from("rag_retrieval_logs") + .delete({ count: "exact" }) + .lt("created_at", cutoffDate); + if (error) throw new Error(error.message); + return { deletedCount: count, error: null }; + } catch (err) { + return { deletedCount: null, error: err instanceof Error ? err : new Error(String(err)) }; + } +} diff --git a/src/lib/answer-thread-storage.ts b/src/lib/answer-thread-storage.ts index 5d8a9e3660..a5198d06a7 100644 --- a/src/lib/answer-thread-storage.ts +++ b/src/lib/answer-thread-storage.ts @@ -72,7 +72,14 @@ function isStoredAnswerTurn(value: unknown): value is StoredAnswerTurn { Boolean(turn.answer) && typeof turn.answer === "object" && typeof turn.answer.answer === "string" && - Array.isArray(turn.sources) + Array.isArray(turn.sources) && + turn.sources.every( + (source) => + Boolean(source) && + typeof source === "object" && + typeof source.id === "string" && + typeof source.document_id === "string", + ) ); } diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 9240f1904b..2ada1a2b41 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -5,7 +5,13 @@ import { logger } from "@/lib/logger"; // failing to record an audit row must never break or roll back the operation it // describes, so failures are logged, not thrown. -export type AuditAction = "document_upload" | "document_delete" | "document_rename" | "document_label_change"; +export type AuditAction = + | "document_upload" + | "document_delete" + | "document_rename" + | "document_label_change" + | "source_review_change" + | "bulk_reindex"; export type AuditLogEntry = { ownerId: string; @@ -48,6 +54,8 @@ function minimumAuditMetadata(entry: AuditLogEntry): Record(); +const inFlightGroundingQueries = new Map>(); export function resetCorpusGroundingCacheForTests() { termStatsCache.clear(); + inFlightGroundingQueries.clear(); } function cacheKey(ownerScopeKey: string, term: string) { @@ -156,54 +158,63 @@ export async function classifyCorpusGrounding(args: { } if (missing.length > 0) { + const flightKey = `${ownerScopeKey}:${[...missing].sort().join(",")}`; try { - const ownerFilter = accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL; - const versioned = await resolveAbortableQuery( - args.supabase.rpc("corpus_topic_term_stats_v2", { - terms: missing, - owner_filter: ownerFilter, - include_public: accessScope.includePublic, - }), - args.signal, - ); - const calls = - !versioned || isMissingRetrievalRpcError(versioned.error) - ? await Promise.all([ - resolveAbortableQuery( - args.supabase.rpc("corpus_topic_term_stats", { terms: missing, owner_filter: ownerFilter }), - args.signal, - ), - accessScope.ownerId && accessScope.includePublic - ? resolveAbortableQuery( - args.supabase.rpc("corpus_topic_term_stats", { - terms: missing, - owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, - }), - args.signal, - ) - : Promise.resolve({ data: [], error: null }), - ]) - : [versioned]; - if (calls.some((call) => call.error)) throw calls.find((call) => call.error)?.error; - const byTerm = new Map(); - for (const call of calls) { - for (const row of (call.data ?? []) as CorpusTopicTermStats[]) { - const current = byTerm.get(row.term); - byTerm.set( - row.term, - current - ? { - term: row.term, - has_ts_signal: current.has_ts_signal || row.has_ts_signal, - title_doc_count: current.title_doc_count + row.title_doc_count, - chunk_present: current.chunk_present || row.chunk_present, - total_doc_count: current.total_doc_count + row.total_doc_count, - } - : row, + let flight = inFlightGroundingQueries.get(flightKey); + if (!flight) { + flight = (async () => { + const ownerFilter = accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL; + const versioned = await resolveAbortableQuery( + args.supabase.rpc("corpus_topic_term_stats_v2", { + terms: missing, + owner_filter: ownerFilter, + include_public: accessScope.includePublic, + }), + args.signal, ); - } + const calls = + !versioned || isMissingRetrievalRpcError(versioned.error) + ? await Promise.all([ + resolveAbortableQuery( + args.supabase.rpc("corpus_topic_term_stats", { terms: missing, owner_filter: ownerFilter }), + args.signal, + ), + accessScope.ownerId && accessScope.includePublic + ? resolveAbortableQuery( + args.supabase.rpc("corpus_topic_term_stats", { + terms: missing, + owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, + }), + args.signal, + ) + : Promise.resolve({ data: [], error: null }), + ]) + : [versioned]; + if (calls.some((call) => call.error)) throw calls.find((call) => call.error)?.error; + const byTerm = new Map(); + for (const call of calls) { + for (const row of (call.data ?? []) as CorpusTopicTermStats[]) { + const current = byTerm.get(row.term); + byTerm.set( + row.term, + current + ? { + term: row.term, + has_ts_signal: current.has_ts_signal || row.has_ts_signal, + title_doc_count: current.title_doc_count + row.title_doc_count, + chunk_present: current.chunk_present || row.chunk_present, + total_doc_count: current.total_doc_count + row.total_doc_count, + } + : row, + ); + } + } + return [...byTerm.values()]; + })(); + inFlightGroundingQueries.set(flightKey, flight); } - const rows = [...byTerm.values()]; + + const rows = await flight; // A term the RPC did not echo back got dropped SQL-side (blank after trim); treat the // whole classification as inconclusive rather than guessing. if (rows.length !== missing.length) return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] }; @@ -217,6 +228,8 @@ export async function classifyCorpusGrounding(args: { // Fail open: missing RPC (migration not applied), transient DB error, demo mode — the // caller keeps today's behaviour (LLM classifier fallback + soft-tail short-circuit). return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] }; + } finally { + inFlightGroundingQueries.delete(flightKey); } } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 1929ec2469..d83ce6b3c2 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -13,9 +13,12 @@ const MAX_DEPTH = 5; const SENSITIVE_KEY = /authorization|cookie|token|secret|api[-_]?key|password|service[-_]?role|email|\bquery\b|prompt|\bcontent\b|\banswer\b|patient|\bmrn\b/i; +const SENSITIVE_VALUE_PATTERN = /\b(?:mrn|ur|unit\s*no\.?)\s*[:#]?\s*\d{6,10}\b|\b\d{3}\s\d{3}\s\d{4}\b/i; + function redactValue(value: unknown, depth: number): unknown { if (value === null || value === undefined) return value; if (typeof value === "string") { + if (SENSITIVE_VALUE_PATTERN.test(value)) return REDACTED; return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated]` : value; } if (typeof value === "number" || typeof value === "boolean") return value; diff --git a/src/lib/rag/rag-query-guard.ts b/src/lib/rag/rag-query-guard.ts index dd994ed160..f3d9915fe8 100644 --- a/src/lib/rag/rag-query-guard.ts +++ b/src/lib/rag/rag-query-guard.ts @@ -9,6 +9,8 @@ export const clearlyOutsideCorpusMedicalPattern = export const unavailableDocumentNoisePattern = /\b(?:newly uploaded|future synthetic|not been uploaded|not uploaded|2027 revised|airport travel policy|gardening equipment checklist)\b/i; +export const DEFAULT_SOFT_TAIL_CONFIDENCE_THRESHOLD = 0.42; + function unsupportedSoftTailEligible(analysis: ClinicalQueryAnalysis) { if (analysis.queryClass !== "unsupported_or_general") return false; if (analysis.documentTitleIntent || analysis.medications.length || analysis.thresholdTerms.length) return false; @@ -21,7 +23,7 @@ export function shouldShortCircuitUnsupportedSearch(query: string, analysis: Cli if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return true; if (!unsupportedSoftTailEligible(analysis)) return false; if (clearlyNonClinicalConsumerPattern.test(query)) return true; - return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; + return analysis.confidence <= DEFAULT_SOFT_TAIL_CONFIDENCE_THRESHOLD && analysis.expandedTerms.length <= 5; } // True only for queries that would short-circuit via the soft tail itself, not a pattern guard. @@ -30,7 +32,7 @@ export function isUnsupportedSoftTailAnalysis(query: string, analysis: ClinicalQ if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return false; if (!unsupportedSoftTailEligible(analysis)) return false; if (clearlyNonClinicalConsumerPattern.test(query)) return false; - return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; + return analysis.confidence <= DEFAULT_SOFT_TAIL_CONFIDENCE_THRESHOLD && analysis.expandedTerms.length <= 5; } /** diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts index 8cd0915e39..6790a7a082 100644 --- a/src/lib/security-headers.ts +++ b/src/lib/security-headers.ts @@ -77,7 +77,7 @@ export function buildContentSecurityPolicy({ // server-side only, so the browser gets no provider origin (2026-07-13 audit). "img-src 'self' data: blob: https://*.supabase.co; " + "media-src 'self' https://*.supabase.co; " + - "connect-src 'self' https://*.supabase.co; " + + "connect-src 'self' https://*.supabase.co https://*.ingest.sentry.io https://*.ingest.de.sentry.io https://*.ingest.us.sentry.io; " + "worker-src 'self'; " + "manifest-src 'self'; " + scriptSrc + diff --git a/src/lib/signed-url-cache.ts b/src/lib/signed-url-cache.ts index 260e2f141e..7a55091086 100644 --- a/src/lib/signed-url-cache.ts +++ b/src/lib/signed-url-cache.ts @@ -22,7 +22,7 @@ const SIGNED_URL_CACHE_MAX_SIZE = 256; // for payloads that omit expiresAt so they self-heal well before the URL dies. const SIGNED_URL_DEFAULT_TTL_MS = 5 * 60_000; // Refresh a few seconds before the hard expiry to avoid serving a near-dead URL. -const SIGNED_URL_EXPIRY_SKEW_MS = 30_000; +const SIGNED_URL_EXPIRY_SKEW_MS = 60_000; const signedUrlCache = new Map(); @@ -60,6 +60,14 @@ export function clearCachedSignedUrl(endpoint: string) { signedUrlCache.delete(endpoint); } +export function clearCachedSignedUrlsForDocument(documentId: string) { + for (const key of signedUrlCache.keys()) { + if (key.includes(documentId)) { + signedUrlCache.delete(key); + } + } +} + export function clearSignedUrlCache() { signedUrlCache.clear(); } diff --git a/src/lib/source-governance.ts b/src/lib/source-governance.ts index 9058f87057..4f83d6b6a7 100644 --- a/src/lib/source-governance.ts +++ b/src/lib/source-governance.ts @@ -97,6 +97,19 @@ function pushUnique(warnings: SourceGovernanceWarning[], warning: SourceGovernan warnings.push(warning); } +export function resolveEvidenceWarningSeverity(relevance: EvidenceRelevance): { + severity: SourceGovernanceWarning["severity"]; + uiToken: SourceGovernanceUiToken; + isDanger: boolean; +} { + const isDanger = relevance.verdict === "none"; + return { + severity: isDanger ? "danger" : "warning", + uiToken: isDanger ? "destructive" : "warning", + isDanger, + }; +} + export function sourceGovernanceWarnings(args: { results: SearchResult[]; relevance?: EvidenceRelevance | null; @@ -105,11 +118,11 @@ export function sourceGovernanceWarnings(args: { const warnings: SourceGovernanceWarning[] = []; if (args.relevance && !args.relevance.isSourceBacked) { - const isDanger = args.relevance.verdict === "none"; + const { isDanger, severity, uiToken } = resolveEvidenceWarningSeverity(args.relevance); pushUnique(warnings, { code: SOURCE_GOVERNANCE_CODES.WEAK_EVIDENCE, - severity: isDanger ? "danger" : "warning", - uiToken: isDanger ? "destructive" : "warning", + severity, + uiToken, message: isDanger ? WEAK_EVIDENCE_DANGER_MESSAGE : args.relevance.supportReason || "The retrieved evidence is weak or nearby-only.", diff --git a/src/proxy.ts b/src/proxy.ts index 8cba7225ec..776b7e606d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -77,6 +77,22 @@ export async function proxy(request: NextRequest) { } } + if ( + ["POST", "PUT", "PATCH", "DELETE"].includes(request.method) && + pathname.startsWith("/api/") && + !pathname.startsWith("/api/webhooks/") + ) { + const secFetchSite = request.headers.get("sec-fetch-site"); + if (secFetchSite === "cross-site") { + const response = NextResponse.json( + { error: "Cross-site request blocked.", code: "cross_site_forbidden" }, + { status: 403 }, + ); + response.headers.set("content-security-policy", csp); + return response; + } + } + // Request headers Next.js reads during SSR: `x-nonce` for our own inline //