diff --git a/.env.example b/.env.example
index 8ad4c4c242..92ce6b6a7e 100644
--- a/.env.example
+++ b/.env.example
@@ -56,6 +56,13 @@ RAG_SEARCH_CACHE_TTL_MS=60000
RAG_SEARCH_CACHE_SIZE=200
RAG_AWAIT_QUERY_LOGS=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. Strongly
+# recommended wherever real clinical queries are logged. Changing or setting
+# the key changes future hashes, so historical dedup/joins reset from then on.
+#RAG_QUERY_HASH_SECRET=
+
# Private buckets created by supabase/schema.sql.
SUPABASE_DOCUMENT_BUCKET=clinical-documents
SUPABASE_IMAGE_BUCKET=clinical-images
diff --git a/docs/audit/repo-audit-2026-07-01.md b/docs/audit/repo-audit-2026-07-01.md
new file mode 100644
index 0000000000..1b5175fe1e
--- /dev/null
+++ b/docs/audit/repo-audit-2026-07-01.md
@@ -0,0 +1,260 @@
+# Repository Audit — Clinical KB Database
+
+**Date:** 2026-07-01
+**Branch:** `claude/cool-wiles-12aade` (worktree)
+**Method:** Multi-agent audit — 19 review lanes (dimension × subsystem) using the `code-review` method, each finding adversarially verified by an independent skeptic, deduped, then a completeness-critic gap-fill round. 80 agents, ~5M tokens.
+**Scope:** Risk-first, full coverage. Deep line-level review of RAG, ingestion, search/retrieval, privacy/auth, source governance, Supabase/DB, worker, and API surface; lighter sweep of UI, scripts, tests/config/deps.
+
+## Result summary
+
+| | Count |
+| -------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
+| **Total findings (post-verification)** | **41** |
+| CONFIRMED | 37 |
+| PLAUSIBLE | 4 |
+| By severity | High **4**, Medium **17**, Low **20** |
+| By category | correctness 17 · data-integrity 8 · privacy/clinical 6 · quality/simplification 6 · performance 2 · security 2 |
+| Flagged safe-cleanup by finders | 17 (only 2 are _truly_ no-behavior-change — see Auto-fixes) |
+
+Finders raised more; the numbers above are what **survived adversarial verification**. Verifiers also corrected several severities/categories (e.g. the perceptual-hash and edge-function-secret findings were downgraded from High; the sanitizer finding was re-categorised to data-integrity).
+
+The dominant theme is **clinical-safety / data-integrity in the answer path**: several places where a correct clinical number, threshold, or freshness signal is silently dropped, mis-attributed, or has its "unreliable — verify against source" caveat stripped before it reaches the clinician. These outrank the two (defence-in-depth) security findings.
+
+---
+
+## High severity (4) — clinical answer correctness
+
+### H1 · Numeric faithfulness gate blanks correct answers whose numbers live in synopsis/image text
+
+`src/lib/answer-verification.ts:90` · correctness · CONFIRMED · safe_cleanup=false
+
+- **Defect:** `sourceTextForResult()` builds the numeric-verification corpus from `content / adjacent_context / section_heading / table_facts / memory_cards / index_unit` but omits `result.retrieval_synopsis` and image text (`tableTextSnippet`, `accessibleTableMarkdown`) — even though `buildRagSourceBlock` (`rag.ts:5610-5647`) _shows both_ to the model.
+- **Failure:** For a `medication_dose_risk`/`table_threshold` query whose evidence is a visual/table chunk, the real dose/threshold (e.g. `12.5 mg`, `ANC 2.0 x10^9/L`) exists only in `retrieval_synopsis`/image text. The model copies it faithfully, but `verifyAnswerNumbers` marks it unverified; `applyNumericVerification` (`rag.ts:5798-5808`) then discards the whole correct answer and returns a generic "review the source passages" non-answer.
+- **Fix:** Include `retrieval_synopsis` + image text in `sourceTextForResult`, mirroring `sourceTextForQuoteVerification` (`rag.ts:880-897`), so the verifier scans the same corpus the model was given.
+
+### H2 · Sanitizer drops clinical threshold sentences misclassified as source-title fragments
+
+`src/lib/source-text-sanitizer.ts:230` (pattern at `:29-30`) · data-integrity · CONFIRMED · safe_cleanup=false
+
+- **Defect:** `clinicalProseUsefulness()` discards any sentence starting near a title keyword (`Guideline/Procedure/Protocol/Policy/Appendix/Scale`) that lacks a concrete-action verb — the `sourceTitleFragmentPattern` regex greedily consumes up to 180 trailing chars (`…Scale\b[^.;]{0,180}`).
+- **Failure (reproduced by the agent running the real function):** input _"Assess the patient on admission. The Glasgow Coma Scale ranges from 3 to 15 with 8 or below indicating severe head injury. Document the score."_ → returns _"Assess the patient on admission. Document the score."_ The GCS threshold sentence is silently removed and the truncated text still wins at `rag-answer-text.ts:150` (`usefulness.text || finalText`).
+- **Fix:** Don't drop a fragment on title/noise grounds when it contains a clinical numeric/threshold token; anchor `sourceTitleFragmentPattern` so it can't swallow trailing clinical prose.
+
+### H3 · Freshness/validation penalties are silently discarded from the final score
+
+`src/lib/retrieval-selection.ts:473` · privacy/clinical · CONFIRMED · safe_cleanup=false
+
+- **Defect:** `annotateResultWithSelection` writes back `Math.max(originalScore, candidate.score)` as `hybrid_score`, so any net-negative `resultBoost` (outdated −0.24, review_due −0.12, unverified −0.08, poor-extraction −0.12) never lowers the score consumers see — the floor is meant to let intent "rescue" _raise_ a score, but it also blocks all penalties.
+- **Failure:** An `outdated` chunk with base 0.70 → ~0.46 after penalties gets floored back to 0.70. `evaluateEvidenceCoverageGate` (`rag.ts:3233`, gate ~0.6) then treats the stale guideline as strong and presents it with high confidence instead of demoting it.
+- **Fix:** Don't floor at the original when `resultBoost` is negative — persist the clamped boosted score so freshness/validation penalties reach coverage gating.
+
+### H4 · Ward-note / clipboard table export bypasses the low-confidence safeguard
+
+`src/lib/ward-output.ts:601` · privacy/clinical · CONFIRMED · safe_cleanup=false
+
+- **Defect:** `clinicalTableToTextRows` renders threshold tables straight from raw card rows (`parseMarkdownTable`) and never calls `normalizeAccessibleTable`, so the on-screen "Table structure could not be confidently reconstructed — verify values against the source document" caveat (`AccessibleTable` / `accessible-table-normalization.ts`) is dropped from the copied ward note.
+- **Failure:** A clozapine monitoring table flagged `lowConfidence` on screen is copied via `formatWardNote`/`formatAnswerForClipboard` with no caveat; a clinician pastes a mis-paired dose grid into the record and trusts it.
+- **Fix:** Route ward-output tables through `normalizeAccessibleTable` (conservativeClinical) and emit the same caveat line when `lowConfidence`.
+
+---
+
+## Medium severity (17)
+
+**Answer/retrieval correctness**
+
+- **M1** `src/lib/rag.ts:647` · `deriveConfidence` takes `max(similarity)` over **all** retrieved chunks, not the cited ones — an uncited high-similarity chunk inflates a weakly-cited answer to high confidence. CONFIRMED.
+- **M2** `src/lib/clinical-search.ts:760` · `classifyQueryIntent` uses `containsAny` (substring, not word-boundary) on short tokens (`im`, `po`, `table`, `flow`) → `"time limit"` trips dosing, `"notable"` trips image focus, perturbing ranking (+0.09 dosing / −0.04 image). CONFIRMED.
+- **M3** `src/lib/clinical-search.ts:768` · escalation substring match (`review`, `risk`, `rapid`) cancels a legitimate dosing signal — `"clozapine dose review schedule"` loses its dosing boost. CONFIRMED.
+- **M4** `src/lib/document-index-units.ts:290` · `fallbackVisualUnitType` tests the flowchart regex (matches bare `yes`/`no`) before the table branch → sparse tables containing "No"/"Yes" are typed `flowchart_step`, degrading typed retrieval. CONFIRMED.
+- **M5** `src/lib/document-organization.ts:1337` · reference-collection fallback guarded on `candidates.length === 0`, but an unconfirmed 0.58 tag candidate stays in `candidates` while being dropped from `confirmedCandidates` → doc mis-classified `site=null/needs_review`. CONFIRMED.
+- **M6** `src/lib/document-organization.ts:1242` · `evidenceText` omits `title`/`file_name`, so a site named only in the title (e.g. "Sir Charles Gairdner Hospital …") is never detected. CONFIRMED.
+- **M7** `src/lib/document-organization.ts:262` · BMJ reference_collection matches the ubiquitous phrase `best practice` (OR'd with `bmj`) → any doc saying "best practice" is falsely attributed to "BMJ Best Practice" @0.92 — a false provenance claim in a governance context. CONFIRMED.
+- **M8** `src/lib/ward-output.ts:606` · when `rows` is null but `columns`+`markdown` present, the parsed markdown **header row is re-emitted as the first data row** (no `slice(1)`). CONFIRMED.
+
+**Data integrity**
+
+- **M9** `src/app/api/documents/[id]/route.ts:503` · DELETE active-job guard checks only `status='processing'`, missing `pending`; a just-queued reindex racing a delete orphans freshly-uploaded storage objects. Reindex routes correctly use `checkIngestionMutationSafety` (`in ['pending','processing']`). CONFIRMED.
+- **M10** `src/components/ClinicalDashboard.tsx:6533` · `executeSearch`/`applySearchResult` have no request-token/abort guard → out-of-order responses can show query A's answer under query B's question (bootstrap auto-search + user submit). CONFIRMED.
+- **M11** `src/lib/deep-memory.ts:644` · `upsertDocumentDeepMemory` deletes memory cards/sections/index-units **non-atomically** before rebuild; an OpenAI/insert failure after the deletes wipes memory while metadata still advertises the old version → silent retrieval degradation until full reindex. CONFIRMED.
+- **M12** `src/lib/image-filtering.ts:349` · 16-bit (4-hex) `lightweightPerceptualHash` collapses distinct same-dimension clinical images into one visual family → one of two different threshold tables silently dropped from the index. CONFIRMED (downgraded from High).
+- **M13** `supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql:120` · `commit_document_index_generation` deletes NULL-generation (legacy) artifact rows, which retrieval treats as committed/visible; a transient artifact-write failure that still commits `status='indexed'` permanently loses the previously-good legacy artifacts. CONFIRMED.
+
+**Privacy / clinical**
+
+- **M14** `src/lib/chunking.ts:12` · `lineNoisePatterns[0]` matches unanchored `p N`/`page N` anywhere in a line and `removePageNoise` drops the whole line → clinical lines like "refer to p 3 for dosing" are deleted before indexing. CONFIRMED.
+- **M15** `src/lib/query-privacy.ts:8` · redacted mode stores an **unsalted SHA-256** of the normalized query as the placeholder and `query_hash`; short low-entropy clinical queries are dictionary-reversible and cross-row correlatable. Fix: HMAC with a server-side secret. CONFIRMED.
+- **M16** `src/lib/ward-output.ts:611` · header/body cell-count mismatch — a ragged row with more cells than columns emits misaligned markdown, pairing the wrong threshold with the wrong action in the copied note. CONFIRMED.
+
+**Correctness (added 2026-07-02 — omitted from the first draft of this report; the workflow produced 17 Medium findings but only 16 were listed)**
+
+- **M17** `src/lib/chunking.ts:320` · `chunkTextBySentence` makes no forward progress when `CHUNK_OVERLAP >= CHUNK_SIZE` (both pass env validation independently): `readableOverlapStart` returns a start ≤ the previous start and the `while` loop spins forever, hanging the ingestion worker on that document. CONFIRMED (reproduced by trace).
+
+---
+
+## Low severity (20)
+
+**Correctness**
+
+- **L1** `scripts/purge-query-logs.ts:25` · unknown flag silently consumes the next arg (typo'd `--owner-emial` swallows the email, then purges the env-configured owner's logs). CONFIRMED.
+- **L2** `scripts/recover-ingestion-queue.ts:49` · typo'd `--limit` → NaN → silently defaults to 20 (mutates up to 20 jobs instead of the intended cap). CONFIRMED.
+- **L3** `src/lib/evidence-relevance.ts:347` · aggregate `nearby` branch uses `|| results.length > 0` (always true) → the `none` verdict is dead; zero-term-match sets are labelled `nearby`, overstating evidence in governance/telemetry. CONFIRMED.
+- **L4** `src/lib/retrieval-selection.ts:463` · boosted score clamped low (`Math.max(0, …)`) but not to an upper bound of 1 → `hybrid_score` can exceed 1.0 into telemetry/consumers. CONFIRMED.
+- **L5** `src/lib/validation/form-data.ts:9` · no-op ternary `typeof value === "string" ? value : value` — a File `title`/`description` part isn't coerced to null and makes the whole `/api/upload` 400. CONFIRMED.
+- **L6** `src/lib/visual-intelligence.ts:401` · deterministic fallback keeps only a hardcoded drug allowlist (clozapine|lithium|…), silently discarding e.g. quetiapine/sertraline extracted by the clinical vocabulary. CONFIRMED.
+- **L7** `supabase/functions/indexing-v3-agent/index.ts:437` · non-retryable OpenAI 4xx (bad model / revoked key) is retried `OPENAI_MAX_RETRIES` times instead of failing fast. CONFIRMED.
+
+**Data integrity**
+
+- **L8** `src/lib/deep-memory.ts:865` · committed-generation filter fails open (`if (documentsError) return true`) — a transient documents-table error during a staged reindex can expose superseded-generation cards. PLAUSIBLE (narrow race).
+- **L9** `worker/main.ts:1406` · `imageCount` (→ `p_image_count`) counts searchable-only rows, excluding audit-retained images that were still inserted; reconciliation tooling may see a mismatch. PLAUSIBLE (likely intentional).
+
+**Performance**
+
+- **L10** `src/app/api/search/route.ts:666` · `buildEvidenceRelevance` runs ≥2× and `buildVisualEvidence` ≥3× per search over the full result set, and the smart-panel relevance is then overwritten — wasted CPU on the hot path. CONFIRMED.
+- **L11** `worker/main.ts:997` · each image is `readFile`'d up to 3× per ingestion (hash, caption cache-miss, upload) → 3× disk I/O + peak memory. CONFIRMED.
+
+**Privacy / clinical**
+
+- **L12** `src/lib/privacy.ts:6` · `redactLogValue` returns non-string values unchanged, so an error whose `details`/`code`/`hint` is an object is logged verbatim (potential URL/email/PHI leak). CONFIRMED.
+
+**Quality / simplification**
+
+- **L13** `next.config.ts:31` · CSP `connect-src` lists both the explicit Supabase host and `https://*.supabase.co` (redundant). CONFIRMED. → **auto-fixed** (see below).
+- **L14** `src/lib/answer-ranking.ts:~324` (reported as `answer-formatting.ts:327`) · `capBoldSegments` lets newly-bolded tokens that collide with pre-existing bold text bypass the max-segment cap. CONFIRMED.
+- **L15** `src/lib/cross-document-synthesis.ts:195` · dead ternary `queryClass === "comparison" ? 2 : 2`. CONFIRMED. → **auto-fixed**.
+- **L16** `src/lib/document-tags.ts:264` · dead `return false` special-cases (default is already `return false`) — behaviorally dead, misleads about protection a future rule would bypass. CONFIRMED. (left as recommendation — encodes intent)
+- **L17** `src/lib/ingestion.ts:7` · unreachable page-number-duplicate retry clause (`isPartialIndexWriteConflict` short-circuits first). CONFIRMED. (left as recommendation — needs an intent decision)
+- **L18** `supabase/migrations/20260630090000_audit_logs_service_role_policy.sql:10` · re-declares the identical `audit logs service role all` policy already created in `20260629110000_audit_logs.sql` — drift risk on replay. CONFIRMED. (do **not** edit an applied migration; recommendation only)
+
+**Security (defence-in-depth)**
+
+- **L19** `next.config.ts:7` · production CSP allows `script-src 'unsafe-inline'` with no nonce/hash. PLAUSIBLE — factually true, but the app has **zero** `dangerouslySetInnerHTML` sinks (React auto-escapes doc text + LLM answers), so no active XSS path today. Recommend nonce-based CSP as hardening.
+- **L20** `supabase/functions/indexing-v3-agent/index.ts:186` · agent secret compared with non-constant-time `!==` on a `verify_jwt=false` function. PLAUSIBLE — timing weakness is real, remote byte-by-byte recovery over TLS jitter is not realistically demonstrable. Recommend a timing-safe compare.
+
+---
+
+## Coverage
+
+19 lanes reviewed; the completeness critic surfaced 6 gaps, all filled:
+
+| Area | Lanes |
+| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Deep (risk-bearing) | rag-engine, rag-routing, search-retrieval, clinical-search, ingestion-core, index-units, worker, enrichment, visual, privacy-auth, governance-safety, supabase-db, openai, api-surface, deep-memory-polish, security-sweep |
+| Light sweep | ui-components, scripts-cli, tests-config-deps |
+| Gap-fill (critic) | clinical-output-rendering, document-mutation-reindex-correctness, deep-memory-logic, database-migrations-rls-rpc, request-primitives-rate-limit |
+
+Clean lanes (no surviving findings): `supabase-db` client setup (no service-role-key leakage to the browser bundle found). The gap round is where H4, M8, M9, M11, M13, M16, L5, L8, L18 were caught — i.e. clinical-output rendering and mutation-race correctness were under-covered by the first pass.
+
+## Corroborated pre-existing debts
+
+- **eslint↔lockfile drift** (lockfile pins eslint 10.4.1 vs `eslint-config-next` needing eslint 9) — confirmed in `docs/redesign/04-deferred.md`; risks a clean-`npm ci` CI lint break. **Not** auto-fixed (dependency/lockfile change needs your sign-off).
+- Embedding-dimension/schema sync has no runtime guard (relates to M-class ingestion risks).
+- RLS multi-user integration test (RET-H4) still missing; HNSW `ef_search` migration remains a no-op.
+
+---
+
+## Remediation — 2026-07-02 (all findings actioned)
+
+Following user approval, every finding was reviewed and fixed (or explicitly dispositioned) on the
+working tree of `claude/cool-wiles-12aade` (uncommitted, nothing pushed). 42 files changed
+(+844/−156), including 14 new regression tests.
+
+### Fixed (behavioral)
+
+| ID | File | Fix |
+| --------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| H1 | `src/lib/answer-verification.ts` | verification corpus now includes `retrieval_synopsis` + image table text (mirrors `sourceTextForQuoteVerification`); 2 regression tests |
+| H2 | `src/lib/source-text-sanitizer.ts` | new `clinicalThresholdSignalPattern` rescue — threshold-bearing fragments are never dropped by title/noise heuristics; GCS regression test + bare-integer noise still dropped |
+| H3+L4 | `src/lib/retrieval-selection.ts` | **SUPERSEDED by PR #118 on main** (merged 2026-07-02, after this audit's base commit): the golden retrieval eval measured that source-governance metadata weighting in selection buries correct documents on the partially-enriched corpus (doc-recall@5 1.0→0.76), so #118 **removed the penalties entirely** and clamped the candidate score — resolving both H3's premise and L4. This branch's `retrieval-selection.ts` + its contract test are now aligned **verbatim** with origin/main's #118 version (any deviation requires re-running `npm run eval:retrieval:quality`, 23/23 required). |
+| H4+M8+M16 | `src/lib/ward-output.ts` | copied tables routed through `normalizeAccessibleTable` (conservativeClinical) with the on-screen low-confidence caveat; markdown header no longer duplicated as a data row; ragged rows padded/merged; 3 regression tests |
+| M1 | `src/lib/rag.ts` | `deriveConfidence` takes the citation array and scopes strongest-similarity to cited chunks (4 call sites) |
+| M2+M3 | `src/lib/clinical-search.ts` | word-boundary signal matching (short acronyms whole-word); explicit dose terms survive escalation cancellation; 5 regression assertions |
+| M4 | `src/lib/document-index-units.ts` | bare `yes`/`no` only counts as flowchart evidence when the image is not structurally a table |
+| M5+M6+M7 | `src/lib/document-organization.ts` | fallbacks gate on confirmed candidates; title/file_name in the evidence haystack (bracket segments stripped so `(FSH)` tags still can't self-confirm — pinned by an existing test); BMJ requires the `bmj` token; 2 regression tests |
+| M9 | `src/app/api/documents/[id]/route.ts` | DELETE blocks on `pending` OR `processing` jobs (parameterized regression test) |
+| M10 | `src/components/ClinicalDashboard.tsx` | request-id ref guard — only the latest search commits answer/error/loading state |
+| M11+L8 | `src/lib/deep-memory.ts` | all embeddings computed BEFORE deleting old memory (failure window no longer spans OpenAI); fallback committed-generation filter fails closed on lookup error (test updated to pin fail-closed) |
+| M12 | `src/lib/image-filtering.ts` | perceptual hash upgraded ph1 (16-bit) → ph2 (192-bit; threshold + quantized-level bits); version bump prevents legacy aliasing |
+| M13 | `supabase/migrations/20260702000000_…` + `schema.sql` | **new** migration: `commit_document_index_generation` purges legacy NULL-generation rows only when replacement rows exist in the same table. **Not yet applied to the live DB** — apply via the normal migration flow |
+| M14 | `src/lib/chunking.ts` | page-noise pattern anchored to the whole line; inline "p 3" references survive (regression test) |
+| M15 | `src/lib/query-privacy.ts`, `env.ts`, `.env.example` | HMAC-SHA256 keyed hash when `RAG_QUERY_HASH_SECRET` (new, optional, min 16 chars) is set; legacy digest kept when unset for continuity — **set the secret in any environment logging real queries** |
+| M17 | `src/lib/chunking.ts` | strict forward-progress guard in `chunkTextBySentence` (regression test at overlap == chunkSize) |
+| L1/L2 | `scripts/purge-query-logs.ts`, `scripts/recover-ingestion-queue.ts` | unknown flags and malformed numeric flags now throw instead of silently misbehaving |
+| L3 | `src/lib/evidence-relevance.ts` | aggregate `none` verdict reachable — nearby requires matched terms |
+| L6 | `src/lib/visual-intelligence.ts` | fallback medications derived from the vocabulary's medication category, not a hardcoded allowlist |
+| L7+L20 | `supabase/functions/indexing-v3-agent/index.ts` | non-retryable 4xx fails fast; timing-safe (hashed) secret comparison; Deno typecheck passes |
+| L10 | `src/app/api/search/route.ts`, `src/lib/evidence.ts` | relevance/visual evidence computed once per request and shared with the smart panel (both call sites) |
+| L12 | `src/lib/privacy.ts` | non-string error fields serialized and redacted instead of passing through verbatim |
+| L14 | `src/lib/answer-ranking.ts` | bold cap counts per-occurrence free passes; new tokens can't tunnel past the cap |
+| L16/L17 | `src/lib/document-tags.ts`, `src/lib/ingestion.ts` | dead/misleading branches removed with intent documented |
+
+### Dispositioned without behavior change (with rationale)
+
+| ID | Disposition |
+| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| L5 | The audit's recommended fix (coerce File→null) was **wrong**: `tests/api-validation-contract.test.ts` pins that non-string multipart metadata must 400 before any storage/DB write. The no-op ternary was replaced by a pass-through with the contract documented. |
+| L9 | `image_count` = searchable-only is intentional (retrieval filters `searchable=true`); the verifier found no consumer reconciling against it. No change. |
+| L11 | Triple `readFile` is a deliberate peak-memory trade-off (holding hundreds of multi-MB buffers is worse than re-reading); documented at the site. |
+| L18 | Both audit_logs policy migrations are already applied — editing applied migrations creates replay drift. Consolidate only if migrations are ever squashed. |
+| L19 | CSP `unsafe-inline` hardening (nonce-based CSP) deferred: no XSS sink exists today (zero `dangerouslySetInnerHTML`), and a nonce migration needs dedicated UI verification. Tracked as accepted risk. |
+| L13/L15 | Applied earlier as the safe auto-fix pass (CSP host dedup; dead ternary). |
+
+### Adversarial diff-review round (2026-07-02)
+
+Five parallel review agents then audited the fix diff itself. They confirmed the bulk of the
+changes sound and surfaced **12 follow-up findings — all fixed** in the same working tree:
+
+| # | Finding (introduced/incomplete) | Resolution |
+| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| R1 | **HIGH** — H3's un-floored penalties **compounded** across the pipeline's 2–3 selection passes (`baseScore` re-read the penalized `hybrid_score`) | resolved by discarding the H3 change and aligning `retrieval-selection.ts` verbatim with PR #118 (relevance-first, no metadata penalties — see H3 row). Residual note: POSITIVE intent boosts still compound mildly across passes on main's design (pre-existing, eval-validated); a `preSelectionScore` idempotency fix was prototyped and withdrawn — reintroduce only gated on the golden eval. |
+| R2 | M2's whole-word `mg`/`mcg` no longer matched digit-attached doses ("10mg"), and could re-cancel dosing via M3 | `(?:\b | \d)` digit-adjacent allowance (regression test "clozapine 100mg…") |
+| R3 | L3's aggregate `none` could contradict per-source "nearby" chips for purely-semantic matches and trip the danger governance banner | aggregate `nearby` also granted when relevance score ≥ 0.5 |
+| R4 | H1 incomplete — rich-mode prompts show table-fact **metadata** snippets (`accessible_table_markdown`/`table_text_snippet`/`cells`) not covered by the corpus | included in both `tableFactText` (verification) and `tableFactQuoteText` (quotes) |
+| R5 | H2's rescued noisy fragments inflated `provenanceScore` past 0.42, flipping `useful` false and blanking text that previously survived | provenance computed over baseline-kept fragments only; thresholds count toward `clinicalSignalScore` |
+| R6 | `x10` threshold branch matched "Rx100"/"0x10" | lookbehind guard `(? {
- const inline = argv.find((arg) => arg.startsWith(`--${name}=`))?.split("=")[1];
- if (inline) return inline;
- const index = argv.indexOf(`--${name}`);
- return index >= 0 ? argv[index + 1] : undefined;
+ const values = new Map();
+ const booleans = new Set();
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (booleanFlags.has(token)) {
+ booleans.add(token);
+ continue;
+ }
+ const equalsIndex = token.indexOf("=");
+ const name = equalsIndex >= 0 ? token.slice(0, equalsIndex) : token;
+ if (!valueFlags.has(name)) throw new Error(`Unknown argument ${token}`);
+ const value = equalsIndex >= 0 ? token.slice(equalsIndex + 1) : argv[index + 1];
+ if (equalsIndex < 0) index += 1;
+ if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}`);
+ values.set(name, value);
+ }
+ const positiveIntFor = (name: string) => {
+ const raw = values.get(`--${name}`);
+ if (raw === undefined) return undefined;
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== raw.trim()) {
+ throw new Error(`--${name} must be a positive integer (received "${raw}").`);
+ }
+ return parsed;
};
return {
- apply: argv.includes("--apply"),
- yes: argv.includes("--yes"),
- staleAfterMinutes: Number.parseInt(valueFor("stale-after-minutes") ?? "", 10),
- limit: Number.parseInt(valueFor("limit") ?? "", 10),
+ apply: booleans.has("--apply"),
+ yes: booleans.has("--yes"),
+ staleAfterMinutes: positiveIntFor("stale-after-minutes"),
+ limit: positiveIntFor("limit"),
};
}
@@ -64,10 +93,8 @@ async function main() {
]);
requireServerEnv();
const args = parseArgs(process.argv.slice(2));
- const staleAfterMinutes = Number.isFinite(args.staleAfterMinutes)
- ? args.staleAfterMinutes
- : env.WORKER_STALE_AFTER_MINUTES;
- const limit = Number.isFinite(args.limit) ? args.limit : 20;
+ const staleAfterMinutes = args.staleAfterMinutes ?? env.WORKER_STALE_AFTER_MINUTES;
+ const limit = args.limit ?? 20;
const supabase = createAdminClient();
console.log("=== Ingestion Queue Recovery ===");
diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts
index 4baae76398..9f68f1cb0e 100644
--- a/src/app/api/documents/[id]/route.ts
+++ b/src/app/api/documents/[id]/route.ts
@@ -496,16 +496,24 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
if (documentError) throw new Error(documentError.message);
if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });
+ // Audit M9: block deletion on PENDING jobs too, matching the reindex
+ // routes' checkIngestionMutationSafety predicate. A just-queued reindex
+ // job (status "pending") racing this DELETE let the worker upload a new
+ // generation of image objects after the storage paths were enumerated,
+ // orphaning them permanently.
const { data: activeJobs, error: activeJobsError } = await supabase
.from("ingestion_jobs")
.select("id,status")
.eq("document_id", id)
- .eq("status", "processing")
+ .in("status", ["pending", "processing"])
.limit(1);
if (activeJobsError) throw new Error(activeJobsError.message);
if ((activeJobs ?? []).length > 0) {
- throw new PublicApiError("Document is currently indexing. Stop or wait for the worker before deleting.", 409);
+ throw new PublicApiError(
+ "Document has pending or processing indexing work. Stop or wait for the worker before deleting.",
+ 409,
+ );
}
const [images, chunks] = await Promise.all([
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
index b5d5a3c6ba..3b61c6969a 100644
--- a/src/app/api/search/route.ts
+++ b/src/app/api/search/route.ts
@@ -680,8 +680,12 @@ async function buildScopedSearchPayload(
limit: isSourceLibrarySearchMode(body.mode) ? body.documentLimit : undefined,
})
: [];
- const smartPanel = buildSmartPanel(searchFocusQuery, results);
+ // Audit L10: compute relevance/visual evidence ONCE and share with the
+ // smart panel — the panel's own recomputation was discarded by the spread
+ // at payload build time anyway.
const relevance = buildEvidenceRelevance(searchFocusQuery, results);
+ const visualEvidence = buildVisualEvidence(results);
+ const smartPanel = buildSmartPanel(searchFocusQuery, results, { relevance, visualEvidence });
const documentMatches = isSourceLibrarySearchMode(body.mode)
? annotateDocumentMatches(searchFocusQuery, relatedDocuments.map(toDocumentMatch), results)
: [];
@@ -707,7 +711,7 @@ async function buildScopedSearchPayload(
const payload = {
results: compactSearchResults(searchFocusQuery, results),
facets: buildSearchFacets(results),
- visualEvidence: buildVisualEvidence(results),
+ visualEvidence,
relevance,
relatedDocuments: relatedDocuments.map((document) => ({
document_id: document.document_id,
@@ -828,12 +832,16 @@ export async function POST(request: Request) {
results,
)
: [];
+ const cachedVisualEvidence = buildVisualEvidence(results);
return NextResponse.json({
results: compactSearchResults(searchFocusQuery, results),
facets: buildSearchFacets(results),
- visualEvidence: buildVisualEvidence(results),
+ visualEvidence: cachedVisualEvidence,
relevance,
- smartPanel: { ...buildSmartPanel(searchFocusQuery, results), relevance },
+ smartPanel: {
+ ...buildSmartPanel(searchFocusQuery, results, { relevance, visualEvidence: cachedVisualEvidence }),
+ relevance,
+ },
smartApiPlan: buildSmartRagApiPlan({
query: searchFocusQuery,
queryClass,
diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx
index ea7e9a671a..c80334b376 100644
--- a/src/components/AccessibleTable.tsx
+++ b/src/components/AccessibleTable.tsx
@@ -333,13 +333,21 @@ export function AccessibleTable({
const restoreFocusRef = useRef(null);
const [open, setOpen] = useState(false);
const canExpand = useMobileTableExpansion(expandOnMobile);
- const parsed = rows?.length ? rows : parseMarkdownTable(markdown);
+ const hasExplicitRows = Boolean(rows?.length);
+ const parsed = hasExplicitRows ? rows : parseMarkdownTable(markdown);
const normalized = useMemo(() => {
if (!parsed?.length) return null;
- const table = normalizeAccessibleTable(parsed, columns);
+ // Audit M8/H4 parity (diff review): markdown-parsed rows include their
+ // own header line as row 0 — passing explicit columns alongside them made
+ // the markdown header render as the first DATA row on screen, and let the
+ // on-screen and copied-ward-note normalizations disagree (different
+ // headers, potentially different lowConfidence caveats). Columns are the
+ // header only for explicit row arrays, matching clinicalTableToTextRows
+ // in ward-output.ts.
+ const table = normalizeAccessibleTable(parsed, hasExplicitRows ? columns : null);
if (!table) return null;
return clinicalOnly ? clinicalOnlyTable(table) : table;
- }, [clinicalOnly, columns, parsed]);
+ }, [clinicalOnly, columns, hasExplicitRows, parsed]);
const dialogOpen = open && canExpand;
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 74cf333aed..88f79d92d8 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -951,8 +951,8 @@ function NaturalLanguageAnswer({
textMuted,
)}
>
- Source-only answer — assembled from your documents without the AI model, so it may be less
- complete. Verify it against the cited passages below.
+ Source-only answer — assembled from your documents without the AI model, so it may be less complete. Verify
+ it against the cited passages below.
) : null}
{sourceCapsuleButton}
@@ -6445,6 +6445,7 @@ export function ClinicalDashboard({
queryText: string,
filtersOverride: SearchScopeFilters = scopeFilters,
queryModeOverride: ClinicalQueryMode = requestQueryMode,
+ onProgress: (message: string) => void = setAnswerProgress,
) {
let response: Response;
try {
@@ -6475,7 +6476,7 @@ export function ClinicalDashboard({
throw makeSearchError(message, response.status, isRetryableStatus(response.status));
}
- const payload = await readAnswerStream(response, setAnswerProgress);
+ const payload = await readAnswerStream(response, onProgress);
return {
kind: "answer" as const,
query: queryText,
@@ -6483,7 +6484,10 @@ export function ClinicalDashboard({
};
}
- async function runWithRetries(operation: () => Promise) {
+ async function runWithRetries(
+ operation: () => Promise,
+ onProgress: (message: string) => void = setAnswerProgress,
+ ) {
let lastError: unknown;
for (let attempt = 0; attempt <= searchRetryCount; attempt += 1) {
try {
@@ -6493,7 +6497,7 @@ export function ClinicalDashboard({
if (!isRetryableError(error) || attempt >= searchRetryCount) break;
const message = progressForRetry(attempt + 1);
- setAnswerProgress(message);
+ onProgress(message);
await sleep(searchRetryDelaysMs[attempt] ?? searchRetryDelaysMs[searchRetryDelaysMs.length - 1]);
}
}
@@ -6507,6 +6511,13 @@ export function ClinicalDashboard({
return answerPayloadIsUsable(payload.payload);
}
+ // Audit M10: monotonically increasing token identifying the latest search.
+ // Concurrent searches (URL-bootstrap auto-search racing a user submit) can
+ // resolve out of order; only the latest request may commit answer/sources/
+ // error/loading state, or a stale response would display one query's answer
+ // under another query's composer text.
+ const searchRequestSeqRef = useRef(0);
+
function applySearchResult(payload: SearchResultModePayload) {
if (payload.kind === "documents") {
setDocumentMatches(payload.documentMatches);
@@ -6571,6 +6582,14 @@ export function ClinicalDashboard({
setError("Search setup not ready.");
return;
}
+ const requestId = ++searchRequestSeqRef.current;
+ // M10 (diff-review hardening): progress updates emitted by this request's
+ // in-flight machinery (retry messages, keyword fallback, stream progress)
+ // must also be discarded once a newer search takes over, or a slow stale
+ // request repaints the progress banner under the newer query.
+ const onProgress = (message: string | null) => {
+ if (requestId === searchRequestSeqRef.current) setAnswerProgress(message);
+ };
setLoading(true);
setError(null);
setSearchRelevance(null);
@@ -6578,7 +6597,7 @@ export function ClinicalDashboard({
setSearchScope(null);
setSourceGovernanceWarnings([]);
setAnswerViewMode("high_yield");
- setAnswerProgress(modeSearch.progressLabel);
+ onProgress(modeSearch.progressLabel);
rememberRecentQuery(trimmedQuery);
const fallbackQuery = keywordQueryFromNaturalLanguage(trimmedQuery);
@@ -6595,15 +6614,19 @@ export function ClinicalDashboard({
let lastError: SearchError | null = null;
for (const entry of queryPlan) {
- if (entry.isKeyword) setAnswerProgress("Trying keyword-based search...");
+ if (entry.isKeyword) onProgress("Trying keyword-based search...");
try {
const payload =
modeSearch.kind === "documents" || modeSearch.kind === "differentials"
- ? await runWithRetries(() =>
- requestSourceLibrarySearch(entry.query, modeSearch.kind, filtersOverride, targetQueryMode),
+ ? await runWithRetries(
+ () => requestSourceLibrarySearch(entry.query, modeSearch.kind, filtersOverride, targetQueryMode),
+ onProgress,
)
- : await runWithRetries(() => requestAnswer(entry.query, filtersOverride, targetQueryMode));
+ : await runWithRetries(
+ () => requestAnswer(entry.query, filtersOverride, targetQueryMode, onProgress),
+ onProgress,
+ );
if (!resultUsable(payload)) {
lastError = makeSearchError("No usable results were found.", 404, false);
@@ -6629,12 +6652,17 @@ export function ClinicalDashboard({
throw new Error("Search did not return usable results.");
}
- applySearchResult(successfulPayload);
+ // M10: discard a stale response — a newer search owns the UI state.
+ if (requestId === searchRequestSeqRef.current) applySearchResult(successfulPayload);
} catch (requestError) {
- setError(requestError instanceof Error ? requestError.message : "Search failed");
+ if (requestId === searchRequestSeqRef.current) {
+ setError(requestError instanceof Error ? requestError.message : "Search failed");
+ }
} finally {
- setLoading(false);
- setAnswerProgress(null);
+ if (requestId === searchRequestSeqRef.current) {
+ setLoading(false);
+ setAnswerProgress(null);
+ }
}
}
diff --git a/src/lib/answer-ranking.ts b/src/lib/answer-ranking.ts
index 10691e6651..1414ed8715 100644
--- a/src/lib/answer-ranking.ts
+++ b/src/lib/answer-ranking.ts
@@ -278,10 +278,22 @@ function applyBoldPatternOutsideExisting(text: string, pattern: RegExp, maxMatch
}
function capBoldSegments(text: string, originalText: string, maxSegments = 4) {
- const existingBold = new Set(Array.from(originalText.matchAll(/\*\*([^*]+)\*\*/g), (match) => match[1]));
+ // Audit L14: allow only as many "free" (uncounted) passes of a bold string
+ // as occurrences that existed in the original text. The previous Set-based
+ // check let every NEWLY-bolded token identical to a pre-existing bold
+ // phrase bypass the cap, so an answer could render far more than
+ // maxSegments bold segments.
+ const existingBoldCounts = new Map();
+ for (const match of originalText.matchAll(/\*\*([^*]+)\*\*/g)) {
+ existingBoldCounts.set(match[1], (existingBoldCounts.get(match[1]) ?? 0) + 1);
+ }
let kept = 0;
return text.replace(/\*\*([^*]+)\*\*/g, (match, content: string) => {
- if (existingBold.has(content)) return match;
+ const freePasses = existingBoldCounts.get(content) ?? 0;
+ if (freePasses > 0) {
+ existingBoldCounts.set(content, freePasses - 1);
+ return match;
+ }
kept += 1;
return kept <= maxSegments ? match : content;
});
diff --git a/src/lib/answer-verification.ts b/src/lib/answer-verification.ts
index 39c1da6437..6842d73553 100644
--- a/src/lib/answer-verification.ts
+++ b/src/lib/answer-verification.ts
@@ -82,18 +82,52 @@ export function extractNumericTokens(text: string): string[] {
}
function tableFactText(fact: DocumentTableFact): string {
- return [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action]
+ // Diff-review completion of audit H1: rich-mode prompts resolve a "table
+ // snippet" from these fact-metadata fields when no image is attached
+ // (tableSnippetForFact in rag.ts), so numbers the model copies from them
+ // must count as verified too.
+ const metadata = (fact.metadata ?? {}) as Record;
+ const metadataString = (key: string) => (typeof metadata[key] === "string" ? (metadata[key] as string) : "");
+ const metadataCells = Array.isArray(metadata.cells) ? (metadata.cells as unknown[]).map(String).join(" ") : "";
+ return [
+ fact.table_title,
+ fact.row_label,
+ fact.clinical_parameter,
+ fact.threshold_value,
+ fact.action,
+ metadataString("accessible_table_markdown"),
+ metadataString("table_text_snippet"),
+ metadataCells,
+ ]
.filter(Boolean)
.join(" ");
}
+// The verification corpus must cover the SAME text the model was shown in
+// buildRagSourceBlock (rag.ts): retrieval_synopsis and image table text are
+// rendered into the prompt, so a number the model faithfully copied from them
+// must count as verified. Omitting them blanked correct dose/threshold answers
+// whose figures lived only in a synopsis or table-crop text (audit H1); this
+// mirrors sourceTextForQuoteVerification in rag.ts.
function sourceTextForResult(result: SearchResult): string {
const parts: string[] = [result.content ?? ""];
if (result.adjacent_context) parts.push(result.adjacent_context);
if (result.section_heading) parts.push(result.section_heading);
+ if (result.retrieval_synopsis) parts.push(result.retrieval_synopsis);
if (result.table_facts?.length) parts.push(result.table_facts.map(tableFactText).join(" "));
if (result.memory_cards?.length) parts.push(result.memory_cards.map((card) => card.content).join(" "));
if (result.index_unit) parts.push([result.index_unit.title, result.index_unit.content].filter(Boolean).join(" "));
+ if (result.images?.length) {
+ parts.push(
+ result.images
+ .map((image) =>
+ [image.tableLabel, image.tableTitle, image.caption, image.tableTextSnippet, image.accessibleTableMarkdown]
+ .filter(Boolean)
+ .join(" "),
+ )
+ .join(" "),
+ );
+ }
return parts.join(" ");
}
diff --git a/src/lib/chunking.ts b/src/lib/chunking.ts
index afbe156ba4..348fc91114 100644
--- a/src/lib/chunking.ts
+++ b/src/lib/chunking.ts
@@ -9,7 +9,11 @@ const metadataNoisePatterns: RegExp[] = [
/\b(?:version|revision)\s+\d+\s*$/i,
];
const lineNoisePatterns: RegExp[] = [
- /\b(page|p\.?)\s*\d+\s*(?:\/\s*\d+)?\b/i,
+ // Audit M14: anchored to the WHOLE line. The previous unanchored form
+ // matched an inline page reference anywhere in a sentence ("refer to p 3
+ // for dosing", "titrate p 20 micrograms") and removePageNoise then deleted
+ // the entire clinical line. Only a standalone page footer counts as noise.
+ /^\s*(?:page|p\.?)\s*\d+\s*(?:(?:\/|of)\s*\d+)?\s*$/i,
/^\s*[-*_]{3,}\s*$/,
/^\s*[\u25cf\u25e6\u2022]\s*$/,
];
@@ -317,7 +321,13 @@ function chunkTextBySentence(clean: string, chunkSize: number, overlap: number)
const chunk = clean.slice(start, end).trim();
if (chunk) chunks.push(chunk);
if (end >= clean.length) break;
- start = readableOverlapStart(clean, end, overlap);
+ // Audit M17: when overlap >= chunkSize, readableOverlapStart can return a
+ // position at or before the current start and the loop never advances,
+ // hanging the ingestion worker on that document. Force strict forward
+ // progress: if the overlap window does not move us forward, continue from
+ // the end of the current chunk instead.
+ const nextStart = readableOverlapStart(clean, end, overlap);
+ start = nextStart > start ? nextStart : end;
}
return chunks;
diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts
index 081a151981..962e61e69d 100644
--- a/src/lib/clinical-search.ts
+++ b/src/lib/clinical-search.ts
@@ -557,8 +557,19 @@ function clamp(value: number) {
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
}
+// Audit M2: intent signal words match at word boundaries, not as bare
+// substrings — "time limit" used to trigger the "im" dosing signal and
+// "notable" the "table" visual signal, corrupting ranking boosts. Short
+// acronym-like tokens (im, po, mg, prn, mcg) must match as whole words, with
+// a digit-adjacent allowance so unit-attached doses ("100mg") still count;
+// longer entries keep deliberate prefix semantics ("escalat" → escalation,
+// "table" → tables).
function containsAny(value: string, values: readonly string[]) {
- return values.some((item) => value.includes(item));
+ return values.some((item) => {
+ const escaped = item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const pattern = item.length <= 3 ? `(?:\\b|\\d)${escaped}\\b` : `\\b${escaped}`;
+ return new RegExp(pattern, "i").test(value);
+ });
}
function normalizeQueryTokenForLookups(value: string) {
@@ -760,18 +771,24 @@ function sectionDepthSignal(querySignal: IntentSignals, sectionHeading: string |
return 0;
}
+// Explicit dose vocabulary that must survive escalation-word cancellation
+// (audit M3): "clozapine dose review schedule" is a genuine dosing query even
+// though "review" is an escalation signal word.
+const explicitDoseTerms = ["dose", "dosage", "dosing", "titrat", "mg", "mcg"] as const;
+
export function classifyQueryIntent(query: string): IntentSignals {
const lowered = query.toLowerCase();
const match = intentPatterns.find((entry) => entry.pattern.test(query));
const hasDosingSignals = containsAny(lowered, intentSignalWords.dosing);
const hasEscalationSignals = containsAny(lowered, intentSignalWords.escalation);
const hasImageSignals = containsAny(lowered, intentSignalWords.visuals);
+ const hasExplicitDoseTerm = containsAny(lowered, explicitDoseTerms);
return {
intent: match?.intent ?? "general",
imageEvidenceFocus: Boolean(match?.imageEvidenceFocus) || hasImageSignals,
sectionedLookup: Boolean(match?.sectionedLookup),
- hasDosingSignals: hasDosingSignals && !hasEscalationSignals,
+ hasDosingSignals: hasDosingSignals && (hasExplicitDoseTerm || !hasEscalationSignals),
};
}
diff --git a/src/lib/cross-document-synthesis.ts b/src/lib/cross-document-synthesis.ts
index ce2d127219..3ec11bd0a8 100644
--- a/src/lib/cross-document-synthesis.ts
+++ b/src/lib/cross-document-synthesis.ts
@@ -192,7 +192,7 @@ export function buildCrossDocumentSynthesisPlan(
const documents = documentCount(results);
const reason = planReason(query, queryClass, documents);
const enabled = documents > 1 && isCrossDocumentIntent(query, queryClass);
- const maxPerDocument = queryClass === "comparison" ? 2 : 2;
+ const maxPerDocument = 2;
const limit = queryClass === "comparison" || queryClass === "broad_summary" ? 8 : 6;
const balanced = enabled
? balanceCrossDocumentResults(results, {
diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts
index 839c01b8c0..63aad53406 100644
--- a/src/lib/deep-memory.ts
+++ b/src/lib/deep-memory.ts
@@ -642,41 +642,16 @@ export async function upsertDocumentDeepMemory(args: {
const cards = buildDocumentMemoryCards({ ...args, sections, modelProfile });
if (cards.length === 0) throw new Error("Deep memory generated no source-backed memory cards.");
- await args.supabase.from("document_memory_cards").delete().eq("document_id", args.document.id);
- await args.supabase.from("document_sections").delete().eq("document_id", args.document.id);
- await args.supabase
- .from("document_index_units")
- .delete()
- .eq("document_id", args.document.id)
- .then(undefined, () => undefined);
-
- const { data: insertedSections, error: sectionError } = await args.supabase
- .from("document_sections")
- .insert(sections)
- .select("id,section_index");
- if (sectionError) throw new Error(sectionError.message);
-
- const sectionIds = new Map();
- for (const section of insertedSections ?? []) {
- sectionIds.set(section.section_index, section.id);
- }
-
+ // Audit M11: compute everything that can fail on the network (memory-card
+ // and index-unit embeddings) BEFORE deleting the existing memory rows. The
+ // previous order deleted first and rebuilt after, so an OpenAI outage
+ // mid-rebuild left the document with ZERO memory rows (silent retrieval
+ // degradation) while documents.metadata still advertised the prior
+ // rag_memory_version. A failure between the delete and insert below is
+ // still possible, but the exposure window no longer spans OpenAI calls.
const embeddings = await embedTexts(cards.map(embeddingText));
if (embeddings.length !== cards.length) throw new Error("OpenAI returned an unexpected memory-card embedding count.");
- for (let start = 0; start < cards.length; start += 50) {
- const batch = cards.slice(start, start + 50).map((card, index) => {
- const { section_index: sectionIndex, ...row } = card;
- return {
- ...row,
- section_id: sectionIndex === undefined ? null : (sectionIds.get(sectionIndex) ?? null),
- embedding: assertEmbeddingDim(embeddings[start + index], `document_memory_cards.${start + index}`),
- };
- });
- const { error } = await args.supabase.from("document_memory_cards").insert(batch);
- if (error) throw new Error(error.message);
- }
-
const indexUnits = buildDocumentIndexUnitInputs({
document: args.document,
chunks: args.chunks,
@@ -713,8 +688,47 @@ export async function upsertDocumentDeepMemory(args: {
};
}),
});
+ const indexUnitEmbeddings =
+ indexUnits.length > 0 ? await embedTexts(indexUnits.map(embeddingTextForDocumentIndexUnit)) : [];
+ if (indexUnits.length > 0 && indexUnitEmbeddings.length !== indexUnits.length) {
+ throw new Error("OpenAI returned an unexpected index-unit embedding count.");
+ }
+
+ // All embeddings are in hand — replace the previous memory atomically-ish:
+ // delete then insert without any intervening network dependency (M11).
+ await args.supabase.from("document_memory_cards").delete().eq("document_id", args.document.id);
+ await args.supabase.from("document_sections").delete().eq("document_id", args.document.id);
+ await args.supabase
+ .from("document_index_units")
+ .delete()
+ .eq("document_id", args.document.id)
+ .then(undefined, () => undefined);
+
+ const { data: insertedSections, error: sectionError } = await args.supabase
+ .from("document_sections")
+ .insert(sections)
+ .select("id,section_index");
+ if (sectionError) throw new Error(sectionError.message);
+
+ const sectionIds = new Map();
+ for (const section of insertedSections ?? []) {
+ sectionIds.set(section.section_index, section.id);
+ }
+
+ for (let start = 0; start < cards.length; start += 50) {
+ const batch = cards.slice(start, start + 50).map((card, index) => {
+ const { section_index: sectionIndex, ...row } = card;
+ return {
+ ...row,
+ section_id: sectionIndex === undefined ? null : (sectionIds.get(sectionIndex) ?? null),
+ embedding: assertEmbeddingDim(embeddings[start + index], `document_memory_cards.${start + index}`),
+ };
+ });
+ const { error } = await args.supabase.from("document_memory_cards").insert(batch);
+ if (error) throw new Error(error.message);
+ }
+
if (indexUnits.length > 0) {
- const indexUnitEmbeddings = await embedTexts(indexUnits.map(embeddingTextForDocumentIndexUnit));
for (let start = 0; start < indexUnits.length; start += 50) {
const batch = indexUnits.slice(start, start + 50).map((unit, index) => ({
...unit,
@@ -872,9 +886,15 @@ export async function fetchMemoryCardsForQuery(args: {
(documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const),
);
+ // Audit L8: fail CLOSED. If the documents lookup errors we cannot tell
+ // which generation is committed, so returning the cards unfiltered could
+ // inject content from an abandoned/superseded reindex generation into a
+ // clinical answer. Dropping the fallback cards for one query (retrieval
+ // degrades gracefully) is safer than exposing stale memory.
+ if (documentsError) return [];
+
return cards
.filter((card) => {
- if (documentsError) return true;
if (!committedGenerationByDocument.has(card.document_id)) return true;
return isCommittedGenerationMetadata({
rowMetadata: card.metadata,
diff --git a/src/lib/document-index-units.ts b/src/lib/document-index-units.ts
index 944a2891c7..b6c43b9c6e 100644
--- a/src/lib/document-index-units.ts
+++ b/src/lib/document-index-units.ts
@@ -287,7 +287,13 @@ function fallbackVisualUnitType(
profile: StructuredVisualProfile,
text: string,
): DocumentIndexUnitType {
- if (/\b(?:flow\s*chart|flowchart|algorithm|decision|yes|no|next step|pathway)\b/i.test(text)) return "flowchart_step";
+ // Audit M4: bare "yes"/"no" are common TABLE cell values ("No further
+ // action", a Yes/No column), so they only count as flowchart evidence when
+ // the image is not structurally a table (table_crop / extracted rows).
+ // Specific flowchart terms still win outright.
+ const structuralTable = image.sourceKind === "table_crop" || (image.tableRows?.length ?? 0) > 0;
+ if (/\b(?:flow\s*chart|flowchart|algorithm|decision|next step|pathway)\b/i.test(text)) return "flowchart_step";
+ if (!structuralTable && /\b(?:yes|no)\b/i.test(text)) return "flowchart_step";
if (/\b(?:risk matrix|red zone|likelihood|consequence|high risk|visual alert)\b/i.test(text))
return "risk_matrix_cell";
if (
diff --git a/src/lib/document-organization.ts b/src/lib/document-organization.ts
index 810c31df15..40d1a11b28 100644
--- a/src/lib/document-organization.ts
+++ b/src/lib/document-organization.ts
@@ -269,7 +269,10 @@ const siteDefinitions: SiteDefinition[] = [
canonical: "BMJ Best Practice",
rawTags: ["bmj"],
kind: "reference_collection",
- evidence: [/\bbmj\b/i, /\bbest practice\b/i],
+ // Audit M7: require the "bmj" token. The generic phrase "best practice"
+ // appears in ordinary local policies ("in line with best practice…") and
+ // falsely attributed them to an external commercial reference.
+ evidence: [/\bbmj\b/i],
},
];
@@ -1525,10 +1528,27 @@ export function canonicalDocumentDisplayTitle(input: Pick candidate.confidence >= 0.75);
const selected = selectSiteCandidate(confirmedCandidates) ?? null;
- const referenceCollection = !selected && candidates.length === 0 ? referenceCollectionFromEvidence(input) : null;
+ // Audit M5: gate the reference fallbacks on the absence of a CONFIRMED
+ // candidate, not on candidates.length — an unconfirmed bracket-tag guess
+ // (confidence 0.58, no corroborating evidence) used to suppress both
+ // fallbacks and leave an obvious clinical reference as site=null.
+ const referenceCollection =
+ !selected && confirmedCandidates.length === 0 ? referenceCollectionFromEvidence(input) : null;
if (referenceCollection) {
return {
label: referenceCollection.canonical,
@@ -1629,12 +1654,12 @@ function classifySite(input: OrganizationDocumentInput, rawTags: string[]) {
kind: referenceCollection.kind,
confidence: 0.86,
evidence_sources: [`source:${referenceCollection.rawTags[0]}`],
- candidates: [],
+ candidates,
};
}
- if (!selected && candidates.length === 0 && hasGeneralClinicalReferenceEvidence(input)) {
- return generalClinicalReferenceSite;
+ if (!selected && confirmedCandidates.length === 0 && hasGeneralClinicalReferenceEvidence(input)) {
+ return { ...generalClinicalReferenceSite, candidates };
}
return {
diff --git a/src/lib/document-tags.ts b/src/lib/document-tags.ts
index 2d1b60d115..f8ec9b10dd 100644
--- a/src/lib/document-tags.ts
+++ b/src/lib/document-tags.ts
@@ -323,7 +323,14 @@ function usefulTokenCount(value: string) {
return value.split(/\s+/).filter((token) => token.length > 2 || acronymDisplay.has(token)).length;
}
+// Audit L16: the function's default is an unconditional `return false`, so
+// any label surviving the noisy checks is kept. The former document_type/site
+// "protection" branches also returned false and were behaviorally dead code
+// that misled maintainers into thinking those labels had protection a future
+// noisy rule would silently bypass. If a rule must never apply to protected
+// labels, short-circuit it ABOVE the noisy checks instead.
function isNoisyLabel(label: string, labelType: DocumentLabelType) {
+ void labelType;
if (!label || label.length < 2 || label.length > 64) return true;
if (lowValuePattern.test(label)) return true;
if (/\b(?:docx?|xlsx?|pptx?|pdf)\b/.test(label)) return true;
@@ -338,13 +345,6 @@ function isNoisyLabel(label: string, labelType: DocumentLabelType) {
if (lowValueExact.has(label)) return true;
if (usefulTokenCount(label) === 0) return true;
if (label.split(/\s+/).length > 6) return true;
- if (labelType === "document_type" && ["clinical form", "clinical checklist"].includes(label)) return false;
- if (
- labelType === "site" &&
- /\b(?:hospital|health service|fiona stanley|rockingham peel|mental health service)\b/.test(label)
- ) {
- return false;
- }
return false;
}
diff --git a/src/lib/env.ts b/src/lib/env.ts
index de5c04e2e5..baca555765 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -75,6 +75,11 @@ const envSchema = z.object({
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
+ // Audit M15: server-side key for the redacted query hash. When set, stored
+ // query hashes are HMAC-SHA256 (not offline-reversible, not correlatable
+ // outside this deployment). When unset, the legacy unsalted SHA-256 is kept
+ // for continuity with previously stored rows.
+ RAG_QUERY_HASH_SECRET: z.string().min(16).optional(),
SUPABASE_DOCUMENT_BUCKET: z.string().default("clinical-documents"),
SUPABASE_IMAGE_BUCKET: z.string().default("clinical-images"),
MAX_UPLOAD_MB: z.coerce.number().int().positive().default(150),
diff --git a/src/lib/evidence-relevance.ts b/src/lib/evidence-relevance.ts
index 2f87784492..80f19d1a5a 100644
--- a/src/lib/evidence-relevance.ts
+++ b/src/lib/evidence-relevance.ts
@@ -344,7 +344,16 @@ export function buildEvidenceRelevance(query: string, results: SearchResult[]):
? "direct"
: directSourceCount > 0 || partialSourceCount > 0
? "partial"
- : matchedTerms.length > 0 || results.length > 0
+ : // Audit L3: base nearby-vs-none on matched terms. `results.length > 0`
+ // was always true here (empty results early-return above), which made
+ // the aggregate "none" verdict unreachable and overstated evidence
+ // for result sets matching zero query terms. Strong retrieval scores
+ // still count as "nearby" (diff-review hardening): term matching is
+ // purely lexical, so a good semantic match ("myocardial infarction"
+ // for "heart attack") must not degrade to "none" and trip the
+ // danger-level governance banner while every per-source chip reads
+ // "nearby".
+ matchedTerms.length > 0 || score >= 0.5
? "nearby"
: "none";
const relevance: EvidenceRelevance = {
diff --git a/src/lib/evidence.ts b/src/lib/evidence.ts
index fe1711f437..10579f332f 100644
--- a/src/lib/evidence.ts
+++ b/src/lib/evidence.ts
@@ -254,12 +254,22 @@ export function buildDocumentBreakdown(results: SearchResult[], quoteCards: Quot
return Array.from(grouped.values()).sort((a, b) => b.top_similarity - a.top_similarity);
}
-export function buildSmartPanel(query: string, results: SearchResult[]) {
+// Audit L10: callers that already computed relevance/visual evidence for the
+// same query+results (the hot search route does) can pass them in instead of
+// paying for a recomputation whose smart-panel copy was then overwritten.
+export function buildSmartPanel(
+ query: string,
+ results: SearchResult[],
+ precomputed?: {
+ relevance?: ReturnType;
+ visualEvidence?: ReturnType;
+ },
+) {
const quoteCards = extractQuoteCards(results, query);
const documentBreakdown = buildDocumentBreakdown(results, quoteCards);
- const visualEvidence = buildVisualEvidence(results);
+ const visualEvidence = precomputed?.visualEvidence ?? buildVisualEvidence(results);
const bestSource = selectBestSourceRecommendation(results, quoteCards);
- const relevance = buildEvidenceRelevance(query, results);
+ const relevance = precomputed?.relevance ?? buildEvidenceRelevance(query, results);
return {
query,
diff --git a/src/lib/image-filtering.ts b/src/lib/image-filtering.ts
index 5d35f31da9..facade75b7 100644
--- a/src/lib/image-filtering.ts
+++ b/src/lib/image-filtering.ts
@@ -321,6 +321,14 @@ function bytesFromHashInput(input: string | Uint8Array | ArrayBuffer) {
return input;
}
+// Audit M12: the previous 16-bucket / 16-bit digest (65,536 possible values)
+// realistically collided across distinct same-dimension clinical table crops,
+// silently collapsing two DIFFERENT threshold tables into one searchable
+// "visual family" (one of them was dropped from the index). The v2 digest
+// uses 64 buckets with both a mean-threshold bit pattern (64 bits) and a
+// 2-bit quantized level per bucket (128 bits), making accidental collisions
+// across a document corpus negligible. The version prefix is bumped
+// (ph1 -> ph2) so legacy stored hashes can never alias newly computed ones.
export function lightweightPerceptualHash(
imageContent: string | Uint8Array | ArrayBuffer,
width?: number | null,
@@ -328,9 +336,9 @@ export function lightweightPerceptualHash(
) {
const bytes = bytesFromHashInput(imageContent);
const sizeKey = `${width ?? "w"}x${height ?? "h"}`;
- if (bytes.length === 0) return `ph1:${sizeKey}:empty`;
+ if (bytes.length === 0) return `ph2:${sizeKey}:empty`;
- const bucketCount = 16;
+ const bucketCount = 64;
const buckets = new Array(bucketCount).fill(0);
const counts = new Array(bucketCount).fill(0);
const step = Math.max(1, Math.floor(bytes.length / 4096));
@@ -345,7 +353,15 @@ export function lightweightPerceptualHash(
const averages = buckets.map((sum, index) => sum / Math.max(counts[index], 1));
const mean = averages.reduce((sum, value) => sum + value, 0) / bucketCount;
- const bitString = averages.map((value) => (value >= mean ? "1" : "0")).join("");
- const hex = Number.parseInt(bitString, 2).toString(16).padStart(4, "0");
- return `ph1:${sizeKey}:${hex}`;
+ const maxAverage = Math.max(...averages, 1);
+ const thresholdBits = averages.map((value) => (value >= mean ? "1" : "0")).join("");
+ const levelBits = averages
+ .map((value) => {
+ const level = Math.min(3, Math.floor((value / maxAverage) * 4));
+ return level.toString(2).padStart(2, "0");
+ })
+ .join("");
+ const thresholdHex = BigInt(`0b${thresholdBits}`).toString(16).padStart(16, "0");
+ const levelHex = BigInt(`0b${levelBits}`).toString(16).padStart(32, "0");
+ return `ph2:${sizeKey}:${thresholdHex}${levelHex}`;
}
diff --git a/src/lib/ingestion.ts b/src/lib/ingestion.ts
index 9a0f7507d4..0d25e13e85 100644
--- a/src/lib/ingestion.ts
+++ b/src/lib/ingestion.ts
@@ -1,10 +1,13 @@
export function isRetryableIngestionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
+ // Duplicate-key conflicts — including document_pages page-number duplicates —
+ // are partial index-write conflicts that the worker routes to manual queue
+ // recovery, never auto-retry. (Audit L17: removed an unreachable retry
+ // special-case for the page-number constraint that this short-circuit made
+ // dead code; recovery is the deliberate path for those conflicts.)
if (isPartialIndexWriteConflict(error)) return false;
- return (
- /\b(429|rate limit|timeout|temporar|network|fetch failed|ECONNRESET|ETIMEDOUT|5\d\d|502|503|504|bad gateway|gateway timeout|service unavailable)\b/i.test(
- message,
- ) || /document_pages_document_id_page_number_key/i.test(message)
+ return /\b(429|rate limit|timeout|temporar|network|fetch failed|ECONNRESET|ETIMEDOUT|5\d\d|502|503|504|bad gateway|gateway timeout|service unavailable)\b/i.test(
+ message,
);
}
diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts
index 81f7e9612b..b6790ae7ca 100644
--- a/src/lib/privacy.ts
+++ b/src/lib/privacy.ts
@@ -2,8 +2,20 @@ export function safeIngestionJobLog(jobId: string) {
return `Processing ingestion job ${jobId}`;
}
-function redactLogValue(value: unknown) {
- if (typeof value !== "string") return value;
+function redactLogValue(value: unknown): unknown {
+ if (typeof value !== "string") {
+ // Audit L12: non-string code/details/hint fields (objects/arrays from
+ // non-standard error shapes) used to pass through verbatim, skipping the
+ // path/url/secret/email redaction below. Serialize them (guarded) and
+ // redact the serialized form; primitives stay as-is.
+ if (value === null || value === undefined) return value;
+ if (typeof value === "number" || typeof value === "boolean") return value;
+ try {
+ return redactLogValue(JSON.stringify(value) ?? "[unserializable]");
+ } catch {
+ return "[unserializable]";
+ }
+ }
const htmlTitle = value.match(/\s*([^<]+?)\s*<\/title>/i)?.[1]?.trim();
const normalizedValue = htmlTitle ? `HTML response: ${htmlTitle}` : value;
return normalizedValue
diff --git a/src/lib/query-privacy.ts b/src/lib/query-privacy.ts
index e887aeca1b..13cd6bd3c0 100644
--- a/src/lib/query-privacy.ts
+++ b/src/lib/query-privacy.ts
@@ -1,12 +1,25 @@
-import { createHash } from "node:crypto";
+import { createHash, createHmac } from "node:crypto";
import { env } from "@/lib/env";
export function normalizeQueryText(query: string) {
return query.toLowerCase().replace(/\s+/g, " ").trim();
}
+// Audit M15: an unsalted SHA-256 of a short, low-entropy clinical query is
+// dictionary-reversible (hash candidate patient/drug strings offline and
+// match) and lets any reader of the log tables correlate the same query
+// across rows — undermining the redaction it implements. When
+// RAG_QUERY_HASH_SECRET is set, the stored hash is a keyed pseudonym
+// (HMAC-SHA256): not offline-reversible and not correlatable outside this
+// deployment. Without the secret, the legacy unsalted digest is kept so
+// existing stored rows still join/dedup; set the secret in any environment
+// where real clinical queries are logged.
export function hashQueryText(query: string) {
- return createHash("sha256").update(normalizeQueryText(query)).digest("hex");
+ const normalized = normalizeQueryText(query);
+ if (env.RAG_QUERY_HASH_SECRET) {
+ return createHmac("sha256", env.RAG_QUERY_HASH_SECRET).update(normalized).digest("hex");
+ }
+ return createHash("sha256").update(normalized).digest("hex");
}
function queryHashStorageText(query: string) {
diff --git a/src/lib/rag-provider.ts b/src/lib/rag-provider.ts
index f22ab2fc95..78d5409366 100644
--- a/src/lib/rag-provider.ts
+++ b/src/lib/rag-provider.ts
@@ -40,12 +40,7 @@ export function allowsAutoDegrade(): boolean {
}
export type ProviderFailureKind =
- | "missing_key"
- | "auth_failed"
- | "quota_exhausted"
- | "rate_limited"
- | "timeout"
- | "provider_failed";
+ "missing_key" | "auth_failed" | "quota_exhausted" | "rate_limited" | "timeout" | "provider_failed";
/**
* Classify why an OpenAI call failed, for telemetry and user-facing fallback messaging.
diff --git a/src/lib/rag.ts b/src/lib/rag.ts
index 4031165be6..428388f7f4 100644
--- a/src/lib/rag.ts
+++ b/src/lib/rag.ts
@@ -459,11 +459,7 @@ function recordRetrievalLayer(
// where telemetry is in scope, record the failing RPC + code so it shows up in rag_retrieval_logs.
type SupabaseRpcError = { message?: string; code?: string; details?: string; hint?: string } | null;
-function recordHybridRpcError(
- telemetry: SearchTelemetry | undefined,
- rpc: string,
- error: SupabaseRpcError,
-) {
+function recordHybridRpcError(telemetry: SearchTelemetry | undefined, rpc: string, error: SupabaseRpcError) {
if (!error) return;
const code = error.code ?? "unknown";
logger.error("hybrid_rpc_failed", { rpc, code, message: error.message, hint: error.hint });
@@ -685,10 +681,20 @@ function allowedChunkMap(results: SearchResult[]) {
return new Map(results.map((result) => [result.id, result]));
}
-function deriveConfidence(results: SearchResult[], acceptedCitationCount: number): RagAnswer["confidence"] {
- if (acceptedCitationCount === 0 || results.length === 0) return "unsupported";
- const strongest = results.reduce((max, result) => Math.max(max, result.similarity), 0);
- if (strongest >= 0.82 && acceptedCitationCount >= 2) return "high";
+// Audit M1: confidence must reflect the strength of the evidence the answer
+// actually CITES. Taking the max similarity over ALL retrieved results let an
+// uncited high-similarity chunk grant "high" confidence to an answer built on
+// weak citations, so the strongest-score scan is scoped to the cited subset.
+// A citation that maps to no known chunk contributes nothing (fail low).
+function deriveConfidence(
+ results: SearchResult[],
+ acceptedCitations: Array>,
+): RagAnswer["confidence"] {
+ if (acceptedCitations.length === 0 || results.length === 0) return "unsupported";
+ const citedIds = new Set(acceptedCitations.map((citation) => citation.chunk_id));
+ const citedResults = results.filter((result) => citedIds.has(result.id));
+ const strongest = citedResults.reduce((max, result) => Math.max(max, result.similarity), 0);
+ if (strongest >= 0.82 && acceptedCitations.length >= 2) return "high";
if (strongest >= 0.64) return "medium";
return "low";
}
@@ -915,7 +921,22 @@ function normalizeQuoteVerificationText(text: string) {
}
function tableFactQuoteText(fact: NonNullable[number]) {
- return [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action]
+ // Mirrors tableFactText in answer-verification.ts: include the fact-metadata
+ // snippet fields that rich-mode prompts show the model (tableSnippetForFact),
+ // so quotes drawn from them verify as exact.
+ const metadata = safeRecord(fact.metadata);
+ const metadataString = (key: string) => (typeof metadata[key] === "string" ? (metadata[key] as string) : "");
+ const metadataCells = Array.isArray(metadata.cells) ? (metadata.cells as unknown[]).map(String).join(" ") : "";
+ return [
+ fact.table_title,
+ fact.row_label,
+ fact.clinical_parameter,
+ fact.threshold_value,
+ fact.action,
+ metadataString("accessible_table_markdown"),
+ metadataString("table_text_snippet"),
+ metadataCells,
+ ]
.filter(Boolean)
.join(" ");
}
@@ -4666,7 +4687,7 @@ function buildExtractiveAnswer(args: {
return {
answer: naturalAnswer.answer,
grounded: hasExtractedAnswer && citations.length > 0,
- confidence: hasExtractedAnswer ? deriveConfidence(args.results, citations.length) : "unsupported",
+ confidence: hasExtractedAnswer ? deriveConfidence(args.results, citations) : "unsupported",
citations: citations.slice(0, 5),
sources: args.results,
modelUsed: null,
@@ -4978,8 +4999,7 @@ export function generatedAnswerQualityFailureReason(answer: RagAnswer, query: st
// summary answers legitimately paraphrase, so enforcing overlap there would reject good answers.
// A model-answer failure here only escalates fast→strong and is recovered for strongly
// source-backed answers, so the downside of enforcing it is a retry, not a wrongful gap.
- const enforceModelAnswerOverlap =
- isSimpleDirectQuestion(query, queryClass) && !isBareDefinitionQuestion(query);
+ const enforceModelAnswerOverlap = isSimpleDirectQuestion(query, queryClass) && !isBareDefinitionQuestion(query);
if (
(answer.routingMode === "extractive" || answer.confidence === "low" || enforceModelAnswerOverlap) &&
!hasRelevantQueryOverlap(cleanedAnswer, query)
@@ -5944,7 +5964,7 @@ export function parseAnswerJson(raw: string, results: SearchResult[], query?: st
try {
const parsed = answerJsonSchema.parse(JSON.parse(raw));
const { citations, modelCited, proposedCount, invalidCount } = sanitizeCitations(parsed.citations, results);
- const derivedConfidence = modelCited ? deriveConfidence(results, citations.length) : "unsupported";
+ const derivedConfidence = modelCited ? deriveConfidence(results, citations) : "unsupported";
const confidence = modelCited ? clampConfidence(parsed.confidence, derivedConfidence) : "unsupported";
const parsedAnswer = parsed.answer ?? "";
const nonArtifactParsedAnswer = parsedAnswer.trim() && !looksLikeJsonArtifact(parsedAnswer) ? parsedAnswer : "";
@@ -6344,7 +6364,11 @@ async function answerQuestionWithScopeUncoalesced(
const sourceOnlyAnswer = isSourceOnlyMode();
const route =
sourceOnlyAnswer && gatedRoute.route.mode !== "unsupported"
- ? { ...gatedRoute.route, mode: "extractive" as const, reason: `${gatedRoute.route.reason}; ${sourceOnlyReason()}` }
+ ? {
+ ...gatedRoute.route,
+ mode: "extractive" as const,
+ reason: `${gatedRoute.route.reason}; ${sourceOnlyReason()}`,
+ }
: gatedRoute.route;
const retrievalDiagnostics: RetrievalDiagnostics = {
...initialRetrievalDiagnostics,
@@ -6866,7 +6890,7 @@ ${qualityRetryInstruction}`
args.query,
),
grounded: false,
- confidence: hasSources ? deriveConfidence(answerInputResults, fallbackCitations.length) : "unsupported",
+ confidence: hasSources ? deriveConfidence(answerInputResults, fallbackCitations) : "unsupported",
citations: hasSources ? fallbackCitations : [],
sources: answerInputResults,
modelUsed: null,
@@ -7113,8 +7137,7 @@ ${qualityRetryInstruction}`
// answer's citations. buildExtractiveAnswer derives its own source-backed
// citations from the retrieved results, so trigger recovery whenever the
// generated answer is unusable and we have retrieved results to extract from.
- const canRecoverExtractively =
- !usedStrongModel && (answer.citations.length > 0 || answerInputResults.length > 0);
+ const canRecoverExtractively = !usedStrongModel && (answer.citations.length > 0 || answerInputResults.length > 0);
if (canRecoverExtractively && isUnusableGeneratedAnswer(answer)) {
answer = buildExtractiveAnswer({
query: args.query,
@@ -7303,7 +7326,7 @@ ${qualityRetryInstruction}`
...baseFallbackAnswer,
answer: boldHighYieldClinicalText(sourceBackedGenerationTimeoutAnswer(args.query), args.query),
grounded: true,
- confidence: deriveConfidence(answerInputResults, baseFallbackAnswer.citations.length),
+ confidence: deriveConfidence(answerInputResults, baseFallbackAnswer.citations),
routingMode: "extractive",
routingReason: reviewRouteReason,
queryAnalysis,
diff --git a/src/lib/source-text-sanitizer.ts b/src/lib/source-text-sanitizer.ts
index 33627700ef..9a94f717e9 100644
--- a/src/lib/source-text-sanitizer.ts
+++ b/src/lib/source-text-sanitizer.ts
@@ -34,6 +34,16 @@ const provenanceNoiseTermPattern =
/\b(?:guideline|procedure|protocol|policy|appendix|source|evidence|document|file|page|scale|lunsers|liverpool university|rating scale|retrieved|excerpt|passage)\b/gi;
const concreteClinicalActionPattern =
/\b(?:administer|arrange|assess|cease|check|complete|contact|document|escalat|follow\s*up|monitor|notify|record|refer|report|review|stop|withhold|dose|prescrib|titrate)\b/i;
+// Audit H2: threshold-bearing numerics (unit-bearing figures, ranges,
+// comparatives like "8 or below", decimals) mark a fragment as carrying
+// clinical VALUES. The noise heuristics below must never drop such a fragment:
+// sourceTitleFragmentPattern greedily consumes up to 180 chars after a title
+// keyword ("… Scale ranges from 3 to 15 …"), which silently deleted threshold
+// sentences. Bare integers ("Appendix 1") deliberately do NOT match, so real
+// title noise is still dropped. Falsely keeping noise is cosmetic; falsely
+// dropping a threshold is a clinical-safety failure, so this leans generous.
+const clinicalThresholdSignalPattern =
+ /\b\d+(?:[.,]\d+)?\s*(?:mg|mcg|micrograms?|μg|µg|g|kg|ml|mmol|mol|units?|iu|hours?|hrs?|mins?|minutes?|days?|weeks?|months?|years?|mmhg|bpm|°c)\b|\b\d+(?:[.,]\d+)?\s*%|\b\d+(?:[.,]\d+)?\s*(?:[-–—]|to)\s*\d+(?:[.,]\d+)?\b|(? {
- if (!fragment) return false;
- if (tokenCount(fragment) < 3) return false;
- if (/^the\s+(?:retrieved|supplied|provided|indexed)\s+/i.test(fragment)) return false;
+ const keptFragments: string[] = [];
+ const baselineKeptFragments: string[] = [];
+ for (const fragment of sentenceFragments(cleaned)) {
+ if (!fragment) continue;
+ if (tokenCount(fragment) < 3) continue;
+ if (/^the\s+(?:retrieved|supplied|provided|indexed)\s+/i.test(fragment)) continue;
const hasClinicalSignal = clinicalSignalPattern.test(fragment);
const hasConcreteClinicalAction = concreteClinicalActionPattern.test(fragment);
+ // H2: a fragment carrying threshold-bearing numerics is never dropped by
+ // the title/noise heuristics — deleting clinical values is worse than
+ // keeping a little provenance noise.
+ const hasClinicalThresholdSignal = clinicalThresholdSignalPattern.test(fragment);
const noiseRatio = provenanceNoiseRatio(fragment);
const mostlySourceTitle = isMostlySourceTitleFragment(fragment);
- if (mostlySourceTitle && !hasConcreteClinicalAction) return false;
- if (noiseRatio >= 0.28 && !hasConcreteClinicalAction) return false;
- if (isLowYieldClinicalText(fragment) && !hasConcreteClinicalAction) return false;
- return hasClinicalSignal || hasConcreteClinicalAction || noiseRatio < 0.16;
- });
- const textWithoutNoise = readableWhitespace(fragments.join(" "));
+ const droppedByBaseline =
+ (mostlySourceTitle && !hasConcreteClinicalAction) ||
+ (noiseRatio >= 0.28 && !hasConcreteClinicalAction) ||
+ (isLowYieldClinicalText(fragment) && !hasConcreteClinicalAction);
+ const keptByBaseline = !droppedByBaseline && (hasClinicalSignal || hasConcreteClinicalAction || noiseRatio < 0.16);
+ if (!keptByBaseline && !hasClinicalThresholdSignal) continue;
+ keptFragments.push(fragment);
+ if (keptByBaseline) baselineKeptFragments.push(fragment);
+ }
+ const textWithoutNoise = readableWhitespace(keptFragments.join(" "));
const clinicalSignalScore =
(textWithoutNoise.match(clinicalSignalPattern) ? 1 : 0) +
- (textWithoutNoise.match(concreteClinicalActionPattern) ? 1 : 0);
- const provenanceScore = provenanceNoiseRatio(textWithoutNoise || cleaned);
+ (textWithoutNoise.match(concreteClinicalActionPattern) ? 1 : 0) +
+ // H2: threshold values ARE clinical signal — text kept purely for its
+ // thresholds must still be classifiable as useful.
+ (textWithoutNoise.match(clinicalThresholdSignalPattern) ? 1 : 0);
+ // Diff-review hardening of H2: the provenance score is computed over the
+ // fragments the BASELINE criteria kept. A noise-dense fragment rescued only
+ // for its threshold values must not inflate the score past the 0.42
+ // usefulness gate — that flipped `useful` to false and made downstream
+ // callers (rag-answer-text, ward-output) discard text that previously
+ // survived, including its clean actionable sentences.
+ const baselineText = readableWhitespace(baselineKeptFragments.join(" "));
+ const provenanceScore = provenanceNoiseRatio(baselineText || textWithoutNoise || cleaned);
return {
text: textWithoutNoise,
useful: Boolean(textWithoutNoise && clinicalSignalScore > 0 && provenanceScore < 0.42),
diff --git a/src/lib/validation/form-data.ts b/src/lib/validation/form-data.ts
index 8116a81295..fd787037b1 100644
--- a/src/lib/validation/form-data.ts
+++ b/src/lib/validation/form-data.ts
@@ -6,7 +6,13 @@ export function optionalFormText(maxLength: number) {
.preprocess(
(value) => {
if (value === undefined || value === null) return null;
- return typeof value === "string" ? value : value;
+ // Non-string form parts (e.g. a File posted under a text field name)
+ // pass through UNCHANGED so the union below rejects them: the API
+ // contract (tests/api-validation-contract.test.ts) requires invalid
+ // multipart metadata to fail with 400 BEFORE any storage upload or
+ // database write, never to be silently discarded. (Audit L5: this
+ // replaces a no-op ternary that obscured the deliberate rejection.)
+ return value;
},
z.union([z.string().trim().max(maxLength), z.null()]),
)
diff --git a/src/lib/visual-intelligence.ts b/src/lib/visual-intelligence.ts
index 47cc3feed2..7b83b6f6f0 100644
--- a/src/lib/visual-intelligence.ts
+++ b/src/lib/visual-intelligence.ts
@@ -1,4 +1,4 @@
-import { clinicalVocabularyTerms } from "@/lib/clinical-vocabulary";
+import { clinicalVocabularyMatches, clinicalVocabularyTerms } from "@/lib/clinical-vocabulary";
import type { ImageEvidenceCategory } from "@/lib/types";
export const visualIntelligenceVersion = "visual-intelligence-v1" as const;
@@ -398,9 +398,13 @@ export function deterministicStructuredVisualProfile(args: {
{
clinical_purpose: compact([args.tableTitle, args.tableLabel, args.caption].filter(Boolean).join(" | "), 280),
key_terms: clinicalVocabularyTerms(text, 12),
- medications: clinicalVocabularyTerms(text, 24).filter((term) =>
- /clozapine|lithium|olanzapine|lorazepam|haloperidol|diazepam|antipsychotic/i.test(term),
- ),
+ // Audit L6: derive medications from the clinical vocabulary's medication
+ // category instead of a hardcoded drug allowlist, which silently dropped
+ // vocabulary drugs (quetiapine, risperidone, promethazine, …) from the
+ // fallback profile.
+ medications: clinicalVocabularyMatches(text, 24)
+ .filter((entry) => entry.type === "medication")
+ .map((entry) => entry.canonical),
thresholds,
actions: text.match(/\b(?:withhold|cease|stop|repeat|monitor|review|escalate|continue)[^.|\n]{0,120}/gi) ?? [],
monitoring_items: text.match(/\b(?:FBC|ANC|WBC|level|observations?|monitoring|blood test)[^.|\n]{0,80}/gi) ?? [],
diff --git a/src/lib/ward-output.ts b/src/lib/ward-output.ts
index 5e26927928..050ee7b786 100644
--- a/src/lib/ward-output.ts
+++ b/src/lib/ward-output.ts
@@ -1,4 +1,5 @@
import { formatCitationLabel } from "@/lib/citations";
+import { normalizeAccessibleTable } from "@/lib/accessible-table-normalization";
import { parseAnswerDisplayContent, type AnswerDisplayGroup } from "@/lib/answer-formatting";
import {
clipboardProvenanceLine,
@@ -600,16 +601,34 @@ export function buildHighYieldClinicalOutputSections(answer: RagAnswer | null |
.filter((section) => section.items.length > 0 || Boolean(section.tables?.length));
}
+// Audit H4+M8+M16: the copied ward-note/clipboard table must go through the
+// SAME conservative normalization as the on-screen AccessibleTable, so that
+// (a) the "verify values against the source document" caveat for tables whose
+// structure could not be confidently reconstructed is not silently dropped
+// from the pasted artifact (H4), (b) a markdown-parsed header row is never
+// re-emitted as the first data row when explicit columns exist (M8), and
+// (c) ragged rows are padded so values cannot shift into the wrong column
+// (M16).
function clinicalTableToTextRows(table: ClinicalThresholdTable) {
- const rows = table.rows?.length ? table.rows : parseMarkdownTable(table.markdown);
- if (!rows?.length) return [];
- const hasColumns = Boolean(table.columns?.length);
- const header = hasColumns ? (table.columns ?? []) : rows[0];
- const body = hasColumns ? rows : rows.slice(1);
- const visibleBody = body.slice(0, 6);
+ const explicitRows = table.rows?.length ? table.rows : null;
+ const parsedRows = explicitRows ? null : parseMarkdownTable(table.markdown);
+ const rawRows = explicitRows ?? parsedRows;
+ if (!rawRows?.length) return [];
+ // Markdown-parsed rows include the header line as row 0; let the normalizer
+ // treat it as the header instead of trusting table.columns (M8). Explicit
+ // rows carry no header row, so table.columns is the header there.
+ const normalized = normalizeAccessibleTable(rawRows, explicitRows ? table.columns : null, {
+ conservativeClinical: true,
+ });
+ if (!normalized) return [];
+ const header = normalized.header;
+ const visibleBody = normalized.body.slice(0, 6);
return [
table.caption,
+ normalized.lowConfidence
+ ? "Table structure could not be confidently reconstructed — verify values against the source document."
+ : "",
header.length ? `| ${header.join(" | ")} |` : "",
header.length ? `| ${header.map(() => "---").join(" | ")} |` : "",
...visibleBody.map((row) => `| ${row.join(" | ")} |`),
@@ -779,12 +798,15 @@ function highYieldSectionsForOutput(answer: RagAnswer, bottomLine: string) {
}
function sectionOutputLines(section: ClinicalOutputSection) {
- return [
- section.title,
- ...section.items.map((item) => `- ${item}`),
- ...(section.tables ?? []).flatMap(clinicalTableToTextRows),
- "",
- ];
+ // Diff-review hardening of audit H4: sections are gated on the RAW table
+ // shape (tableHasUsableShape), but emission now runs through
+ // normalizeAccessibleTable, which can reject a table the shape gate passed
+ // (e.g. header-continuation absorbs the only body row, or all cells are
+ // dash placeholders). A section whose items are empty and whose tables all
+ // normalize to nothing must not emit a dangling heading.
+ const tableLines = (section.tables ?? []).flatMap(clinicalTableToTextRows);
+ if (section.items.length === 0 && tableLines.length === 0) return [];
+ return [section.title, ...section.items.map((item) => `- ${item}`), ...tableLines, ""];
}
function citationOutputLines(answer: RagAnswer) {
diff --git a/supabase/functions/indexing-v3-agent/index.ts b/supabase/functions/indexing-v3-agent/index.ts
index 2ef58915ce..254ea19a32 100644
--- a/supabase/functions/indexing-v3-agent/index.ts
+++ b/supabase/functions/indexing-v3-agent/index.ts
@@ -171,7 +171,27 @@ const TYPE_BUDGET: Record = {
unclear: 4,
};
-function authorizeRequest(req: Request): Response | null {
+// Audit L20: constant-time secret comparison. This function runs with
+// verify_jwt=false, so the shared secret is the ONLY auth gate; a plain !==
+// short-circuits on the first mismatching character and leaks match length
+// via response timing. Hashing both sides to fixed-length digests and
+// XOR-comparing removes the content-dependent timing signal.
+async function timingSafeSecretEqual(candidate: string, expected: string): Promise {
+ const encoder = new TextEncoder();
+ const [candidateDigest, expectedDigest] = await Promise.all([
+ crypto.subtle.digest("SHA-256", encoder.encode(candidate)),
+ crypto.subtle.digest("SHA-256", encoder.encode(expected)),
+ ]);
+ const candidateBytes = new Uint8Array(candidateDigest);
+ const expectedBytes = new Uint8Array(expectedDigest);
+ let difference = 0;
+ for (let index = 0; index < candidateBytes.length; index += 1) {
+ difference |= candidateBytes[index] ^ expectedBytes[index];
+ }
+ return difference === 0;
+}
+
+async function authorizeRequest(req: Request): Promise {
if (!AGENT_SECRET) {
return Response.json(
{ ok: false, error: "INDEXING_V3_AGENT_SECRET is required when JWT verification is disabled" },
@@ -183,7 +203,7 @@ function authorizeRequest(req: Request): Response | null {
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
const headerSecret = req.headers.get("x-indexing-agent-secret") ?? req.headers.get("x-cron-secret") ?? bearer ?? "";
- if (headerSecret !== AGENT_SECRET) {
+ if (!(await timingSafeSecretEqual(headerSecret, AGENT_SECRET))) {
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
@@ -416,7 +436,13 @@ async function fetchEmbeddingBatch(texts: string[]): Promise {
if (!response.ok) {
const body = await response.text();
const error = new Error(`OpenAI embedding request failed (${response.status}): ${body.slice(0, 500)}`);
- if (response.status !== 429 && response.status < 500) throw error;
+ if (response.status !== 429 && response.status < 500) {
+ // Audit L7: a 4xx client error (bad model name, revoked key) can
+ // never succeed on retry — tag it so the catch below fails fast
+ // instead of re-sending the identical doomed request.
+ (error as Error & { nonRetryable?: boolean }).nonRetryable = true;
+ throw error;
+ }
lastError = error;
} else {
const payload = (await response.json()) as { data?: Array<{ embedding?: unknown; index?: number }> };
@@ -434,7 +460,8 @@ async function fetchEmbeddingBatch(texts: string[]): Promise {
}
} catch (e) {
lastError = e;
- if (e instanceof Error && e.name !== "AbortError" && attempt >= OPENAI_MAX_RETRIES) throw e;
+ const nonRetryable = e instanceof Error && (e as Error & { nonRetryable?: boolean }).nonRetryable === true;
+ if (e instanceof Error && e.name !== "AbortError" && (nonRetryable || attempt >= OPENAI_MAX_RETRIES)) throw e;
} finally {
clearTimeout(timeout);
}
@@ -2076,7 +2103,7 @@ Deno.serve({ port: Number(Deno.env.get("PORT") ?? "8000") }, async (req: Request
if (req.method !== "POST" && req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
- const unauthorized = authorizeRequest(req);
+ const unauthorized = await authorizeRequest(req);
if (unauthorized) return unauthorized;
const url = new URL(req.url);
diff --git a/supabase/migrations/20260702000000_commit_generation_preserve_legacy_artifacts.sql b/supabase/migrations/20260702000000_commit_generation_preserve_legacy_artifacts.sql
new file mode 100644
index 0000000000..d8e00b4411
--- /dev/null
+++ b/supabase/migrations/20260702000000_commit_generation_preserve_legacy_artifacts.sql
@@ -0,0 +1,229 @@
+-- Audit M13 (repo audit 2026-07-01): commit_document_index_generation used to
+-- delete legacy NULL-generation rows unconditionally. Rows created before
+-- generation tracking existed carry no index_generation_id, and retrieval
+-- treats NULL as committed/visible (is_committed_artifact_generation), so a
+-- reindex pass that transiently produced no replacement rows for a category
+-- (e.g. an image-extraction failure that still committed status='indexed')
+-- permanently destroyed the previously-good legacy artifacts with no rollback.
+--
+-- This migration recreates the function so legacy NULL-generation rows are
+-- purged ONLY when the new generation actually produced replacement rows in
+-- the same table. Rows tagged with a different (superseded) generation are
+-- still always removed. On the next successful reindex that produces rows,
+-- the retained legacy rows are purged as before.
+--
+-- Scope of the guarantee (per FK topology): document_images (no chunk FK),
+-- document_memory_cards (section_id ON DELETE SET NULL), and
+-- document_sections are fully protected. document_table_facts,
+-- document_embedding_fields, and document_index_units reference
+-- document_chunks(id) ON DELETE CASCADE, so when legacy chunks are replaced
+-- (the normal case — the worker inserts chunks before committing) their
+-- legacy chunk-anchored rows cascade away with them regardless of these
+-- guards; that is structurally required, because retrieval RPCs join those
+-- artifacts through a non-null source_chunk_id and an orphaned artifact
+-- would be unreachable anyway. The guarded deletes for those three tables
+-- protect only rows with source_chunk_id IS NULL.
+create or replace function public.commit_document_index_generation(
+ p_document_id uuid,
+ p_index_generation_id uuid,
+ p_status text default 'indexed',
+ p_page_count integer default 0,
+ p_chunk_count integer default 0,
+ p_image_count integer default 0,
+ p_metadata jsonb default '{}'::jsonb,
+ p_pages jsonb default null,
+ p_quality jsonb default null
+)
+returns jsonb
+language plpgsql
+set search_path = public, extensions, pg_temp
+as $$
+begin
+ perform set_config('statement_timeout', '180000', true);
+
+ update public.documents
+ set
+ status = p_status,
+ page_count = p_page_count,
+ chunk_count = p_chunk_count,
+ image_count = p_image_count,
+ error_message = null,
+ metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id),
+ updated_at = now()
+ where id = p_document_id;
+
+ if p_pages is not null then
+ delete from public.document_pages
+ where document_id = p_document_id;
+
+ insert into public.document_pages (document_id, page_number, text, ocr_used, metadata)
+ select
+ p_document_id,
+ page_row.page_number,
+ coalesce(page_row.text, ''),
+ coalesce(page_row.ocr_used, false),
+ coalesce(page_row.metadata, '{}'::jsonb)
+ from jsonb_to_recordset(coalesce(p_pages, '[]'::jsonb)) as page_row(
+ page_number integer,
+ text text,
+ ocr_used boolean,
+ metadata jsonb
+ )
+ where page_row.page_number is not null;
+ end if;
+
+ if p_quality is not null then
+ insert into public.document_index_quality (
+ document_id,
+ owner_id,
+ quality_score,
+ extraction_quality,
+ metrics,
+ issues,
+ updated_at
+ )
+ values (
+ p_document_id,
+ nullif(p_quality->>'owner_id', '')::uuid,
+ coalesce((p_quality->>'quality_score')::real, 0),
+ coalesce(nullif(p_quality->>'extraction_quality', ''), 'unknown'),
+ coalesce(p_quality->'metrics', '{}'::jsonb),
+ coalesce(
+ array(select jsonb_array_elements_text(coalesce(p_quality->'issues', '[]'::jsonb))),
+ '{}'::text[]
+ ),
+ now()
+ )
+ on conflict on constraint document_index_quality_pkey
+ do update set
+ owner_id = excluded.owner_id,
+ quality_score = excluded.quality_score,
+ extraction_quality = excluded.extraction_quality,
+ metrics = excluded.metrics,
+ issues = excluded.issues,
+ updated_at = excluded.updated_at;
+ end if;
+
+ -- M13: superseded-generation rows always go; legacy NULL-generation rows go
+ -- only when this generation wrote replacement rows into the same table.
+ delete from public.document_chunks
+ where document_id = p_document_id
+ and (
+ (index_generation_id is not null and index_generation_id <> p_index_generation_id)
+ or (
+ index_generation_id is null
+ and exists (
+ select 1
+ from public.document_chunks replacement
+ where replacement.document_id = p_document_id
+ and replacement.index_generation_id = p_index_generation_id
+ )
+ )
+ );
+
+ delete from public.document_images
+ where document_id = p_document_id
+ and (
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_images replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
+ );
+
+ delete from public.document_table_facts
+ where document_id = p_document_id
+ and (
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_table_facts replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
+ );
+
+ delete from public.document_embedding_fields
+ where document_id = p_document_id
+ and (
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_embedding_fields replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
+ );
+
+ delete from public.document_index_units
+ where document_id = p_document_id
+ and (
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_index_units replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
+ );
+
+ delete from public.document_memory_cards
+ where document_id = p_document_id
+ and (
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_memory_cards replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
+ );
+
+ delete from public.document_sections
+ where document_id = p_document_id
+ and (
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_sections replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
+ );
+
+ return jsonb_build_object(
+ 'ok', true,
+ 'document_id', p_document_id,
+ 'index_generation_id', p_index_generation_id
+ );
+end;
+$$;
+
+revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
+grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 2262fbf114..ec9b58a638 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -1171,50 +1171,122 @@ begin
updated_at = excluded.updated_at;
end if;
+ -- M13 (audit 2026-07-01): superseded-generation rows always go; legacy
+ -- NULL-generation rows go only when this generation wrote replacement rows
+ -- into the same table (see 20260702000000_commit_generation_preserve_legacy_artifacts).
+ -- Guarantee scope: fully protects document_images/document_memory_cards/
+ -- document_sections; chunk-anchored artifacts (table facts, embedding
+ -- fields, index units) cascade with their legacy chunks via
+ -- source_chunk_id ON DELETE CASCADE when chunks are replaced.
delete from public.document_chunks
where document_id = p_document_id
- and (index_generation_id is null or index_generation_id <> p_index_generation_id);
+ and (
+ (index_generation_id is not null and index_generation_id <> p_index_generation_id)
+ or (
+ index_generation_id is null
+ and exists (
+ select 1
+ from public.document_chunks replacement
+ where replacement.document_id = p_document_id
+ and replacement.index_generation_id = p_index_generation_id
+ )
+ )
+ );
delete from public.document_images
where document_id = p_document_id
and (
- nullif(metadata->>'index_generation_id', '') is null
- or metadata->>'index_generation_id' <> p_index_generation_id::text
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_images replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
);
delete from public.document_table_facts
where document_id = p_document_id
and (
- nullif(metadata->>'index_generation_id', '') is null
- or metadata->>'index_generation_id' <> p_index_generation_id::text
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_table_facts replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
);
delete from public.document_embedding_fields
where document_id = p_document_id
and (
- nullif(metadata->>'index_generation_id', '') is null
- or metadata->>'index_generation_id' <> p_index_generation_id::text
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_embedding_fields replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
);
delete from public.document_index_units
where document_id = p_document_id
and (
- nullif(metadata->>'index_generation_id', '') is null
- or metadata->>'index_generation_id' <> p_index_generation_id::text
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_index_units replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
);
delete from public.document_memory_cards
where document_id = p_document_id
and (
- nullif(metadata->>'index_generation_id', '') is null
- or metadata->>'index_generation_id' <> p_index_generation_id::text
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_memory_cards replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
);
delete from public.document_sections
where document_id = p_document_id
and (
- nullif(metadata->>'index_generation_id', '') is null
- or metadata->>'index_generation_id' <> p_index_generation_id::text
+ (nullif(metadata->>'index_generation_id', '') is not null
+ and metadata->>'index_generation_id' <> p_index_generation_id::text)
+ or (
+ nullif(metadata->>'index_generation_id', '') is null
+ and exists (
+ select 1
+ from public.document_sections replacement
+ where replacement.document_id = p_document_id
+ and replacement.metadata->>'index_generation_id' = p_index_generation_id::text
+ )
+ )
);
return jsonb_build_object(
diff --git a/tests/answer-prose-runons.test.ts b/tests/answer-prose-runons.test.ts
index 24e8a80451..c9853ecb27 100644
--- a/tests/answer-prose-runons.test.ts
+++ b/tests/answer-prose-runons.test.ts
@@ -14,7 +14,9 @@ describe("flattened-table run-on separation", () => {
});
it("handles the pattern with no comma present", () => {
- const out = polishClinicalAnswerProse("U&Es are repeated every 6 months for inpatients for community patients they are checked annually.");
+ const out = polishClinicalAnswerProse(
+ "U&Es are repeated every 6 months for inpatients for community patients they are checked annually.",
+ );
expect(out).toContain("for inpatients. For community patients,");
});
diff --git a/tests/answer-verification.test.ts b/tests/answer-verification.test.ts
index 2b537995b4..f614fa9975 100644
--- a/tests/answer-verification.test.ts
+++ b/tests/answer-verification.test.ts
@@ -127,6 +127,45 @@ describe("answer-verification (GEN-C2 / GEN-H2)", () => {
expect(verification.unverifiedTokens).toContain("12.5mg");
});
+ // H1 (audit 2026-07-01): the verification corpus must include everything the
+ // model was shown in buildRagSourceBlock. A number living only in the chunk's
+ // retrieval synopsis or a table-crop image's text previously flagged as
+ // unverified, blanking a faithful answer.
+ it("verifies a number that appears only in the retrieval synopsis (H1)", () => {
+ const result = source({
+ content: "See the monitoring summary.",
+ retrieval_synopsis: "Withhold clozapine when ANC falls below 2.0 ×10⁹/L; restart at 12.5 mg.",
+ });
+ const verification = verifyAnswerNumbers(
+ "Withhold below 2.0 ×10⁹/L and restart at 12.5 mg.",
+ [{ chunk_id: "chunk-1" }],
+ [result],
+ );
+ expect(verification.hasUnverifiedNumbers).toBe(false);
+ });
+
+ it("verifies a number that appears only in a cited image's table text (H1)", () => {
+ const result = source({
+ content: "Refer to the threshold table.",
+ images: [
+ {
+ id: "img-1",
+ page_number: 4,
+ storage_path: "images/doc-1/img-1.png",
+ caption: "Monitoring thresholds",
+ tableTextSnippet: "Amber: ANC 1.5-2.0, increase monitoring.",
+ accessibleTableMarkdown: "| Band | ANC | Action |\n| Amber | 1.5-2.0 | Increase monitoring |",
+ },
+ ],
+ });
+ const verification = verifyAnswerNumbers(
+ "In the amber band (ANC 1.5-2.0) increase monitoring.",
+ [{ chunk_id: "chunk-1" }],
+ [result],
+ );
+ expect(verification.hasUnverifiedNumbers).toBe(false);
+ });
+
it("matches numbers in table facts of a cited chunk", () => {
const result = source({
content: "See monitoring table.",
diff --git a/tests/chunking.test.ts b/tests/chunking.test.ts
index baf7528257..f9c5a8ecb0 100644
--- a/tests/chunking.test.ts
+++ b/tests/chunking.test.ts
@@ -17,6 +17,26 @@ describe("chunkTextWithOverlap", () => {
expect(chunks.join(" ")).toContain("Sentence 0.");
});
+ // M17 (audit 2026-07-01): overlap >= chunkSize previously made the sentence
+ // window loop spin forever (no forward progress), hanging the worker.
+ it("terminates when overlap >= chunkSize (M17)", () => {
+ const text = `${"Withhold clozapine and review blood results. ".repeat(60)}`.trim();
+ const chunks = chunkTextWithOverlap(text, 200, 200);
+ expect(chunks.length).toBeGreaterThan(1);
+ expect(chunks.join(" ")).toContain("Withhold clozapine");
+ });
+
+ // M14: only standalone page footers are noise. An inline page reference in a
+ // clinical sentence must never delete the whole line.
+ it("keeps clinical lines containing inline page references (M14)", () => {
+ const text = "Give paracetamol, refer to p 3 for dosing.\nPage 3 of 12\nWithhold clozapine if ANC is low.";
+ const chunks = chunkTextWithOverlap(text, 2000, 200);
+ const joined = chunks.join(" ");
+ expect(joined).toContain("refer to p 3 for dosing");
+ expect(joined).toContain("Withhold clozapine");
+ expect(joined).not.toMatch(/Page 3 of 12/);
+ });
+
it("prefers paragraph boundaries before falling back to sentence windows", () => {
const chunks = chunkTextWithOverlap("Heading\n\nFirst clinical paragraph.\n\nSecond clinical paragraph.", 32, 4);
diff --git a/tests/clinical-search.test.ts b/tests/clinical-search.test.ts
index 1f77e51bbb..065e9662eb 100644
--- a/tests/clinical-search.test.ts
+++ b/tests/clinical-search.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
analyzeClinicalQuery,
buildClinicalTextSearchQuery,
+ classifyQueryIntent,
classifyRagQuery,
clinicalRankExplanation,
expandClinicalQuery,
@@ -36,6 +37,15 @@ describe("clinical search query normalization", () => {
it("classifies common RAG query shapes for routing and observability", () => {
expect(classifyRagQuery("Find the NOCC document").queryClass).toBe("document_lookup");
expect(classifyRagQuery("What should a patient safety plan include?").queryClass).toBe("document_lookup");
+ // M2/M3 (audit 2026-07-01): intent signals match at word boundaries, and
+ // explicit dose vocabulary survives escalation-word cancellation.
+ expect(classifyQueryIntent("What is the time limit for a notable review?").hasDosingSignals).toBe(false);
+ expect(classifyQueryIntent("What is the time limit for a notable review?").imageEvidenceFocus).toBe(false);
+ expect(classifyQueryIntent("clozapine dose review schedule").hasDosingSignals).toBe(true);
+ expect(classifyQueryIntent("What IM options are listed for agitation?").hasDosingSignals).toBe(true);
+ expect(classifyQueryIntent("clozapine 100mg starting schedule").hasDosingSignals).toBe(true);
+ expect(classifyQueryIntent("Show the monitoring table for lithium").imageEvidenceFocus).toBe(true);
+
expect(classifyRagQuery("What forms are required for a patient safety plan?").queryClass).toBe("document_lookup");
expect(classifyRagQuery("What are NOCC requirements?").queryClass).toBe("document_lookup");
expect(classifyRagQuery("What assessment documentation is required?").queryClass).toBe("document_lookup");
diff --git a/tests/deep-memory.test.ts b/tests/deep-memory.test.ts
index bd002a9c23..ca5e7af7f0 100644
--- a/tests/deep-memory.test.ts
+++ b/tests/deep-memory.test.ts
@@ -234,7 +234,11 @@ describe("deep RAG memory indexing", () => {
expect(boosted[0].hybrid_score).toBeGreaterThan(0.7);
});
- it("keeps memory cards when committed-generation metadata lookup fails", async () => {
+ // L8 (audit 2026-07-01): fail CLOSED. When the documents lookup errors we
+ // cannot verify which reindex generation is committed, so the fallback must
+ // drop its cards for that query rather than risk injecting content from an
+ // abandoned/superseded generation into a clinical answer.
+ it("drops fallback memory cards when committed-generation metadata lookup fails", async () => {
const cards = [
{
id: "card-new",
@@ -295,7 +299,7 @@ describe("deep RAG memory indexing", () => {
matchCount: 8,
});
- expect(result.map((card) => card.id)).toEqual(["card-new"]);
+ expect(result).toEqual([]);
});
it("persists memory cards without leaking internal section indexes into inserts", async () => {
diff --git a/tests/document-organization.test.ts b/tests/document-organization.test.ts
index 3a1a071199..d3bdbc4a1b 100644
--- a/tests/document-organization.test.ts
+++ b/tests/document-organization.test.ts
@@ -60,6 +60,30 @@ describe("document organization classifier", () => {
expect(classification.profile.secondary_facets.service).toEqual(["kara maar"]);
});
+ // M6 (audit 2026-07-01): a site named in PLAIN TEXT in the title must be
+ // detected even when the body never repeats it.
+ it("detects a site named only in the title text (M6)", () => {
+ const classification = classifyDocumentOrganization({
+ title: "Fiona Stanley Hospital Sepsis Pathway",
+ file_name: "sepsis-pathway.pdf",
+ contentText: "Escalate suspected sepsis immediately.",
+ });
+
+ expect(classification.profile.site.label).toBe("Fiona Stanley Hospital");
+ });
+
+ // M7: the generic phrase "best practice" must not attribute a local policy
+ // to the BMJ Best Practice reference collection.
+ it("does not attribute BMJ on the bare phrase 'best practice' (M7)", () => {
+ const classification = classifyDocumentOrganization({
+ title: "Local Escalation Policy",
+ file_name: "local-escalation-policy.pdf",
+ contentText: "In line with best practice, staff should escalate concerns to the senior clinician.",
+ });
+
+ expect(classification.profile.site.label).not.toBe("BMJ Best Practice");
+ });
+
it("maps health-service and program tags conservatively", () => {
expect(
classifyDocumentOrganization({
diff --git a/tests/image-filtering.test.ts b/tests/image-filtering.test.ts
index c4340f7028..b6c774efc1 100644
--- a/tests/image-filtering.test.ts
+++ b/tests/image-filtering.test.ts
@@ -156,7 +156,10 @@ describe("smart image filtering", () => {
});
it("builds a stable lightweight perceptual hash key", () => {
- expect(lightweightPerceptualHash("1234567890abcdef", 100, 200)).toMatch(/^ph1:100x200:[0-9a-f]{4}$/);
+ // M12 (audit 2026-07-01): ph2 digest — 16 hex threshold bits + 32 hex
+ // quantized-level bits; ph1's 4-hex space collided across distinct
+ // same-dimension clinical tables.
+ expect(lightweightPerceptualHash("1234567890abcdef", 100, 200)).toMatch(/^ph2:100x200:[0-9a-f]{48}$/);
});
it("groups identical sampled image bytes but separates different bytes", () => {
diff --git a/tests/openai-error-mapping.test.ts b/tests/openai-error-mapping.test.ts
index df8cd90dd4..1d337f6829 100644
--- a/tests/openai-error-mapping.test.ts
+++ b/tests/openai-error-mapping.test.ts
@@ -25,10 +25,7 @@ describe("mapOpenAIError quota vs rate-limit classification", () => {
});
it("detects quota exhaustion from the message even when no error code is set", () => {
- const mapped = mapOpenAIError(
- openAIError("Billing hard limit reached.", { status: 429 }),
- "answer",
- );
+ const mapped = mapOpenAIError(openAIError("Billing hard limit reached.", { status: 429 }), "answer");
expect(mapped.details?.code).toBe("insufficient_quota");
expect(mapped.message).not.toMatch(/retry in a moment/i);
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index 6bd3dc9a08..ce2291fc7d 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -2130,30 +2130,35 @@ describe("private document API access", () => {
expect(client.storageMocks.remove).not.toHaveBeenCalled();
});
- it("blocks permanent delete while a document is actively indexing", async () => {
- const client = createSupabaseMock((call) => {
- if (call.table === "documents" && call.operation === "select") {
- return ok({ id: documentId, owner_id: userId, title: "Owned", storage_path: "source.pdf" });
- }
- if (call.table === "ingestion_jobs" && call.operation === "select") {
- return ok([{ id: "job-1", status: "processing" }]);
- }
- return ok([]);
- });
- mockRuntime(client);
- const { DELETE } = await import("../src/app/api/documents/[id]/route");
+ // M9 (audit 2026-07-01): the guard covers PENDING jobs too — a just-queued
+ // reindex racing a delete used to orphan freshly-uploaded storage objects.
+ it.each(["processing", "pending"] as const)(
+ "blocks permanent delete while a document has %s indexing work",
+ async (jobStatus) => {
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select") {
+ return ok({ id: documentId, owner_id: userId, title: "Owned", storage_path: "source.pdf" });
+ }
+ if (call.table === "ingestion_jobs" && call.operation === "select") {
+ return ok([{ id: "job-1", status: jobStatus }]);
+ }
+ return ok([]);
+ });
+ mockRuntime(client);
+ const { DELETE } = await import("../src/app/api/documents/[id]/route");
- const response = await DELETE(authenticatedRequest(`/api/documents/${documentId}`, { method: "DELETE" }), {
- params: Promise.resolve({ id: documentId }),
- });
+ const response = await DELETE(authenticatedRequest(`/api/documents/${documentId}`, { method: "DELETE" }), {
+ params: Promise.resolve({ id: documentId }),
+ });
- expect(response.status).toBe(409);
- expect(await payload(response)).toEqual({
- error: "Document is currently indexing. Stop or wait for the worker before deleting.",
- });
- expect(client.calls.some((call) => call.table === "documents" && call.operation === "delete")).toBe(false);
- expect(client.storageMocks.remove).not.toHaveBeenCalled();
- });
+ expect(response.status).toBe(409);
+ expect(await payload(response)).toEqual({
+ error: "Document has pending or processing indexing work. Stop or wait for the worker before deleting.",
+ });
+ expect(client.calls.some((call) => call.table === "documents" && call.operation === "delete")).toBe(false);
+ expect(client.storageMocks.remove).not.toHaveBeenCalled();
+ },
+ );
it("rejects unauthenticated search and answer requests", async () => {
const searchChunksWithTelemetry = vi.fn(async () => ({
diff --git a/tests/rag-content-accuracy.test.ts b/tests/rag-content-accuracy.test.ts
index 2b65ac8f40..19658ec855 100644
--- a/tests/rag-content-accuracy.test.ts
+++ b/tests/rag-content-accuracy.test.ts
@@ -46,10 +46,7 @@ describe("unboldUnverifiedNumbers — emphasis tracks verification (P8)", () =>
});
it("keeps bold around verified figures and non-numeric emphasis", () => {
- const out = unboldUnverifiedNumbers(
- "Give **10 mg** now and **withhold** if unstable.",
- new Set(["500mg"]),
- );
+ const out = unboldUnverifiedNumbers("Give **10 mg** now and **withhold** if unstable.", new Set(["500mg"]));
expect(out).toBe("Give **10 mg** now and **withhold** if unstable.");
});
diff --git a/tests/rag-provider.test.ts b/tests/rag-provider.test.ts
index 5f22dfc2cd..590872ae92 100644
--- a/tests/rag-provider.test.ts
+++ b/tests/rag-provider.test.ts
@@ -51,7 +51,9 @@ describe("provider failure classification", () => {
);
expect(p.classifyProviderFailure(Object.assign(new Error("x"), { status: 401 }))).toBe("auth_failed");
expect(
- p.classifyProviderFailure(Object.assign(new Error("Rate limit reached"), { status: 429, code: "rate_limit_exceeded" })),
+ p.classifyProviderFailure(
+ Object.assign(new Error("Rate limit reached"), { status: 429, code: "rate_limit_exceeded" }),
+ ),
).toBe("rate_limited");
expect(p.classifyProviderFailure(Object.assign(new Error("Request timed out"), { code: "ETIMEDOUT" }))).toBe(
"timeout",
diff --git a/tests/retrieval-selection.test.ts b/tests/retrieval-selection.test.ts
index 1163f354fb..1d730fcb09 100644
--- a/tests/retrieval-selection.test.ts
+++ b/tests/retrieval-selection.test.ts
@@ -45,6 +45,38 @@ function sourceMetadata(
}
describe("retrieval source selection", () => {
+ // Audit H3 disposition (2026-07-02): SUPERSEDED by PR #118, which removed
+ // source-governance metadata weighting from retrieval selection entirely —
+ // measured on the golden retrieval eval (doc-recall@5 1.0 -> 0.76 with
+ // weighting). There are no freshness/validation penalties in selection to
+ // propagate; governance is enforced by ranking penalties and the
+ // answer/source-governance layer. See the amended governance contract test
+ // below ("keeps relevance ordering ...").
+
+ // L4: stacked boosts must never push the annotated score above 1.0.
+ it("clamps the annotated hybrid_score to at most 1.0 (L4)", () => {
+ const selection = selectRetrievalEvidence({
+ query: "What IM or PO options are listed for agitation?",
+ queryClass: "medication_dose_risk",
+ topK: 2,
+ maxResultsPerDocument: 2,
+ results: [
+ source({
+ id: "high-base",
+ title: "Agitation Medication Chart",
+ content: "IM and PO options for agitation: olanzapine 5-10 mg PO, droperidol 5 mg IM.",
+ hybrid_score: 0.98,
+ source_metadata: sourceMetadata(),
+ match_explanation: { titleHit: true, contentHit: true, reasons: ["title"] },
+ }),
+ ],
+ });
+
+ const annotated = selection.results.find((result) => result.id === "high-base");
+ expect(annotated).toBeDefined();
+ expect(annotated!.hybrid_score).toBeLessThanOrEqual(1);
+ });
+
it("rescues active-community ED document evidence above generic community hits", () => {
const selection = selectRetrievalEvidence({
query: "How are active community patients in ED managed?",
diff --git a/tests/source-text-sanitizer.test.ts b/tests/source-text-sanitizer.test.ts
index 0d1192f7c9..356ffcbd0e 100644
--- a/tests/source-text-sanitizer.test.ts
+++ b/tests/source-text-sanitizer.test.ts
@@ -108,6 +108,30 @@ describe("source text sanitizer", () => {
expect(usefulness.useful).toBe(true);
});
+ // H2 (audit 2026-07-01): a sentence carrying clinical threshold values must
+ // survive even when it starts near a title keyword (Scale/Guideline/…) and
+ // has no concrete-action verb — the greedy title-fragment match previously
+ // deleted it wholesale.
+ it("keeps threshold sentences that resemble source-title fragments (H2)", () => {
+ const text =
+ "Assess the patient on admission. The Glasgow Coma Scale ranges from 3 to 15 with 8 or below indicating severe head injury. Document the score.";
+
+ const usefulness = clinicalProseUsefulness(text);
+
+ expect(usefulness.text).toContain("ranges from 3 to 15");
+ expect(usefulness.text).toContain("8 or below");
+ expect(usefulness.text).toContain("Assess the patient on admission");
+ });
+
+ it("still drops bare-integer title noise like 'Guideline Appendix 1' (H2 guard)", () => {
+ const noisy =
+ "Dose evidence: LUNSERS (Liverpool University Neuroleptic Side Effect Rating Scale) - using for monitoring Neuroleptic side effect Guideline Appendix 1.";
+
+ const usefulness = clinicalProseUsefulness(noisy);
+
+ expect(usefulness.text).not.toContain("Liverpool University");
+ });
+
it("scores document-control snippets as low yield without hiding document viewer provenance", () => {
const text = "Neuroleptic side effect Guideline PAE-PRO-0338/16 Page 5 of 5";
diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts
index d52da78eec..226b0852b8 100644
--- a/tests/supabase-schema.test.ts
+++ b/tests/supabase-schema.test.ts
@@ -496,8 +496,12 @@ describe("Supabase schema Data API grants", () => {
}
// schema.sql mirrors the live-codified ranked ordering (single sort key, wider candidate
// bound); the original migration kept the similarity tie-breaker and tighter bound.
- expect(extractIndexUnitHybridFunction(schema)).toContain("order by text_rank desc limit greatest(match_count * 3, 48)");
- expect(extractIndexUnitHybridFunction(documentIndexUnitsMigration)).toContain("order by text_rank desc, similarity desc");
+ expect(extractIndexUnitHybridFunction(schema)).toContain(
+ "order by text_rank desc limit greatest(match_count * 3, 48)",
+ );
+ expect(extractIndexUnitHybridFunction(documentIndexUnitsMigration)).toContain(
+ "order by text_rank desc, similarity desc",
+ );
});
it("stores smart image metadata, document labels, and high-yield summaries", () => {
diff --git a/tests/ward-output.test.ts b/tests/ward-output.test.ts
index 5905122bd6..b40c742db8 100644
--- a/tests/ward-output.test.ts
+++ b/tests/ward-output.test.ts
@@ -204,6 +204,132 @@ describe("ward output helpers", () => {
expect(thresholds?.items[0]).toContain("Withhold clozapine");
});
+ // H4/M8/M16 (audit 2026-07-01): copied ward-note tables must go through the
+ // same conservative normalization as the on-screen AccessibleTable.
+ it("carries the low-confidence caveat into the copied table output (H4)", () => {
+ const thresholdAnswer: RagAnswer = {
+ ...answer,
+ answer: "Withhold clozapine if ANC is below the required threshold and urgently review.",
+ answerSections: [
+ {
+ heading: "Threshold",
+ body: "Withhold clozapine if ANC is below the required threshold and urgently review.",
+ citation_chunk_ids: ["chunk-1"],
+ },
+ ],
+ visualEvidence: [
+ {
+ id: "image-1",
+ image_id: "image-1",
+ signed_url_endpoint: "/api/images/image-1/signed-url",
+ caption: "FBC/ANC monitoring thresholds",
+ document_id: "doc-1",
+ title: "Clozapine source",
+ file_name: "clozapine.pdf",
+ page_number: 2,
+ source_chunk_id: "chunk-1",
+ chunk_index: 0,
+ viewer_href: "/documents/doc-1?page=2&chunk=chunk-1",
+ tableLabel: "Table 1",
+ tableTitle: "FBC/ANC thresholds",
+ // Interleaved generic column on a clinical table triggers the
+ // conservative raw-grid fallback (ambiguous_generic_column).
+ tableRows: [
+ ["Below 1.5", "withhold dose", "contact prescriber"],
+ ["1.5-2.0", "increase monitoring", "review threshold"],
+ ],
+ tableColumns: ["ANC level", "", "Action"],
+ },
+ ],
+ };
+
+ const copy = formatAnswerForClipboard(thresholdAnswer);
+ expect(copy).toContain("verify values against the source document");
+ });
+
+ it("does not duplicate the markdown header as a data row (M8)", () => {
+ const thresholdAnswer: RagAnswer = {
+ ...answer,
+ answer: "Withhold clozapine if ANC is below the required threshold and urgently review.",
+ answerSections: [
+ {
+ heading: "Threshold",
+ body: "Withhold clozapine if ANC is below the required threshold and urgently review.",
+ citation_chunk_ids: ["chunk-1"],
+ },
+ ],
+ visualEvidence: [
+ {
+ id: "image-1",
+ image_id: "image-1",
+ signed_url_endpoint: "/api/images/image-1/signed-url",
+ caption: "FBC/ANC monitoring thresholds",
+ document_id: "doc-1",
+ title: "Clozapine source",
+ file_name: "clozapine.pdf",
+ page_number: 2,
+ source_chunk_id: "chunk-1",
+ chunk_index: 0,
+ viewer_href: "/documents/doc-1?page=2&chunk=chunk-1",
+ tableLabel: "Table 1",
+ tableTitle: "FBC/ANC thresholds",
+ tableRows: null,
+ tableColumns: ["ANC", "Action"],
+ accessibleTableMarkdown:
+ "| ANC | Action |\n| --- | --- |\n| Below 1.5 | Withhold clozapine |\n| 1.5-2.0 | Increase monitoring |",
+ },
+ ],
+ };
+
+ const copy = formatAnswerForClipboard(thresholdAnswer);
+ const headerOccurrences = copy.split("| ANC | Action |").length - 1;
+ expect(headerOccurrences).toBe(1);
+ expect(copy).toContain("| Below 1.5 | Withhold clozapine |");
+ });
+
+ it("pads ragged rows so values cannot shift columns (M16)", () => {
+ const thresholdAnswer: RagAnswer = {
+ ...answer,
+ answer: "Withhold clozapine if ANC is below the required threshold and urgently review.",
+ answerSections: [
+ {
+ heading: "Threshold",
+ body: "Withhold clozapine if ANC is below the required threshold and urgently review.",
+ citation_chunk_ids: ["chunk-1"],
+ },
+ ],
+ visualEvidence: [
+ {
+ id: "image-1",
+ image_id: "image-1",
+ signed_url_endpoint: "/api/images/image-1/signed-url",
+ caption: "FBC/ANC monitoring thresholds",
+ document_id: "doc-1",
+ title: "Clozapine source",
+ file_name: "clozapine.pdf",
+ page_number: 2,
+ source_chunk_id: "chunk-1",
+ chunk_index: 0,
+ viewer_href: "/documents/doc-1?page=2&chunk=chunk-1",
+ tableLabel: "Table 1",
+ tableTitle: "FBC/ANC thresholds",
+ // Second row has an extra trailing cell (ragged extraction).
+ tableRows: [
+ ["Below 1.5", "withhold dose"],
+ ["1.5-2.0", "increase monitoring", "contact prescriber daily"],
+ ],
+ tableColumns: ["Level", "Action"],
+ },
+ ],
+ };
+
+ const copy = formatAnswerForClipboard(thresholdAnswer);
+ // The 3-cell row must not render as a 3-column line under a 2-column
+ // header; the trailing cell merges into the nearest named column.
+ expect(copy).not.toContain("| 1.5-2.0 | increase monitoring | contact prescriber daily |");
+ expect(copy).toContain("increase monitoring contact prescriber daily");
+ });
+
it("does not promote nearby table evidence for unsupported answers", () => {
const unsupportedAnswer: RagAnswer = {
...answer,
diff --git a/worker/main.ts b/worker/main.ts
index 06fc57d325..4718f1b2ca 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -438,7 +438,25 @@ async function replacePageRows(documentId: string, pages: ReturnType {
+ let query = supabase.from(table).select("id").eq("document_id", documentId).limit(1);
+ query = direct
+ ? query.eq("index_generation_id", indexGenerationId)
+ : query.eq("metadata->>index_generation_id", indexGenerationId);
+ const { data, error } = await query;
+ if (error) throw supabaseStageError(`check replacement ${table}`, error);
+ return (data ?? []).length > 0;
+ };
const deleteDirectGenerationRows = async (table: string) => {
const stale = await supabase
.from(table)
@@ -446,6 +464,7 @@ async function deleteStaleIndexGenerationRows(documentId: string, indexGeneratio
.eq("document_id", documentId)
.neq("index_generation_id", indexGenerationId);
if (stale.error) throw supabaseStageError(`delete stale ${table}`, stale.error);
+ if (!(await hasReplacementRows(table, true))) return;
const missing = await supabase.from(table).delete().eq("document_id", documentId).is("index_generation_id", null);
if (missing.error) throw supabaseStageError(`delete generationless ${table}`, missing.error);
};
@@ -456,6 +475,7 @@ async function deleteStaleIndexGenerationRows(documentId: string, indexGeneratio
.eq("document_id", documentId)
.neq("metadata->>index_generation_id", indexGenerationId);
if (stale.error) throw supabaseStageError(`delete stale ${table}`, stale.error);
+ if (!(await hasReplacementRows(table, false))) return;
const missing = await supabase
.from(table)
.delete()
@@ -783,6 +803,11 @@ async function uploadAndCaptionImages(
let skippedImages = 0;
const skipReasons = new Map();
const imageTypeCounts = new Map();
+ // Deliberate trade-off (audit L11): image bytes are re-read from disk at
+ // each stage (hash here, caption on cache miss, upload) instead of being
+ // cached, because holding every extracted image Buffer for a large document
+ // (hundreds of multi-MB page images) would multiply the worker's peak
+ // memory. Disk I/O is the cheaper resource for this background pipeline.
const preparedImages: Array<{ imageHash: string; bytesLength: number; perceptualHash: string }> = [];
for (const image of extracted.images) {
const bytes = await readFile(image.path);