From b30bcb030edce84e2a0704a8714ee3ef1dcf642d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:13:43 +0800 Subject: [PATCH 1/6] feat(rag): remediate clinical retrieval and safety --- .env.example | 4 + docs/privacy-impact-assessment.md | 105 +-- docs/site-map.md | 1 + scripts/compare-retrieval-eval.ts | 12 + scripts/eval-quality.ts | 4 + scripts/eval-rag-offline.mjs | 16 +- scripts/eval-retrieval.ts | 90 ++- .../fixtures/rag-offline-contract-tests.json | 23 + scripts/generate-site-map.ts | 1 + scripts/production-readiness.ts | 10 + scripts/rag-offline-contract.mjs | 38 + src/app/api/answer/route.ts | 11 +- src/app/api/answer/stream/route.ts | 17 +- src/app/api/documents/[id]/reindex/route.ts | 10 +- src/app/api/documents/bulk/reindex/route.ts | 11 +- src/app/api/search/route.ts | 10 +- src/app/api/upload/route.ts | 43 +- src/app/globals.css | 1 + src/app/privacy/page.tsx | 79 ++ src/components/AccessibleTable.tsx | 5 +- .../DocumentManagerPanel.tsx | 2 + .../answer-result-surface.tsx | 16 +- .../master-search-header.tsx | 4 + .../clinical-dashboard/visual-evidence.tsx | 71 +- src/components/mode-home-template.tsx | 10 +- src/components/privacy-input-notice.tsx | 26 + src/lib/answer-render-policy.ts | 163 ++++- src/lib/answer-verification.ts | 270 ++++++- src/lib/citations.ts | 9 +- src/lib/clinical-search.ts | 11 +- src/lib/corpus-grounding.ts | 55 +- src/lib/cross-document-synthesis.ts | 10 +- src/lib/deep-memory.ts | 301 +++++--- src/lib/demo-data.ts | 4 +- src/lib/document-enrichment.ts | 50 +- src/lib/env.ts | 6 + src/lib/evidence.ts | 2 +- src/lib/owner-scope.ts | 39 + src/lib/query-privacy.ts | 4 + src/lib/rag-cache.ts | 58 +- src/lib/rag-claim-support.ts | 444 +++++++++++ src/lib/rag-comparison.ts | 379 ++++++++++ src/lib/rag-contracts.ts | 2 + src/lib/rag-extractive-answer.ts | 11 +- src/lib/rag-quote-verification.ts | 2 +- src/lib/rag-retrieval-variants.ts | 13 +- src/lib/rag-routing.ts | 12 +- src/lib/rag.ts | 560 ++++++++++---- src/lib/retrieval-rpc-rollout.ts | 17 + src/lib/search-scope.ts | 15 +- src/lib/supabase/database.types.ts | 137 ++++ src/lib/types.ts | 54 ++ src/lib/upload-admission.ts | 46 ++ supabase/drift-manifest.json | 103 ++- ...000_clear_historical_rag_query_answers.sql | 5 + ...0713020000_owner_plus_public_retrieval.sql | 286 ++++++++ ...0713030000_producer_scoped_deep_memory.sql | 417 +++++++++++ supabase/schema.sql | 692 +++++++++++++++++- tests/answer-render-policy.test.ts | 385 ++++++++++ tests/answer-verification.test.ts | 179 ++++- tests/canonical-answer-table.test.ts | 72 ++ tests/clinical-search.test.ts | 17 + tests/corpus-grounding.test.ts | 42 +- tests/cross-document-synthesis.test.ts | 45 ++ tests/deep-memory-transaction-sql.test.ts | 71 ++ tests/deep-memory.test.ts | 555 +++++++++++++- ...ocument-enrichment-retrieval-scope.test.ts | 38 + tests/eval-quality.test.ts | 4 + tests/eval-retrieval.test.ts | 189 +++++ tests/owner-scope.test.ts | 26 + tests/privacy-ui.test.ts | 37 + tests/privacy.test.ts | 18 + tests/private-access-routes.test.ts | 369 +++++++++- tests/private-rag-access.test.ts | 25 + tests/rag-answer-fallback.test.ts | 223 ++++-- tests/rag-claim-support.test.ts | 493 +++++++++++++ tests/rag-comparison.test.ts | 236 ++++++ tests/rag-offline-answer.test.ts | 37 +- tests/rag-offline-contract.test.ts | 38 + tests/rag-routing.test.ts | 80 +- tests/retrieval-access-scope.test.ts | 93 +++ tests/retrieval-hydration-scope.test.ts | 186 +++++ tests/retrieval-owner-filter-guard.test.ts | 5 + tests/retrieval-rpc-rollout.test.ts | 24 + tests/search-scope.test.ts | 3 +- tests/ui-smoke.spec.ts | 178 +++++ tests/upload-admission.test.ts | 43 ++ 87 files changed, 7988 insertions(+), 520 deletions(-) create mode 100644 scripts/fixtures/rag-offline-contract-tests.json create mode 100644 scripts/rag-offline-contract.mjs create mode 100644 src/app/privacy/page.tsx create mode 100644 src/components/privacy-input-notice.tsx create mode 100644 src/lib/rag-claim-support.ts create mode 100644 src/lib/rag-comparison.ts create mode 100644 src/lib/retrieval-rpc-rollout.ts create mode 100644 src/lib/upload-admission.ts create mode 100644 supabase/migrations/20260713010000_clear_historical_rag_query_answers.sql create mode 100644 supabase/migrations/20260713020000_owner_plus_public_retrieval.sql create mode 100644 supabase/migrations/20260713030000_producer_scoped_deep_memory.sql create mode 100644 tests/canonical-answer-table.test.ts create mode 100644 tests/deep-memory-transaction-sql.test.ts create mode 100644 tests/document-enrichment-retrieval-scope.test.ts create mode 100644 tests/privacy-ui.test.ts create mode 100644 tests/rag-claim-support.test.ts create mode 100644 tests/rag-comparison.test.ts create mode 100644 tests/rag-offline-contract.test.ts create mode 100644 tests/retrieval-access-scope.test.ts create mode 100644 tests/retrieval-hydration-scope.test.ts create mode 100644 tests/retrieval-rpc-rollout.test.ts create mode 100644 tests/upload-admission.test.ts diff --git a/.env.example b/.env.example index cdf6c9c01..9107e9936 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,8 @@ RAG_AWAIT_QUERY_LOGS=false #NEXT_PUBLIC_MOCKUPS_ENABLED=false # Persist raw clinical query text in logs. Default off; blocked in production readiness. #RAG_PERSIST_RAW_QUERY_TEXT=false +# Persist generated answer text in rag_queries. Default off and forbidden in production-like environments. +#RAG_PERSIST_ANSWER_TEXT=false # Server-side key for the redacted query-hash placeholder (min 16 chars). # When set, stored query hashes are HMAC-SHA256 keyed pseudonyms — not # offline-reversible and not correlatable outside this deployment. REQUIRED in @@ -100,6 +102,8 @@ SUPABASE_IMAGE_BUCKET=clinical-images # Conservative local-first defaults. Raise only after testing your worker machine, # Supabase plan limits, and confidentiality policy. MAX_UPLOAD_MB=150 +MAX_CONCURRENT_UPLOADS=1 +MAX_IN_FLIGHT_UPLOAD_MB=151 MAX_IMPORT_JOBS_PER_RUN=5 MAX_IMPORT_BYTES_PER_RUN=157286400 CHUNK_SIZE=2000 diff --git a/docs/privacy-impact-assessment.md b/docs/privacy-impact-assessment.md index fae5b515f..297ea7e03 100644 --- a/docs/privacy-impact-assessment.md +++ b/docs/privacy-impact-assessment.md @@ -42,15 +42,15 @@ material. **Top gaps (full register in §10):** -| ID | Risk | One-line | -| ----- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| PIA-1 | High | Cross-border disclosure to OpenAI (US) has no code-visible DPA/ZDR and no user-facing notice/consent (APP 8, APP 5). | -| PIA-2 | High | Query-hash HMAC downgrades to **unsalted, dictionary-reversible SHA-256** if `RAG_QUERY_HASH_SECRET` is unset — nothing enforces it in production. | -| PIA-3 | Medium | Generated **answer text is stored un-redacted** in `rag_queries`, and can restate the patient details from the query. | -| PIA-4 | Medium | `rag_query_misses` has **no retention/purge job** (only `rag_queries` and `rag_retrieval_logs` do). | -| PIA-5 | Medium | No privacy policy / collection notice / data-handling documentation shipped (APP 1, APP 5). | -| PIA-6 | Low-Med | OpenAI **prompt-cache retention is forced to 24h** for gpt-5.5 regardless of config — query + retrieved excerpts persist ≤24h at OpenAI. | -| PIA-7 | Low | `RAG_PERSIST_RAW_QUERY_TEXT=true` would store raw PHI query text with no secondary safeguard beyond the 30-day purge. | +| ID | Risk | One-line | +| ----- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PIA-1 | High | Cross-border disclosure to OpenAI (US) has no code-visible DPA/ZDR. A draft provider disclosure now ships in-product, but its wording lacks governance approval (APP 8, APP 5). | +| PIA-2 | High | Query-hash HMAC downgrades to **unsalted, dictionary-reversible SHA-256** if `RAG_QUERY_HASH_SECRET` is unset — nothing enforces it in production. | +| PIA-3 | Mitigated | Generated answer text is omitted from `rag_queries` by default. `RAG_PERSIST_ANSWER_TEXT=true` is explicit opt-in and blocked by production readiness. | +| PIA-4 | Medium | `rag_query_misses` has **no retention/purge job** (only `rag_queries` and `rag_retrieval_logs` do). | +| PIA-5 | Medium | Draft point-of-entry collection notices and a `/privacy` data-processing page ship, but no governance-approved final privacy policy exists (APP 1, APP 5). | +| PIA-6 | Low-Med | OpenAI **prompt-cache retention is forced to 24h** for gpt-5.5 regardless of config — query + retrieved excerpts persist ≤24h at OpenAI. | +| PIA-7 | Low | `RAG_PERSIST_RAW_QUERY_TEXT=true` would store raw PHI query text with no secondary safeguard beyond the 30-day purge. | --- @@ -60,7 +60,7 @@ material. | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------ | | Clinical reference corpus (documents, chunks, embeddings, images, tables) | Supabase (Sydney) + storage buckets | Low–Medium | Published guidelines are not PHI; **uploaded** docs _could_ contain PHI. | | Free-text clinical queries | Transient in request; hashed into `rag_queries` / `rag_query_misses` / `rag_retrieval_logs`; sent to OpenAI (US) | **High (potential PHI)** | The primary incidental-PHI vector. | -| Generated answers | `rag_queries.answer`, `rag_response_cache.payload` | **High (derived from PHI query + corpus)** | Stored un-redacted; see PIA-3. | +| Generated answers | transient response; `rag_response_cache.payload` | **High (derived from PHI query + corpus)** | `rag_queries.answer` is null by default; see PIA-3. | | User identity | Supabase Auth (`auth.users`), `owner_id` foreign keys | Medium (PII) | Email + SSO identity; managed by Supabase Auth. | | Audit trail | `audit_logs` | Medium | Append-only, service-role-only, retained indefinitely by design. | | Operational telemetry | `rag_retrieval_logs`, ingestion job tables | Low–Medium | Redacted query text; per-owner. | @@ -105,7 +105,7 @@ Clinician browser │ insertRagQuery(): rag.ts:1983 │ • query = **hash placeholder** (queryTextForStorage) ← redacted │ • normalized_query= **hash placeholder** ← redacted - │ • **answer = full generated text** ← NOT redacted (PIA-3) + │ • **answer = null by default** ← prose persistence disabled │ • source_chunk_ids= real chunk UUIDs ← owner's own data │ • metadata.query_hash = HMAC/SHA-256 (query-privacy.ts:51) │ @@ -178,12 +178,12 @@ privacy item to close before real patient use (PIA-1). All three log tables are **owner-stamped** and **RLS-enabled** (owner-read for authenticated users; service-role for writes). Redaction is applied centrally at every write site. -| Table | Raw query stored? | Redaction mechanism | Other sensitive columns | RLS | -| -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- | -| `rag_queries` | No (hash placeholder) | `queryTextForStorage` / `normalizedQueryTextForStorage` ([query-privacy.ts:33-39](src/lib/query-privacy.ts)); write at [rag.ts:1983-1994](src/lib/rag.ts) | **`answer` stored un-redacted** (PIA-3); `source_chunk_ids` (own data) | owner-read, [schema.sql:3932](supabase/schema.sql) | -| `rag_query_misses` | No (hash placeholder) | same helpers; writes in [search/route.ts:558-559](src/app/api/search/route.ts), [interaction/route.ts:88-89](src/app/api/search/interaction/route.ts) | `metadata.query_hash` | owner-read, [schema.sql:3935](supabase/schema.sql) | -| `rag_retrieval_logs` | No (hash placeholder) | same helpers; write at [search/route.ts:556-559](src/app/api/search/route.ts) | retrieval telemetry only | owner-read, [schema.sql:3938](supabase/schema.sql) | -| `audit_logs` | N/A (no query text) | action/resource metadata only; error strings pass through `redactLogValue` ([privacy.ts:5-31](src/lib/privacy.ts)) | `owner_id`, `action`, `resource_id` | service-role-only, [schema.sql:3959](supabase/schema.sql) | +| Table | Raw query stored? | Redaction mechanism | Other sensitive columns | RLS | +| -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `rag_queries` | No (hash placeholder) | `queryTextForStorage` / `normalizedQueryTextForStorage` ([query-privacy.ts:33-39](src/lib/query-privacy.ts)); centralized write in `insertRagQuery` | `answer` is null by default and stored only with explicit `RAG_PERSIST_ANSWER_TEXT=true`; `source_chunk_ids` (own data) | owner-read, [schema.sql:3932](supabase/schema.sql) | +| `rag_query_misses` | No (hash placeholder) | same helpers; writes in [search/route.ts:558-559](src/app/api/search/route.ts), [interaction/route.ts:88-89](src/app/api/search/interaction/route.ts) | `metadata.query_hash` | owner-read, [schema.sql:3935](supabase/schema.sql) | +| `rag_retrieval_logs` | No (hash placeholder) | same helpers; write at [search/route.ts:556-559](src/app/api/search/route.ts) | retrieval telemetry only | owner-read, [schema.sql:3938](supabase/schema.sql) | +| `audit_logs` | N/A (no query text) | action/resource metadata only; error strings pass through `redactLogValue` ([privacy.ts:5-31](src/lib/privacy.ts)) | `owner_id`, `action`, `resource_id` | service-role-only, [schema.sql:3959](supabase/schema.sql) | ### 5.1 M15 HMAC query-hash fix — verified present, conditionally active @@ -301,19 +301,21 @@ the operative framework for a WA private clinician. (The WA _Privacy and Respons Act 2024_ targets WA **public-sector** entities and may apply to public-health deployments — confirm with counsel if this is deployed inside a WA Health service.) -| APP | Obligation | Status in this app | Gap | -| ----------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -| **APP 1** — open & transparent management | Have a clear, up-to-date APP privacy policy | No privacy policy/collection notice ships in the repo | PIA-5 | -| **APP 3** — collection of sensitive info | Collect health info only with consent + where reasonably necessary | App does not solicit PHI; incidental via free-text. No consent gate/notice on the query box | PIA-5 | -| **APP 5** — notification of collection | Tell individuals what's collected & disclosed (incl. overseas) | No collection notice; OpenAI disclosure not surfaced to the clinician | PIA-1, PIA-5 | -| **APP 6** — use/disclosure | Use only for the primary purpose or a permitted secondary purpose | Query used for answer generation (primary). Log retention = quality/eval (secondary) — defensible but should be documented | PIA-5 | -| **APP 8** — cross-border disclosure | Discloser stays accountable for the overseas recipient unless an exception applies | Disclosure to OpenAI (US); no code-visible DPA/ZDR; accountability unclear | **PIA-1** | -| **APP 11** — security & destruction | Reasonable security; destroy/de-identify when no longer needed | Strong: Sydney residency, RLS, private storage, query hashing, 30/90-day purge. Weakened by PIA-2 (conditional HMAC), PIA-3 (un-redacted answers), PIA-4 (misses never purged) | PIA-2/3/4 | -| **NDB scheme** (Pt IIIC) | Notify OAIC + individuals of eligible breaches of health info | No documented breach-response runbook tied to these tables | Recommend adding | +| APP | Obligation | Status in this app | Gap | +| ----------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| **APP 1** — open & transparent management | Have a clear, up-to-date APP privacy policy | A draft `/privacy` data-processing page ships, but it is explicitly governance-review-required and is not represented as the final approved APP privacy policy | PIA-5 | +| **APP 3** — collection of sensitive info | Collect health info only with consent + where reasonably necessary | App does not solicit PHI; incidental entry remains possible. “Do not enter patient-identifiable information” notices now appear beside query/upload controls, but no governance-approved consent framework is claimed | PIA-5 | +| **APP 5** — notification of collection | Tell individuals what's collected & disclosed (incl. overseas) | Draft point-of-entry notices and the `/privacy` page disclose data processing and provider use; final wording and legal/governance approval remain outstanding | PIA-1, PIA-5 | +| **APP 6** — use/disclosure | Use only for the primary purpose or a permitted secondary purpose | Query used for answer generation (primary). Log retention = quality/eval (secondary) — defensible but should be documented | PIA-5 | +| **APP 8** — cross-border disclosure | Discloser stays accountable for the overseas recipient unless an exception applies | Disclosure to OpenAI (US); no code-visible DPA/ZDR; accountability unclear | **PIA-1** | +| **APP 11** — security & destruction | Reasonable security; destroy/de-identify when no longer needed | Strong: Sydney residency, RLS, private storage, query hashing, default-null answer logs, 30/90-day purge. Remaining gaps are PIA-2 (conditional HMAC), exceptional answer-persistence governance, and PIA-4 (misses never purged) | PIA-2/4 | +| **NDB scheme** (Pt IIIC) | Notify OAIC + individuals of eligible breaches of health info | No documented breach-response runbook tied to these tables | Recommend adding | **Overall:** the _engineering_ controls for data-at-rest are strong and largely APP-11-aligned. The -material shortfalls are **governance/contractual** (APP 8 cross-border, APP 1/5 notices) plus two -**hardening** items (conditional HMAC, un-redacted answer retention). None of these are cross-tenant +material shortfalls are **governance/contractual** (APP 8 cross-border terms and final approval of the +draft APP 1/5 policy/notice wording) plus the +**hardening** items of mandatory HMAC and query-miss retention. Answer prose is omitted by default; +enabling its persistence is an exceptional, non-production mode requiring governance approval. None of these are cross-tenant data-leak bugs — the tenancy review found **zero** confirmed cross-tenant leaks — they are compliance-posture and PHI-minimisation gaps. @@ -324,14 +326,15 @@ compliance-posture and PHI-minimisation gaps. ### PIA-1 — Cross-border disclosure to OpenAI lacks visible DPA/ZDR + notice **(High)** - **Risk:** Health/PHI in queries and excerpts is disclosed to OpenAI (US) with no code-visible - contractual data-processing terms and no user notice → APP 8 accountability exposure and APP 5 - notification gap. + contractual data-processing terms. A draft in-product provider disclosure now exists, reducing the + point-of-entry visibility gap, but it is not governance-approved legal wording → APP 8 accountability + exposure and a residual APP 5 governance gap. - **Evidence:** plain client to `api.openai.com` ([openai.ts:69-73](src/lib/openai.ts)); raw query + excerpts sent ([rag.ts:7144, 6306](src/lib/rag.ts)); no ZDR/baseURL. - **Fix (ranked):** (1) Execute an OpenAI DPA and, ideally, obtain **ZDR** for the org; record it in - `docs/`. (2) Add an APP-5 **collection/OpenAI-disclosure notice** at the query UI and in a privacy - policy. (3) Consider an **on-query PHI reminder** ("do not enter identifiable patient details"). - (4) Optionally, a lightweight PHI-scrub / entity-strip on the outbound query as defence-in-depth. + `docs/`. (2) Obtain governance/legal approval for the shipped draft APP-5 collection/provider + disclosure and final privacy policy. (3) Retain the shipped on-query/upload PHI reminder. + (4) Optionally, add a lightweight PHI-scrub / entity-strip on the outbound query as defence-in-depth. ### PIA-2 — Query-hash HMAC silently downgrades without the secret **(High)** @@ -343,15 +346,16 @@ compliance-posture and PHI-minimisation gaps. `requireServerEnv` pattern) when `NODE_ENV=production` and the secret is missing. Set it on the live project now. -### PIA-3 — Generated answers stored un-redacted in `rag_queries` **(Medium)** +### PIA-3 — Generated answers omitted from `rag_queries` by default **(Mitigated)** -- **Risk:** The `answer` column holds the full generated text, which can restate patient specifics - echoed from the query; the query itself is hashed but the answer is not. Owner-scoped (not - cross-tenant) and purged at 30 days, but it is un-redacted PHI-derived content at rest. -- **Evidence:** answer-path insert stores `answer` verbatim ([rag.ts:7580-7584](src/lib/rag.ts), traced). -- **Fix:** Gate answer-text persistence behind the same `RAG_PERSIST_RAW_QUERY_TEXT` flag (or a new - `RAG_PERSIST_ANSWER_TEXT`), defaulting off; store only what eval/quality actually needs. Confirm the - eval pipeline's real requirement before changing. +- **Risk:** If explicitly enabled outside production, the `answer` column can hold full generated text + that restates patient specifics echoed from the query. The query itself is hashed but answer prose + is not, so enabling this mode requires governance approval. +- **Evidence:** answer-path inserts store `answer: null` under the default + `RAG_PERSIST_ANSWER_TEXT=false`; production-like readiness rejects enabling answer-text persistence. +- **Fix implemented:** Answer-text persistence is gated by `RAG_PERSIST_ANSWER_TEXT`, defaults off, and + production-like readiness rejects enabling it. A cleanup migration is prepared but has not been executed; + no live cleanup or legal approval is claimed. Confirm the eval pipeline's real requirement before changing. ### PIA-4 — `rag_query_misses` never purged **(Medium)** @@ -361,12 +365,13 @@ compliance-posture and PHI-minimisation gaps. - **Fix:** Add a `pg_cron` purge for `rag_query_misses` (30–90 days) mirroring [migration 20260702120000](supabase/migrations/20260702120000_rag_retrieval_logs_retention.sql). -### PIA-5 — No privacy policy / collection notice / data-handling doc **(Medium)** +### PIA-5 — Draft notices/page ship; final approved privacy policy remains outstanding **(Medium)** -- **Risk:** APP 1 (policy) and APP 5 (notification) are unmet; users are not told what is collected, - retained, or disclosed overseas. -- **Fix:** Ship a privacy policy + in-product collection notice; document retention windows and the - OpenAI disclosure. Low engineering cost, high compliance value. +- **Risk:** The shipped draft point-of-entry notices and `/privacy` page explain collection, retention, + and overseas/provider processing, but they are explicitly pending governance review and do not by + themselves establish an approved APP privacy policy. +- **Fix:** Have governance/legal owners review, amend and approve the draft wording; publish the final + APP privacy policy and retain the point-of-entry links/notices. No legal approval is claimed here. ### PIA-6 — OpenAI prompt-cache retention forced to 24h **(Low-Medium)** @@ -388,11 +393,15 @@ compliance-posture and PHI-minimisation gaps. ## 11. Recommendation Before the app is used with real patients in a WA clinical setting, close **PIA-1** and **PIA-2** as -launch-blockers (cross-border contractual basis + user notice; mandatory HMAC secret), then **PIA-3/4** -as fast follow-ups (minimise answer retention; purge `rag_query_misses`), and ship the **PIA-5** +launch-blockers (cross-border contractual basis + user notice; mandatory HMAC secret), then **PIA-4** +as a fast follow-up (purge `rag_query_misses`), and ship the **PIA-5** privacy documentation. The data-at-rest security posture (Sydney residency, RLS, private storage, query hashing, automated purge) is already strong and should be highlighted in the privacy policy as evidence of "reasonable steps" under APP 11. +PIA-3 is mitigated by default-null answer logging. If exceptional non-production answer persistence is +ever enabled, it remains governance-gated. The historical cleanup migration is prepared but unexecuted; +this assessment does not claim live cleanup or legal approval. + See the companion **[tenancy defense-in-depth review](docs/tenancy-defense-in-depth-review.md)** for the cross-tenant isolation analysis referenced above. diff --git a/docs/site-map.md b/docs/site-map.md index 6012d1938..4ee8d4994 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -16,6 +16,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. +- `/privacy` - Privacy and data-processing governance draft. Source: `src/app/privacy/page.tsx`. - `/reference/colour-coding` - Route discovered from app directory Source: `src/app/reference/colour-coding/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. diff --git a/scripts/compare-retrieval-eval.ts b/scripts/compare-retrieval-eval.ts index c8e724d2b..cdb2898d6 100644 --- a/scripts/compare-retrieval-eval.ts +++ b/scripts/compare-retrieval-eval.ts @@ -14,6 +14,11 @@ function numberValue(summary: Record, key: string) { return typeof value === "number" && Number.isFinite(value) ? value : 0; } +function optionalNumberValue(summary: Record, key: string) { + const value = summary[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + function arrayLength(summary: Record, key: string) { const value = summary[key]; return Array.isArray(value) ? value.length : 0; @@ -72,6 +77,13 @@ function main() { ], ["index_units_layer_count", layerCount(baseline, "index_units"), layerCount(candidate, "index_units"), 0], ]; + for (const key of ["ndcg_at_10", "irrelevant_source_rate_at_10", "required_signal_coverage_at_10"] as const) { + const baselineValue = optionalNumberValue(baseline, key); + const candidateValue = optionalNumberValue(candidate, key); + if (baselineValue !== undefined && candidateValue !== undefined) { + metrics.push([key, baselineValue, candidateValue]); + } + } console.log("Retrieval eval comparison: candidate (delta from baseline)"); for (const [name, baseValue, candidateValue, digits] of metrics) { diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index fdeddd5fb..f3b0088c4 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -668,6 +668,10 @@ ${markdownTable([ ["Document recall@5", retrieval.document_recall_at_5], ["Content recall@5", retrieval.content_recall_at_5], ["MRR@10", retrieval.mrr_at_10], + ["nDCG@10", retrieval.ndcg_at_10], + ["Irrelevant source rate@10", retrieval.irrelevant_source_rate_at_10], + ["Required signal coverage@10", retrieval.required_signal_coverage_at_10], + ["Signal metric cases", retrieval.signal_metric_case_count], ["Median latency ms", retrieval.median_latency_ms], ["Failed cases", retrieval.failed_cases.length], ])} diff --git a/scripts/eval-rag-offline.mjs b/scripts/eval-rag-offline.mjs index ff8f35f6b..8606ab77a 100644 --- a/scripts/eval-rag-offline.mjs +++ b/scripts/eval-rag-offline.mjs @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { validateOfflineContractTests } from "./rag-offline-contract.mjs"; const goldenPath = "scripts/fixtures/rag-retrieval-golden.json"; const cases = JSON.parse(readFileSync(goldenPath, "utf8")); @@ -68,13 +69,14 @@ if (failures.length > 0) { console.log(`Offline RAG fixture schema passed (${cases.length} golden retrieval cases).`); -const contractTests = [ - "tests/rag-offline-answer.test.ts", - "tests/rag-answer-fallback.test.ts", - "tests/retrieval-selection.test.ts", - "tests/citations.test.ts", - "tests/smart-rag-api.test.ts", -]; +const contractTestsPath = "scripts/fixtures/rag-offline-contract-tests.json"; +const contractTests = JSON.parse(readFileSync(contractTestsPath, "utf8")); +const contractTestFailures = validateOfflineContractTests(contractTests); +if (contractTestFailures.length > 0) { + console.error(`${contractTestsPath} failed the offline safety contract:`); + for (const failure of contractTestFailures) console.error(`- ${failure}`); + process.exit(1); +} const vitestPath = resolve("node_modules/vitest/vitest.mjs"); const offlineEnv = { ...process.env }; for (const key of [ diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index a17b87b22..f990898da 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -67,6 +67,10 @@ export type GoldenRetrievalResult = { topK: number; reciprocalRankAt10: number; contentReciprocalRankAt10: number; + declaredSignalCount: number; + ndcgAt10: number; + irrelevantSourceRateAt10: number; + requiredSignalCoverageAt10: number; latencyMs: number; retrievalStrategy: string | null; retrievalPlan: string | null; @@ -361,9 +365,9 @@ function expectedContentHits( results: SearchResult[], limit: number, ) { - const topContentText = results.slice(0, limit).map(resultContentText).join(" "); + const topResults = results.slice(0, limit); const hits = expectedTerms.filter((expectation) => - contentExpectationAlternatives(expectation).some((term) => textContainsClinicalTerm(topContentText, term)), + topResults.some((result) => contentExpectationMatchesResult(expectation, result)), ); return { hits: hits.map(contentExpectationLabel), @@ -371,6 +375,14 @@ function expectedContentHits( }; } +function contentExpectationMatchesResult( + expectation: GoldenRetrievalCase["expectedContentTerms"][number], + result: SearchResult, +) { + const contentText = resultContentText(result); + return contentExpectationAlternatives(expectation).some((term) => textContainsClinicalTerm(contentText, term)); +} + function reciprocalRankAt10(expectedSubstrings: string[], results: SearchResult[]) { if (expectedSubstrings.length === 0) return 0; const index = results.slice(0, 10).findIndex((result) => @@ -410,6 +422,60 @@ function contentReciprocalRankAt10( return total / expectedTerms.length; } +type RetrievalSignalMetrics = { + declaredSignalCount: number; + ndcgAt10: number; + irrelevantSourceRateAt10: number; + requiredSignalCoverageAt10: number; +}; + +/** + * Scores only signals explicitly declared by a golden case. Each document expectation, + * content expectation group, and required table-evidence flag is one signal. A result's + * graded relevance is the number of those signals it carries; nDCG uses the ideal ordering + * of the observed grades, so it measures ordering independently from coverage. + */ +function retrievalSignalMetricsAt10(testCase: GoldenRetrievalCase, results: SearchResult[]): RetrievalSignalMetrics { + const top = results.slice(0, 10); + const documentSignals = testCase.expectedDocumentSubstrings.map((expectation) => (result: SearchResult) => { + const text = + !clinicalDocumentAliases[expectation] && !/\.pdf$/i.test(expectation) + ? resultDocumentEvidenceText(result) + : resultDocumentText(result); + return documentExpectationAlternatives(expectation).some((alternative) => text.includes(alternative)); + }); + const contentSignals = testCase.expectedContentTerms.map( + (expectation) => (result: SearchResult) => contentExpectationMatchesResult(expectation, result), + ); + const signals = [ + ...documentSignals, + ...contentSignals, + ...(testCase.expectTableEvidence ? [(result: SearchResult) => hasTableEvidence([result], 1)] : []), + ]; + const declaredSignalCount = signals.length; + if (declaredSignalCount === 0) { + return { + declaredSignalCount: 0, + ndcgAt10: 1, + irrelevantSourceRateAt10: 0, + requiredSignalCoverageAt10: 1, + }; + } + + const grades = top.map((result) => signals.filter((matches) => matches(result)).length); + const dcg = (values: number[]) => + values.reduce((sum, grade, index) => sum + (2 ** grade - 1) / Math.log2(index + 2), 0); + const idealDcg = dcg([...grades].sort((left, right) => right - left)); + const matchedSignals = signals.filter((matches) => top.some((result) => matches(result))).length; + + return { + declaredSignalCount, + ndcgAt10: idealDcg > 0 ? dcg(grades) / idealDcg : 0, + irrelevantSourceRateAt10: top.length > 0 ? grades.filter((grade) => grade === 0).length / top.length : 0, + requiredSignalCoverageAt10: matchedSignals / declaredSignalCount, + }; +} + export function retrievalLimitForGoldenCase(testCase: GoldenRetrievalCase) { return Math.max(testCase.topK, 10); } @@ -528,6 +594,7 @@ export function evaluateGoldenRetrievalCase(args: { : contentHits.hits.length / args.testCase.expectedContentTerms.length; const tableEvidenceFound = hasTableEvidence(args.results, 5); const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK); + const signalMetrics = retrievalSignalMetricsAt10(args.testCase, args.results); const actualQueryClass = args.telemetry.query_class ?? null; const failures: string[] = []; const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce( @@ -581,6 +648,7 @@ export function evaluateGoldenRetrievalCase(args: { topK, reciprocalRankAt10: reciprocalRankAt10(args.testCase.expectedDocumentSubstrings, args.results), contentReciprocalRankAt10: contentReciprocalRankAt10(args.testCase.expectedContentTerms, args.results), + ...signalMetrics, latencyMs: args.latencyMs, retrievalStrategy: args.telemetry.retrieval_strategy ?? null, retrievalPlan: args.telemetry.retrieval_plan ?? null, @@ -645,6 +713,15 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] return counts; }, {}); const forceEmbeddingResults = results.filter((result) => result.forceEmbedding); + const signalMetricResults = results.filter((result) => result.declaredSignalCount > 0); + const signalMetricAverage = (select: (result: GoldenRetrievalResult) => number, neutral: number) => + signalMetricResults.length > 0 + ? Number( + (signalMetricResults.reduce((sum, result) => sum + select(result), 0) / signalMetricResults.length).toFixed( + 4, + ), + ) + : neutral; return { case_count: results.length, document_recall_at_5: Number( @@ -664,6 +741,10 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] ).toFixed(4), ), content_mrr_case_count: contentRankCases.length, + ndcg_at_10: signalMetricAverage((result) => result.ndcgAt10, 1), + irrelevant_source_rate_at_10: signalMetricAverage((result) => result.irrelevantSourceRateAt10, 0), + required_signal_coverage_at_10: signalMetricAverage((result) => result.requiredSignalCoverageAt10, 1), + signal_metric_case_count: signalMetricResults.length, median_latency_ms: percentile( results.map((result) => result.latencyMs), 50, @@ -753,6 +834,9 @@ function printHumanSummary(summary: ReturnType = { "/forms/[slug]": "Registry-backed form detail.", "/medications": "Medication index redirect.", "/medications/[slug]": "Medication detail.", + "/privacy": "Privacy and data-processing governance draft.", "/services": "Services home and search surface.", "/services/[slug]": "Registry-backed service detail.", }; diff --git a/scripts/production-readiness.ts b/scripts/production-readiness.ts index a63b05d3a..c2f2db5a8 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -108,6 +108,15 @@ function recordRawQueryPersistenceProductionCheck() { } } +function recordAnswerPersistenceProductionCheck() { + if ( + (process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production") && + process.env.RAG_PERSIST_ANSWER_TEXT === "true" + ) { + result.failures.push("RAG_PERSIST_ANSWER_TEXT=true is not allowed in a production-like environment."); + } +} + async function checkFileForServiceRoleExposure() { const envFiles = [".env", ".env.production", ".env.development"]; for (const fileName of envFiles) { @@ -132,6 +141,7 @@ async function main() { recordNoAuthProductionCheck(); recordDemoModeProductionCheck(); recordRawQueryPersistenceProductionCheck(); + recordAnswerPersistenceProductionCheck(); await checkFileForServiceRoleExposure(); if (!(await checkRequiredFile(path.join(process.cwd(), "package-lock.json"), "package-lock.json is required"))) { diff --git a/scripts/rag-offline-contract.mjs b/scripts/rag-offline-contract.mjs new file mode 100644 index 000000000..97b12c054 --- /dev/null +++ b/scripts/rag-offline-contract.mjs @@ -0,0 +1,38 @@ +export const requiredOfflineContractTests = Object.freeze([ + "tests/rag-offline-contract.test.ts", + "tests/rag-offline-answer.test.ts", + "tests/rag-answer-fallback.test.ts", + "tests/retrieval-selection.test.ts", + "tests/citations.test.ts", + "tests/smart-rag-api.test.ts", + "tests/deep-memory.test.ts", + "tests/deep-memory-transaction-sql.test.ts", + "tests/retrieval-access-scope.test.ts", + "tests/retrieval-hydration-scope.test.ts", + "tests/answer-verification.test.ts", + "tests/rag-routing.test.ts", + "tests/cross-document-synthesis.test.ts", + "tests/rag-comparison.test.ts", + "tests/rag-claim-support.test.ts", + "tests/answer-render-policy.test.ts", + "tests/canonical-answer-table.test.ts", + "tests/privacy.test.ts", + "tests/private-rag-access.test.ts", + "tests/upload-admission.test.ts", + "tests/privacy-ui.test.ts", +]); + +export function validateOfflineContractTests(suites) { + const failures = []; + if (!Array.isArray(suites) || suites.length === 0) { + return ["contract suite list must be a non-empty array"]; + } + if (!suites.every((suite) => typeof suite === "string" && suite.trim().length > 0)) { + failures.push("contract suite list contains an invalid path"); + } + if (new Set(suites).size !== suites.length) failures.push("contract suite list contains duplicates"); + for (const required of requiredOfflineContractTests) { + if (!suites.includes(required)) failures.push(`missing required suite: ${required}`); + } + return failures; +} diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 0c902a48a..3b087cce4 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; -import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { isDemoMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; import { @@ -14,6 +14,7 @@ import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +import { resolveRetrievalAccessScope } from "@/lib/owner-scope"; import { hasDangerSourceGovernanceWarning, sourceGovernanceRefusalAnswer, @@ -36,7 +37,6 @@ const answerSchema = z.object({ documentIds: z.array(z.string().uuid()).max(25).optional(), filters: searchScopeFiltersSchema.optional(), queryMode: clinicalQueryModeSchema.optional().default("auto"), - skipCache: z.boolean().optional().default(false), }); type AnswerRequestBody = z.infer; @@ -82,7 +82,7 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); - const publicOnly = !access.authenticated && !isLocalNoAuthMode(); + const accessScope = resolveRetrievalAccessScope(access.ownerId); const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -96,8 +96,7 @@ export async function POST(request: Request) { const scope = await resolveSearchScope({ supabase, - ownerId: access.ownerId, - publicOnly, + accessScope, documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined), filters: answerBody.filters, }); @@ -127,9 +126,9 @@ export async function POST(request: Request) { answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined)), ownerId: access.ownerId, + accessScope, allowGlobalSearch: !access.ownerId, queryMode: answerBody.queryMode, - skipCache: answerBody.skipCache, signal: request.signal, }); const warnings = sourceGovernanceWarnings({ diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index e44b57281..223f02139 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; -import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { isDemoMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; import { allowRateLimitInMemoryFallbackOnUnavailable, @@ -15,6 +15,7 @@ import { annotateSearchResults, buildEvidenceRelevance } from "@/lib/evidence-re import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +import { resolveRetrievalAccessScope, type RetrievalAccessScope } from "@/lib/owner-scope"; import { hasDangerSourceGovernanceWarning, sourceGovernanceRefusalAnswer, @@ -38,7 +39,6 @@ const answerSchema = z.object({ documentIds: z.array(z.string().uuid()).max(25).optional(), filters: searchScopeFiltersSchema.optional(), queryMode: clinicalQueryModeSchema.optional().default("auto"), - skipCache: z.boolean().optional().default(false), }); type AnswerBody = z.infer; @@ -136,7 +136,8 @@ function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) { }; } -function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, publicOnly = false) { +function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signal?: AbortSignal) { + const ownerId = accessScope.ownerId; const encoder = new TextEncoder(); return new Response( @@ -166,8 +167,7 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, ? null : await resolveSearchScope({ supabase: createAdminClient(), - ownerId, - publicOnly, + accessScope, documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), filters: body.filters, }); @@ -197,9 +197,9 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, ? undefined : (scope?.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined)), ownerId, + accessScope, allowGlobalSearch: !ownerId, queryMode: body.queryMode, - skipCache: body.skipCache, onProgress, onToken, onRevising, @@ -276,11 +276,10 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, export async function POST(request: Request) { try { const body = await parseJsonBody(request, answerSchema, "Invalid answer request."); - if (isDemoMode()) return streamAnswer(body, undefined, request.signal); + if (isDemoMode()) return streamAnswer(body, resolveRetrievalAccessScope(), request.signal); const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); - const publicOnly = !access.authenticated && !isLocalNoAuthMode(); const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -290,7 +289,7 @@ export async function POST(request: Request) { }); if (rateLimit.limited) return rateLimitStream(rateLimit); - return streamAnswer(body, access.ownerId, request.signal, publicOnly); + return streamAnswer(body, resolveRetrievalAccessScope(access.ownerId), request.signal); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(error); diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 1d1ddde96..aeacb1089 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -3,7 +3,11 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import { env, isDemoMode } from "@/lib/env"; import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; -import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; +import { + assertLocalDeepMemoryOwnership, + DeepMemoryOwnershipConflictError, + upsertDocumentDeepMemory, +} from "@/lib/deep-memory"; import { jsonError } from "@/lib/http"; import { activeIngestionJobColumns, @@ -185,6 +189,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Document has no indexed chunks to enrich." }, { status: 400 }); } + await assertLocalDeepMemoryOwnership(supabase, id); const enrichment = await upsertDocumentEnrichment({ supabase, document: document as Parameters[0]["document"], @@ -308,6 +313,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ job }, { status: 201 }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); + if (error instanceof DeepMemoryOwnershipConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } return jsonError(error); } } diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 2d146277f..f1e7a12ee 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; -import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; +import { + assertLocalDeepMemoryOwnership, + DeepMemoryOwnershipConflictError, + upsertDocumentDeepMemory, +} from "@/lib/deep-memory"; import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; @@ -162,6 +166,7 @@ export async function POST(request: Request) { committedImages.sort( (a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0), ); + await assertLocalDeepMemoryOwnership(supabase, document.id); const enrichment = await upsertDocumentEnrichment({ supabase, document: document as Parameters[0]["document"], @@ -279,6 +284,7 @@ export async function POST(request: Request) { } results.push({ documentId: document.id, mode: parsed.mode, ok: true, jobId: job.id }); } catch (error) { + if (error instanceof DeepMemoryOwnershipConflictError) throw error; results.push({ documentId: document.id, mode: parsed.mode, @@ -296,6 +302,9 @@ export async function POST(request: Request) { }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); + if (error instanceof DeepMemoryOwnershipConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } if (error instanceof PublicApiError) return jsonError(error, error.status); return jsonError(error, 500); } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 044ab1e4c..8316e88dc 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -24,6 +24,7 @@ import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +import { resolveRetrievalAccessScope } from "@/lib/owner-scope"; import { sourceGovernanceWarnings } from "@/lib/source-governance"; import { normalizedQueryTextForStorage, @@ -681,15 +682,14 @@ async function buildScopedSearchPayload( body: SearchRequestBody, supabase: ReturnType, ownerId?: string | null, - publicOnly = false, ) { const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode); const effectiveQueryClass = queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(searchFocusQuery).queryClass; + const accessScope = resolveRetrievalAccessScope(ownerId); const scope = await resolveSearchScope({ supabase, - ownerId: ownerId ?? undefined, - publicOnly, + accessScope, documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), filters: body.filters, }); @@ -734,6 +734,7 @@ async function buildScopedSearchPayload( : (body.topK ?? 8), documentIds: scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined), ownerId: ownerId ?? undefined, + accessScope, allowGlobalSearch: !ownerId, queryMode: body.queryMode, }); @@ -751,6 +752,7 @@ async function buildScopedSearchPayload( ? await fetchRelatedDocuments({ supabase, ownerId: ownerId ?? undefined, + accessScope, query: searchFocusQuery, results, limit: isSourceLibrarySearchMode(body.mode) ? body.documentLimit : undefined, @@ -923,7 +925,7 @@ export async function POST(request: Request) { const key = scopedSearchKey(searchBody, ownerId, publicOnly); const { payload, coalesced } = await coalesceScopedSearch(key, () => - buildScopedSearchPayload(searchBody, supabase!, ownerId, publicOnly), + buildScopedSearchPayload(searchBody, supabase!, ownerId), ); return NextResponse.json({ ...payload, diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 410715ef5..7df847c97 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -6,17 +6,14 @@ import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; -import { - allowRateLimitInMemoryFallbackOnUnavailable, - consumeSubjectApiRateLimit, - rateLimitJsonResponse, -} from "@/lib/api-rate-limit"; +import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { publicAccessContext } from "@/lib/public-api-access"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; +import { acquireUploadAdmission, parseUploadContentLength } from "@/lib/upload-admission"; export const runtime = "nodejs"; @@ -40,6 +37,12 @@ function isContentHashDuplicateError(error: unknown) { ); } +function assertUploadNotAborted(request: Request) { + if (request.signal.aborted) { + throw new PublicApiError("Upload cancelled by client.", 499, { code: "client_cancelled" }); + } +} + async function duplicateUploadResponse(args: { supabase: ReturnType; ownerId: string; @@ -86,6 +89,7 @@ export async function POST(request: Request) { let uploadedPath: string | null = null; let insertedDocumentId: string | null = null; let insertedDocumentOwnerId: string | null = null; + let releaseAdmission: (() => void) | null = null; try { supabase = createAdminClient(); @@ -100,7 +104,7 @@ export async function POST(request: Request) { supabase: adminSupabase, subject: access.rateLimitSubject, bucket: "document_upload", - allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + allowInMemoryFallbackOnUnavailable: false, }); if (rateLimit.limited) { return rateLimitJsonResponse( @@ -109,6 +113,27 @@ export async function POST(request: Request) { ); } + const multipartOverheadBytes = 1024 * 1024; + const maximumBodyBytes = env.MAX_UPLOAD_MB * 1024 * 1024 + multipartOverheadBytes; + const contentLength = parseUploadContentLength(request.headers.get("content-length")); + if (contentLength !== null && contentLength > maximumBodyBytes) { + throw new PublicApiError("Upload body exceeds the configured size limit.", 413, { + code: "upload_body_too_large", + }); + } + const admission = acquireUploadAdmission({ + bytes: contentLength ?? maximumBodyBytes, + maxConcurrent: env.MAX_CONCURRENT_UPLOADS, + maxBytes: env.MAX_IN_FLIGHT_UPLOAD_MB * 1024 * 1024, + }); + if (!admission.ok) { + throw new PublicApiError("Upload capacity is temporarily exhausted. Retry shortly.", 503, { + code: admission.reason === "bytes" ? "upload_byte_budget_exhausted" : "upload_capacity_exhausted", + }); + } + releaseAdmission = admission.release; + assertUploadNotAborted(request); + const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", @@ -132,6 +157,7 @@ export async function POST(request: Request) { const documentId = randomUUID(); const safeName = file.name.replace(/[^\w.\-() ]+/g, "_"); const storagePath = `${uploadOwnerId}/documents/${documentId}/${safeName}`; + assertUploadNotAborted(request); const buffer = Buffer.from(await file.arrayBuffer()); // The declared MIME type is client-supplied; verify the real byte signature // before persisting a clinical document. @@ -158,6 +184,7 @@ export async function POST(request: Request) { const health = await probeSupabaseHealth(adminSupabase); if (!health.ok) return NextResponse.json({ error: `Upload is paused. ${health.message}` }, { status: 503 }); + assertUploadNotAborted(request); const upload = await adminSupabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET).upload(storagePath, buffer, { contentType: file.type, upsert: false, @@ -180,6 +207,7 @@ export async function POST(request: Request) { const description = uploadMetadata.description; const uploadedAt = new Date().toISOString(); + assertUploadNotAborted(request); const { data: document, error: documentError } = await supabase .from("documents") .insert({ @@ -236,6 +264,7 @@ export async function POST(request: Request) { insertedDocumentId = documentId; insertedDocumentOwnerId = uploadOwnerId; + assertUploadNotAborted(request); const { data: job, error: jobError } = await supabase .from("ingestion_jobs") .insert({ @@ -324,5 +353,7 @@ export async function POST(request: Request) { } return jsonError(error); + } finally { + releaseAdmission?.(); } } diff --git a/src/app/globals.css b/src/app/globals.css index 9a4cb8040..7854db3c7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -67,6 +67,7 @@ --text-base-minus: 0.9375rem; --text-lg-minus: 1.0625rem; --text-2xl-minus: 1.375rem; + --text-2xl-compact: 1.45rem; --text-3xl-minus: 1.625rem; /* Font families: bind Tailwind's font-sans / font-mono to the loaded Geist diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 000000000..f7d2f64e2 --- /dev/null +++ b/src/app/privacy/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { cn, panelSubtle, textMuted } from "@/components/ui-primitives"; + +export const metadata: Metadata = { + title: "Privacy and data processing | Clinical KB", + description: "Draft product information about Clinical KB data processing.", +}; + +const sections = [ + { + title: "Data categories", + body: "The product handles clinical questions, retrieved source excerpts, uploaded documents and their extracted content, document metadata, account identifiers, and operational or retrieval telemetry. Query logging uses hashed or redacted values by default, but free text and uploaded material can still contain sensitive information if a user enters it.", + }, + { + title: "External provider processing", + body: "The configured architecture uses Supabase for authentication, database, and private file storage. When model-backed answering is enabled, the question and selected source excerpts are sent to the configured OpenAI API for answer generation. Provider mode can also fall back to a local source-only response.", + }, + { + title: "Storage", + body: "Documents, extracted evidence, metadata, and owner-scoped operational records are stored in the configured Supabase project. Document and image buckets are private, and the product issues time-limited signed links after an ownership check. Actual deployment region and contractual controls must be confirmed by the operator.", + }, + { + title: "Retention", + body: "Repository migrations configure 30-day retention for RAG query records and 90-day retention for retrieval logs and query-miss records when the database scheduler is available. The generated answer prose is not persisted by default; answer-text storage requires an explicit setting that production-readiness checks reject for production-like use. Audit and document records follow separate operational and governance requirements.", + }, + { + title: "Possible overseas processing", + body: "Model-backed requests use the configured OpenAI API endpoint and may be processed outside Australia. Supabase processing location depends on the configured project region. The operator must verify provider regions, contracts, retention terms, and any cross-border disclosure obligations before clinical use.", + }, + { + title: "Your responsibilities", + body: "Do not enter patient-identifiable information. Upload only material you are authorised to use, keep access credentials private, and review the original linked sources before relying on clinical output. Report suspected privacy or access issues through your organisation's approved process.", + }, +] as const; + +export default function PrivacyPage() { + return ( +
+
+ + Back to Clinical KB + +

+ Draft for privacy and clinical-governance approval +

+

+ Privacy and data processing +

+

+ This is product information based on the repository's configured behaviour. It is not legal advice, a + final privacy policy, or an assertion of governance approval. +

+
+ +
+ {sections.map((section) => ( +
+

+ {section.title} +

+

{section.body}

+
+ ))} +
+
+ ); +} diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx index b9e1a44fe..377e39ebf 100644 --- a/src/components/AccessibleTable.tsx +++ b/src/components/AccessibleTable.tsx @@ -293,6 +293,7 @@ export function AccessibleTable({ markdown, rows, columns, + normalizedTable, compact = false, expandOnMobile = false, previewRows, @@ -309,6 +310,7 @@ export function AccessibleTable({ markdown?: string | null; rows?: string[][] | null; columns?: string[] | null; + normalizedTable?: NormalizedAccessibleTable | null; compact?: boolean; expandOnMobile?: boolean; previewRows?: number; @@ -336,6 +338,7 @@ export function AccessibleTable({ return hasExplicitRows ? rows : parseMarkdownTable(markdown); }, [hasExplicitRows, rows, markdown]); const normalized = useMemo(() => { + if (normalizedTable) return normalizedTable; if (!parsed?.length) return null; // Audit M8/H4 parity (diff review): markdown-parsed rows include their // own header line as row 0 — passing explicit columns alongside them made @@ -347,7 +350,7 @@ export function AccessibleTable({ const table = normalizeAccessibleTable(parsed, hasExplicitRows ? columns : null); if (!table) return null; return clinicalOnly ? clinicalOnlyTable(table) : table; - }, [clinicalOnly, columns, hasExplicitRows, parsed]); + }, [clinicalOnly, columns, hasExplicitRows, normalizedTable, parsed]); const dialogOpen = open; diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 4bbaae83d..631ed6ed1 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -20,6 +20,7 @@ import { import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { StatusBadge } from "@/components/clinical-dashboard/badges"; +import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import type { ClinicalDocument, IngestionJob, ImportBatch } from "@/lib/types"; // Setup and quality types @@ -288,6 +289,7 @@ export function UploadPanel({ return (
+