From 78e2beb89b646c3d5c2d4745e3f2f692f9ba61a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 01:58:06 +0000 Subject: [PATCH 01/25] perf: implement the free and flag-gated latency audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-28 latency audit was proposed as PR #1312 and closed unmerged because its additive-index migration lacked synchronized schema/drift proof. The closing note also said "compatible latency work is already on main" — verification on 6021f6d found none of the six applied changes had landed, and the audit document itself was absent, so its follow-ups were untracked. This re-lands the free- and flag-gated work with tests, authors the operator SQL without touching supabase/**, and files the remainder in the ledger. Applied: - Server-Timing preamble stages (auth/ratelimit/scope). /api/answer/stream — the route the UI actually calls — emitted no header at all; /api/search emitted none either. Only pre-header stages can appear on the stream route, since routing in-stream durations through the SSE contract would put instrumentation inside a governed clinical payload. - L1-2: /api/answer resolves scope concurrently with the rate-limit RPC, aborts it on deny, and threads AbortSignal.any so a client disconnect cancels scope's paginated queries. The promise is settled, never floating. - L1-1: the shared-cache-hit path no longer awaits setCachedAnswer before responding. forceRefresh stays — it is the mid-request staleness guard, now documented on the function. - L2-6: three select("*") on document_table_facts narrowed to explicit projections, reusing tableFactDetailProjection. Keeps the generated search_tsv and owner_id off the wire; response shape unchanged. - L2-9: /api/medications builds the governance map and index projection once instead of per anonymous request. - L3-4/L3-5: ten ssr:false surfaces gain LoadingPanel fallbacks (role=status); Supabase preconnect/dns-prefetch added. Both ship ~zero bytes, so neither is gated behind #017, which governs payload decisions. Not applied, deliberately: the six canary-gated findings, the seven #017-gated findings, the four retired during verification, and the L2-3/L2-5 indexes — authored as reviewed CREATE INDEX CONCURRENTLY statements in the operator runbook with the apply/mirror/regenerate/register ordering spelled out. L4-2 is retracted: the worker's triple image read is a deliberate peak-memory trade-off already documented at worker/main.ts:866-869. RAG impact: no retrieval behaviour change — the only src/lib/rag/** edits defer a process-local cache write off the response path and add a doc comment; no scoring, ordering, selection, alias, or citation logic is touched, and the mid-request staleness guard is preserved. Verification: verify:cheap exit 0 (418 files, 4244 passed / 4 skipped); verify:pr-local exit 0 (same suite, production build compiled in 59s, client bundle secret surface check passed, 36 golden cases / 21 suites validated). tests/answer-route-preamble.test.ts fails against the pre-change serial shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/README.md | 1 + docs/audit/latency-audit-2026-07-28.md | 383 ++++++++++++++++++ docs/codebase-index.md | 23 +- ...r-apply-performance-latency-remediation.md | 60 +++ docs/outstanding-issues.md | 12 +- src/app/api/answer/route.ts | 51 ++- src/app/api/answer/stream/route.ts | 17 +- .../api/documents/[id]/table-facts/route.ts | 16 +- src/app/api/medications/route.ts | 30 +- src/app/api/search/route.ts | 30 +- src/app/layout.tsx | 34 ++ .../clinical-dashboard-lazy.tsx | 33 +- .../clinical-dashboard/dashboard-nav.tsx | 6 +- src/lib/document-detail.ts | 5 +- src/lib/rag/rag-cache.ts | 15 + src/lib/rag/rag.ts | 2 +- src/lib/server-timing.ts | 25 ++ tests/answer-route-preamble.test.ts | 165 ++++++++ tests/private-rag-access.test.ts | 3 + tests/server-timing.test.ts | 28 +- 20 files changed, 888 insertions(+), 51 deletions(-) create mode 100644 docs/audit/latency-audit-2026-07-28.md create mode 100644 tests/answer-route-preamble.test.ts diff --git a/docs/README.md b/docs/README.md index c358801123..c64c52b1ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -89,6 +89,7 @@ as it was on that date; supersede with a new dated document rather than editing. - [audit/](audit/) — repo and UX/accessibility audits - [audit/2026-07-20-repository-maturity.md](audit/2026-07-20-repository-maturity.md) — full repository maturity, mapping, and organisation audit +- [audit/latency-audit-2026-07-28.md](audit/latency-audit-2026-07-28.md) — latency audit: server request path, client first paint, database and ingestion - [forward-codify-retrieval-rpcs-workorder.md](forward-codify-retrieval-rpcs-workorder.md) — completed retrieval RPC codification workorder - [project-alignment-cleanup.md](archive/project-alignment-cleanup.md) — completed June 2026 repo-alignment record - [capacity-review.md](capacity-review.md), [scale-readiness-review.md](scale-readiness-review.md), [tenancy-defense-in-depth-review.md](tenancy-defense-in-depth-review.md) diff --git a/docs/audit/latency-audit-2026-07-28.md b/docs/audit/latency-audit-2026-07-28.md new file mode 100644 index 0000000000..ecd0b6f1b5 --- /dev/null +++ b/docs/audit/latency-audit-2026-07-28.md @@ -0,0 +1,383 @@ +# Latency Audit — Clinical KB Database + +**Date:** 2026-07-28 +**Branch:** `claude/latency-audit-f1cbcd` (worktree `prompt-skill-improvements-7d5f80`), base 1 commit behind `origin/main` (`ea6d2d954`, mockups-only — no latency surface) +**Method:** Three read-only reconnaissance sweeps (server request path / client-browser path / database + prior-work), then line-level verification of every load-bearing claim by the primary author. Four planned remediations were **retired during verification** because the evidence did not support them — recorded under "Retired during verification" rather than silently dropped. +**Scope:** Latency only. Full server request path, client first-paint path, database/RPC surface, and the ingestion/worker path where it bounds a user-visible wait. Excludes correctness, security, and clinical-governance findings except where they _gate_ a latency fix. +**Guardrail posture:** Obeys (1) the `src/lib/rag/**` FLAG + `RAG impact:` rule and its live-canary requirement for behaviour change; (2) ledger `#017`, which gates client payload work behind measured Web-Vitals evidence; (3) `docs/capacity-review.md:123-125` explicit non-actions; (4) the provider-confirmation boundary — no OpenAI/Supabase/hosted-CI call was made. + +--- + +## Status on `main` (added 2026-07-29) + +This audit was first proposed as **PR #1312**, which was **closed unmerged** on 2026-07-28 with +the note: _"The unsafe additive-index migration lacked synchronized schema/drift proof; +compatible latency work is already on main."_ The first clause was correct. The second was not — +verification on `6021f6d` found that **none** of the six "Applied in this pass" changes had +reached `main`, and this document was not on `main` either, so its follow-ups were untracked. + +The 2026-07-29 pass re-lands the free- and flag-gated work with tests and files the remainder in +`docs/outstanding-issues.md` as `#098`–`#105`. Two deliberate departures from the original pass: + +- **No migration ships.** L2-3's bare-column trigram indexes and L2-5's composite are authored + as reviewed operator SQL in [`operator-apply-performance-latency-remediation.md`](../operator-apply-performance-latency-remediation.md) + instead of `supabase/migrations/*.sql`. `supabase/**` is untouched, so no drift manifest needs + regenerating and the objection that closed PR #1312 cannot recur. Tracked as `#102`. +- **L4-2 is retracted.** See the correction in the L4 section below — it is not an open finding. + +Ledger IDs in the body below are the 2026-07-29 numbers. The original draft used `#085`–`#092`; +those IDs were taken by unrelated items before this landed. + +--- + +## Result summary + +| | Count | +| ----------------------------------- | ---------------------------------------------------------------------- | +| **Open findings** | **24** (25 as first written; L4-2 retracted) | +| By tier | L0 **1** · L1 **5** · L2 **9** · L3 **8** · L4 **1** | +| Already fixed — recorded, not filed | **16** (tier L5) | +| Deliberate design, not defects | **5** (tier L6, including the retracted L4-2) | +| By gate | free **5** · flag **2** · `#017` **7** · canary **6** · operator **4** | +| Applied (2026-07-29 pass) | **7** ("Applied") | +| Retired during verification | **4** ("Retired during verification") | + +The dominant theme is **not inefficiency — it is delivery architecture**. The single largest +contributor to perceived answer latency is that a fully-verified answer is delivered in one frame +at the end of generation, so time-to-first-content equals total latency. Every other finding in +this report, summed, is roughly one order of magnitude smaller than the window that one design +choice creates. The second theme is that the repo's own telemetry could not see the preamble: the +route the UI actually calls emitted no `Server-Timing` at all. + +--- + +## How to read this report + +Severity is computed from three axes. **Gate cost is deliberately excluded from severity** — it +sets action order only. Keeping them separate is the point: the failure mode this repo is prone +to is letting "what am I allowed to touch" quietly reorder "what hurts the clinician". + +| Axis | Values | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| **A · Wait class** | `P` clinician blocked watching a spinner · `S` secondary interactive path · `C` cold-start/build/ingestion | +| **B · Evidence** | `measured` (telemetry or hosted numbers exist) · `arithmetic` (round-trip count × a measured RT distribution) · `inferred` (code shape only) | +| **C · Cost shape** | `fixed` every request pays · `multiplicative` scales with corpus/results · `once` per process | +| **D · Gate** (order) | `free` · `flag` (`src/lib/rag/**` glob → FLAG + `RAG impact:` line) · `#017` · `canary` (provider $, approval) · `operator` | + +`B` exists because this repo has already spent effort on inferred latency claims that hosted +profiling refuted — ledger `#069` closed with warm p90 175–243 ms and the conclusion _"plans are +not the multi-second tail."_ Every finding below carries its grade so that cannot recur. + +Tiers: **L0** structural · **L1** fixed per-request answer-path overhead · **L2** scale-dependent +answer-path overhead · **L3** client first-paint · **L4** cold-start/ingestion · **L5** +measured-and-cleared · **L6** deliberate. + +--- + +## L0 — Structural (1) + +### L0-1 · Buffered generation delivered in a single frame: time-to-first-content == total latency + +`src/lib/openai.ts:465-481`, `src/app/api/answer/stream/route.ts:231-260` · `A=P B=measured C=fixed` · gate=**canary + clinical governance** + +- **Evidence.** `src/lib/openai.ts:465` states it outright: _"Buffered (non-streaming) request — the baseline behaviour."_ Only `client.responses.create` / `.parse` are ever called — never `.stream`, never `stream: true`. `stream/route.ts` awaits the entire answer and emits it in **one** `final` SSE frame. The SSE transport exists and carries `progress` events, but no answer text flows until generation completes. +- **Cost model.** Route budgets are extractive 12 s / fast 25 s / strong 35 s (`src/lib/rag/rag-route-budget.ts:3-8`); SLOs are fast ≤ 10 s / strong ≤ 25 s (`docs/observability-slos.md:37-38`); recent answer canaries recorded p95 **17,003 ms** and a final-gate p95 of **7,494 ms**. Because there is exactly one content frame, a strong answer that is _perfectly inside SLO_ still shows the clinician a blank panel for up to 25 s. **The SLO can be met while the experience is a blank panel** — the metric and the wait have been allowed to diverge. +- **The repo has already conceded this in code.** `src/lib/sse-heartbeat.ts` sends a comment frame every **15 s** because generation _"legitimately goes silent for stretches."_ A 15 s keepalive is only necessary if the silent window routinely exceeds 15 s. The heartbeat is not a fix; it is instrumentation of the defect. +- **Why the obvious fix is refused.** `src/lib/answer-stream-contract.ts:18-21` deliberately excludes the legacy `token` and `revising` events: _"A new client can be routed to an older server during a rolling deployment, so accepting those events would re-expose unvalidated clinical prose."_ Token streaming **existed here and was removed as a clinical-safety control.** It is corroborated by the post-generation pipeline every answer must clear — `sanitizeCitations`, `sanitizeAnswerText`/`sanitizeStructuredText`, `applyNumericVerification`/`unboldUnverifiedNumbers`, `sanitizeQuoteCards`, `assessAndEnforceClaimSupport` — over a `responses.parse` structured object. Forwarding raw tokens would bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. +- **Only admissible shape.** Progressive disclosure of _already-verified units_: emit retrieval-complete evidence and sources first, then each answer section after **that section** clears verification, over the existing whitelisted `progress` event — never a reintroduced `token`. Needs a clinical-governance decision plus a canary pair. +- **Risk if wrong.** Re-landing token streaming would reintroduce a hazard this repo removed on purpose. Filed as ledger `#100` with the refutation recorded so a future agent cannot rediscover the "obvious" fix. + +--- + +## L1 — Fixed per-request overhead on the answer/search path (5) + +### L1-1 · Cache-hit paths pay an uncached `documents` round trip before responding + +`src/lib/rag/rag.ts:3234`, `src/lib/rag/rag-cache.ts:189,334` · `A=P B=arithmetic C=fixed` · gate=**flag** · **APPLIED (narrowed)** + +- **Evidence.** `setCachedAnswer` and `setCachedSearch` both call `await cacheIndexingVersion(args, { forceRefresh: true })`, deliberately bypassing their own 5 s stamp cache (`cacheIndexingVersionTtlMs = 5000`). At `rag.ts:3234` a **shared-cache hit** — the fastest path in the system — awaited `setCachedAnswer` _before responding_, so serving a cached answer required a fresh `documents` query first. Eight `setCachedSearch` awaits do the same on terminal retrieval branches (`rag.ts:2379,2421,2489,2538,2664,2692,2891,2984`). +- **`forceRefresh` is load-bearing — do not remove it.** `rag-cache.ts:190` compares the fresh stamp against `indexingVersionAtRetrievalStart` and **drops the write** if the corpus moved mid-request. That is a staleness guard, not sloppiness. The only admissible transform is deferral. +- **Applied.** `rag.ts:3234` only. The deferral is provably safe there: `annotateSearchResults` is pure (`evidence-relevance.ts:302-307` maps to new objects), the response payload is spread into a new object, `cloneAnswer` is `structuredClone`, and nothing else holds a reference to `sharedCachedAnswer` — so the deferred clone captures exactly what an awaited call would have. The constraint is now documented on `setCachedAnswer` itself. +- **Not applied — the 8 `setCachedSearch` sites.** Two blockers: `setCachedSearch` calls `throwIfAborted(args.signal)` twice, so deferring changes abort semantics; and it clones `results` **after** an `await`, so deferring widens an existing mutation window on arrays that stay live downstream. Discharging that across 8 branches needs tracing this repo's own record says offline review cannot settle. Filed as `#099`. + +### L1-2 · Sequential, unbudgeted preamble before any deadline exists + +`src/app/api/answer/route.ts` · `A=P B=arithmetic C=fixed` · gate=**free** · **APPLIED (`/api/answer`)** + +- **Evidence.** `publicAccessContext` (auth round trip) → `consumeSubjectApiRateLimit` (Supabase RPC) → `resolveSearchScope` (0–N Supabase round trips) ran strictly sequentially, and all three completed **before** `createAnswerRouteDeadline` existed — bounded only by client abort. `resolveSearchScope` was called without `signal`, so its queries never received `.abortSignal(...)` despite `search-scope.ts:200,327` supporting it. +- **Applied.** Scope now starts concurrently with the rate-limit RPC and is aborted the moment the limiter denies, so a throttled caller still costs nothing. The signal is threaded (`AbortSignal.any([request.signal, scopeAbort.signal])`), which also fixes the missing-abort defect. The scope promise is _settled_, never left floating — the limiter can return first, and an unhandled rejection would crash the process. Pinned by `tests/answer-route-preamble.test.ts`, which fails against the serial shape. +- **Irreducible.** Auth → rate-limit is a genuine data dependency (`consumeSubjectApiRateLimit` needs `access.rateLimitSubject`). Only scope can overlap. +- **Not applied — the stream route.** There, scope already runs _inside_ the stream after `sendProgress({stage:"scoping"})`, so it is not blocking the first byte in the same way; auth and rate-limit remain pre-stream by necessity. +- **Report-only — extending deadline coverage over the preamble.** That changes _which_ requests get cancelled, converting some slow-but-successful answers into timeouts. Answer-path behaviour; do not change budget numbers in a latency pass. + +### L1-3 · Two independent identity resolutions per authenticated request + +`src/proxy.ts:125`; `src/lib/public-api-access.ts:135` → `src/lib/supabase/auth.ts:187-204` · `A=P B=inferred C=fixed` · gate=**free but not actionable in-process** + +- **Evidence.** `proxy.ts:125` awaits `supabase.auth.getClaims()` on every matched request — the matcher excludes only static assets, so **every `/api/*` call** is included. Each public API route then independently resolves identity via `getOptionalAuthenticatedUser`, and `auth.ts:161` constructs a **fresh `createServerClient` per request**, with no caching of the resolved user. +- **Correction to the first-pass finding.** `proxy.ts:102-105` short-circuits when no `sb-` cookie is present, so **anonymous traffic pays nothing here**, and `getClaims()` may be local JWKS verification rather than a network call depending on the project's JWT signing algorithm. The accurate claim is _two independent identity resolutions per authenticated request, at least one of which (`getUser`) always contacts the Auth server_ — not "always two network round trips". +- **Why no memo was added.** Verified: every route resolves identity **exactly once per HTTP method handler** — the multi-call files have one call per `GET`/`POST`/`PATCH`/`DELETE`, never two per request. The real duplication spans the proxy and the route handler, which are separate invocations holding **different `Request` objects**, so no in-process memo can bridge them. A memo would dedupe nothing. +- **Capacity relevance.** `docs/capacity-review.md:106-113` names the Auth tier's ~10 absolute DB connections the **first hard failure**. Halving per-request auth resolutions is a capacity lever, not only a latency one — which is why `#099` cross-references `#011`. +- **Fix shape (report-only).** Have the proxy forward its already-verified claims to the route handler through a request header it controls, so the route trusts the proxy's resolution instead of repeating it. Requires care: the header must be proxy-set and unspoofable from outside. + +### L1-4 · Anonymous `answer`/`document_upload` consume two sequential rate-limit RPCs + +`src/lib/api-rate-limit.ts:276-282` · `A=P B=arithmetic C=fixed` · gate=**operator (needs a migration first)** + +- **Evidence.** `consumeAnonymousLimit(subjectKey)` then, if not limited, `consumeAnonymousLimit('anon::global')` — two serial Supabase RPCs before an anonymous answer request starts work. +- **`Promise.all` is the wrong fix.** It would consume the global bucket even when the subject bucket already denied, corrupting the counters. +- **The correct fix already has a precedent in this repo.** `consume_summary_rate_limits_atomic` (`supabase/migrations/20260717172000_atomic_summary_rate_limits.sql`) exists for exactly this reason — `api-rate-limit.ts:297-302` documents it as locking _"every participating bucket in a stable order, avoiding the partial accounting and lock-order risk of two serial RPC calls."_ +- **Why not applied.** No generic subject+global atomic RPC exists (only `consume_api_rate_limit`, `consume_api_subject_rate_limit`, and the summary special case). Adding one means new locking SQL, and the app cannot call it until the operator applies the migration — switching the call site first would break production. Untested hand-authored locking SQL is also the exact category this repo marks do-not-hand-author. Filed as `#099`. + +### L1-5 · `/api/search/universal` has no server-side coalescing + +`src/app/api/search/universal/route.ts:129-186` vs `src/app/api/search/route.ts` · `A=S B=arithmetic C=fixed` · gate=**flag** + +- **Evidence.** Every keystroke reaching the server costs an auth round trip + a rate-limit RPC + an 11-domain fan-out. `/api/search` coalesces identical concurrent requests in-process (`scopedSearchInflight`, keyed on the request body); the typeahead route does not. The rate limiter is the only throttle (registry bucket: 120/60 s authenticated, 60/60 s anonymous). +- **Mitigating context.** Typeahead correctly skips the per-keystroke embedding (`universal-search.ts:474-484`, `lexicalOnly: true`) and owner catalogues sit behind a 5 s TTL with in-flight sharing, so a typing burst issues one catalogue fetch per window. +- **Gated on evidence.** Resolved `#083` shows this exact surface is timeout-sensitive, and a coalescer changes what a keystroke returns under race. Needs the round-trip harness plus fake-timer tests first. Filed as `#101`. + +--- + +## L2 — Scale-dependent overhead on the answer/search path (9) + +### L2-1 · Sequential hydration triples, repeated on four retrieval branches + +`src/lib/rag/rag.ts:2460,2493,2521` (and `2596/2605/2630`, `2847/2850/2868`, `2935/2943/2961`) · `A=P B=arithmetic C=multiplicative` · gate=**canary** + +Metadata → memory → visual hydration run as three sequential Supabase stages, on up to four distinct branches within one request. `withMemoryBoostedCandidates` (`rag-candidate-sources.ts:1205,1208`) is itself 2 sequential round trips. Parallelising changes candidate assembly under partial failure and timeout — retrieval behaviour, so canary-gated. Note the contrast: `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all` (comment "A1"), so the pattern is established and the omission here is inconsistency rather than intent. + +### L2-2 · Nested `await`-in-loop on the scope critical path + +`src/lib/search-scope.ts:202,328` · `A=P B=arithmetic C=multiplicative` · gate=**canary** (loop) / **operator** (index) + +`:202` awaits a label query inside a **doubly nested** loop (200-document batches × 1,000-row pages, cap 100 pages/batch). `:328` awaits a document query in a pagination loop up to `maxResolvedDocuments = 5000` — up to 5 sequential round trips before retrieval starts. Batching changes set assembly and truncation at the page cap; resolved `#075` proves this code has already produced a recall defect once, so it is canary-gated rather than free. + +### L2-3 · Bare-column `ILIKE` cannot use the only trigram index + +`src/app/api/documents/route.ts:193`; `src/lib/rag/rag-candidate-sources.ts:477` · `A=P|S B=inferred C=multiplicative` · gate=**operator** · **SQL AUTHORED, NOT APPLIED** + +`documents_title_trgm_idx` (`schema.sql:687`) indexes the **concatenated expression** `lower(coalesce(title,'') || ' ' || coalesce(file_name,''))`. Both call sites filter the bare columns (`title.ilike.%q%,file_name.ilike.%q%`), which that expression index cannot serve, so both fall back to scanning `documents`. The RAG-path site is bounded by `.limit(12)`, but a non-matching query still scans. Two bare-column GIN trigram indexes serve those predicates directly — **additive and semantics-neutral, so no query text changes and recall is byte-identical**, which is what keeps this out of canary territory. + +**Deliberately no migration file.** The reviewed statements live in +[`operator-apply-performance-latency-remediation.md`](../operator-apply-performance-latency-remediation.md). +A migration that adds indexes without a synchronized `schema.sql` mirror and regenerated +`drift-manifest.json` is what closed PR #1312; and the mirror cannot be written first, because +`required_indexes` inside `search_schema_health()` (`schema.sql:3178`) runs against the live +database and would fail until the indexes exist. Apply → mirror → regenerate → register, in that +order, in one operator change. Tracked as `#102`. + +### L2-4 · Up to three sequential OpenAI generation calls, 60 s cumulative budget + +`src/lib/rag/rag.ts:4226,4260,4349,4397`; budget `:4043` · `A=P B=measured C=multiplicative` · gate=**canary**, overlaps `#021` + +Initial → truncation/quality retry to strong → strong quality-repair, with `OPENAI_GENERATION_MAX_RETRIES = 0` and a cumulative budget of `OPENAI_ANSWER_TIMEOUT_MS × 2` = 60 s — against route budgets of 25/35 s. Already parked as `#021` ("weakest cost/benefit on the queue") by explicit decision; recorded here for completeness, not reopened. + +### L2-5 · `.eq("status","indexed").order("id")` paged to 5,000 with no `(status,id)` composite + +`src/lib/search-scope.ts:271-277` · `A=P B=inferred C=multiplicative` · gate=**operator** + +`documents_status_idx` (`schema.sql:678`) is single-column, so the `ORDER BY id` requires a sort per page. Compounds L2-2. Authored alongside L2-3 in the operator runbook; same window, same ordering constraint. + +### L2-6 · `select("*")` pulled a generated tsvector over the wire + +`src/app/api/documents/[id]/table-facts/route.ts` · `A=S B=inferred C=multiplicative` · gate=**free** · **APPLIED** + +Three `select("*")` calls on `document_table_facts` transferred the generated `search_tsv` tsvector and `normalized_terms` array, while `src/lib/document-detail.ts` already had a narrow projection for the same table. All three now use explicit projections. The PATCH response is unchanged: `tableFactDetailProjection` matches the `TableFactRow` DTO (`src/components/document-viewer/types.ts:42-53`) field for field, and `DocumentViewer.tsx:1058` replaces client state with that object. Narrowing also stops `owner_id` leaving on the PATCH response. + +### L2-7 · Unbounded per-owner `differential_records` fetch + +`src/app/api/differentials/[slug]/route.ts:166-175` · `A=S B=inferred C=multiplicative` · gate=**report-only** + +`select("*").eq("owner_id", …)` with **no `.limit()`**, returning every owner row including `payload`/`source` jsonb. **Two first-pass claims were wrong and are corrected here:** (1) the JS-side `kind` filter is _not_ waste — `kind` is constrained to exactly `('presentation','diagnosis')` (`schema.sql:6074`) and the code uses **both** partitions, so a push-down would be a no-op; (2) the projection cannot be narrowed, because `payload`/`source` _are_ the rendered data. The only real issue is the missing bound, and a silent `.limit()` would truncate `getDifferentialDetailContext` — changing what a clinician sees. Any cap needs a truncation signal, not a silent limit. Deliberately **not** fixed. + +### L2-8 · Typeahead results are never cached + +`src/lib/rag/rag.ts:2698-2711` · `A=S B=inferred C=fixed` · gate=**canary** + +The `lexicalOnly`/source-only branch returns before `setCachedSearch`, so no typeahead result is ever cached. Adding caching changes what the _next_ keystroke returns. Filed as `#101`. + +### L2-9 · `/api/medications` rebuilds the whole catalogue per anonymous request + +`src/app/api/medications/route.ts`; `src/lib/medication-snapshot.ts:1` · `A=S B=inferred C=fixed` · gate=**free** · **APPLIED** + +Anonymous callers got `defaultMedicationRecords()` plus a `governance` object built with `Object.fromEntries` over every record, per request, backed by a statically imported 3.52 MB JSON snapshot. `defaultMedicationRecords()` was already memoised via `loadMedicationSnapshot`, so the per-request cost was the governance map (every request) and the `fields=index` projection (mapping every record again). Both are query-independent and are now built once at module scope; ranking still runs per query. Responses already carried `PUBLIC_FIXTURE_CACHE_CONTROL` (`public, max-age=300, s-maxage=3600, stale-while-revalidate=86400`), so repeat traffic was partly absorbed upstream — this removes the origin cost for the misses. + +--- + +## L3 — Client first-paint and interaction (8) + +> **Tier gate.** Ledger `#017` requires reproducible live Lighthouse/Web-Vitals evidence **before** payload work, and it gates `#012`/`#013`/`#016`. Everything below is report-only **except** L3-4 and L3-5, which are `#017`-exempt on a principled reading: `#017` gates _payload_ decisions (`#012`/`#013`/`#016` are all byte-count items). A loading fallback ships zero bytes and a resource hint ships ~60; neither can be justified or refuted by a Lighthouse number. + +### L3-1 · Route-group layout makes 71.6 KB of single-feature CSS render-blocking everywhere + +`src/app/(search-app)/layout.tsx:4` · gate=`#017` + +`import "@/components/therapy-compass/therapy-compass.css"` sits in the **route-group** layout, so 3,427 lines / 71.6 KB of Therapy-Compass-only CSS is render-blocking on `/`, `/documents`, `/forms`, `/dsm` and every mode home. `globals.css` is a further 2,911 lines / 90.6 KB. + +### L3-2 · Shared shell statically imports the Therapy Compass barrel + +`src/components/clinical-dashboard/shared-search-app-shell.tsx:8` · gate=`#017` + +A static `@/components/therapy-compass` barrel import pulls `workspace.tsx` → `bindings.tsx` (524 lines) + `nav.tsx` into **every** `(search-app)` route, not just `/therapy-compass/*`. Note this is the only barrel in `src/`; there are no `from "@/components"` / `from "@/lib"` imports anywhere, so the cost is the transitive graph, not barrel breadth. + +### L3-3 · 3.16 MB of catalogue JSON fetched client-side on mount + +`src/components/therapy-compass/bindings.tsx:207-211` → `data/use-therapy-data.ts:28,34-40` · gate=`#017` + +`therapies.json` is 2,470 KB and `therapies-index.json` 690.6 KB, fetched on mount with nothing server-rendered. **Correcting `#016`'s framing:** the filenames are unversioned and Next serves `/public` with an ETag, so repeat visits pay ~4 revalidation round trips, **not 3.16 MB**. First visit pays the bytes. The fix is therefore content-hashed filenames + `immutable` (touching `scripts/build-therapies-index.mjs` and `check:therapy-data-index`), **not** a bare `Cache-Control` line — which is what `#016` currently implies. `next.config.ts:100-125` sets cache headers only for `/sw.js`, `/offline.html`, and `/manifest.webmanifest`; nothing for `/public/*`. + +### L3-4 · Ten `ssr: false` surfaces rendered nothing between HTML arrival and chunk execution + +`src/components/clinical-dashboard/clinical-dashboard-lazy.tsx`; `src/components/clinical-dashboard/dashboard-nav.tsx` · gate=**`#017`-exempt** · **APPLIED** + +11 dashboard surfaces are `ssr: false`; only `StagedAnswerResultSurface` had a `loading` fallback. The other 10 — including the `/tools` hub and the four entries that all resolve to the same 852-line `DocumentManagerPanel` module — rendered literally nothing while their chunk was in flight, which reads to a user (and to a screen reader) as nothing happening. All 10 now use the existing shared `LoadingPanel` primitive (`ui-primitives.tsx:455`), which carries `role="status"` + an accessible label. The two sidebar dialogs are intentionally excluded: they mount on open, so a fallback would render into a closed dialog. + +### L3-5 · No connection warm-up for the Supabase origin + +`src/app/layout.tsx`; `src/lib/supabase/client.tsx:194` · gate=**`#017`-exempt** · **APPLIED** + +Root-layout `AuthProvider` awaits `Promise.all([auth.getUser(), auth.getSession()])` on mount — a cross-origin request that every auth-gated client fetch queues behind — and there was **no `preconnect`, `dns-prefetch`, `preload`, or `ReactDOM.preconnect` anywhere in `src/`**. Added `preconnect` (with `crossOrigin`, required for the connection to be reused by supabase-js's CORS fetches) + `dns-prefetch`, guarded so demo mode without public env emits nothing. + +### L3-6 · Three client waterfalls + +`src/components/clinical-dashboard/use-app-preferences.ts:156-182`; `src/components/ClinicalDashboard.tsx:977-1069`; `src/components/clinical-dashboard/signed-image.tsx:60-84` + `use-signed-image-url.ts:39` · gate=`#017` + +Three sequential hops each: (a) `getUser()` → `GET /api/account/preferences` → conditional `PUT` bootstrap awaited inside the GET's `.then`; (b) local identity → `setup-status` → a parallel fan-out of 4 (only the fan-out is parallel; the two preceding hops are strictly serial); (c) per evidence image, mount → IntersectionObserver → signed-URL fetch → first image byte, i.e. 2 sequential round trips before any image data. Worth noting the good news: **zero** route files are client components — all ~120 `src/app/**` pages are Server Components. + +### L3-7 · Paint and motion cost in `globals.css` + +`src/app/globals.css` · gate=`#017` + +`.edge-glass-header-backdrop` (`:709-748`) stacks **three** `backdrop-filter` blur passes (14/20/26 px, each with its own mask) on one always-mounted element that translates on every scroll-hide. `.answer-footer-search-pill` (`:677-684`) puts `box-shadow` — non-composited — in its `transition` list alongside a `backdrop-filter`. `@keyframes shimmer` (`:2289-2296`) animates `background-position` (paint, not composited) on the shared `Skeleton` primitive, so every skeleton repaints continuously; the opacity-only `.animate-skeleton-shimmer` (`:2484-2489`) is the well-behaved variant already used in `answer-status.tsx`. Four `will-change: padding-bottom` + `transition: padding-bottom 240ms` rules (`:2851-2869`) animate a layout property, though phone-scoped and gated on a transient marker. `html.theme-transitioning *` (`:2884-2891`) transitions 6 properties on **every element** for 200 ms. Ten `backdrop-filter` declarations total. Counterweight: the other 9 keyframes animate only `opacity`/`transform`, and reduced-motion/forced-colors escapes are thorough. + +### L3-8 · Nonce CSP forces every route dynamic + +`src/app/layout.tsx` · gate=`#017` · already `#016(a)` + +Reading `headers()` for the per-request nonce opts the entire app into dynamic rendering — acknowledged in-code as _"inherent to nonce-based CSP."_ There is no `unstable_cache`, `revalidate`, `fetchCache`, or `"use cache"` anywhere in `src/`, so the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation) get no static generation at all. + +--- + +## L4 — Cold-start, build, ingestion (1) + +### L4-1 · 5.6 MB of snapshot JSON statically ESM-imported + +`src/lib/medication-snapshot.ts:1`; `src/lib/differential-fixtures.ts:1`; `src/lib/service-catalog.ts:1` · `A=C C=once` · gate=`#017`-adjacent + +`medications-snapshot.json` 3.52 MB + `differentials-snapshot.json` 1.19 MB + `services-snapshot.json` 915 KB (plus specifiers 659 KB, search-index 245 KB, forms 170 KB) parsed at module evaluation. Cold-start cost, not per-request. Partially covered by `#013`. + +### ~~L4-2 · `worker/main.ts` reads each image up to 3× per ingestion~~ — RETRACTED 2026-07-29 + +The first pass carried this forward from the 2026-07-01 audit as finding `L11` and called it +"CONFIRMED with no fix evidence". **That was wrong on the second half.** The 2026-07-01 audit's own +disposition table records it as _"a deliberate peak-memory trade-off … documented at the site"_, +and the documentation is present at `worker/main.ts:866-869`: + +> _"…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."_ + +The three reads are real (`worker/main.ts:872`, `:1034`, `:1129`) but they are the accepted side of +a decided trade-off, on ingestion throughput rather than clinician latency. Re-filing it as open +work would re-litigate a settled decision. Moved to **L6** as deliberate design; the correction is +recorded in ledger `#104` so a third audit does not resurrect it. + +--- + +## L5 — Measured and cleared, or dormant (16) — do not re-report + +Recorded so the next audit cannot re-file these. + +1. **Table-facts RPC latency** — ledger `#069`, closed 2026-07-27 with hosted profiling: warm client median/p90 167–189 / 174–243 ms, first-unprimed 278–663 ms. Conclusion: _"Plans are not the multi-second tail."_ +2. `match_document_chunks_text` OR-across-relations → split into two GIN probes, `20260713100000`. +3. Per-row `document_index_quality` correlated subquery → now a `left join` (`schema.sql:6574,6589`). +4. N+1 label/summary per retrieved chunk → batch CTEs (`schema.sql:3888,3917`) + `20260702170000`. +5. `match_documents_for_query` unbounded `similarity()` over documents×labels×summaries → removed; effective definition is tsvector-only. +6. Legacy ivfflat/dead vector indexes → dropped, **~4.4 GB reclaimed**, DB 13 → 8.6 GB (`20260702014803`); guarded by `detect_legacy_ivfflat_indexes()`. +7. `LIMIT NULL` on versioned retrieval entry points → clamped 1..96 / 1..100 (`20260717162000`). +8. `work_mem` on 8 hybrid/lexical RPCs → 64 MB (`20260724000000`). +9. Duplicate/redundant FK and dead indexes → dropped across four migrations. +10. `buildEvidenceRelevance`/`buildVisualEvidence` recomputation (audit `L10`) → computed once and shared (`search/route.ts:836-838`). +11. Latency SLOs, per-phase telemetry, `Server-Timing`, `/api/health?deep=1` counters → shipped (the `Server-Timing` coverage gap on the stream/search routes is closed by this pass). +12. Documents-only universal-search timeout → `#083` resolved. +13. Bundle-size budget → enforced (`bundle-budget.json`, `enforce: true`, 1,309,274 gzip bytes ±10%). +14. **`hnsw.ef_search` not attachable to the two `language sql` RPCs** → known hosted-platform blocker; `20260627000000` is an explicit no-op recording `ERROR: permission denied to set parameter "hnsw.ef_search" (42501)`. Not a defect. +15. Wide vs narrow `document_table_facts` trigram index mismatch → verified moot; the effective RPC expression (`schema.sql:6726`) matches the narrow index (`:6425`). +16. Versioned-RPC fallback chains (`rag-candidate-sources.ts:100-118`, `deep-memory.ts:920,941,950`) → **dormant** while the `_v2`/`_v3` functions exist. A resilience note (3× sequential round trips if they ever go missing), not live latency debt. + +--- + +## L6 — Deliberate design, not defects (5) + +1. **Mode-home and dashboard prefetch** (`master-search-header.tsx:858-957`, `ClinicalDashboard.tsx:846-849`) — this is PR #1275 / `551f3f666 perf(ui): prefetch mode homes for seamless switching`, reviewed and intentional. The unconditional 250 ms mount timer prefetching `/tools` + `/favourites` + `/differentials` does compete with the dashboard's own `setup-status` → `documents` chain, and a pointer sweep across the mode menu prefetches every mode traversed (deduped per session). Recorded as **a measured trade-off to watch**, not a defect. +2. **Anonymous answers are never cached or coalesced** (`rag-cache.ts:141-143`) — a PHI invariant. +3. **`SignedImage` `unoptimized`** (`signed-image.tsx:142-161`) — deliberate; bearer URLs must not enter the unauthenticated `/_next/image` cache. Resolved `#014`. CLS is already handled by a fixed aspect frame. +4. **`minimumCacheTTL` left at 60 s** (`next.config.ts:53-54`) — deliberate; a day-long floor could retain optimizer output past signed-URL lifetimes. +5. **Worker re-reads each ingestion image per stage** (`worker/main.ts:866-869`) — deliberate peak-memory trade-off, documented at the site. See the L4-2 retraction above. + +--- + +## Guardrail disposition + +| Gate | Findings | What it requires | +| ------------ | --------------------------------------------------- | -------------------------------------------------------------- | +| **free** | L1-2, L2-6, L2-9, L3-4, L3-5 | Ordinary review — all applied | +| **flag** | L1-1 (applied), L1-5 | FLAG + `RAG impact:` line; no canary when no behaviour changes | +| **canary** | L0-1, L2-1, L2-2, L2-4, L2-8, L1-5 (if behavioural) | Provider-backed eval pair, ~$1–2, explicit approval | +| **`#017`** | L3-1, L3-2, L3-3, L3-6, L3-7, L3-8, L4-1 | Live Lighthouse/Web-Vitals evidence first | +| **operator** | L1-4, L2-3, L2-5, + `#011` connection allocation | Hosted apply / dashboard change | + +**Explicit non-actions**, per `docs/capacity-review.md:123-125`: no read replicas, no horizontal app scaling, **no retrieval concurrency semaphore** until soak data shows queueing. Its verdict — _Postgres CPU under hybrid-RPC concurrency is the first soft failure; "answer p95 inflates well before errors appear"_ — is precisely why L1/L2 round-trip reduction has capacity value beyond latency, and why a semaphore remains the wrong lever. + +--- + +## Measurement plan (provider-free) + +Every L1 finding is a **round-trip count**, and round-trip counts can be pinned offline and priced +with numbers this repo already owns (`#069`: warm p90 175–243 ms; first-unprimed 278–663 ms). No +provider call is needed to size the top of the ranking. + +1. **`Server-Timing` first (done).** It existed but was emitted only by `/api/answer` and `/api/search/universal` — **not by `/api/answer/stream`, the route the UI actually uses.** That was the largest measurement gap in the repo. Now: `/api/answer` emits `auth`/`ratelimit`/`scope` alongside its existing entries; `/api/search` emits `auth`/`ratelimit`/`search`/`total`; `/api/answer/stream` emits `auth`/`ratelimit`. **Documented limitation:** on a streaming response, headers flush before the first frame, so in-stream stage durations cannot reach a response header — and routing them through the SSE contract would put instrumentation inside a governed clinical payload (`answer-stream-contract.ts:21` whitelists only `progress`/`final`/`error`). `/api/answer` covers those stages over the same `resolveSearchScope` + RAG path. +2. **Offline round-trip budget harness.** `tests/answer-route-preamble.test.ts` pins the answer-route preamble: scope must start before the limiter settles, the signal must be threaded, and a denial must abort scope without a floating rejection. Broadening this to a general counting-proxy over the offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`) is the enabler for L1-5 and remains open as `#098`. Zero providers, zero DB. +3. **Provider-free wall-clock hot path.** Use the typeahead route (`lexicalOnly: true` skips embeddings) plus demo mode to time auth + rate-limit + scope + RPC with no OpenAI call — isolating exactly the L1 stack. +4. **Disposable local Postgres + `EXPLAIN (ANALYZE, BUFFERS)`** for L2-3/L2-5/L2-6. **Caveat to carry:** `hnsw.ef_search` _is_ settable locally, so no local vector plan may be generalised to hosted. +5. **Local client evidence that does not claim to be `#017`.** `build:analyze`, the enforced bundle budget, the `next build` route table (`ƒ Dynamic` vs `○ Static`), and a Playwright run on the existing local harness reading `PerformanceObserver` LCP/CLS/long-tasks plus `performance.getEntriesByType("resource")` on a cold profile. This **ranks** L3 items against each other; it does **not** discharge `#017`, which requires live-site evidence. + +**Excluded without explicit approval:** `profile:retrieval` (hosted), `check:supabase-project`, `verify:release`, `scripts/soak-test.ts`, any live eval canary, live Lighthouse. + +--- + +## Applied (7) + +| Finding | Change | Files | +| ----------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Measurement | `Server-Timing` on `/api/answer/stream` + `/api/search`; `auth`/`ratelimit`/`scope` on `/api/answer` | `src/lib/server-timing.ts`, `src/app/api/answer/route.ts`, `src/app/api/answer/stream/route.ts`, `src/app/api/search/route.ts` | +| L1-1 | Shared-cache-hit promotion deferred off the response path, staleness guard intact and documented | `src/lib/rag/rag.ts:3234`, `src/lib/rag/rag-cache.ts` | +| L1-2 | Scope overlapped with the rate-limit RPC, abort-on-deny, signal threaded | `src/app/api/answer/route.ts`, `tests/answer-route-preamble.test.ts` | +| L2-6 | Three `select("*")` narrowed to explicit projections | `src/app/api/documents/[id]/table-facts/route.ts`, `src/lib/document-detail.ts` | +| L2-9 | Governance map and index projection built once instead of per anonymous request | `src/app/api/medications/route.ts` | +| L2-3 / L2-5 | Bare-column trigram + `(status,id)` composite **authored as operator SQL, not applied** | `docs/operator-apply-performance-latency-remediation.md` | +| L3-4 / L3-5 | 10 `loading` fallbacks; Supabase `preconnect`/`dns-prefetch` | `clinical-dashboard-lazy.tsx`, `dashboard-nav.tsx`, `src/app/layout.tsx` | + +**`RAG impact: no retrieval behaviour change** — the only `src/lib/rag/**` edits defer a process-local cache write off the response path and add a doc comment; no scoring, ordering, selection, alias, or citation logic is touched, and the mid-request staleness guard is preserved.** + +## Retired during verification (4) + +Recorded because a plan that survives contact unchanged usually means the verification was too shallow. + +1. **Per-request identity memo** — would dedupe nothing; identity resolves once per method handler, and the real duplication spans two invocations with different `Request` objects (L1-3). +2. **Batched anonymous rate limit** — requires new locking SQL the app cannot call before the operator applies it (L1-4). +3. **`differential_records` `kind` push-down + narrowed projection** — push-down is a no-op against the schema's `kind` check constraint; `payload`/`source` _are_ the data; a silent `.limit()` would truncate clinical context (L2-7). +4. **Deferring the 8 `setCachedSearch` awaits** — changes abort semantics and widens a real mutation window on live arrays (L1-1). + +--- + +## Method, coverage, limitations + +- **Verified vs inferred.** Every L0/L1 finding and every "already fixed" claim was read at the cited line by the primary author. L2/L3 findings carry their evidence grade inline. No wall-clock measurement was taken. +- **`schema.sql` duplicate-definition trap.** `supabase/schema.sql` contains **duplicate definitions of 12 functions**, replayed top to bottom, so **the later definition wins**. Reviewing only the first occurrence produces false findings. Affected retrieval RPCs and their effective line numbers: `match_document_chunks` **:6522**, `match_document_chunks_hybrid` **:6559**, `match_documents_for_query` **:6783**, `match_document_table_facts_text` **:6645**, `match_document_embedding_fields_text` **:6621**. This is a deliberate artefact of `20260701140631_codify_live_retrieval_rpcs` capturing live-only fixes; see `docs/process-hardening.md:151`. +- **Environment variance.** `docs/process-hardening.md:356-357` records this cloud environment's Supabase p95 at ≈ 49 s. Large local eval latencies are known variance, **not** regressions. +- **Schema divergence found.** `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**. Different owner and verification path (`check:drift`, drift manifest) than the index work above — filed separately as `#103`. +- **Out of scope.** Correctness, security, and clinical-governance findings; the `#017`-gated client payload decisions; hosted application of any index. diff --git a/docs/codebase-index.md b/docs/codebase-index.md index fb510e9973..0af4904fe6 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -337,17 +337,18 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: ## Related docs -| Topic | Doc | -| ------------------------ | ---------------------------------------------------------------- | -| Full documentation index | `docs/README.md` | -| Routes and modes | `docs/site-map.md` | -| Search/RAG roadmap | `docs/search-rag-master-plan.md` | -| Universal task ledger | `docs/outstanding-issues.md` | -| Reindex operations | `docs/reindex-runbook.md` | -| Production readiness | `docs/production-readiness-checklist.md` | -| Capacity / scale-up | `docs/capacity-review.md`, `docs/auth-connection-cap-runbook.md` | -| Frontend architecture | `docs/frontend-architecture.md` | -| Repo audit (2026-07-01) | `docs/audit/repo-audit-2026-07-01.md` | +| Topic | Doc | +| -------------------------- | ---------------------------------------------------------------- | +| Full documentation index | `docs/README.md` | +| Routes and modes | `docs/site-map.md` | +| Search/RAG roadmap | `docs/search-rag-master-plan.md` | +| Universal task ledger | `docs/outstanding-issues.md` | +| Reindex operations | `docs/reindex-runbook.md` | +| Production readiness | `docs/production-readiness-checklist.md` | +| Capacity / scale-up | `docs/capacity-review.md`, `docs/auth-connection-cap-runbook.md` | +| Frontend architecture | `docs/frontend-architecture.md` | +| Repo audit (2026-07-01) | `docs/audit/repo-audit-2026-07-01.md` | +| Latency audit (2026-07-28) | `docs/audit/latency-audit-2026-07-28.md` | --- diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md index ac3eb6543e..eccc6e9d95 100644 --- a/docs/operator-apply-performance-latency-remediation.md +++ b/docs/operator-apply-performance-latency-remediation.md @@ -35,6 +35,66 @@ exists` is a no-op. Do not mark the migration applied merely because the index exists: the cleanup function, hardened privileges, and three lifecycle triggers must still be installed through the normal authorized migration rollout. +## Bare-column trigram and status/id composite on `documents` (latency audit 2026-07-28) + +Authored 2026-07-29 for findings L2-3 and L2-5 in +[audit/latency-audit-2026-07-28.md](audit/latency-audit-2026-07-28.md). Tracked as ledger `#102`. +**No migration file exists for these**, deliberately — see the ordering constraint below. + +`documents_title_trgm_idx` (`supabase/schema.sql:687`) indexes the concatenated expression +`lower(coalesce(title, '') || ' ' || coalesce(file_name, ''))`, so it can only serve a predicate +written against that same expression. Two live call sites instead filter the bare columns: + +- `src/app/api/documents/route.ts:193` — `title.ilike.%q%,file_name.ilike.%q%` +- `src/lib/rag/rag-candidate-sources.ts:477` — same shape, on the RAG retrieval path + +Neither can use the expression index, so both fall back to scanning `documents`. Separately, +`src/lib/search-scope.ts:271-277` pages `.eq("status","indexed").order("id")` to 5,000 rows +against the single-column `documents_status_idx` (`schema.sql:678`), so each page sorts. + +These three indexes are **additive and semantics-neutral**: no query text changes, so matching +behaviour — and therefore retrieval recall — is byte-identical before and after. That is what +keeps this out of canary-gated territory. Create them outside a transaction: + +```sql +create index concurrently if not exists documents_title_bare_trgm_idx + on public.documents using gin (title gin_trgm_ops); + +create index concurrently if not exists documents_file_name_bare_trgm_idx + on public.documents using gin (file_name gin_trgm_ops); + +create index concurrently if not exists documents_status_id_idx + on public.documents (status, id); +``` + +`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block and does not take a write lock, +but it does two table passes and can leave an `INVALID` index if it fails. Check +`pg_index.indisvalid` for each name afterwards and `DROP INDEX CONCURRENTLY` + retry any invalid +one rather than leaving it in place. + +### Ordering constraint — do all four steps in one change + +1. Create the indexes concurrently on the live database, and confirm all three are valid. +2. Mirror the three `create index` statements into `supabase/schema.sql` beside the existing + `documents` indexes. +3. Regenerate `supabase/drift-manifest.json` with `npm run drift:manifest` (requires Docker). + `tests/drift-detection.test.ts` pins the manifest to `schema.sql`'s sha256 and fails while it + is stale. +4. Only then add `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and + `documents_status_id_idx` to the `required_indexes` list inside `search_schema_health()` + (`supabase/schema.sql:3178`). + +Step 4 must come last: `search_schema_health()` runs against the live database and reports a +missing required index as a failure, so registering the names before the indexes exist turns a +health check red. Equally, shipping step 1 as a migration without steps 2–3 is what caused +PR #1312 to be closed on 2026-07-28 — an additive index migration with no synchronized +schema/drift proof. Expect `npm run check:drift` to report the three indexes as unexpected +between steps 1 and 2. + +Rollback is `DROP INDEX CONCURRENTLY` per index, reversing steps 4 → 1. Nothing reads these +indexes by name outside `search_schema_health()`, and no query text depends on them, so dropping +them restores the pre-change plans exactly. + ## Safe rollback Treat rollback as another reviewed forward migration; do not delete or repair the diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 056df14d5d..74c49b1dc4 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -76,7 +76,7 @@ removed after current-main verification; it is not missing recommended work. | 28 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | | 29 | `#086` | A3 | High — repository structure + Specialist | On explicit go-ahead for X3; later packages own their gates | 1 PR per work order | Ship remaining maturity backlog (X3 rag.ts; X7 src/lib reorg; X6 coverage floors; X5 ACL consolidation; L1 one-shot archive; L4 ledger rotation; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | - + ## Open items @@ -102,7 +102,7 @@ removed after current-main verification; it is not missing recommended work. | #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | | #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | | #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | -| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | +| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them); (e) `src/app/(search-app)/layout.tsx:4` imports 71.6 KB of Therapy-Compass-only CSS in the ROUTE-GROUP layout, making it render-blocking on `/`, `/documents`, `/forms`, `/dsm` and every mode home; (f) `shared-search-app-shell.tsx:8` statically imports the `therapy-compass` barrel, pulling `workspace.tsx` + `bindings.tsx` + `nav.tsx` into every `(search-app)` route; (g) three client waterfalls (`use-app-preferences.ts:156-182`, `ClinicalDashboard.tsx:977-1069`, `signed-image.tsx:60-84` + `use-signed-image-url.ts:39`) and the paint offenders in `globals.css` beyond the sidebar grid — three stacked `backdrop-filter` passes on an always-mounted translating element (`:709-748`), `box-shadow` inside a `transition` list (`:677-684`), and `@keyframes shimmer` animating `background-position` on the shared `Skeleton` (`:2289-2296`). **CORRECTED 2026-07-29 on (c):** the Therapy Compass filenames are unversioned and Next serves `/public` with an ETag, so only the FIRST visit pays 690.6 KB / 2,470 KB — repeat visits pay ~4 revalidation round trips. The fix is content-hashed filenames + `immutable` (touching `scripts/build-therapies-index.mjs` and `check:therapy-data-index`), NOT a bare `Cache-Control` line. See `docs/audit/latency-audit-2026-07-28.md` L3-1/L3-2/L3-3/L3-6/L3-7. | session 2026-07-21 (build route table + design audit) | 2026-07-21 | | #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | | #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | | #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | @@ -131,6 +131,14 @@ removed after current-main verification; it is not missing recommended work. | #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | | #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | | #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | +| #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins the answer-route preamble specifically and fails against the serial shape. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`). Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | +| #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, aborted on deny, signal threaded (`answer/route.ts`). **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | +| #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | +| #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | +| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Done 2026-07-29:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive and semantics-neutral, so no query text changes and recall is byte-identical. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** apply concurrently, confirm `indisvalid`, mirror into `schema.sql`, run `npm run drift:manifest` (Docker), THEN register the three names in `required_indexes` — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | +| #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | +| #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | +| #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Done 2026-07-29:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | ## Resolved / archive diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 9195f15e02..b5c5d4db7e 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -23,7 +23,7 @@ import { buildGovernedAnswerClientResponse, buildGovernedDemoAnswerClientResponse, } from "@/lib/answer-response"; -import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing"; +import { answerServerTimingEntries, buildServerTimingHeader, preambleServerTimingEntries } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; @@ -76,25 +76,55 @@ export async function POST(request: Request) { } const supabase = createAdminClient(); + const authStartedAt = Date.now(); const access = await publicAccessContext(request, supabase); + const authMs = Date.now() - authStartedAt; const accessScope = resolveRetrievalAccessScope(access.ownerId); + // Scope resolution has no data dependency on the rate-limit RPC, so the two + // overlap instead of running back-to-back before retrieval starts. The + // limiter must still be able to deny for free, so the scope queries are + // aborted the moment it does — and threading a signal at all is what lets + // a client disconnect cancel scope's paginated queries. + const scopeAbort = new AbortController(); + const scopeStartedAt = Date.now(); + let scopeMs: number | undefined; + const scopeSettled = resolveSearchScope({ + supabase, + accessScope, + documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined), + filters: answerBody.filters, + signal: AbortSignal.any([request.signal, scopeAbort.signal]), + }).then( + (value) => { + scopeMs = Date.now() - scopeStartedAt; + return { ok: true as const, value }; + }, + // Settled, never rejected: the limiter can return before this promise is + // awaited, and a floating rejection would take down the process. + (error: unknown) => { + scopeMs = Date.now() - scopeStartedAt; + return { ok: false as const, error }; + }, + ); + + const rateLimitStartedAt = Date.now(); const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, bucket: "answer", allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); + const rateLimitMs = Date.now() - rateLimitStartedAt; if (rateLimit.limited) { + scopeAbort.abort(); + await scopeSettled; return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); } - const scope = await resolveSearchScope({ - supabase, - accessScope, - documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined), - filters: answerBody.filters, - }); + const resolvedScope = await scopeSettled; + if (!resolvedScope.ok) throw resolvedScope.error; + const scope = resolvedScope.value; if (scope.documentIds?.length === 0) { return NextResponse.json({ answer: emptyScopeAnswer, @@ -135,9 +165,10 @@ export async function POST(request: Request) { }); // Durations only — see server-timing.ts for the trust-boundary constraint. - const serverTiming = buildServerTimingHeader( - answerServerTimingEntries(answer.latencyTimings, Date.now() - routeStartedAt), - ); + const serverTiming = buildServerTimingHeader([ + ...preambleServerTimingEntries({ authMs, rateLimitMs, scopeMs }), + ...answerServerTimingEntries(answer.latencyTimings, Date.now() - routeStartedAt), + ]); return NextResponse.json( { ...governedResponse.payload, diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index e111472a2c..61890c919b 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -30,6 +30,7 @@ import { isSupabaseApiKeyConfigurationError, nonProductionSupabaseDemoFallbackRe import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { logger } from "@/lib/logger"; import { safeErrorLogDetails } from "@/lib/privacy"; +import { buildServerTimingHeader, preambleServerTimingEntries } from "@/lib/server-timing"; import { startSseHeartbeat } from "@/lib/sse-heartbeat"; import { parseJsonBody } from "@/lib/validation/body"; import { answerRequestSchema, type AnswerRequestBody } from "@/lib/validation/answer-request"; @@ -152,6 +153,7 @@ function streamAnswer( accessScope: RetrievalAccessScope, signal?: AbortSignal, streamAbortController?: AbortController, + serverTiming?: string | null, ) { const ownerId = accessScope.ownerId; const encoder = new TextEncoder(); @@ -290,6 +292,9 @@ function streamAnswer( "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", + // Only the pre-stream preamble stages can appear here — headers flush + // before the first frame, so in-stream durations are not yet known. + ...(serverTiming ? { "Server-Timing": serverTiming } : {}), }, }, ); @@ -303,8 +308,11 @@ export async function POST(request: Request) { if (isDemoMode()) return streamAnswer(body, resolveRetrievalAccessScope(), streamSignal, streamAbortController); const supabase = createAdminClient(); + const authStartedAt = Date.now(); const access = await publicAccessContext(request, supabase); + const authMs = Date.now() - authStartedAt; + const rateLimitStartedAt = Date.now(); if (body.summaryMode) { const decision = await consumeSummaryRateLimits({ supabase, @@ -324,8 +332,15 @@ export async function POST(request: Request) { }); if (rateLimit.limited) return rateLimitStream(rateLimit); } + const rateLimitMs = Date.now() - rateLimitStartedAt; - return streamAnswer(body, resolveRetrievalAccessScope(access.ownerId), streamSignal, streamAbortController); + return streamAnswer( + body, + resolveRetrievalAccessScope(access.ownerId), + streamSignal, + streamAbortController, + buildServerTimingHeader(preambleServerTimingEntries({ authMs, rateLimitMs })), + ); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(error); diff --git a/src/app/api/documents/[id]/table-facts/route.ts b/src/app/api/documents/[id]/table-facts/route.ts index 130a3ee6d4..d57ed20728 100644 --- a/src/app/api/documents/[id]/table-facts/route.ts +++ b/src/app/api/documents/[id]/table-facts/route.ts @@ -5,6 +5,7 @@ import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { invalidateRagCachesForOwner } from "@/lib/rag/rag"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; +import { tableFactDetailProjection } from "@/lib/document-detail"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; @@ -19,6 +20,14 @@ const updateSchema = tableReviewSchema.extend({ }); const tableFactsRouteParamsSchema = z.object({ id: z.string().uuid() }); +// The GET list projection adds the three columns its response mapping needs on +// top of the shared TableFactRow shape. Explicit columns keep the generated +// `search_tsv` tsvector and `owner_id` off the wire. +const tableFactListProjection = + "id,document_id,page_number,table_title,row_label,clinical_parameter,threshold_value,action,normalized_terms,source_chunk_id,source_image_id,created_at,metadata" as const; +// PATCH only reads the committed-generation marker and the linked image id. +const tableFactReviewProjection = "id,metadata,source_image_id" as const; + function metadataRecord(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {}; } @@ -62,7 +71,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { data, error } = await supabase .from("document_table_facts") - .select("*") + .select(tableFactListProjection) .eq("document_id", id) .order("page_number", { ascending: true }) .order("created_at", { ascending: true }); @@ -119,7 +128,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const { data: fact, error: factError } = await supabase .from("document_table_facts") - .select("*") + .select(tableFactReviewProjection) .eq("id", parsed.factId) .eq("document_id", id) .eq("owner_id", user.id) @@ -157,7 +166,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .update({ metadata: nextMetadata }) .eq("id", parsed.factId) .eq("owner_id", user.id) - .select("*") + // Exactly the TableFactRow fields DocumentViewer replaces in client state. + .select(tableFactDetailProjection) .single(); if (updateError) throw new Error(updateError.message); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 7562cf4185..e453b66ee0 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -75,9 +75,14 @@ function matchesPayload(matches: MedicationSearchMatch[]) { })); } -function publicMedicationPayload(q: string | undefined, limit: number, fields?: "index") { - const records = fields === "index" ? toIndexRecords(defaultMedicationRecords()) : defaultMedicationRecords(); - const governance = Object.fromEntries( +// The anonymous payload is entirely derived from the curated snapshot, so both +// the index projection and the governance map are query-independent — rebuilding +// them per request mapped every record twice for nothing (latency audit +// 2026-07-28, L2-9). `defaultMedicationRecords()` is already memoised via +// `loadMedicationSnapshot`, so caching these two derivations introduces no +// aliasing the route did not already have. Ranking still runs per query. +function buildPublicGovernance(records: MedicationRecord[]) { + return Object.fromEntries( records.map((record) => [ record.slug, { @@ -86,6 +91,25 @@ function publicMedicationPayload(q: string | undefined, limit: number, fields?: }, ]), ); +} + +let cachedPublicIndexRecords: MedicationRecord[] | null = null; +let cachedPublicGovernance: ReturnType | null = null; + +function publicIndexRecords() { + cachedPublicIndexRecords ??= toIndexRecords(defaultMedicationRecords()); + return cachedPublicIndexRecords; +} + +function publicGovernance(records: MedicationRecord[]) { + // Slugs are identical for the full and index projections, so one map serves both. + cachedPublicGovernance ??= buildPublicGovernance(records); + return cachedPublicGovernance; +} + +function publicMedicationPayload(q: string | undefined, limit: number, fields?: "index") { + const records = fields === "index" ? publicIndexRecords() : defaultMedicationRecords(); + const governance = publicGovernance(records); const matches = q ? rankMedicationRecords(records, q, limit) : undefined; return { records, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index c109cd7e5e..e1a5900888 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -34,6 +34,7 @@ import { queryVocabularyAliasesForStorage, } from "@/lib/query-privacy"; import { safeErrorLogDetails } from "@/lib/privacy"; +import { buildServerTimingHeader, preambleServerTimingEntries } from "@/lib/server-timing"; import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import type { ChunkImage, ClinicalSourceMetadata, SearchResult } from "@/lib/types"; @@ -971,6 +972,7 @@ export async function POST(request: Request) { let supabase: ReturnType | null = null; let ownerId: string | null = null; let body: SearchRequestBody | null = null; + const routeStartedAt = Date.now(); try { const searchBody = await parseJsonBody(request, searchSchema, "Invalid search request."); @@ -980,16 +982,20 @@ export async function POST(request: Request) { } supabase = createAdminClient(); + const authStartedAt = Date.now(); const access = await publicAccessContext(request, supabase); + const authMs = Date.now() - authStartedAt; ownerId = access.ownerId ?? null; const publicOnly = !access.authenticated && !isLocalNoAuthMode(); + const rateLimitStartedAt = Date.now(); const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, bucket: "search", allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); + const rateLimitMs = Date.now() - rateLimitStartedAt; if (rateLimit.limited) { return rateLimitJsonResponse( "Search is temporarily rate limited because too many requests were received. Retry shortly.", @@ -998,18 +1004,30 @@ export async function POST(request: Request) { } const key = scopedSearchKey(searchBody, ownerId, publicOnly); + const searchStartedAt = Date.now(); const { payload, coalesced } = await coalesceScopedSearch( key, (signal) => buildScopedSearchPayload(searchBody, supabase!, ownerId, signal), request.signal, ); - return NextResponse.json({ - ...payload, - telemetry: { - ...payload.telemetry, - coalesced, + const searchMs = Date.now() - searchStartedAt; + + // Durations only — see server-timing.ts for the trust-boundary constraint. + const serverTiming = buildServerTimingHeader([ + ...preambleServerTimingEntries({ authMs, rateLimitMs }), + { name: "search", durMs: searchMs }, + { name: "total", durMs: Date.now() - routeStartedAt }, + ]); + return NextResponse.json( + { + ...payload, + telemetry: { + ...payload.telemetry, + coalesced, + }, }, - }); + serverTiming ? { headers: { "Server-Timing": serverTiming } } : undefined, + ); } catch (error) { if (error instanceof serverAuth.AuthenticationError) { return serverAuth.unauthorizedResponse(error); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5912586bbb..c0f582b33c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,28 @@ import { APP_THEME_COLORS, THEME_BOOTSTRAP_SCRIPT, THEME_COOKIE_NAME } from "@/l import { MobileKeyboardProvider } from "@/components/use-mobile-keyboard"; import "./globals.css"; +/** + * Origin of the Supabase project, or null when the public env is absent (demo mode). + * + * AuthProvider calls auth.getUser() on mount, so the very first thing the app does + * after hydration is a cross-origin request to this host — and every auth-gated + * client fetch queues behind it. Warming DNS/TLS while the document and JS are + * still downloading takes that handshake off the critical path. + */ +function supabaseOrigin() { + // Read process.env directly rather than the `@/lib/env` contract: that module is + // `server-only`, and the root layout sits at the head of the client module graph + // (tests/client-secret-surface.test.ts guards that boundary). NEXT_PUBLIC_* values + // are build-time inlined, so no server contract is needed to read one. + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + const geistSans = localFont({ src: "../../node_modules/next/dist/next-devtools/server/font/geist-latin.woff2", variable: "--font-geist-sans", @@ -84,6 +106,7 @@ export default async function RootLayout({ const clinicalTheme = cookieStore.get(THEME_COOKIE_NAME)?.value; const isDark = clinicalTheme === "dark"; const themeClass = isDark ? "dark" : ""; + const authOrigin = supabaseOrigin(); return ( + {/* Rendered in the tree rather than inside a hand-written , which + would compete with the framework's own head management. React hoists + hoistable tags into for us. crossOrigin is required for + the preconnect to be reused by the CORS fetches @supabase/supabase-js + makes — without it the browser opens a second connection. */} + {authOrigin ? ( + <> + + + + ) : null} {/* Applies the resolved theme before first paint on every route (standalone pages don't mount useTheme, and hydration-time toggling flashes light). Mirrors resolveThemePreference in src/lib/theme.ts: stored choice wins, diff --git a/src/components/clinical-dashboard/clinical-dashboard-lazy.tsx b/src/components/clinical-dashboard/clinical-dashboard-lazy.tsx index 704d1a05f1..006e6d8f5e 100644 --- a/src/components/clinical-dashboard/clinical-dashboard-lazy.tsx +++ b/src/components/clinical-dashboard/clinical-dashboard-lazy.tsx @@ -3,25 +3,36 @@ import dynamic from "next/dynamic"; import { AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; +import { LoadingPanel } from "@/components/ui-primitives"; + +// Every surface here is `ssr: false`, so the server sends no markup for it and +// the slot stays EMPTY until the chunk downloads and executes. Without a +// `loading` fallback that reads to the user (and to a screen reader) as nothing +// happening. LoadingPanel carries role="status" + an accessible label, so each +// slot announces itself while its chunk is in flight. export const DifferentialsHome = dynamic( () => import("@/components/clinical-dashboard/differentials-home").then((m) => m.DifferentialsHome), - { ssr: false }, + { ssr: false, loading: () => }, ); export const FavouritesHub = dynamic( () => import("@/components/clinical-dashboard/favourites-hub").then((m) => m.FavouritesHub), - { ssr: false }, + { ssr: false, loading: () => }, ); export const MedicationPrescribingWorkspace = dynamic( () => import("@/components/clinical-dashboard/medication-prescribing-workspace").then( (m) => m.MedicationPrescribingWorkspace, ), - { ssr: false }, + { + ssr: false, + loading: () => , + }, ); export const DocumentDrawer = dynamic( () => import("@/components/clinical-dashboard/document-admin").then((m) => m.DocumentDrawer), - { ssr: false }, + // The drawer mounts on open, so a spinner reads better than a content skeleton. + { ssr: false, loading: () => }, ); // Results surfaces load lazily. Preload the primary answer surface after hydration so a cold @@ -34,27 +45,29 @@ export const StagedAnswerResultSurface = dynamic(loadStagedAnswerResultSurface, }); export const RelatedDocumentsPanel = dynamic( () => import("@/components/clinical-dashboard/document-results").then((m) => m.RelatedDocumentsPanel), - { ssr: false }, + { ssr: false, loading: () => }, ); export const DocumentSearchResultsPanel = dynamic( () => import("@/components/clinical-dashboard/document-search-results").then((m) => m.DocumentSearchResultsPanel), - { ssr: false }, + { ssr: false, loading: () => }, ); // Admin/setup tools are rare paths — keep them out of the initial dashboard chunk. +// All four resolve to the same DocumentManagerPanel module, so whichever mounts +// first pays for the whole chunk; the fallback covers that wait. export const SetupChecklist = dynamic( () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.SetupChecklist), - { ssr: false }, + { ssr: false, loading: () => }, ); export const UploadPanel = dynamic( () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.UploadPanel), - { ssr: false }, + { ssr: false, loading: () => }, ); export const IndexingMonitor = dynamic( () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.IndexingMonitor), - { ssr: false }, + { ssr: false, loading: () => }, ); export const IngestionQualityConsole = dynamic( () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.IngestionQualityConsole), - { ssr: false }, + { ssr: false, loading: () => }, ); diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index f75fcc533c..813579bbcf 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -5,13 +5,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { FileText, X } from "lucide-react"; import { mobileSectionFabMediaQuery, navigationHashes } from "@/components/clinical-dashboard/dashboard-contracts"; -import { cn } from "@/components/ui-primitives"; +import { cn, LoadingPanel } from "@/components/ui-primitives"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { type AppModeId, appModeSearchConfig } from "@/lib/app-modes"; const ApplicationsLauncherWorkspace = dynamic( () => import("@/components/applications-launcher-page").then((module) => module.ApplicationsLauncherWorkspace), - { ssr: false }, + // ssr: false renders nothing server-side, so /tools would otherwise be blank + // until this chunk executes. + { ssr: false, loading: () => }, ); export function ToolsHub({ query, desktopComposerSlotId }: { query: string; desktopComposerSlotId?: string }) { diff --git a/src/lib/document-detail.ts b/src/lib/document-detail.ts index 566b383655..0b8e4f88a8 100644 --- a/src/lib/document-detail.ts +++ b/src/lib/document-detail.ts @@ -36,7 +36,10 @@ const selectedChunkNeighborCount = 3; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const documentDetailProjection = "id,owner_id,title,description,file_name,file_type,file_size,storage_path,content_hash,source_path,import_batch_id,status,page_count,chunk_count,image_count,error_message,metadata,created_at,updated_at" as const; -const tableFactDetailProjection = +// Matches the TableFactRow DTO in components/document-viewer/types.ts field for +// field. Selecting these explicitly keeps the generated `search_tsv` tsvector and +// `owner_id` off the wire; the table-facts route shares it for the same reason. +export const tableFactDetailProjection = "id,document_id,source_image_id,page_number,table_title,row_label,clinical_parameter,threshold_value,action,metadata" as const; const documentLabelDetailProjection = "id,document_id,owner_id,label,label_type,source,confidence,metadata,created_at,updated_at" as const; diff --git a/src/lib/rag/rag-cache.ts b/src/lib/rag/rag-cache.ts index 7df7cce135..a3b8615191 100644 --- a/src/lib/rag/rag-cache.ts +++ b/src/lib/rag/rag-cache.ts @@ -175,6 +175,21 @@ export async function getCachedAnswer( return answer; } +/** + * Store an answer in the process-local cache (and, fire-and-forget, the shared cache). + * + * Callers may deliberately NOT await this — it is a cache write, never part of the + * response contract. `answerQuestionWithScopeUncoalesced` defers it on a shared-cache + * hit precisely so the fastest path in the system does not pay this function's + * `documents` round trip before responding (latency audit 2026-07-28, L1-1). Deferring + * is safe only while nothing mutates `answer` after the call: the clone below happens + * after an `await`, so a caller that mutates it in the meantime would cache the mutation. + * + * Do NOT drop the `forceRefresh: true` below to save that round trip. The freshly read + * indexing version is compared against `indexingVersionAtRetrievalStart` to DISCARD the + * write when the corpus changed mid-request; reusing an already-held stamp would defeat + * that staleness guard. + */ export async function setCachedAnswer( args: Pick< SearchChunksArgs, diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts index f00c3aaebc..0cf4833381 100644 --- a/src/lib/rag/rag.ts +++ b/src/lib/rag/rag.ts @@ -3231,7 +3231,7 @@ async function answerQuestionWithScopeUncoalesced( ? null : await getSharedCachedAnswer(args, startedAt, { indexingVersionAtRequestStart: indexingVersionAtRetrievalStart }); if (sharedCachedAnswer) { - await setCachedAnswer(args, sharedCachedAnswer, { indexingVersionAtRetrievalStart }); + void setCachedAnswer(args, sharedCachedAnswer, { indexingVersionAtRetrievalStart }).catch(() => undefined); const cachedSources = annotateSearchResults(answerFocusQuery, sharedCachedAnswer.sources ?? []); const cachedRelevance = sharedCachedAnswer.relevance ?? buildEvidenceRelevance(answerFocusQuery, cachedSources); await args.onProgress?.({ diff --git a/src/lib/server-timing.ts b/src/lib/server-timing.ts index e72dd29c05..17c13d6996 100644 --- a/src/lib/server-timing.ts +++ b/src/lib/server-timing.ts @@ -34,6 +34,31 @@ export function buildServerTimingHeader(entries: ServerTimingEntry[]): string | return parts.length ? parts.join(", ") : null; } +// Per-request preamble timings: the identity, rate-limit and scope round trips +// every answer/search request pays before retrieval starts. These are the stages +// no other telemetry covers — rag_queries only records timings from retrieval +// onward, so without these the preamble is invisible. +// +// On a streaming route only the stages that complete before the response headers +// flush can be reported here (auth, ratelimit); in-stream stages cannot reach a +// response header, and routing them through the SSE contract would put +// instrumentation inside a governed client payload. /api/answer covers the +// remaining stages over the same resolveSearchScope + RAG path. +export function preambleServerTimingEntries(timings: { + authMs?: number; + rateLimitMs?: number; + scopeMs?: number; +}): ServerTimingEntry[] { + const entries: ServerTimingEntry[] = []; + const push = (name: string, durMs: number | undefined) => { + if (typeof durMs === "number" && Number.isFinite(durMs)) entries.push({ name, durMs }); + }; + push("auth", timings.authMs); + push("ratelimit", timings.rateLimitMs); + push("scope", timings.scopeMs); + return entries; +} + // Answer-route timings from RagAnswer.latencyTimings (all values are millisecond // durations computed in rag.ts). Missing fields are simply omitted. export function answerServerTimingEntries( diff --git a/tests/answer-route-preamble.test.ts b/tests/answer-route-preamble.test.ts new file mode 100644 index 0000000000..886c93f1cb --- /dev/null +++ b/tests/answer-route-preamble.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-trip budget guard for the /api/answer preamble (latency audit 2026-07-28, L1-2). + * + * Auth -> rate-limit is a genuine data dependency (the limiter needs + * `access.rateLimitSubject`), so only scope resolution can overlap. These tests pin + * the three properties that make the overlap safe, so a refactor that re-serialises + * the preamble — or drops the abort/settle handling — goes red rather than silently + * putting a Supabase round trip back on the critical path. + */ + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +const publicAccessContext = vi.fn(); +const consumeSubjectApiRateLimit = vi.fn(); +const resolveSearchScope = vi.fn(); +const answerQuestionWithScope = vi.fn(); + +vi.mock("@/lib/env", async (importOriginal) => ({ + ...(await importOriginal()), + isDemoMode: () => false, + isLocalNoAuthMode: () => false, +})); +vi.mock("@/lib/supabase/admin", () => ({ + // Only the route's fire-and-forget telemetry insert touches the client directly; + // scope and the limiter are mocked below. Without `from` the insert logs a noisy + // failure that has nothing to do with what these tests assert. + createAdminClient: () => ({ + from: () => ({ insert: async () => ({ data: null, error: null }) }), + }), +})); +vi.mock("@/lib/public-api-access", () => ({ publicAccessContext })); +vi.mock("@/lib/api-rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + consumeSubjectApiRateLimit, +})); +vi.mock("@/lib/search-scope", async (importOriginal) => ({ + ...(await importOriginal()), + resolveSearchScope, +})); +vi.mock("@/lib/rag/rag", () => ({ answerQuestionWithScope })); + +function answerRequest() { + return new Request("http://localhost/api/answer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "clozapine monitoring thresholds" }), + }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function rateLimitDecision(limited: boolean) { + return { + limited, + limit: 100, + remaining: limited ? 0 : 99, + retryAfterSeconds: limited ? 60 : 0, + resetAt: new Date(Date.now() + 60_000).toISOString(), + }; +} + +beforeEach(() => { + publicAccessContext.mockResolvedValue({ + ownerId, + authenticated: true, + rateLimitSubject: { kind: "owner", id: ownerId }, + }); + answerQuestionWithScope.mockResolvedValue({ + answer: "stub", + grounded: true, + confidence: "supported", + citations: [], + sources: [], + latencyTimings: { total_latency_ms: 1 }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe("/api/answer preamble", () => { + it("starts scope resolution before the rate-limit RPC settles", async () => { + const limiter = deferred>(); + consumeSubjectApiRateLimit.mockReturnValue(limiter.promise); + resolveSearchScope.mockResolvedValue({ documentIds: undefined, filters: {}, activeFilterCount: 0, warnings: [] }); + + const { POST } = await import("../src/app/api/answer/route"); + const response = POST(answerRequest()); + + // The limiter has not resolved yet. If scope were awaited after it — the + // pre-2026-07-28 shape — this would still be 0. + await vi.waitFor(() => expect(resolveSearchScope).toHaveBeenCalledTimes(1)); + + limiter.resolve(rateLimitDecision(false)); + expect((await response).status).toBe(200); + }); + + it("aborts the in-flight scope queries when the limiter denies, and still returns 429", async () => { + consumeSubjectApiRateLimit.mockResolvedValue(rateLimitDecision(true)); + + let observed: AbortSignal | undefined; + resolveSearchScope.mockImplementation(async (args: { signal?: AbortSignal }) => { + observed = args.signal; + // Model a paginated scope query that is still in flight when the limiter denies. + await new Promise((resolve) => setTimeout(resolve, 5)); + if (args.signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return { documentIds: undefined, filters: {}, activeFilterCount: 0, warnings: [] }; + }); + + const { POST } = await import("../src/app/api/answer/route"); + const response = await POST(answerRequest()); + + expect(response.status).toBe(429); + expect(observed).toBeInstanceOf(AbortSignal); + // A throttled caller must not pay for scope: the queries are cancelled, and + // the rejection is absorbed rather than left floating (an unhandled rejection + // here would take the process down). + expect(observed?.aborted).toBe(true); + }); + + it("threads an abort signal so a client disconnect cancels scope's paginated queries", async () => { + consumeSubjectApiRateLimit.mockResolvedValue(rateLimitDecision(false)); + let observed: AbortSignal | undefined; + resolveSearchScope.mockImplementation(async (args: { signal?: AbortSignal }) => { + observed = args.signal; + return { documentIds: undefined, filters: {}, activeFilterCount: 0, warnings: [] }; + }); + + const { POST } = await import("../src/app/api/answer/route"); + const clientAbort = new AbortController(); + const request = new Request("http://localhost/api/answer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "lithium range" }), + signal: clientAbort.signal, + }); + + await POST(request); + expect(observed).toBeInstanceOf(AbortSignal); + expect(observed?.aborted).toBe(false); + clientAbort.abort(); + expect(observed?.aborted).toBe(true); + }); + + it("surfaces a scope failure rather than swallowing it with the settle wrapper", async () => { + consumeSubjectApiRateLimit.mockResolvedValue(rateLimitDecision(false)); + resolveSearchScope.mockRejectedValue(new Error("scope query failed")); + + const { POST } = await import("../src/app/api/answer/route"); + const response = await POST(answerRequest()); + + expect(response.status).toBeGreaterThanOrEqual(500); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/private-rag-access.test.ts b/tests/private-rag-access.test.ts index bc3705fb2f..78664fd079 100644 --- a/tests/private-rag-access.test.ts +++ b/tests/private-rag-access.test.ts @@ -38,6 +38,9 @@ function createSupabaseMock() { }), order: vi.fn(() => builder), range: vi.fn(() => builder), + // Real PostgREST builders expose this; resolveSearchScope calls it whenever + // the caller threads an AbortSignal, so the mock must model it. + abortSignal: vi.fn(() => builder), insert: vi.fn(async (payload: unknown) => { inserts.push({ table, payload }); return { data: null, error: null }; diff --git a/tests/server-timing.test.ts b/tests/server-timing.test.ts index 8f029acf8c..67dd743de7 100644 --- a/tests/server-timing.test.ts +++ b/tests/server-timing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing"; +import { answerServerTimingEntries, buildServerTimingHeader, preambleServerTimingEntries } from "@/lib/server-timing"; describe("buildServerTimingHeader", () => { it("joins entries with durations rounded to whole milliseconds", () => { @@ -61,3 +61,29 @@ describe("answerServerTimingEntries", () => { expect(buildServerTimingHeader(answerServerTimingEntries(undefined, 42))).toBe("total;dur=42"); }); }); + +describe("preambleServerTimingEntries", () => { + it("reports the auth, rate-limit and scope stages in request order", () => { + expect(buildServerTimingHeader(preambleServerTimingEntries({ authMs: 40, rateLimitMs: 90, scopeMs: 310 }))).toBe( + "auth;dur=40, ratelimit;dur=90, scope;dur=310", + ); + }); + + it("omits stages that did not run — a streaming route can only report the pre-header stages", () => { + // /api/answer/stream resolves scope inside the stream, after headers flush. + expect(buildServerTimingHeader(preambleServerTimingEntries({ authMs: 40, rateLimitMs: 90 }))).toBe( + "auth;dur=40, ratelimit;dur=90", + ); + expect(buildServerTimingHeader(preambleServerTimingEntries({}))).toBeNull(); + }); + + it("composes ahead of the answer entries without colliding on a metric name", () => { + const preamble = preambleServerTimingEntries({ authMs: 5, rateLimitMs: 6, scopeMs: 7 }); + const answer = answerServerTimingEntries({ search_latency_ms: 8 }, 30); + const names = [...preamble, ...answer].map((entry) => entry.name); + expect(new Set(names).size).toBe(names.length); + expect(buildServerTimingHeader([...preamble, ...answer])).toBe( + "auth;dur=5, ratelimit;dur=6, scope;dur=7, search;dur=8, total;dur=30", + ); + }); +}); From 1b11fe98c9252a4ae5291b54d38be74d64223788 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 02:00:53 +0000 Subject: [PATCH 02/25] docs(ledger): record the latency audit implementation review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 18998f9f51..1cd4db41f2 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1206,3 +1206,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-28 | codex/consolidate-design-mockups | d120dbcca5ab4c5caab85176aace052788320405 | dirty-work consolidation review | APPROVE. Isolated calculator and therapy-navigation experiments under mockup routes, kept production calculators/navigation unchanged, and fixed the final decorative-icon accessibility lint finding. | boundary Vitest 3/3; design-system contract PASS; sitemap PASS; mockup Playwright 16/16; verify:cheap PASS (413 files, 4201 passed, 3 skipped); no provider checks. | | 2026-07-28 | PR-1295 | 2d474c973828736bc199c59136018220debb9907 | PR #1295 final remediation vs current origin/main | APPROVE after exact-head CI; redundant workflows and stale audit artifacts removed, tablet action cards aligned | Clean merge-tree; ledger guard PASS; diff check PASS; verify:pr-local dry-run selected runtime, lock parity, format, lint, typecheck, full test, build, and RAG fixture checks; local execution unavailable because node_modules is absent | | 2026-07-28 | fix/audit-remediation-from-main | 88deecfb988da030d806b1d8c0a4c8349502a5f8 | stale-checkout P0 regression discovery | P0 regressions found in stale file checkouts | Confirmed affected worker/main.ts and tests/reconciliation-preflight.test.ts; superseded by later remediation and current final review | +| 2026-07-29 | claude/latency-findings-impl-s8g01v | 78e2beb89b646c3d5c2d4745e3f2f692f9ba61a0 | latency audit implementation (PR #1377) | Implemented the free/flag-gated findings of docs/audit/latency-audit-2026-07-28.md after PR #1312 closed unmerged: Server-Timing preamble on answer/stream/search, scope-vs-ratelimit overlap with abort, deferred shared-cache-hit write, narrowed table-facts projections, medication catalogue memo, 10 loading fallbacks, Supabase preconnect. L2-3/L2-5 authored as operator SQL only; supabase/ untouched. L4-2 retracted as deliberate. Remainder filed as ledger 098-105. | verify:cheap exit 0; verify:pr-local exit 0 (418 files, 4244 passed/4 skipped, build compiled 59s, client bundle scan passed, 36 golden cases validated) | From e52c25c79a2f0bcd6af72b52f4cf3cf371bdf4b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 04:20:51 +0000 Subject: [PATCH 03/25] fix(answer): keep scope resolution behind rate-limit admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review raised this as P1 on PR #1377 and it is correct. The L1-2 change started resolveSearchScope concurrently with the rate-limit RPC and aborted it on deny, on the claim that a throttled caller "still costs nothing". That claim is false. resolveSearchScope only returns without touching the database when there are no filters and no explicit document ids (search-scope.ts:242,253). With either present it enters the paginated `documents` loop at :269 plus the nested label loop. An AbortSignal cancels the client request; it does not un-execute a statement Postgres has already begun. `filters` is caller-controlled, so a throttled caller could keep spending database capacity while collecting 429s — the opposite of what admission control is for, and the wrong direction against capacity-review.md:106-113, which names Postgres CPU under concurrency the first soft failure. Scope now sits behind admission again. Two parts of the original change are kept because they are independent of the overlap and unambiguously correct: - `signal: request.signal` is threaded into resolveSearchScope, so a client disconnect finally cancels its paginated queries. search-scope.ts:200,328 always supported .abortSignal(...); this route never passed one. - the `scope` stage is still reported in Server-Timing. tests/answer-route-preamble.test.ts is inverted to the guard the reviewer asked for: no scope query may begin before the limiter admits, and a denied request (sent with filters, the shape that reaches the paginated loop) dispatches none at all. Both cases fail against the overlapping shape. The audit's L1-2 section and ledger #099 record the refutation so a later latency pass does not rediscover the overlap; re-attempting it requires a non-database admission gate ahead of the durable limiter. Verification: verify:cheap exit 0 on the merged tree (423 files, 4278 passed / 4 skipped). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/audit/latency-audit-2026-07-28.md | 10 +++-- docs/outstanding-issues.md | 2 +- src/app/api/answer/route.ts | 57 +++++++++++--------------- tests/answer-route-preamble.test.ts | 52 +++++++++++------------ 4 files changed, 58 insertions(+), 63 deletions(-) diff --git a/docs/audit/latency-audit-2026-07-28.md b/docs/audit/latency-audit-2026-07-28.md index ecd0b6f1b5..5fbba0e757 100644 --- a/docs/audit/latency-audit-2026-07-28.md +++ b/docs/audit/latency-audit-2026-07-28.md @@ -105,8 +105,10 @@ measured-and-cleared · **L6** deliberate. `src/app/api/answer/route.ts` · `A=P B=arithmetic C=fixed` · gate=**free** · **APPLIED (`/api/answer`)** - **Evidence.** `publicAccessContext` (auth round trip) → `consumeSubjectApiRateLimit` (Supabase RPC) → `resolveSearchScope` (0–N Supabase round trips) ran strictly sequentially, and all three completed **before** `createAnswerRouteDeadline` existed — bounded only by client abort. `resolveSearchScope` was called without `signal`, so its queries never received `.abortSignal(...)` despite `search-scope.ts:200,327` supporting it. -- **Applied.** Scope now starts concurrently with the rate-limit RPC and is aborted the moment the limiter denies, so a throttled caller still costs nothing. The signal is threaded (`AbortSignal.any([request.signal, scopeAbort.signal])`), which also fixes the missing-abort defect. The scope promise is _settled_, never left floating — the limiter can return first, and an unhandled rejection would crash the process. Pinned by `tests/answer-route-preamble.test.ts`, which fails against the serial shape. -- **Irreducible.** Auth → rate-limit is a genuine data dependency (`consumeSubjectApiRateLimit` needs `access.rateLimitSubject`). Only scope can overlap. +- **Applied — the missing abort signal only.** `resolveSearchScope` is now called with `signal: request.signal`, so a client disconnect cancels its paginated queries. `search-scope.ts:200,328` had always supported `.abortSignal(...)`; this route simply never passed one. The `scope` stage is also now reported in `Server-Timing`. +- **REFUTED — the overlap itself (corrected 2026-07-29, PR #1377 review).** The concurrent version was written and shipped for review, then removed. It started `resolveSearchScope` alongside the rate-limit RPC and aborted it on deny, on the claim that "the limiter can still deny for free". **That claim is false.** Whenever the caller sends filters or explicit document ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) and enters the paginated `documents` loop at `:269` plus the nested label loop. An `AbortSignal` cancels the client request; it does not un-execute a statement Postgres has already begun. So a throttled caller kept spending database capacity while collecting 429s — and `filters` is caller-controlled, so this is reachable deliberately, not only by accident. That is the opposite of what admission control exists for, and the wrong direction against `capacity-review.md:106-113`, which names Postgres CPU under concurrency the **first soft failure** — the very reason this audit argues round-trip reduction has capacity value. Scope now sits behind admission again, pinned by `tests/answer-route-preamble.test.ts`, which fails against the overlapping shape. +- **What would make the overlap admissible.** A non-database admission gate ahead of the durable limiter, so a denied request is rejected before any Supabase work is dispatched. That is new state with its own correctness questions and is not a latency-pass change — carried in `#099`. +- **Irreducible.** Auth → rate-limit is a genuine data dependency (`consumeSubjectApiRateLimit` needs `access.rateLimitSubject`), and scope must now follow admission, so the preamble is fully sequential by design. - **Not applied — the stream route.** There, scope already runs _inside_ the stream after `sendProgress({stage:"scoping"})`, so it is not blocking the first byte in the same way; auth and rate-limit remain pre-stream by necessity. - **Report-only — extending deadline coverage over the preamble.** That changes _which_ requests get cancelled, converting some slow-but-successful answers into timeouts. Answer-path behaviour; do not change budget numbers in a latency pass. @@ -323,7 +325,7 @@ Recorded so the next audit cannot re-file these. | Gate | Findings | What it requires | | ------------ | --------------------------------------------------- | -------------------------------------------------------------- | -| **free** | L1-2, L2-6, L2-9, L3-4, L3-5 | Ordinary review — all applied | +| **free** | L2-6, L2-9, L3-4, L3-5 applied; L1-2 partly | Ordinary review — L1-2's overlap was refuted on review | | **flag** | L1-1 (applied), L1-5 | FLAG + `RAG impact:` line; no canary when no behaviour changes | | **canary** | L0-1, L2-1, L2-2, L2-4, L2-8, L1-5 (if behavioural) | Provider-backed eval pair, ~$1–2, explicit approval | | **`#017`** | L3-1, L3-2, L3-3, L3-6, L3-7, L3-8, L4-1 | Live Lighthouse/Web-Vitals evidence first | @@ -355,7 +357,7 @@ provider call is needed to size the top of the ranking. | ----------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Measurement | `Server-Timing` on `/api/answer/stream` + `/api/search`; `auth`/`ratelimit`/`scope` on `/api/answer` | `src/lib/server-timing.ts`, `src/app/api/answer/route.ts`, `src/app/api/answer/stream/route.ts`, `src/app/api/search/route.ts` | | L1-1 | Shared-cache-hit promotion deferred off the response path, staleness guard intact and documented | `src/lib/rag/rag.ts:3234`, `src/lib/rag/rag-cache.ts` | -| L1-2 | Scope overlapped with the rate-limit RPC, abort-on-deny, signal threaded | `src/app/api/answer/route.ts`, `tests/answer-route-preamble.test.ts` | +| L1-2 | Client abort signal threaded into scope (overlap written, then **refuted** on review — see L1-2) | `src/app/api/answer/route.ts`, `tests/answer-route-preamble.test.ts` | | L2-6 | Three `select("*")` narrowed to explicit projections | `src/app/api/documents/[id]/table-facts/route.ts`, `src/lib/document-detail.ts` | | L2-9 | Governance map and index projection built once instead of per anonymous request | `src/app/api/medications/route.ts` | | L2-3 / L2-5 | Bare-column trigram + `(status,id)` composite **authored as operator SQL, not applied** | `docs/operator-apply-performance-latency-remediation.md` | diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 74c49b1dc4..fa97525119 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -132,7 +132,7 @@ removed after current-main verification; it is not missing recommended work. | #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | | #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | | #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins the answer-route preamble specifically and fails against the serial shape. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`). Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | -| #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, aborted on deny, signal threaded (`answer/route.ts`). **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | +| #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | | #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | | #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Done 2026-07-29:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive and semantics-neutral, so no query text changes and recall is byte-identical. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** apply concurrently, confirm `indisvalid`, mirror into `schema.sql`, run `npm run drift:manifest` (Docker), THEN register the three names in `required_indexes` — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index b5c5d4db7e..af22938b9c 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -81,33 +81,6 @@ export async function POST(request: Request) { const authMs = Date.now() - authStartedAt; const accessScope = resolveRetrievalAccessScope(access.ownerId); - // Scope resolution has no data dependency on the rate-limit RPC, so the two - // overlap instead of running back-to-back before retrieval starts. The - // limiter must still be able to deny for free, so the scope queries are - // aborted the moment it does — and threading a signal at all is what lets - // a client disconnect cancel scope's paginated queries. - const scopeAbort = new AbortController(); - const scopeStartedAt = Date.now(); - let scopeMs: number | undefined; - const scopeSettled = resolveSearchScope({ - supabase, - accessScope, - documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined), - filters: answerBody.filters, - signal: AbortSignal.any([request.signal, scopeAbort.signal]), - }).then( - (value) => { - scopeMs = Date.now() - scopeStartedAt; - return { ok: true as const, value }; - }, - // Settled, never rejected: the limiter can return before this promise is - // awaited, and a floating rejection would take down the process. - (error: unknown) => { - scopeMs = Date.now() - scopeStartedAt; - return { ok: false as const, error }; - }, - ); - const rateLimitStartedAt = Date.now(); const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -117,14 +90,34 @@ export async function POST(request: Request) { }); const rateLimitMs = Date.now() - rateLimitStartedAt; if (rateLimit.limited) { - scopeAbort.abort(); - await scopeSettled; return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); } - const resolvedScope = await scopeSettled; - if (!resolvedScope.ok) throw resolvedScope.error; - const scope = resolvedScope.value; + // Scope resolution stays BEHIND admission, deliberately. It has no data + // dependency on the limiter, so overlapping the two is tempting — but + // whenever the caller sends filters or explicit ids, `resolveSearchScope` + // pages `documents` (up to maxResolvedDocuments) and their labels + // (`search-scope.ts:242,253` are the only zero-query early returns). An + // AbortSignal cancels the client request; it does not un-execute a query + // Postgres has already started. Overlapping therefore let a throttled + // caller keep spending database capacity while collecting 429s — the + // opposite of what admission control is for, and the wrong direction for + // `capacity-review.md:106-113`, which names Postgres CPU under concurrency + // the first soft failure. `filters` is caller-controlled, so this is + // reachable on purpose, not only by accident. + // + // Threading the signal is still worth doing on its own: without it a client + // disconnect could not cancel scope's paginated queries at all, even though + // `search-scope.ts:200,328` have always supported `.abortSignal(...)`. + const scopeStartedAt = Date.now(); + const scope = await resolveSearchScope({ + supabase, + accessScope, + documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined), + filters: answerBody.filters, + signal: request.signal, + }); + const scopeMs = Date.now() - scopeStartedAt; if (scope.documentIds?.length === 0) { return NextResponse.json({ answer: emptyScopeAnswer, diff --git a/tests/answer-route-preamble.test.ts b/tests/answer-route-preamble.test.ts index 886c93f1cb..7dab9ef328 100644 --- a/tests/answer-route-preamble.test.ts +++ b/tests/answer-route-preamble.test.ts @@ -1,13 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /** - * Round-trip budget guard for the /api/answer preamble (latency audit 2026-07-28, L1-2). + * Admission-cost guard for the /api/answer preamble (latency audit 2026-07-28, L1-2). * - * Auth -> rate-limit is a genuine data dependency (the limiter needs - * `access.rateLimitSubject`), so only scope resolution can overlap. These tests pin - * the three properties that make the overlap safe, so a refactor that re-serialises - * the preamble — or drops the abort/settle handling — goes red rather than silently - * putting a Supabase round trip back on the critical path. + * A denied request must cost zero scope queries. `resolveSearchScope` pages + * `documents` and their labels whenever the caller sends filters or explicit ids, and + * an AbortSignal cancels the client request without un-executing a query Postgres has + * already started — so "start scope early and abort on deny" is NOT free, and was + * removed after review. These tests pin the ordering so a future latency pass cannot + * reintroduce the overlap, and pin the signal threading that was kept. */ const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -89,7 +90,7 @@ afterEach(() => { }); describe("/api/answer preamble", () => { - it("starts scope resolution before the rate-limit RPC settles", async () => { + it("does not begin scope resolution until the limiter has admitted the request", async () => { const limiter = deferred>(); consumeSubjectApiRateLimit.mockReturnValue(limiter.promise); resolveSearchScope.mockResolvedValue({ documentIds: undefined, filters: {}, activeFilterCount: 0, warnings: [] }); @@ -97,35 +98,34 @@ describe("/api/answer preamble", () => { const { POST } = await import("../src/app/api/answer/route"); const response = POST(answerRequest()); - // The limiter has not resolved yet. If scope were awaited after it — the - // pre-2026-07-28 shape — this would still be 0. - await vi.waitFor(() => expect(resolveSearchScope).toHaveBeenCalledTimes(1)); + // Give the route every chance to dispatch scope early: if it overlapped the + // two, this microtask drain would be enough for the call to land. + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(resolveSearchScope).not.toHaveBeenCalled(); limiter.resolve(rateLimitDecision(false)); expect((await response).status).toBe(200); + expect(resolveSearchScope).toHaveBeenCalledTimes(1); }); - it("aborts the in-flight scope queries when the limiter denies, and still returns 429", async () => { + it("dispatches no scope query at all when the limiter denies", async () => { consumeSubjectApiRateLimit.mockResolvedValue(rateLimitDecision(true)); - - let observed: AbortSignal | undefined; - resolveSearchScope.mockImplementation(async (args: { signal?: AbortSignal }) => { - observed = args.signal; - // Model a paginated scope query that is still in flight when the limiter denies. - await new Promise((resolve) => setTimeout(resolve, 5)); - if (args.signal?.aborted) throw new DOMException("Aborted", "AbortError"); - return { documentIds: undefined, filters: {}, activeFilterCount: 0, warnings: [] }; - }); + resolveSearchScope.mockResolvedValue({ documentIds: undefined, filters: {}, activeFilterCount: 0, warnings: [] }); const { POST } = await import("../src/app/api/answer/route"); - const response = await POST(answerRequest()); + // Filters are what push resolveSearchScope past its zero-query early returns + // (search-scope.ts:242,253) and into the paginated `documents` loop, so this + // is the shape that made the old overlap expensive for a throttled caller. + const response = await POST( + new Request("http://localhost/api/answer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "clozapine monitoring", filters: { sourceStatuses: ["current"] } }), + }), + ); expect(response.status).toBe(429); - expect(observed).toBeInstanceOf(AbortSignal); - // A throttled caller must not pay for scope: the queries are cancelled, and - // the rejection is absorbed rather than left floating (an unhandled rejection - // here would take the process down). - expect(observed?.aborted).toBe(true); + expect(resolveSearchScope).not.toHaveBeenCalled(); }); it("threads an abort signal so a client disconnect cancels scope's paginated queries", async () => { From d50a0f76671832b4460238ef7162d76c7646cdef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 04:21:46 +0000 Subject: [PATCH 04/25] docs(ledger): record PR #1377 CI/review babysit Capture the main sync, Codex P1 admission-order fix, and local verification on tip e353e1d0. --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 3e51844f96..02d502ab7b 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1253,3 +1253,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | codex/search-composer-focus-pwa | f9a14e8e38a568d8675c4154597f73340161e4ea | current diff vs origin/main: PWA search composer focus | P1 fix reviewed; passive viewport scroll no longer blurs search | focused Vitest 48/48; typecheck; ledger guard; phone and PR plans inspected | | 2026-07-29 | codex/search-composer-focus-pwa | 5c6a75c21833dc31d4f8658cec15154f58918a36 | PR #1373 unresolved review comment remediation | P2 test race and keyboard/scrollbar intent gaps fixed | focused Vitest 48/48; typecheck; diff check | | 2026-07-29 | codex/search-composer-focus-pwa | 3f7cd1e4069b7ef8f8b18519adfb0dba0dc349a8 | PR #1373 CircleCI lint remediation | Deterministic unused locator warning removed | CircleCI format passed; lint root cause captured; focused Vitest 48/48; typecheck | +| 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | e353e1d0cd20912d098ead2fe94202492468299e | PR #1377 CI/review babysit | Synced main (DIRTY was staleness). Codex P1 fixed: scope behind admission (e52c25c7). Thread resolved. No Bugbot findings. verify:cheap + focused preamble + build + rag fixtures green locally; hosted CI re-running on tip. | verify:cheap exit 0 (423 files / 4278 passed); vitest answer-route-preamble 4/4; build + check:rag:fixtures pass; no provider gates | From 11b88dd3b28c7354192e7fd15183e328845cec69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 04:21:58 +0000 Subject: [PATCH 05/25] docs(ledger): supersede PR #1377 babysit record at tip --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 02d502ab7b..859280235e 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1254,3 +1254,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | codex/search-composer-focus-pwa | 5c6a75c21833dc31d4f8658cec15154f58918a36 | PR #1373 unresolved review comment remediation | P2 test race and keyboard/scrollbar intent gaps fixed | focused Vitest 48/48; typecheck; diff check | | 2026-07-29 | codex/search-composer-focus-pwa | 3f7cd1e4069b7ef8f8b18519adfb0dba0dc349a8 | PR #1373 CircleCI lint remediation | Deterministic unused locator warning removed | CircleCI format passed; lint root cause captured; focused Vitest 48/48; typecheck | | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | e353e1d0cd20912d098ead2fe94202492468299e | PR #1377 CI/review babysit | Synced main (DIRTY was staleness). Codex P1 fixed: scope behind admission (e52c25c7). Thread resolved. No Bugbot findings. verify:cheap + focused preamble + build + rag fixtures green locally; hosted CI re-running on tip. | verify:cheap exit 0 (423 files / 4278 passed); vitest answer-route-preamble 4/4; build + check:rag:fixtures pass; no provider gates | +| 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | d50a0f76671832b4460238ef7162d76c7646cdef | PR #1377 CI/review babysit | Ledger tip after babysit record. Product fix remains e52c25c7 (scope behind admission); Codex P1 thread resolved; main synced. | same local gates as prior row; hosted CI pending on tip | From d7aa4c6ccfe5bc42c8db0d6ddf31c29a56b78559 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 04:26:57 +0000 Subject: [PATCH 06/25] docs(audit): correct two stale measurement-plan claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both raised at P2 by Codex review on PR #1377 and both verified against the code before acting. 1. The measurement plan still described L1-2's REFUTED contract — "scope must start before the limiter settles". The L1-2 section was corrected when the overlap was removed but this paragraph was missed, so the doc contradicted both the route and tests/answer-route-preamble.test.ts. Anyone generalising the harness under #098 would have enforced the opposite invariant and reintroduced database work for throttled requests. It now states the actual admission-cost contract: scope starts only after admission, and a denial dispatches zero scope queries. 2. The provider-free wall-clock step claimed the typeahead route plus demo mode times "auth + rate-limit + scope + RPC". False on both counts: src/app/api/search/universal/route.ts:137 returns on isDemoMode() || isLocalNoAuthMode() BEFORE createAdminClient (:150), publicAccessContext (:151) and consumeSubjectApiRateLimit (:153), and resolveSearchScope is never called from that route. A demo-mode run would measure local fixture search and appear to clear an L1 finding it never exercised. The step now says to run it non-demo, and to time scope separately via /api/answer. Docs only; no source or test behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/audit/latency-audit-2026-07-28.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/audit/latency-audit-2026-07-28.md b/docs/audit/latency-audit-2026-07-28.md index 5fbba0e757..c4d9b43b98 100644 --- a/docs/audit/latency-audit-2026-07-28.md +++ b/docs/audit/latency-audit-2026-07-28.md @@ -342,8 +342,8 @@ with numbers this repo already owns (`#069`: warm p90 175–243 ms; first-unprim provider call is needed to size the top of the ranking. 1. **`Server-Timing` first (done).** It existed but was emitted only by `/api/answer` and `/api/search/universal` — **not by `/api/answer/stream`, the route the UI actually uses.** That was the largest measurement gap in the repo. Now: `/api/answer` emits `auth`/`ratelimit`/`scope` alongside its existing entries; `/api/search` emits `auth`/`ratelimit`/`search`/`total`; `/api/answer/stream` emits `auth`/`ratelimit`. **Documented limitation:** on a streaming response, headers flush before the first frame, so in-stream stage durations cannot reach a response header — and routing them through the SSE contract would put instrumentation inside a governed clinical payload (`answer-stream-contract.ts:21` whitelists only `progress`/`final`/`error`). `/api/answer` covers those stages over the same `resolveSearchScope` + RAG path. -2. **Offline round-trip budget harness.** `tests/answer-route-preamble.test.ts` pins the answer-route preamble: scope must start before the limiter settles, the signal must be threaded, and a denial must abort scope without a floating rejection. Broadening this to a general counting-proxy over the offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`) is the enabler for L1-5 and remains open as `#098`. Zero providers, zero DB. -3. **Provider-free wall-clock hot path.** Use the typeahead route (`lexicalOnly: true` skips embeddings) plus demo mode to time auth + rate-limit + scope + RPC with no OpenAI call — isolating exactly the L1 stack. +2. **Offline round-trip budget harness.** `tests/answer-route-preamble.test.ts` pins the answer-route preamble as an **admission-cost** contract, not an overlap one: scope resolution starts only **after** the limiter admits, a denied request dispatches **zero** scope queries, and the client abort signal is threaded. Any generalisation of this harness must enforce that direction — asserting the reverse would reintroduce the database work for throttled requests that L1-2's refutation removed. Broadening it to a general counting-proxy over the offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`) is the enabler for L1-5 and remains open as `#098`. Zero providers, zero DB. +3. **Provider-free wall-clock hot path.** The typeahead route is useful because `lexicalOnly: true` skips the per-keystroke embedding, so it times a real request with no OpenAI call. **Corrected 2026-07-29 — do not pair it with demo mode, and do not claim it covers scope.** `src/app/api/search/universal/route.ts:137` returns on `isDemoMode() || isLocalNoAuthMode()` _before_ `createAdminClient` (`:150`), `publicAccessContext` (`:151`) and `consumeSubjectApiRateLimit` (`:153`), so a demo-mode run measures local fixture search and skips the auth and limiter stages entirely; and `resolveSearchScope` is never called from that route at all. Timing it in demo mode would therefore appear to clear an L1 finding it never exercised. Run it **non-demo** against a local Supabase or a mocked client to cover auth + rate-limit + RPC, and time the **scope** stage separately via `/api/answer`, which is the route that actually resolves it. 4. **Disposable local Postgres + `EXPLAIN (ANALYZE, BUFFERS)`** for L2-3/L2-5/L2-6. **Caveat to carry:** `hnsw.ef_search` _is_ settable locally, so no local vector plan may be generalised to hosted. 5. **Local client evidence that does not claim to be `#017`.** `build:analyze`, the enforced bundle budget, the `next build` route table (`ƒ Dynamic` vs `○ Static`), and a Playwright run on the existing local harness reading `PerformanceObserver` LCP/CLS/long-tasks plus `performance.getEntriesByType("resource")` on a cold profile. This **ranks** L3 items against each other; it does **not** discharge `#017`, which requires live-site evidence. From 4927410e0a943b04fdb62e71c3c7cf85be053f8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 04:45:04 +0000 Subject: [PATCH 07/25] docs(ledger): record PR #1377 babysit after #1378 sync --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 115798ec57..dd2b73b595 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1256,3 +1256,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | e353e1d0cd20912d098ead2fe94202492468299e | PR #1377 CI/review babysit | Synced main (DIRTY was staleness). Codex P1 fixed: scope behind admission (e52c25c7). Thread resolved. No Bugbot findings. verify:cheap + focused preamble + build + rag fixtures green locally; hosted CI re-running on tip. | verify:cheap exit 0 (423 files / 4278 passed); vitest answer-route-preamble 4/4; build + check:rag:fixtures pass; no provider gates | | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | d50a0f76671832b4460238ef7162d76c7646cdef | PR #1377 CI/review babysit | Ledger tip after babysit record. Product fix remains e52c25c7 (scope behind admission); Codex P1 thread resolved; main synced. | same local gates as prior row; hosted CI pending on tip | | 2026-07-29 | codex/remove-source-overlays | a08a81d320c9f8e1bbbe1facc266d8257213b1ad | PR #1378 babysit | FIXED Codex P1s (restore governance notice); overlays/Preview removed; merged main; verify:cheap PASS; Bugbot no open findings | verify:cheap 4273 pass; focused DOM 4/4; eslint/tsc/build PASS; hosted CI re-running after main sync | +| 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 80df35f6cebe5f8a29dbbe98c960c114c64de8c7 | PR #1377 CI/review babysit | Re-synced main after #1378 (DIRTY=staleness). Codex P1+P2 threads resolved. Hosted CI green on d7aa4c6c prior tip; re-running after sync. | prior tip CI: PR required success; Production UI/Unit/Build/Static/Migration success; CircleCI success; merge-tree clean | From 9f21672ca946b962a46cc902f7c6c3c17763d3c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:03:23 +0000 Subject: [PATCH 08/25] docs(ledger): record #1376 conflict reconcile on PR #1377 --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ff33ba7e69..5317ad2566 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1260,3 +1260,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | claude/latency-fixes-2026-07-29 | 0e30215e98328513e23342a8dceafc9d1728bf9d | pr-1376-ci-bugbot-repair | fixed-p1s-plus-followups;owner-scoped-epochs;shared-cache-race;stream-signal;empty-scope-timing;threads-resolved | vitest:15-pass;docs:check-links:pass;verify:cheap:earlier-pass | | 2026-07-29 | codex/remove-source-overlays | a08a81d320c9f8e1bbbe1facc266d8257213b1ad | PR #1378 babysit | FIXED Codex P1s (restore governance notice); overlays/Preview removed; merged main; verify:cheap PASS; Bugbot no open findings | verify:cheap 4273 pass; focused DOM 4/4; eslint/tsc/build PASS; hosted CI re-running after main sync | | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 80df35f6cebe5f8a29dbbe98c960c114c64de8c7 | PR #1377 CI/review babysit | Re-synced main after #1378 (DIRTY=staleness). Codex P1+P2 threads resolved. Hosted CI green on d7aa4c6c prior tip; re-running after sync. | prior tip CI: PR required success; Production UI/Unit/Build/Static/Migration success; CircleCI success; merge-tree clean | +| 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 8b8d4b8952fc96401116af9e34604c2a3e6e53b4 | PR #1377 CI/review babysit | Merged #1376 from main with real conflicts: kept admission-before-scope + L1-2 REFUTED docs; took #1376 invalidation epochs / empty-scope Server-Timing / stream signal. Codex threads resolved earlier. MERGEABLE; CI re-running. | vitest preamble+rag-cache-invalidation 7/7; check:rag:fixtures 36/21; prior tip PR-required green before #1376 land | From 07c6aaeb2c5db9b085a1632354881487ef84ae53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 11:25:10 +0000 Subject: [PATCH 09/25] docs(ledger): record PR #1377 babysit after #1375 sync --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index d4b889df99..7e6fb068c0 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1266,3 +1266,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 80df35f6cebe5f8a29dbbe98c960c114c64de8c7 | PR #1377 CI/review babysit | Re-synced main after #1378 (DIRTY=staleness). Codex P1+P2 threads resolved. Hosted CI green on d7aa4c6c prior tip; re-running after sync. | prior tip CI: PR required success; Production UI/Unit/Build/Static/Migration success; CircleCI success; merge-tree clean | | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 8b8d4b8952fc96401116af9e34604c2a3e6e53b4 | PR #1377 CI/review babysit | Merged #1376 from main with real conflicts: kept admission-before-scope + L1-2 REFUTED docs; took #1376 invalidation epochs / empty-scope Server-Timing / stream signal. Codex threads resolved earlier. MERGEABLE; CI re-running. | vitest preamble+rag-cache-invalidation 7/7; check:rag:fixtures 36/21; prior tip PR-required green before #1376 land | | 2026-07-29 | claude/clinical-design-system-update-e34ca9 | 0cdae091ad92f40e0ad7335b3e2d396c44188a4f | PR #1375 conflict fix + Bugbot | FIXED second CONFLICTING after #1378: took main removal of SelectedDocumentEvidencePanel; retained tracking-eyebrow on surviving document-search-results. Prior DocumentViewerRail + form-detail settlement retained. MERGEABLE; CI re-running. | local: document-search-record-fault + design-token tests; merge-tree CLEAN; prior Production UI PASS on 6903f51f; form-detail e2e 2/2. | +| 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 4909d26afc45c5a1f330a69c5a5693264027b2de | PR #1377 CI/review babysit | Synced #1375 (DIRTY=staleness, merge-tree clean). CI was green on prior tip 9f21672c. No unresolved threads; no Bugbot findings. Tip 4909d26a; CI re-running. | vitest preamble+server-timing+rag-cache-invalidation 17/17; typecheck exit 0; prior tip PR-required SUCCESS | From 4b4f98102bb6965c55c1318ad83ea97cfdce70e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 11:43:19 +0000 Subject: [PATCH 10/25] docs(ledger): record PR #1377 babysit after #1383 sync --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 6e9de7ecd6..389a509433 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1270,3 +1270,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 4909d26afc45c5a1f330a69c5a5693264027b2de | PR #1377 CI/review babysit | Synced #1375 (DIRTY=staleness, merge-tree clean). CI was green on prior tip 9f21672c. No unresolved threads; no Bugbot findings. Tip 4909d26a; CI re-running. | vitest preamble+server-timing+rag-cache-invalidation 17/17; typecheck exit 0; prior tip PR-required SUCCESS | | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks | | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change | +| 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 0badfedac1a8bd2ad32587ad90dbea68617f64e9 | PR #1377 CI/review babysit | Synced #1382/#1383. Real conflict only in outstanding-issues: kept corrected latency #098-#105 + added #106/#107. CI green on prior tip 07c6aaeb. 0 unresolved threads; no Bugbot findings. | vitest preamble+calculator-scoring+private-access-routes 208/208; check:branch-review-ledger pass; prior tip PR-required SUCCESS | From 71db10c41de872fca6e400626704a549f145c66c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 12:39:25 +0000 Subject: [PATCH 11/25] fix(ledger): remove two union-merge duplicate review records `Static PR checks` went red on f6a13cde: the branch-review-ledger guard found 2 exact duplicate records at lines 1276/1277, repeating the ref/HEAD/scope of lines 1272/1273. This is ledger #088's exact watch condition. `docs/branch-review-ledger.md` carries `merge=union` in .gitattributes so concurrent appends survive, but a same-hunk merge can keep both the incoming and existing copy. Repeated origin/main syncs on this PR duplicated two PR #1383 babysit records from claude/test-coverage-analysis-2vcd8a. Verified both pairs byte-identical (664 and 352 chars) before touching anything, then removed only the later copies. The ledger contract allows removing exact duplicates and forbids rewriting surrounding records, so the diff is 2 deletions and 0 additions. check:branch-review-ledger now passes: 1231 table records, union merge active, six cells each, no conflict markers, mojibake, heading records, or duplicates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b256f0bbd8..9db11cb204 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1273,6 +1273,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change | | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 0badfedac1a8bd2ad32587ad90dbea68617f64e9 | PR #1377 CI/review babysit | Synced #1382/#1383. Real conflict only in outstanding-issues: kept corrected latency #098-#105 + added #106/#107. CI green on prior tip 07c6aaeb. 0 unresolved threads; no Bugbot findings. | vitest preamble+calculator-scoring+private-access-routes 208/208; check:branch-review-ledger pass; prior tip PR-required SUCCESS | | 2026-07-29 | codex/document-reader-condensed-view | 7cefb24e99f9745a61843c7e48c4889f7324ec42 | pr-1380-main-merge-coderabbit-density | merged origin/main; resolved source-panels conflict (kept condensed details + tracking-eyebrow); density in-memory fallback when storage blocked; summary keys + search/plain compact tests; local vitest/lint/typecheck/format/playwright condensed pass; awaiting hosted CI | vitest document suites 20/20; lint; typecheck; format:check; playwright condensed 4/4; merge-tree clean | -| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks | -| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change | | 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 | From 377d0c21de98f34f6df79741a86c263f4db734a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 12:52:28 +0000 Subject: [PATCH 12/25] fix(audit): retract the byte-identical recall claim and clear the review batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight review findings (1 Codex P2, 7 CodeRabbit), each verified against the code before acting. MOST CONSEQUENTIAL — the L2-3 "recall is byte-identical" claim was wrong, and it was load-bearing. fetchDocumentTitleAliasRows (rag-candidate-sources.ts:482) applies .limit(12) with no ORDER BY, so which twelve documents return is plan-dependent; a new index can change the title-alias set feeding candidate assembly. "No query text changes" is true, but recall does not follow from it. That claim was the argument for keeping L2-3 out of canary territory, so the gating is revised: the documents-list and (status,id) uses stay ordering-safe, the RAG-path index is canary-gated unless the unordered .limit(12) is made deterministic first — the cheaper fix, since an unordered LIMIT is latent nondeterminism regardless of this work. Operator SQL alone never reaches staging, DR, or local replay: migrations/ is the source of truth and schema.sql only a mirror, so hand-run statements hit the live database and nothing else, and a required_indexes registration would fail on every replayed environment. Authoring the migration is now a required part of #102, following the 20260717170000 idempotent pattern. This PR still ships no migration (the #1312 objection), but the runbook no longer implies the operator sequence is sufficient. Test guard hardened: the ordering case anchored on a fixed 5 ms sleep, which can expire before the handler reaches the limiter. It now waits for consumeSubjectApiRateLimit to be entered, then asserts scope is untouched — the same guarantee without the timing fragility. Ledger: the 78e2beb record still described the reverted scope-vs-ratelimit overlap. The ledger is append-only, so this appends a superseding record via ledger:append --supersede rather than editing the row, per the contract. Status wording: #102 is "runbook prepared", not done, while the operator steps are pending; #105 separates shipped implementation from pending browser verification; #098/#099/#102/#103/#105 restored to the execution queue with their remaining actions. Fixed the MD038 malformed RAG-impact code span. Verification: verify:cheap exit 0 (427 files, 4386 passed / 4 skipped); check:branch-review-ledger pass (1232 records, no duplicates). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/audit/latency-audit-2026-07-28.md | 13 +++- docs/branch-review-ledger.md | 1 + ...r-apply-performance-latency-remediation.md | 40 +++++++++++- docs/outstanding-issues.md | 63 ++++++++++--------- tests/answer-route-preamble.test.ts | 8 ++- 5 files changed, 88 insertions(+), 37 deletions(-) diff --git a/docs/audit/latency-audit-2026-07-28.md b/docs/audit/latency-audit-2026-07-28.md index c4d9b43b98..16e8368f04 100644 --- a/docs/audit/latency-audit-2026-07-28.md +++ b/docs/audit/latency-audit-2026-07-28.md @@ -23,6 +23,11 @@ The 2026-07-29 pass re-lands the free- and flag-gated work with tests and files as reviewed operator SQL in [`operator-apply-performance-latency-remediation.md`](../operator-apply-performance-latency-remediation.md) instead of `supabase/migrations/*.sql`. `supabase/**` is untouched, so no drift manifest needs regenerating and the objection that closed PR #1312 cannot recur. Tracked as `#102`. + **Caveat added 2026-07-29 (review):** deferring the migration is not the same as not needing + one. `migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator + SQL reaches the live database and nothing else — staging, disaster-recovery replay and + `supabase db reset` all stay without the indexes. Authoring the migration is therefore a + required part of `#102`, not an optional extra. - **L4-2 is retracted.** See the correction in the L4 section below — it is not an open finding. Ledger IDs in the body below are the 2026-07-29 numbers. The original draft used `#085`–`#092`; @@ -159,7 +164,11 @@ Metadata → memory → visual hydration run as three sequential Supabase stages `src/app/api/documents/route.ts:193`; `src/lib/rag/rag-candidate-sources.ts:477` · `A=P|S B=inferred C=multiplicative` · gate=**operator** · **SQL AUTHORED, NOT APPLIED** -`documents_title_trgm_idx` (`schema.sql:687`) indexes the **concatenated expression** `lower(coalesce(title,'') || ' ' || coalesce(file_name,''))`. Both call sites filter the bare columns (`title.ilike.%q%,file_name.ilike.%q%`), which that expression index cannot serve, so both fall back to scanning `documents`. The RAG-path site is bounded by `.limit(12)`, but a non-matching query still scans. Two bare-column GIN trigram indexes serve those predicates directly — **additive and semantics-neutral, so no query text changes and recall is byte-identical**, which is what keeps this out of canary territory. +`documents_title_trgm_idx` (`schema.sql:687`) indexes the **concatenated expression** `lower(coalesce(title,'') || ' ' || coalesce(file_name,''))`. Both call sites filter the bare columns (`title.ilike.%q%,file_name.ilike.%q%`), which that expression index cannot serve, so both fall back to scanning `documents`. The RAG-path site is bounded by `.limit(12)`, but a non-matching query still scans. Two bare-column GIN trigram indexes serve those predicates directly. + +**CORRECTED 2026-07-29 (PR #1377 review) — the "recall is byte-identical" claim was wrong, and it was load-bearing.** The original text argued the indexes are "additive and semantics-neutral, so no query text changes and recall is byte-identical", and used that to keep L2-3 out of canary territory. The premise does not hold on the RAG path: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with **no `ORDER BY`**, so _which_ twelve documents come back is plan-dependent. Adding an index changes the plan, so it can change the title-alias set fed into candidate assembly — a retrieval input, not just a speed-up. "No query text changes" is true; "recall is byte-identical" does not follow from it. + +**Revised gating.** The `api/documents/route.ts:193` site is a user-facing document list and carries no retrieval consequence. The `rag-candidate-sources.ts:477` site does. Treat the RAG-path index as **canary-gated**, or make the ordering deterministic first — adding a stable `ORDER BY` to that `.limit(12)` would restore the semantics-neutral argument and is the cheaper route, since an unordered `LIMIT` is a latent nondeterminism regardless of this index. Tracked in `#102`; do not apply the RAG-path index on the strength of the retracted claim. **Deliberately no migration file.** The reviewed statements live in [`operator-apply-performance-latency-remediation.md`](../operator-apply-performance-latency-remediation.md). @@ -363,7 +372,7 @@ provider call is needed to size the top of the ranking. | L2-3 / L2-5 | Bare-column trigram + `(status,id)` composite **authored as operator SQL, not applied** | `docs/operator-apply-performance-latency-remediation.md` | | L3-4 / L3-5 | 10 `loading` fallbacks; Supabase `preconnect`/`dns-prefetch` | `clinical-dashboard-lazy.tsx`, `dashboard-nav.tsx`, `src/app/layout.tsx` | -**`RAG impact: no retrieval behaviour change** — the only `src/lib/rag/**` edits defer a process-local cache write off the response path and add a doc comment; no scoring, ordering, selection, alias, or citation logic is touched, and the mid-request staleness guard is preserved.** +**RAG impact: no retrieval behaviour change** — the only `src/lib/rag/**` edits defer a process-local cache write off the response path and add a doc comment; no scoring, ordering, selection, alias, or citation logic is touched, and the mid-request staleness guard is preserved. ## Retired during verification (4) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 9db11cb204..b03df4feb0 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1274,3 +1274,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 0badfedac1a8bd2ad32587ad90dbea68617f64e9 | PR #1377 CI/review babysit | Synced #1382/#1383. Real conflict only in outstanding-issues: kept corrected latency #098-#105 + added #106/#107. CI green on prior tip 07c6aaeb. 0 unresolved threads; no Bugbot findings. | vitest preamble+calculator-scoring+private-access-routes 208/208; check:branch-review-ledger pass; prior tip PR-required SUCCESS | | 2026-07-29 | codex/document-reader-condensed-view | 7cefb24e99f9745a61843c7e48c4889f7324ec42 | pr-1380-main-merge-coderabbit-density | merged origin/main; resolved source-panels conflict (kept condensed details + tracking-eyebrow); density in-memory fallback when storage blocked; summary keys + search/plain compact tests; local vitest/lint/typecheck/format/playwright condensed pass; awaiting hosted CI | vitest document suites 20/20; lint; typecheck; format:check; playwright condensed 4/4; merge-tree clean | | 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 | +| 2026-07-29 | claude/latency-findings-impl-s8g01v | 71db10c41de872fca6e400626704a549f145c66c | latency audit implementation (PR #1377) | SUPERSEDES the 78e2beb record: its 'scope-vs-ratelimit overlap with abort' description is stale and describes behaviour that was REVERTED. Codex review raised it P1 and it was correct — an AbortSignal cannot un-execute a statement Postgres already began, and resolveSearchScope only skips the database when there are no filters and no explicit ids (search-scope.ts:242,253), so a throttled caller kept spending DB capacity while collecting 429s. Shipped behaviour is rate-limit admission BEFORE scope evaluation, with request.signal threaded so a client disconnect still cancels scope's paginated queries, pinned by tests/answer-route-preamble.test.ts. Also retracted on this head: the L2-3 'recall is byte-identical' claim, since fetchDocumentTitleAliasRows applies .limit(12) with no ORDER BY. | verify:cheap exit 0 (423 files, 4278 passed/4 skipped); check:branch-review-ledger pass; focused preamble + server-timing suites pass | diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md index eccc6e9d95..963447617b 100644 --- a/docs/operator-apply-performance-latency-remediation.md +++ b/docs/operator-apply-performance-latency-remediation.md @@ -52,9 +52,23 @@ Neither can use the expression index, so both fall back to scanning `documents`. `src/lib/search-scope.ts:271-277` pages `.eq("status","indexed").order("id")` to 5,000 rows against the single-column `documents_status_idx` (`schema.sql:678`), so each page sorts. -These three indexes are **additive and semantics-neutral**: no query text changes, so matching -behaviour — and therefore retrieval recall — is byte-identical before and after. That is what -keeps this out of canary-gated territory. Create them outside a transaction: +**CORRECTED 2026-07-29 — do not treat all three as semantics-neutral.** An earlier version of +this section claimed all three are "additive and semantics-neutral … retrieval recall is +byte-identical", and used that to keep them out of canary-gated territory. That is wrong for the +RAG-path index: `fetchDocumentTitleAliasRows` (`src/lib/rag/rag-candidate-sources.ts:482`) applies +`.limit(12)` with **no `ORDER BY`**, so which twelve rows return is plan-dependent and a new index +can change the title-alias set feeding candidate assembly. No query text changes — but recall does +not follow from that. + +- `documents_status_id_idx` and the `documents_title_bare_trgm_idx` benefit to + `src/app/api/documents/route.ts:193` are ordering-safe: that path is a user-facing document + list with no retrieval consequence. +- The **RAG-path** use of the bare-column trigram indexes is **canary-gated**, or must be preceded + by making that `.limit(12)` deterministic with a stable `ORDER BY` — the cheaper fix, since an + unordered `LIMIT` is latent nondeterminism regardless of this work. Do not apply on the strength + of the retracted claim. + +Create them outside a transaction: ```sql create index concurrently if not exists documents_title_bare_trgm_idx @@ -72,6 +86,26 @@ but it does two table passes and can leave an `INVALID` index if it fails. Check `pg_index.indisvalid` for each name afterwards and `DROP INDEX CONCURRENTLY` + retry any invalid one rather than leaving it in place. +### A migration is required — operator SQL alone does not reach staging, DR, or local replay + +**Added 2026-07-29 after PR #1377 review.** `supabase/migrations/` is the source of truth and +`supabase/schema.sql` is a mirror (see the repository layout in `CLAUDE.md`). Running the +statements above by hand creates the indexes **only on the database you ran them against**. +`supabase db push`, the staging tier, disaster-recovery replay, and a local `supabase db reset` +all build from migrations, so without a committed migration they never get these indexes — and a +`required_indexes` registration in `search_schema_health()` would then fail on exactly those +environments. + +Follow the pattern this document already uses for `documents_registry_projection_lookup_idx`: +commit an idempotent `create index if not exists` migration, pre-create the indexes +`CONCURRENTLY` on a busy target first, and let the migration land as a no-op there while +recording the lineage for every other environment. + +**PR #1377 deliberately ships no migration**, because an additive-index migration without a +synchronized `schema.sql` mirror and regenerated drift manifest is what caused PR #1312 to be +closed. That makes authoring the migration a **required part of `#102`**, not an optional extra: +the runbook below is step one of the sequence, not the whole of it. + ### Ordering constraint — do all four steps in one change 1. Create the indexes concurrently on the live database, and confirm all three are valid. diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 028595b6a3..c28f4d7b67 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -48,33 +48,38 @@ removed after current-main verification; it is not missing recommended work. database/RAG/clinical/privacy expertise; Operator = named provider/product/legal authority. - **Estimate:** focused active time, excluding approval, hosted runtime, soak, and review waits. -| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition | -| ----: | -------------- | -------- | ------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | `#059` | A1 | Operator security + independent reviewer | Immediate approved security window | 1–3 hours plus verification | Verify every reported exposed credential (GitHub, OpenAI, Supabase service role/database, E2E) is retired; rotate anything still valid and update only intended secret stores. Never record values; stop before provider action without approval. | -| 2 | `#053` | A1 | Operator — legal/privacy | Start now; finish before real patient use/privacy-approved release | 4–8 hours internal; 1–6 weeks elapsed | Execute DPAs; decide ZDR/residency; obtain cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not change public copy before approval. | -| 5 | `#024` | A2 | High — browser/Next diagnostics | Provider-free macOS Safari host available | 1–2 hours | Reproduce document-source fallbacks in Safari/STP without Playwright interception; capture `_rsc` response evidence. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without proof. | -| 6 | `#022` | A2 | Operator — clinical governance + Specialist | Policy implemented locally; hosted apply and human review pending | 1–2 hours apply; 0.5–1 day first ten | The auditable BMJ `third_party_reference_attested` policy, migration and top-ten evidence manifest are prepared without changing `clinical_validation_status=unverified`. A qualified operator must review evidence, apply the migration deliberately, attest eligible records, review the ten visible local documents, then remeasure warnings. | -| 7 | `#023` | A2 | Specialist — RAG/browser diagnostics | After hosted dependency audit is green | 1–2 hours | Capture the skipped Firefox/WebKit scheduled datapoint and disposition the human irrelevant-at-10 labels. Retrieval and answer artifacts are already compared under resolved #051; do not spend on another RAG run. | -| 8 | `#018` | A2 | Specialist — clinical RAG/retrieval | Lithium closed; ADHD/metabolic evidence debt remains | Corpus/operator follow-up | Lithium's bounded subject/row-aware fix passed its targeted answer plus the full 36-case retrieval and 44-case answer canaries. ADHD's expected CAMHS document remains absent and the surfaced chart has no accessible table; metabolic schedule evidence remains unavailable and its standalone classifier candidate was reverted. | -| 10 | `#001` | A2 | Specialist — retrieval/ranking | After rollout approval | 0.5–1 day plus canary | Keep semantic reranking off unless an approved ambiguity comparison preserves 36/36, recall 1.0, zero per-case regressions, and shows measured gain; otherwise record keep-off and stop. | -| 11 | `#025` | A2 | Operator — Railway/GitHub/chat/Supabase | Next approved observability window | 1–3 hours/channel | Choose owned deployment, CI, ingestion, and SLO alerts; mock first, then one approved controlled provider event/channel. The merged Supabase trigger remains inert until its verified inputs are configured. Stop without an accountable responder. | -| 12 | `#055` | A2 | Specialist release owner + Operator | Before next full-confidence release/handoff | 2–4 hours plus runtime | On one exact SHA, run local/provider gates, Firefox/WebKit, required hosted CI, and close actionable GitHub threads. Stop at first failure and rerun only the repaired smallest gate. | -| 13 | `#056` | A2 | Operator — Supabase/Railway + Specialist | Next approved staging schema window | 2–4 hours | Reconcile the existing healthy, empty staging tier's 23-migration history gap using the exact repository migration chain, then re-run indexing, health, identity and data-boundary proof. Never recreate it or copy production clinical documents. | -| 14 | `#057` | A2 | High — release/SRE + Operator | After `#056` | 2–4 hours plus soak | Run documented staging soak and rollback against an exact candidate. Retain latency/error/rollback evidence; stop on unsafe data, identity mismatch, or unowned rollback. | -| 16 | `#011` | A3 | Operator — Supabase capacity | Immediately before first compute scale-up | 30–60 min plus observation | Switch Auth to percentage allocation, record before/after, and run approved advisor/health checks. Stop if no scale-up is planned. | -| 17 | `#017` | A3 | High — performance/browser | Before `#013`/`#016`; approved live-site window | 1–2 hours | Capture reproducible mobile/desktop Lighthouse/Web-Vitals evidence and decide whether payload work is justified. Stop if metrics are acceptable or evidence is too noisy. | -| 18 | `#033` | A3 | Specialist — prompt/source governance | After `#022` and explicit evaluation approval | 1–2 days plus approved eval | Design unknown-vs-adverse metadata wording and prompt tests. Require no supported-grounding drop and zero citation failures; stop on broad over-caveating or degradation. | -| 19 | `#037` | A3 | Operator — clinical/product + Standard | Next trust-policy review | 30–60 min; up to 0.5 day | Decide whether routine claims cap at medium trust. Record policy; if accepted, change only the flag/expectations and run focused tests. | -| 20 | `#013`, `#016` | A3 | High — bundling/runtime performance | After `#017` or equivalent evidence | 0.5–2 days/route | Optimize only a production route with measured payload/render/motion harm. Require material gain plus focused, `verify:cheap`, and browser evidence; stop on small gain. | -| 21 | `#035` | A3 | Specialist — evidence rules | After a demonstrated missed conflict | 0.5–1 day design; code separate | Define a clinically reviewed conflict class with positive and negative fixtures. Stop if no bounded class can be shown; behavior change requires protected review. | -| 22 | `#027` | Optional | Operator — SRE/provider | When an owned external alert path is wanted | 1–2 hours | Decide vendor/cost/privacy/owner; if accepted, prove one non-PHI outage and recovery alert. Stop when no responder owns it. | -| 23 | `#028` | Optional | Specialist privacy/observability + Operator | After privacy/ownership/cost approval | 1–3 days | Define vendor/region/retention/redaction/sampling/source-map envelope before SDK work. Prove no clinical text, identifiers, or secrets leave; stop if unacceptable. | -| 24 | `#038` | Optional | High — product/design architecture | When a new comparison surface is approved | 0.5–1 day | Define a shared interaction contract without flattening mode-specific content. Stop when no concrete new surface exists. | -| 25 | `#040` | Optional | High — visual QA/accessibility | When baseline owner/update workflow exists | 1–2 days | Establish a small stable desktop/mobile/accessibility baseline set. Do not make it blocking if flake or maintenance cost outweighs detection value. | -| 26 | `#039` | Optional | High — frontend architecture | During a concrete catalogue-toolbar project | 0.5–1 day inventory; 1–3 days code | Converge only repeated toolbar behavior without flattening search semantics. Stop when there is no bounded implementation target. | -| 27 | `#065` | A2 | High — document-viewer UI | Only when the user explicitly resumes the paused task | 0.5–1.5 days | Finish the compact source-text accordion, citation/search auto-open, print restoration, and 320/390/1280 px coverage. Keep the preserved branch untouched until explicit resume; no provider calls. | -| 28 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | -| 29 | `#086` | A3 | High — repository structure + Specialist | On explicit go-ahead for X3; later packages own their gates | 1 PR per work order | Ship remaining maturity backlog (X3 rag.ts; X7 src/lib reorg; X6 coverage floors; X5 ACL consolidation; L1 one-shot archive; L4 ledger rotation; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | +| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition | +| ----: | -------------- | -------- | ------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `#059` | A1 | Operator security + independent reviewer | Immediate approved security window | 1–3 hours plus verification | Verify every reported exposed credential (GitHub, OpenAI, Supabase service role/database, E2E) is retired; rotate anything still valid and update only intended secret stores. Never record values; stop before provider action without approval. | +| 2 | `#053` | A1 | Operator — legal/privacy | Start now; finish before real patient use/privacy-approved release | 4–8 hours internal; 1–6 weeks elapsed | Execute DPAs; decide ZDR/residency; obtain cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not change public copy before approval. | +| 5 | `#024` | A2 | High — browser/Next diagnostics | Provider-free macOS Safari host available | 1–2 hours | Reproduce document-source fallbacks in Safari/STP without Playwright interception; capture `_rsc` response evidence. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without proof. | +| 6 | `#022` | A2 | Operator — clinical governance + Specialist | Policy implemented locally; hosted apply and human review pending | 1–2 hours apply; 0.5–1 day first ten | The auditable BMJ `third_party_reference_attested` policy, migration and top-ten evidence manifest are prepared without changing `clinical_validation_status=unverified`. A qualified operator must review evidence, apply the migration deliberately, attest eligible records, review the ten visible local documents, then remeasure warnings. | +| 7 | `#023` | A2 | Specialist — RAG/browser diagnostics | After hosted dependency audit is green | 1–2 hours | Capture the skipped Firefox/WebKit scheduled datapoint and disposition the human irrelevant-at-10 labels. Retrieval and answer artifacts are already compared under resolved #051; do not spend on another RAG run. | +| 8 | `#018` | A2 | Specialist — clinical RAG/retrieval | Lithium closed; ADHD/metabolic evidence debt remains | Corpus/operator follow-up | Lithium's bounded subject/row-aware fix passed its targeted answer plus the full 36-case retrieval and 44-case answer canaries. ADHD's expected CAMHS document remains absent and the surfaced chart has no accessible table; metabolic schedule evidence remains unavailable and its standalone classifier candidate was reverted. | +| 10 | `#001` | A2 | Specialist — retrieval/ranking | After rollout approval | 0.5–1 day plus canary | Keep semantic reranking off unless an approved ambiguity comparison preserves 36/36, recall 1.0, zero per-case regressions, and shows measured gain; otherwise record keep-off and stop. | +| 11 | `#025` | A2 | Operator — Railway/GitHub/chat/Supabase | Next approved observability window | 1–3 hours/channel | Choose owned deployment, CI, ingestion, and SLO alerts; mock first, then one approved controlled provider event/channel. The merged Supabase trigger remains inert until its verified inputs are configured. Stop without an accountable responder. | +| 12 | `#055` | A2 | Specialist release owner + Operator | Before next full-confidence release/handoff | 2–4 hours plus runtime | On one exact SHA, run local/provider gates, Firefox/WebKit, required hosted CI, and close actionable GitHub threads. Stop at first failure and rerun only the repaired smallest gate. | +| 13 | `#056` | A2 | Operator — Supabase/Railway + Specialist | Next approved staging schema window | 2–4 hours | Reconcile the existing healthy, empty staging tier's 23-migration history gap using the exact repository migration chain, then re-run indexing, health, identity and data-boundary proof. Never recreate it or copy production clinical documents. | +| 14 | `#057` | A2 | High — release/SRE + Operator | After `#056` | 2–4 hours plus soak | Run documented staging soak and rollback against an exact candidate. Retain latency/error/rollback evidence; stop on unsafe data, identity mismatch, or unowned rollback. | +| 16 | `#011` | A3 | Operator — Supabase capacity | Immediately before first compute scale-up | 30–60 min plus observation | Switch Auth to percentage allocation, record before/after, and run approved advisor/health checks. Stop if no scale-up is planned. | +| 17 | `#017` | A3 | High — performance/browser | Before `#013`/`#016`; approved live-site window | 1–2 hours | Capture reproducible mobile/desktop Lighthouse/Web-Vitals evidence and decide whether payload work is justified. Stop if metrics are acceptable or evidence is too noisy. | +| 18 | `#033` | A3 | Specialist — prompt/source governance | After `#022` and explicit evaluation approval | 1–2 days plus approved eval | Design unknown-vs-adverse metadata wording and prompt tests. Require no supported-grounding drop and zero citation failures; stop on broad over-caveating or degradation. | +| 19 | `#037` | A3 | Operator — clinical/product + Standard | Next trust-policy review | 30–60 min; up to 0.5 day | Decide whether routine claims cap at medium trust. Record policy; if accepted, change only the flag/expectations and run focused tests. | +| 20 | `#013`, `#016` | A3 | High — bundling/runtime performance | After `#017` or equivalent evidence | 0.5–2 days/route | Optimize only a production route with measured payload/render/motion harm. Require material gain plus focused, `verify:cheap`, and browser evidence; stop on small gain. | +| 21 | `#035` | A3 | Specialist — evidence rules | After a demonstrated missed conflict | 0.5–1 day design; code separate | Define a clinically reviewed conflict class with positive and negative fixtures. Stop if no bounded class can be shown; behavior change requires protected review. | +| 22 | `#027` | Optional | Operator — SRE/provider | When an owned external alert path is wanted | 1–2 hours | Decide vendor/cost/privacy/owner; if accepted, prove one non-PHI outage and recovery alert. Stop when no responder owns it. | +| 23 | `#028` | Optional | Specialist privacy/observability + Operator | After privacy/ownership/cost approval | 1–3 days | Define vendor/region/retention/redaction/sampling/source-map envelope before SDK work. Prove no clinical text, identifiers, or secrets leave; stop if unacceptable. | +| 24 | `#038` | Optional | High — product/design architecture | When a new comparison surface is approved | 0.5–1 day | Define a shared interaction contract without flattening mode-specific content. Stop when no concrete new surface exists. | +| 25 | `#040` | Optional | High — visual QA/accessibility | When baseline owner/update workflow exists | 1–2 days | Establish a small stable desktop/mobile/accessibility baseline set. Do not make it blocking if flake or maintenance cost outweighs detection value. | +| 26 | `#039` | Optional | High — frontend architecture | During a concrete catalogue-toolbar project | 0.5–1 day inventory; 1–3 days code | Converge only repeated toolbar behavior without flattening search semantics. Stop when there is no bounded implementation target. | +| 27 | `#065` | A2 | High — document-viewer UI | Only when the user explicitly resumes the paused task | 0.5–1.5 days | Finish the compact source-text accordion, citation/search auto-open, print restoration, and 320/390/1280 px coverage. Keep the preserved branch untouched until explicit resume; no provider calls. | +| 28 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | +| 29 | `#086` | A3 | High — repository structure + Specialist | On explicit go-ahead for X3; later packages own their gates | 1 PR per work order | Ship remaining maturity backlog (X3 rag.ts; X7 src/lib reorg; X6 coverage floors; X5 ACL consolidation; L1 one-shot archive; L4 ledger rotation; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | +| 30 | `#098` | A3 | High — test infrastructure | Before `#099` or `#101`; it is their enabler | 2–4 hours | Generalise the answer-route preamble guard into a counting-proxy round-trip budget harness over the existing offline fixtures. Must enforce admission-before-scope, never the reverse. No providers, no DB. Stop if it would require live credentials. | +| 31 | `#102` | A3 | Operator — Supabase + Specialist | Next approved index window, after the ordering question is settled | 1–2 hours plus apply | Author the migration (operator SQL alone never reaches staging/DR/local replay), then apply → mirror `schema.sql` → regenerate drift manifest → register `required_indexes`. **Stop:** the RAG-path index is canary-gated until `fetchDocumentTitleAliasRows`'s unordered `.limit(12)` is made deterministic — the byte-identical claim was retracted. | +| 32 | `#099` | A3 | Specialist — answer path | After `#098` | Half a day per sub-item | Remaining fixed per-request round trips: the 8 `setCachedSearch` deferrals (abort semantics + mutation window), the anonymous subject+global limiter pair (needs a new atomic RPC first), and proxy→route identity duplication. Stop before hand-authoring locking SQL. | +| 33 | `#103` | A3 | Operator — Supabase schema | Same window as `#102` | 30–60 minutes | Confirm whether the wide `document_table_facts` trigram index from `20260714190000` exists live, then either mirror it into `schema.sql` or record it in `drift-allowlist.json` with the reason. Stop: do not drop it without live scan evidence. | +| 34 | `#105` | Optional | High — browser/UI verification | When the heavy-run lock is free | 20–40 minutes | Run `verify:ui` over the ten `LoadingPanel` fallbacks and confirm the Supabase `preconnect` reaches `` on a live page. Implementation already shipped; this row is the outstanding verification only. | @@ -135,10 +140,10 @@ removed after current-main verification; it is not missing recommended work. | #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | | #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | -| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Done 2026-07-29:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive and semantics-neutral, so no query text changes and recall is byte-identical. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** apply concurrently, confirm `indisvalid`, mirror into `schema.sql`, run `npm run drift:manifest` (Docker), THEN register the three names in `required_indexes` — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | +| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated unless that `.limit(12)` is made deterministic first. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. Then apply concurrently, confirm `indisvalid`, mirror into `schema.sql`, run `npm run drift:manifest` (Docker), THEN register the three names in `required_indexes` — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | | #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | | #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | -| #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Done 2026-07-29:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | +| #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Implementation shipped 2026-07-29; browser verification still PENDING:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | | #106 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | | #107 | P2 | rec | Component state matrices are the largest untested surface | **Outcome:** loading / empty / error / disabled states on interactive components are covered by executing tests, not only by E2E happy paths. **Detail:** measured 2026-07-29 — production components (excluding mockups) sit at **38.2% lines / 22.8% branch** across 12,602 lines, with **83 of 208 files at zero executed lines**; there are 51 `.dom.test.tsx` files against 195 components. Playwright does visit these routes, so they are smoke-covered, but branch coverage is where the state matrix lives and smoke journeys rarely reach it. Worst by uncovered lines: `global-search-shell.tsx` (7%), `mode-action-popup.tsx` (21%), `answer-content.tsx` (27%), `document-search-results.tsx` (32%), `universal-search-command-surface.tsx` (39%), `master-search-header.tsx` (43%). A concrete first target with clinical meaning: `calculator-ui.tsx` now covers all exported scoring logic, but `seedCheckboxDefaults`, `toggleCheckboxAnswer` and `selectOptionAnswer` stay uncovered because they are module-private and only reachable through React event handlers — `seedCheckboxDefaults` is what makes an all-negative CAGE / SAD PERSONS screen read as a valid 0 rather than incomplete, so a regression there is a false-negative risk. **Next:** treat as a per-PR convention rather than a backfill push — `docs/testing.md` already prescribes the state matrix, so the gap is enforcement. Start with `global-search-shell.tsx`, which `docs/search-chrome-behaviour.md` treats as a contract surface. Keep additions in the jsdom tier (measured ~0.54s per file) instead of new Playwright journeys (~231 production journeys already run serially at `workers: 1` against a 45-minute CI budget). **Stop:** do not chase the coverage percentage by backfilling low-risk components; the re-ratcheted broad floor in `vitest.config.mts` holds the line. | session 2026-07-29 test-coverage analysis | 2026-07-29 | diff --git a/tests/answer-route-preamble.test.ts b/tests/answer-route-preamble.test.ts index 7dab9ef328..6b22c9ca0b 100644 --- a/tests/answer-route-preamble.test.ts +++ b/tests/answer-route-preamble.test.ts @@ -98,9 +98,11 @@ describe("/api/answer preamble", () => { const { POST } = await import("../src/app/api/answer/route"); const response = POST(answerRequest()); - // Give the route every chance to dispatch scope early: if it overlapped the - // two, this microtask drain would be enough for the call to land. - await new Promise((resolve) => setTimeout(resolve, 5)); + // Anchor on a limiter-start signal rather than a fixed sleep. A timeout can + // expire before the handler even reaches the limiter, which would let this + // pass without proving anything; waiting for the limiter to be entered means + // the route is demonstrably inside the preamble when scope is checked. + await vi.waitFor(() => expect(consumeSubjectApiRateLimit).toHaveBeenCalledTimes(1)); expect(resolveSearchScope).not.toHaveBeenCalled(); limiter.resolve(rateLimitDecision(false)); From 6d3b3779e43f8f25d4defd3ab17aaed655895c60 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:00:32 +0000 Subject: [PATCH 13/25] docs: fix the offline-harness references, ORDER BY claim, and rollback order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three valid review findings from the 377d0c2 round. A fourth (Codex P1 on the ledger) is refuted below. 1. The measurement plan named `test-cache-path.mjs` and `check-rag-fixtures.mjs` as the offline suite to build the round-trip harness over. Neither exercises a RAG request: test-cache-path computes Vitest/TypeScript cache paths, and check-rag-fixtures only validates fixture manifests. `test-rag-offline.mjs` and `rag-offline-contract.mjs` are the executable suite, and main's copy of this audit already cited them — a stale reference carried forward from the closed-branch draft. Restored. 2. Calling a stable ORDER BY on the unordered .limit(12) a way to "restore the semantics-neutral argument" was wrong. An unordered LIMIT has no stable selection to preserve, so imposing an order can select a different twelve than the database returns today; it converts one unvalidated change into another. Still worth doing — unordered LIMIT on a retrieval input is latent nondeterminism — but its own recall impact needs validating, and the RAG-path index stays canary-gated either way. 3. Rollback said only "DROP INDEX CONCURRENTLY, reversing steps". Dropping a physical index while required_indexes still names it leaves search_schema_health() red, and dropping it while schema.sql and the drift manifest still describe it fails drift validation. Rollback now retracts the expectations, deploys that, and drops last. Migration authoring is also an explicit numbered step in the rollout rather than only prose. REFUTED — Codex P1 "keep existing ledger entries in place", claiming the 7cefb24e row was deleted from its original location and re-added. It was not. Verified against the refs: that row appears exactly once on both 71db10c and HEAD; the record count goes 1231 -> 1232; the set difference of rows shows nothing removed and exactly one added (the ledger:append supersede record). The appearance of a move is a git diff-anchoring artifact — deleting two lines from a run of similar rows lets git render the deletion at a different anchor. The append-only contract was not violated. Verification: verify:cheap exit 0 (427 files, 4386 passed / 4 skipped). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/audit/latency-audit-2026-07-28.md | 4 +- ...r-apply-performance-latency-remediation.md | 49 +++++++++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/audit/latency-audit-2026-07-28.md b/docs/audit/latency-audit-2026-07-28.md index 16e8368f04..99cd266972 100644 --- a/docs/audit/latency-audit-2026-07-28.md +++ b/docs/audit/latency-audit-2026-07-28.md @@ -168,7 +168,7 @@ Metadata → memory → visual hydration run as three sequential Supabase stages **CORRECTED 2026-07-29 (PR #1377 review) — the "recall is byte-identical" claim was wrong, and it was load-bearing.** The original text argued the indexes are "additive and semantics-neutral, so no query text changes and recall is byte-identical", and used that to keep L2-3 out of canary territory. The premise does not hold on the RAG path: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with **no `ORDER BY`**, so _which_ twelve documents come back is plan-dependent. Adding an index changes the plan, so it can change the title-alias set fed into candidate assembly — a retrieval input, not just a speed-up. "No query text changes" is true; "recall is byte-identical" does not follow from it. -**Revised gating.** The `api/documents/route.ts:193` site is a user-facing document list and carries no retrieval consequence. The `rag-candidate-sources.ts:477` site does. Treat the RAG-path index as **canary-gated**, or make the ordering deterministic first — adding a stable `ORDER BY` to that `.limit(12)` would restore the semantics-neutral argument and is the cheaper route, since an unordered `LIMIT` is a latent nondeterminism regardless of this index. Tracked in `#102`; do not apply the RAG-path index on the strength of the retracted claim. +**Revised gating.** The `api/documents/route.ts:193` site is a user-facing document list and carries no retrieval consequence. The `rag-candidate-sources.ts:477` site does. Treat the RAG-path index as **canary-gated**, or make the ordering deterministic first — adding a stable `ORDER BY` to that `.limit(12)` makes selection deterministic **but does not by itself make the change safe** — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve than the database happens to return today, and the resulting recall still needs validating. It is worth doing on its own merits, since an unordered `LIMIT` feeding retrieval candidates is latent nondeterminism regardless of this index, but it converts one unvalidated change into another rather than removing the need for a canary. Tracked in `#102`; do not apply the RAG-path index on the strength of the retracted claim. **Deliberately no migration file.** The reviewed statements live in [`operator-apply-performance-latency-remediation.md`](../operator-apply-performance-latency-remediation.md). @@ -351,7 +351,7 @@ with numbers this repo already owns (`#069`: warm p90 175–243 ms; first-unprim provider call is needed to size the top of the ranking. 1. **`Server-Timing` first (done).** It existed but was emitted only by `/api/answer` and `/api/search/universal` — **not by `/api/answer/stream`, the route the UI actually uses.** That was the largest measurement gap in the repo. Now: `/api/answer` emits `auth`/`ratelimit`/`scope` alongside its existing entries; `/api/search` emits `auth`/`ratelimit`/`search`/`total`; `/api/answer/stream` emits `auth`/`ratelimit`. **Documented limitation:** on a streaming response, headers flush before the first frame, so in-stream stage durations cannot reach a response header — and routing them through the SSE contract would put instrumentation inside a governed clinical payload (`answer-stream-contract.ts:21` whitelists only `progress`/`final`/`error`). `/api/answer` covers those stages over the same `resolveSearchScope` + RAG path. -2. **Offline round-trip budget harness.** `tests/answer-route-preamble.test.ts` pins the answer-route preamble as an **admission-cost** contract, not an overlap one: scope resolution starts only **after** the limiter admits, a denied request dispatches **zero** scope queries, and the client abort signal is threaded. Any generalisation of this harness must enforce that direction — asserting the reverse would reintroduce the database work for throttled requests that L1-2's refutation removed. Broadening it to a general counting-proxy over the offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`) is the enabler for L1-5 and remains open as `#098`. Zero providers, zero DB. +2. **Offline round-trip budget harness.** `tests/answer-route-preamble.test.ts` pins the answer-route preamble as an **admission-cost** contract, not an overlap one: scope resolution starts only **after** the limiter admits, a denied request dispatches **zero** scope queries, and the client abort signal is threaded. Any generalisation of this harness must enforce that direction — asserting the reverse would reintroduce the database work for throttled requests that L1-2's refutation removed. Broadening it to a general counting-proxy over the offline fixtures (`scripts/eval-rag-offline.mjs`, `scripts/test-rag-offline.mjs`, `scripts/rag-offline-contract.mjs`) is the enabler for L1-5 and remains open as `#098`. Zero providers, zero DB. 3. **Provider-free wall-clock hot path.** The typeahead route is useful because `lexicalOnly: true` skips the per-keystroke embedding, so it times a real request with no OpenAI call. **Corrected 2026-07-29 — do not pair it with demo mode, and do not claim it covers scope.** `src/app/api/search/universal/route.ts:137` returns on `isDemoMode() || isLocalNoAuthMode()` _before_ `createAdminClient` (`:150`), `publicAccessContext` (`:151`) and `consumeSubjectApiRateLimit` (`:153`), so a demo-mode run measures local fixture search and skips the auth and limiter stages entirely; and `resolveSearchScope` is never called from that route at all. Timing it in demo mode would therefore appear to clear an L1 finding it never exercised. Run it **non-demo** against a local Supabase or a mocked client to cover auth + rate-limit + RPC, and time the **scope** stage separately via `/api/answer`, which is the route that actually resolves it. 4. **Disposable local Postgres + `EXPLAIN (ANALYZE, BUFFERS)`** for L2-3/L2-5/L2-6. **Caveat to carry:** `hnsw.ef_search` _is_ settable locally, so no local vector plan may be generalised to hosted. 5. **Local client evidence that does not claim to be `#017`.** `build:analyze`, the enforced bundle budget, the `next build` route table (`ƒ Dynamic` vs `○ Static`), and a Playwright run on the existing local harness reading `PerformanceObserver` LCP/CLS/long-tasks plus `performance.getEntriesByType("resource")` on a cold profile. This **ranks** L3 items against each other; it does **not** discharge `#017`, which requires live-site evidence. diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md index 963447617b..911318cf3b 100644 --- a/docs/operator-apply-performance-latency-remediation.md +++ b/docs/operator-apply-performance-latency-remediation.md @@ -64,9 +64,11 @@ not follow from that. `src/app/api/documents/route.ts:193` are ordering-safe: that path is a user-facing document list with no retrieval consequence. - The **RAG-path** use of the bare-column trigram indexes is **canary-gated**, or must be preceded - by making that `.limit(12)` deterministic with a stable `ORDER BY` — the cheaper fix, since an - unordered `LIMIT` is latent nondeterminism regardless of this work. Do not apply on the strength - of the retracted claim. + by making that `.limit(12)` deterministic with a stable `ORDER BY`. **Note the ordering fix is + not itself free:** an unordered `LIMIT` has no stable selection to preserve, so imposing an order + can select a different twelve than the database returns today — it is worth doing because + unordered `LIMIT` on a retrieval input is latent nondeterminism, but its own recall impact needs + validating. Either way, do not apply on the retracted semantics-neutral claim. Create them outside a transaction: @@ -106,28 +108,45 @@ synchronized `schema.sql` mirror and regenerated drift manifest is what caused P closed. That makes authoring the migration a **required part of `#102`**, not an optional extra: the runbook below is step one of the sequence, not the whole of it. -### Ordering constraint — do all four steps in one change +### Ordering constraint — do all five steps in one change -1. Create the indexes concurrently on the live database, and confirm all three are valid. -2. Mirror the three `create index` statements into `supabase/schema.sql` beside the existing +1. **Author and commit the idempotent migration** (`create index if not exists`, per the + `20260717170000` pattern). Without this the indexes never reach staging, disaster recovery, or + a local `supabase db reset`, however carefully the remaining steps are followed. +2. Create the indexes concurrently on the live database, and confirm all three are valid. The + migration's `if not exists` then lands as a no-op there while carrying the lineage everywhere + else. +3. Mirror the three `create index` statements into `supabase/schema.sql` beside the existing `documents` indexes. -3. Regenerate `supabase/drift-manifest.json` with `npm run drift:manifest` (requires Docker). +4. Regenerate `supabase/drift-manifest.json` with `npm run drift:manifest` (requires Docker). `tests/drift-detection.test.ts` pins the manifest to `schema.sql`'s sha256 and fails while it is stale. -4. Only then add `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and +5. Only then add `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and `documents_status_id_idx` to the `required_indexes` list inside `search_schema_health()` (`supabase/schema.sql:3178`). -Step 4 must come last: `search_schema_health()` runs against the live database and reports a +Step 5 must come last: `search_schema_health()` runs against the live database and reports a missing required index as a failure, so registering the names before the indexes exist turns a -health check red. Equally, shipping step 1 as a migration without steps 2–3 is what caused -PR #1312 to be closed on 2026-07-28 — an additive index migration with no synchronized +health check red. Equally, committing the migration (step 1) without carrying steps 3–4 in the same change is what +caused PR #1312 to be closed on 2026-07-28 — an additive index migration with no synchronized schema/drift proof. Expect `npm run check:drift` to report the three indexes as unexpected -between steps 1 and 2. +between steps 2 and 3. -Rollback is `DROP INDEX CONCURRENTLY` per index, reversing steps 4 → 1. Nothing reads these -indexes by name outside `search_schema_health()`, and no query text depends on them, so dropping -them restores the pre-change plans exactly. +### Rollback — retract the expectations before dropping the indexes + +Reverse the sequence, and **remove the expectations first**. Dropping a physical index while +`required_indexes` still names it leaves `search_schema_health()` red, and dropping it while +`schema.sql`/`drift-manifest.json` still describe it fails drift validation: + +1. Remove the three names from `required_indexes` in `search_schema_health()`. +2. Remove the `create index` statements from `supabase/schema.sql` and revert the migration as a + new forward migration — never by deleting the applied one. +3. Regenerate `supabase/drift-manifest.json`. +4. Deploy those expectation changes. +5. Only then `DROP INDEX CONCURRENTLY` each index. + +Nothing reads these indexes by name outside `search_schema_health()`, and no query text depends on +them, so once the expectations are retracted the drop restores the pre-change plans exactly. ## Safe rollback From 269a47e1c43ff9359723fc6ff79ae4462e2affe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:04:42 +0000 Subject: [PATCH 14/25] docs(audit): restore the #098 harness build guidance lost in the re-land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following up my own reply on the offline-harness thread: restoring the script names was only half the fix. #1376 had added real implementation guidance to measurement-plan item 2 on main, and re-landing this audit overwrote it. That detail is what makes #098 buildable rather than merely described. Restored, with every reference verified rather than copied: - scripts/fixtures/rag-offline-contract-tests.json — exists. - The seam: vi.doMock("@/lib/supabase/admin", ...) before importing rag.ts. main cited tests/rag-offline-answer.test.ts:79-108; the admin doMock is actually at :102 (a second doMock at :108), so this cites :102. - Why it is the only zero-production-edit seam: rag.ts and rag-cache.ts construct their client inline per phase rather than accepting an injected one — verified, 4 and 8 createAdminClient() call sites respectively. - Ship as a vitest suite, not an npm script, so it rides npm run test instead of forcing a ci.yml edit via check-gate-manifest.mjs. Kept from this branch: the admission-cost direction guard, which main's copy lacks — any generalisation must assert scope starts only after the limiter admits and a denial dispatches zero scope queries, never the reverse. Also records why test-cache-path.mjs / check-rag-fixtures.mjs were the wrong reference, so the substitution is not repeated. Verification: verify:cheap exit 0 (427 files, 4386 passed / 4 skipped). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/audit/latency-audit-2026-07-28.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/audit/latency-audit-2026-07-28.md b/docs/audit/latency-audit-2026-07-28.md index 99cd266972..bf28d62e2c 100644 --- a/docs/audit/latency-audit-2026-07-28.md +++ b/docs/audit/latency-audit-2026-07-28.md @@ -351,7 +351,12 @@ with numbers this repo already owns (`#069`: warm p90 175–243 ms; first-unprim provider call is needed to size the top of the ranking. 1. **`Server-Timing` first (done).** It existed but was emitted only by `/api/answer` and `/api/search/universal` — **not by `/api/answer/stream`, the route the UI actually uses.** That was the largest measurement gap in the repo. Now: `/api/answer` emits `auth`/`ratelimit`/`scope` alongside its existing entries; `/api/search` emits `auth`/`ratelimit`/`search`/`total`; `/api/answer/stream` emits `auth`/`ratelimit`. **Documented limitation:** on a streaming response, headers flush before the first frame, so in-stream stage durations cannot reach a response header — and routing them through the SSE contract would put instrumentation inside a governed clinical payload (`answer-stream-contract.ts:21` whitelists only `progress`/`final`/`error`). `/api/answer` covers those stages over the same `resolveSearchScope` + RAG path. -2. **Offline round-trip budget harness.** `tests/answer-route-preamble.test.ts` pins the answer-route preamble as an **admission-cost** contract, not an overlap one: scope resolution starts only **after** the limiter admits, a denied request dispatches **zero** scope queries, and the client abort signal is threaded. Any generalisation of this harness must enforce that direction — asserting the reverse would reintroduce the database work for throttled requests that L1-2's refutation removed. Broadening it to a general counting-proxy over the offline fixtures (`scripts/eval-rag-offline.mjs`, `scripts/test-rag-offline.mjs`, `scripts/rag-offline-contract.mjs`) is the enabler for L1-5 and remains open as `#098`. Zero providers, zero DB. +2. **Offline round-trip budget harness.** `tests/answer-route-preamble.test.ts` pins the answer-route preamble as an **admission-cost** contract, not an overlap one: scope resolution starts only **after** the limiter admits, a denied request dispatches **zero** scope queries, and the client abort signal is threaded. Any generalisation of this harness must enforce that direction — asserting the reverse would reintroduce the database work for throttled requests that L1-2's refutation removed. Broadening it to a general counting proxy is the enabler for L1-5 and remains open as `#098`. + + **How to build it** (restored 2026-07-29 — this guidance was added on `main` by #1376 and lost when this copy was re-landed): wrap the Supabase client in a counting proxy and assert per-scenario query budgets against the existing offline surface — `scripts/eval-rag-offline.mjs`, `scripts/test-rag-offline.mjs`, `scripts/rag-offline-contract.mjs` and `scripts/fixtures/rag-offline-contract-tests.json`. Drive it the way `tests/rag-offline-answer.test.ts:102` already does: `vi.doMock("@/lib/supabase/admin", …)` **before** importing `rag.ts`. That is the only zero-production-edit seam, because `rag.ts` and `rag-cache.ts` construct their client inline per phase rather than accepting an injected one (4 and 8 `createAdminClient()` call sites respectively). Ship it as a vitest suite rather than a new npm script, so it rides `npm run test` instead of forcing a `ci.yml` edit via `check-gate-manifest.mjs`. Converts "an extra uncached round trip" from inference into a pinned integer **and leaves a permanent regression guard**. Zero providers, zero DB. + + **An earlier draft named `test-cache-path.mjs` and `check-rag-fixtures.mjs` here.** Neither exercises a RAG request — the first computes vitest/tsc cache directories, the second validates fixture manifests — so a harness built from that reference could not have produced the round-trip budget it promises. + 3. **Provider-free wall-clock hot path.** The typeahead route is useful because `lexicalOnly: true` skips the per-keystroke embedding, so it times a real request with no OpenAI call. **Corrected 2026-07-29 — do not pair it with demo mode, and do not claim it covers scope.** `src/app/api/search/universal/route.ts:137` returns on `isDemoMode() || isLocalNoAuthMode()` _before_ `createAdminClient` (`:150`), `publicAccessContext` (`:151`) and `consumeSubjectApiRateLimit` (`:153`), so a demo-mode run measures local fixture search and skips the auth and limiter stages entirely; and `resolveSearchScope` is never called from that route at all. Timing it in demo mode would therefore appear to clear an L1 finding it never exercised. Run it **non-demo** against a local Supabase or a mocked client to cover auth + rate-limit + RPC, and time the **scope** stage separately via `/api/answer`, which is the route that actually resolves it. 4. **Disposable local Postgres + `EXPLAIN (ANALYZE, BUFFERS)`** for L2-3/L2-5/L2-6. **Caveat to carry:** `hnsw.ef_search` _is_ settable locally, so no local vector plan may be generalised to hosted. 5. **Local client evidence that does not claim to be `#017`.** `build:analyze`, the enforced bundle budget, the `next build` route table (`ƒ Dynamic` vs `○ Static`), and a Playwright run on the existing local harness reading `PerformanceObserver` LCP/CLS/long-tasks plus `performance.getEntriesByType("resource")` on a cold profile. This **ranks** L3 items against each other; it does **not** discharge `#017`, which requires live-site evidence. From 0e937f80f1c4fdfa66a4e5abc2918e763ed51bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:11:12 +0000 Subject: [PATCH 15/25] docs(operator): split the index rollback into three deployed phases The rollback listed one five-step sequence that removed the schema.sql statements and authored the revert migration together, deployed them, and only then dropped concurrently. That deploy would have taken the lock the whole procedure exists to avoid. A forward migration that genuinely reverts the create migration has to contain the drops, or a fresh `supabase db reset`, staging rebuild, or DR replay recreates the indexes with nothing to remove them. But Supabase wraps migrations in a transaction, so that migration cannot drop concurrently -- 20260702110000_drop_redundant_indexes.sql and 20260711000000_drop_redundant_registry_sources_record_index.sql both record this in their headers and settle for a plain DROP INDEX because their tables are small. `documents` is not. Rollback is now three deployments: retract `required_indexes` and deploy; drop concurrently on the live database; then deploy the schema.sql removal plus an idempotent forward `drop index if exists` migration, which is a no-op live and carries lineage to every replayed environment. Also makes the mirror-image ordering explicit on the apply side: commit the create migration but do not deploy it ahead of the concurrent pre-create, for the same lock reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- ...r-apply-performance-latency-remediation.md | 69 ++++++++++++++----- docs/outstanding-issues.md | 2 +- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md index 911318cf3b..2294340eb1 100644 --- a/docs/operator-apply-performance-latency-remediation.md +++ b/docs/operator-apply-performance-latency-remediation.md @@ -111,11 +111,13 @@ the runbook below is step one of the sequence, not the whole of it. ### Ordering constraint — do all five steps in one change 1. **Author and commit the idempotent migration** (`create index if not exists`, per the - `20260717170000` pattern). Without this the indexes never reach staging, disaster recovery, or - a local `supabase db reset`, however carefully the remaining steps are followed. -2. Create the indexes concurrently on the live database, and confirm all three are valid. The - migration's `if not exists` then lands as a no-op there while carrying the lineage everywhere - else. + `20260717170000` pattern), but **do not deploy it to the busy database yet**. Without the + migration the indexes never reach staging, disaster recovery, or a local `supabase db reset`, + however carefully the remaining steps are followed — and deploying it ahead of step 2 builds + the indexes inside the migration's transaction, taking the very lock this procedure avoids. +2. Create the indexes concurrently on the live database, and confirm all three are valid. Only + then deploy the migration: its `if not exists` lands as a no-op there while carrying the + lineage everywhere else. 3. Mirror the three `create index` statements into `supabase/schema.sql` beside the existing `documents` indexes. 4. Regenerate `supabase/drift-manifest.json` with `npm run drift:manifest` (requires Docker). @@ -132,21 +134,56 @@ caused PR #1312 to be closed on 2026-07-28 — an additive index migration with schema/drift proof. Expect `npm run check:drift` to report the three indexes as unexpected between steps 2 and 3. -### Rollback — retract the expectations before dropping the indexes +### Rollback — three deployed phases, with the live drop in the middle -Reverse the sequence, and **remove the expectations first**. Dropping a physical index while -`required_indexes` still names it leaves `search_schema_health()` red, and dropping it while -`schema.sql`/`drift-manifest.json` still describe it fails drift validation: +**CORRECTED 2026-07-29 after PR #1377 review.** An earlier version of this section listed a single +five-step sequence that removed the `schema.sql` statements and the revert migration together in +step 2, then deployed them in step 4 and dropped concurrently in step 5. That is unsafe, for the +mirror-image of the reason the apply side pre-creates concurrently: -1. Remove the three names from `required_indexes` in `search_schema_health()`. -2. Remove the `create index` statements from `supabase/schema.sql` and revert the migration as a - new forward migration — never by deleting the applied one. -3. Regenerate `supabase/drift-manifest.json`. -4. Deploy those expectation changes. -5. Only then `DROP INDEX CONCURRENTLY` each index. +- A forward migration that genuinely reverts the index migration has to **contain the drops**, + otherwise a fresh `supabase db reset`, a staging rebuild, or disaster-recovery replay runs the + original `create index` migration and recreates the indexes with nothing to remove them. +- Deploying that migration therefore drops the indexes on the live database at that moment — and + it cannot do so concurrently. `20260702110000_drop_redundant_indexes.sql` and + `20260711000000_drop_redundant_registry_sources_record_index.sql` both record why in their + headers: _"DROP INDEX CONCURRENTLY cannot run inside a transaction block. Supabase migrations + are wrapped in a transaction by default."_ Both settle for a plain `DROP INDEX` because their + tables are small. `documents` is not, which is the whole reason this procedure exists. + +So a plain `DROP INDEX` in the migration takes the `ACCESS EXCLUSIVE` lock this runbook is written +to avoid, and omitting the drops leaves every replayed environment inconsistent with production. +The resolution is to separate them into three deployments: + +**Phase A — retract the health expectations, and deploy.** + +1. Remove `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and + `documents_status_id_idx` from `required_indexes` in `search_schema_health()` + (`supabase/schema.sql:3178`). +2. Deploy that change alone. The indexes still exist, `schema.sql` and `drift-manifest.json` still + describe them, so both the health check and drift validation stay green across this phase. + +**Phase B — drop concurrently on the live database.** + +3. `DROP INDEX CONCURRENTLY IF EXISTS` each of the three, outside any transaction, and confirm each + is gone. Nothing names them any more, so nothing goes red on their absence — but `check:drift` + now reports the three as **missing** until phase C lands, exactly mirroring the "unexpected" + window between apply steps 2 and 3. + +**Phase C — carry the removal to every other environment, and deploy.** + +4. Remove the three `create index` statements from `supabase/schema.sql`. +5. Add a new forward migration containing `drop index concurrently`-free, idempotent + `drop index if exists public.;` statements — never by deleting or editing the applied + create migration. It is a **no-op on the live database**, because phase B already dropped them + there; its purpose is lineage for staging, disaster recovery, and local replay. A plain + `DROP INDEX` is safe here for exactly the reason it is unsafe in the merged sequence: by the + time it reaches a busy production database there is no index left to lock against. +6. Regenerate `supabase/drift-manifest.json` (`npm run drift:manifest`, requires Docker) and deploy + phase C. `check:drift` returns to green. Nothing reads these indexes by name outside `search_schema_health()`, and no query text depends on -them, so once the expectations are retracted the drop restores the pre-change plans exactly. +them, so once phase A has retracted the expectations the drop restores the pre-change plans exactly. ## Safe rollback diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index c28f4d7b67..472577a021 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -140,7 +140,7 @@ removed after current-main verification; it is not missing recommended work. | #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | | #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | -| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated unless that `.limit(12)` is made deterministic first. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. Then apply concurrently, confirm `indisvalid`, mirror into `schema.sql`, run `npm run drift:manifest` (Docker), THEN register the three names in `required_indexes` — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | +| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated unless that `.limit(12)` is made deterministic first. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. Then apply concurrently, confirm `indisvalid`, mirror into `schema.sql`, run `npm run drift:manifest` (Docker), THEN register the three names in `required_indexes` — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | | #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | | #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | | #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Implementation shipped 2026-07-29; browser verification still PENDING:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | From b66d41d0a47bee6be79889c8fde6727ce3ed59d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:12:27 +0000 Subject: [PATCH 16/25] docs(ledger): record the operator rollback sequencing review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b03df4feb0..4c33df5045 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1275,3 +1275,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | codex/document-reader-condensed-view | 7cefb24e99f9745a61843c7e48c4889f7324ec42 | pr-1380-main-merge-coderabbit-density | merged origin/main; resolved source-panels conflict (kept condensed details + tracking-eyebrow); density in-memory fallback when storage blocked; summary keys + search/plain compact tests; local vitest/lint/typecheck/format/playwright condensed pass; awaiting hosted CI | vitest document suites 20/20; lint; typecheck; format:check; playwright condensed 4/4; merge-tree clean | | 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 71db10c41de872fca6e400626704a549f145c66c | latency audit implementation (PR #1377) | SUPERSEDES the 78e2beb record: its 'scope-vs-ratelimit overlap with abort' description is stale and describes behaviour that was REVERTED. Codex review raised it P1 and it was correct — an AbortSignal cannot un-execute a statement Postgres already began, and resolveSearchScope only skips the database when there are no filters and no explicit ids (search-scope.ts:242,253), so a throttled caller kept spending DB capacity while collecting 429s. Shipped behaviour is rate-limit admission BEFORE scope evaluation, with request.signal threaded so a client disconnect still cancels scope's paginated queries, pinned by tests/answer-route-preamble.test.ts. Also retracted on this head: the L2-3 'recall is byte-identical' claim, since fetchDocumentTitleAliasRows applies .limit(12) with no ORDER BY. | verify:cheap exit 0 (423 files, 4278 passed/4 skipped); check:branch-review-ledger pass; focused preamble + server-timing suites pass | +| 2026-07-29 | claude/latency-findings-impl-s8g01v | 0e937f80f1c4fdfa66a4e5abc2918e763ed51bc1 | PR #1377 latency findings — operator rollback sequencing | Codex P2 confirmed and fixed: the index rollback merged the revert migration into the expectation deploy, so the deployed migration would take ACCESS EXCLUSIVE on documents before the concurrent drop; split into three deployed phases (retract required_indexes -> concurrent live drop -> schema.sql removal + idempotent forward drop migration). Apply-side commit-vs-deploy ordering made explicit. Docs only. | prettier --check clean; docs:check-links 1355; docs:check-scripts 390; check:branch-review-ledger 1232 records | From 310d0fbc4f08ca88cb097f3e480896e75d0007bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:17:45 +0000 Subject: [PATCH 17/25] docs(operator): register required_indexes via migration, not the mirror Apply step 5 and rollback phase A both described the required_indexes change as an edit to `search_schema_health()` at supabase/schema.sql:3177. schema.sql is a mirror, so that edit never reaches the hosted function. search_schema_health() is redefined by `create or replace function` in eleven migrations; 20260705180000_reconcile_search_health_indexes.sql is the precedent -- it creates indexes and carries the updated required_indexes array (:62) in the same migration. As written the procedure left the three new indexes unmonitored on live, and rollback was worse: phase A retracted only in the mirror, so phase B would drop indexes the hosted function still required and turn the health check red -- the exact failure phase A exists to prevent. Apply now authors one migration carrying both the index creates and the create-or-replace-function registration, mirrors both into schema.sql, and deploys last. Phase A gets its own retraction migration plus matching mirror and regenerated drift manifest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- ...r-apply-performance-latency-remediation.md | 69 +++++++----- docs/outstanding-issues.md | 102 +++++++++--------- 2 files changed, 96 insertions(+), 75 deletions(-) diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md index 2294340eb1..3374d2dc5f 100644 --- a/docs/operator-apply-performance-latency-remediation.md +++ b/docs/operator-apply-performance-latency-remediation.md @@ -110,29 +110,44 @@ the runbook below is step one of the sequence, not the whole of it. ### Ordering constraint — do all five steps in one change -1. **Author and commit the idempotent migration** (`create index if not exists`, per the - `20260717170000` pattern), but **do not deploy it to the busy database yet**. Without the - migration the indexes never reach staging, disaster recovery, or a local `supabase db reset`, - however carefully the remaining steps are followed — and deploying it ahead of step 2 builds - the indexes inside the migration's transaction, taking the very lock this procedure avoids. -2. Create the indexes concurrently on the live database, and confirm all three are valid. Only - then deploy the migration: its `if not exists` lands as a no-op there while carrying the - lineage everywhere else. -3. Mirror the three `create index` statements into `supabase/schema.sql` beside the existing - `documents` indexes. +**The health-function registration is a migration, not a `schema.sql` edit.** Added 2026-07-29 +after PR #1377 review: an earlier version of step 5 said to add the three names to +`required_indexes` "inside `search_schema_health()` (`supabase/schema.sql:3178`)", which reads as a +mirror edit. `schema.sql` is a mirror, so editing it never changes the hosted function and the new +indexes would stay unmonitored on live. `search_schema_health()` is redefined by +`create or replace function` in eleven migrations; `20260705180000_reconcile_search_health_indexes.sql` +is the precedent to copy — it creates indexes **and** carries the updated `required_indexes` array +(`:62`) in the same migration. + +1. **Author and commit one idempotent migration**, but **do not deploy it to the busy database + yet**. It contains both halves, per the `20260705180000` shape: + - `create index if not exists` for all three indexes (the `20260717170000` pattern); + - `create or replace function public.search_schema_health()` with + `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and + `documents_status_id_idx` added to the `required_indexes` array. + + Without the migration the indexes and the health registration never reach staging, disaster + recovery, or a local `supabase db reset`, however carefully the remaining steps are followed — + and deploying it ahead of step 2 builds the indexes inside the migration's transaction, taking + the very lock this procedure avoids. + +2. Create the indexes concurrently on the live database, and confirm all three are valid. +3. Mirror the three `create index` statements **and the identical function body** into + `supabase/schema.sql`, beside the existing `documents` indexes and at + `search_schema_health()`'s `required_indexes` (`supabase/schema.sql:3177`). The mirror must + match the migration exactly or drift validation fails. 4. Regenerate `supabase/drift-manifest.json` with `npm run drift:manifest` (requires Docker). `tests/drift-detection.test.ts` pins the manifest to `schema.sql`'s sha256 and fails while it is stale. -5. Only then add `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and - `documents_status_id_idx` to the `required_indexes` list inside `search_schema_health()` - (`supabase/schema.sql:3178`). +5. **Deploy the migration last.** On the live database the index half is a no-op — step 2 already + built them — and the function half registers the three names. Deploying it before step 2 would + both take the lock and register required indexes that do not yet exist. -Step 5 must come last: `search_schema_health()` runs against the live database and reports a -missing required index as a failure, so registering the names before the indexes exist turns a -health check red. Equally, committing the migration (step 1) without carrying steps 3–4 in the same change is what -caused PR #1312 to be closed on 2026-07-28 — an additive index migration with no synchronized -schema/drift proof. Expect `npm run check:drift` to report the three indexes as unexpected -between steps 2 and 3. +Deployment must come last because `search_schema_health()` runs against the live database and +reports a missing required index as a failure. Equally, committing the migration (step 1) without +carrying steps 3–4 in the same change is what caused PR #1312 to be closed on 2026-07-28 — an +additive index migration with no synchronized schema/drift proof. Expect `npm run check:drift` to +report the three indexes as unexpected between steps 2 and 3. ### Rollback — three deployed phases, with the live drop in the middle @@ -157,11 +172,17 @@ The resolution is to separate them into three deployments: **Phase A — retract the health expectations, and deploy.** -1. Remove `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and - `documents_status_id_idx` from `required_indexes` in `search_schema_health()` - (`supabase/schema.sql:3178`). -2. Deploy that change alone. The indexes still exist, `schema.sql` and `drift-manifest.json` still - describe them, so both the health check and drift validation stay green across this phase. +1. Author a migration that does the retraction on the hosted database — a + `create or replace function public.search_schema_health()` with + `documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx` and + `documents_status_id_idx` **removed** from `required_indexes`, per the same + `20260705180000_reconcile_search_health_indexes.sql` precedent the apply side uses. Mirror the + identical function body into `supabase/schema.sql` (`:3177`) and regenerate + `supabase/drift-manifest.json`. Retracting in the mirror alone leaves the hosted function still + requiring all three, so phase B would drop indexes it demands and turn the health check red — + the exact failure this phase exists to prevent. +2. Deploy that migration alone. The indexes still exist and `schema.sql`/`drift-manifest.json` + still describe them, so both the health check and drift validation stay green across this phase. **Phase B — drop concurrently on the live database.** diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 472577a021..c208108e16 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -95,57 +95,57 @@ removed after current-main verification; it is not missing recommended work. > > **Exact-head release confirmation (2026-07-28):** after final review narrowed expanded chunk selection to the measured clozapine blood-count action shape, `output/rag-retrieval-post-exact-head.json` repeated all 36 cases with document/content recall 1.0, MRR 0.8921, content MRR 0.9406, nDCG 0.9308, irrelevant-at-10 0.0917, zero failures and zero per-case document/content reciprocal-rank regressions versus `rag-retrieval-post-final.json`. Median latency rose 13,563 -> 19,729 ms while p90 improved 56,765 -> 55,660 ms; neither run had a latency-failed case, so no ranking or latency gate changed. Cache-bypassed exact-head answer probes for both admission/discharge cases and the clozapine threshold case were substantive, grounded, expected-source-backed and free of citation/numeric/route failures; all used zero provider requests and $0 generation cost. This was the protected behavior-change merge gate, not a rerun for #023. -| ID | Pri | Type | Summary | Detail / next action | Source | Added | -| ---- | --- | ----- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | -| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | -| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | -| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | -| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | -| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | -| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | -| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | -| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | -| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | -| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | -| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them); (e) `src/app/(search-app)/layout.tsx:4` imports 71.6 KB of Therapy-Compass-only CSS in the ROUTE-GROUP layout, making it render-blocking on `/`, `/documents`, `/forms`, `/dsm` and every mode home; (f) `shared-search-app-shell.tsx:8` statically imports the `therapy-compass` barrel, pulling `workspace.tsx` + `bindings.tsx` + `nav.tsx` into every `(search-app)` route; (g) three client waterfalls (`use-app-preferences.ts:156-182`, `ClinicalDashboard.tsx:977-1069`, `signed-image.tsx:60-84` + `use-signed-image-url.ts:39`) and the paint offenders in `globals.css` beyond the sidebar grid — three stacked `backdrop-filter` passes on an always-mounted translating element (`:709-748`), `box-shadow` inside a `transition` list (`:677-684`), and `@keyframes shimmer` animating `background-position` on the shared `Skeleton` (`:2289-2296`). **CORRECTED 2026-07-29 on (c):** the Therapy Compass filenames are unversioned and Next serves `/public` with an ETag, so only the FIRST visit pays 690.6 KB / 2,470 KB — repeat visits pay ~4 revalidation round trips. The fix is content-hashed filenames + `immutable` (touching `scripts/build-therapies-index.mjs` and `check:therapy-data-index`), NOT a bare `Cache-Control` line. See `docs/audit/latency-audit-2026-07-28.md` L3-1/L3-2/L3-3/L3-6/L3-7. | session 2026-07-21 (build route table + design audit) | 2026-07-21 | -| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | -| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | -| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | -| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | -| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | -| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | -| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | -| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | -| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | -| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | -| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | -| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | -| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | -| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | -| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | -| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | -| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | -| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | -| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | -| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | -| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Reproduced locally 2026-07-28** (isolated _production_ build via `run-playwright.mjs`, full `verify:ui`): `ui-tools.spec.ts:563` duplicated `forms-home` and `ui-smoke.spec.ts:3001` duplicated `favourite-row-lithium-monitoring-guideline`; in both, copy 1 is nested under `mobile-composer-reserve-pad`. Both pass when run alone, so it is load/order-dependent, not build-mode dependent — this also corrects an earlier note that CI uses `next dev`; it does not. **Strongest evidence (CI run `30345484316`, 2026-07-28): `ui-overlap.spec.ts:199` on `/` asserted `toHaveCount(1)` successfully and then the same `header#search` locator resolved to 2 a statement later, one of them hidden.** A duplicate that appears _after_ a passing count assertion is a stream/hydration artifact by construction, not a static double mount and not something a CSS or component change can cause. That makes four distinct testids across four specs with the identical shape. **Mitigated, not fixed, on `main` (2026-07-28):** `3a8edb93` rewrapped `gotoHome` in `tests/ui-overlap.spec.ts` to retry count-and-visibility together via `toPass`, so a transient second header no longer trips strict mode there — its own note says "checking count then immediately calling waitFor races that flicker into a strict-mode violation". That hardens one helper; the duplicate root itself is unchanged and other specs remain exposed. **Confirmed pre-existing:** at `631d90d2`, the commit before PR #1316's first commit, that spec already documented "two `header#search` nodes" and "a second transient `header#search` can exist briefly" — so this predates that branch. **Next:** with a full-suite repro now available, bisect the preceding specs to find the state that triggers the second mount, then either scope the shared helpers to the visible root once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | -| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | -| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | -| #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | -| #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | -| #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins admission-before-scope (no scope call while the limiter is pending or after a deny) and the client-disconnect abort signal. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`). Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | -| #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | -| #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | -| #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | -| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated unless that `.limit(12)` is made deterministic first. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. Then apply concurrently, confirm `indisvalid`, mirror into `schema.sql`, run `npm run drift:manifest` (Docker), THEN register the three names in `required_indexes` — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | -| #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | -| #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | -| #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Implementation shipped 2026-07-29; browser verification still PENDING:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | -| #106 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | -| #107 | P2 | rec | Component state matrices are the largest untested surface | **Outcome:** loading / empty / error / disabled states on interactive components are covered by executing tests, not only by E2E happy paths. **Detail:** measured 2026-07-29 — production components (excluding mockups) sit at **38.2% lines / 22.8% branch** across 12,602 lines, with **83 of 208 files at zero executed lines**; there are 51 `.dom.test.tsx` files against 195 components. Playwright does visit these routes, so they are smoke-covered, but branch coverage is where the state matrix lives and smoke journeys rarely reach it. Worst by uncovered lines: `global-search-shell.tsx` (7%), `mode-action-popup.tsx` (21%), `answer-content.tsx` (27%), `document-search-results.tsx` (32%), `universal-search-command-surface.tsx` (39%), `master-search-header.tsx` (43%). A concrete first target with clinical meaning: `calculator-ui.tsx` now covers all exported scoring logic, but `seedCheckboxDefaults`, `toggleCheckboxAnswer` and `selectOptionAnswer` stay uncovered because they are module-private and only reachable through React event handlers — `seedCheckboxDefaults` is what makes an all-negative CAGE / SAD PERSONS screen read as a valid 0 rather than incomplete, so a regression there is a false-negative risk. **Next:** treat as a per-PR convention rather than a backfill push — `docs/testing.md` already prescribes the state matrix, so the gap is enforcement. Start with `global-search-shell.tsx`, which `docs/search-chrome-behaviour.md` treats as a contract surface. Keep additions in the jsdom tier (measured ~0.54s per file) instead of new Playwright journeys (~231 production journeys already run serially at `workers: 1` against a 45-minute CI budget). **Stop:** do not chase the coverage percentage by backfilling low-risk components; the re-ratcheted broad floor in `vitest.config.mts` holds the line. | session 2026-07-29 test-coverage analysis | 2026-07-29 | +| ID | Pri | Type | Summary | Detail / next action | Source | Added | +| ---- | --- | ----- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | +| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | +| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | +| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | +| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | +| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | +| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | +| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | +| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | +| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | +| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | +| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them); (e) `src/app/(search-app)/layout.tsx:4` imports 71.6 KB of Therapy-Compass-only CSS in the ROUTE-GROUP layout, making it render-blocking on `/`, `/documents`, `/forms`, `/dsm` and every mode home; (f) `shared-search-app-shell.tsx:8` statically imports the `therapy-compass` barrel, pulling `workspace.tsx` + `bindings.tsx` + `nav.tsx` into every `(search-app)` route; (g) three client waterfalls (`use-app-preferences.ts:156-182`, `ClinicalDashboard.tsx:977-1069`, `signed-image.tsx:60-84` + `use-signed-image-url.ts:39`) and the paint offenders in `globals.css` beyond the sidebar grid — three stacked `backdrop-filter` passes on an always-mounted translating element (`:709-748`), `box-shadow` inside a `transition` list (`:677-684`), and `@keyframes shimmer` animating `background-position` on the shared `Skeleton` (`:2289-2296`). **CORRECTED 2026-07-29 on (c):** the Therapy Compass filenames are unversioned and Next serves `/public` with an ETag, so only the FIRST visit pays 690.6 KB / 2,470 KB — repeat visits pay ~4 revalidation round trips. The fix is content-hashed filenames + `immutable` (touching `scripts/build-therapies-index.mjs` and `check:therapy-data-index`), NOT a bare `Cache-Control` line. See `docs/audit/latency-audit-2026-07-28.md` L3-1/L3-2/L3-3/L3-6/L3-7. | session 2026-07-21 (build route table + design audit) | 2026-07-21 | +| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | +| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | +| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | +| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | +| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | +| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | +| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | +| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | +| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | +| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | +| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | +| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | +| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | +| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | +| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | +| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | +| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | +| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | +| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | +| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | +| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Reproduced locally 2026-07-28** (isolated _production_ build via `run-playwright.mjs`, full `verify:ui`): `ui-tools.spec.ts:563` duplicated `forms-home` and `ui-smoke.spec.ts:3001` duplicated `favourite-row-lithium-monitoring-guideline`; in both, copy 1 is nested under `mobile-composer-reserve-pad`. Both pass when run alone, so it is load/order-dependent, not build-mode dependent — this also corrects an earlier note that CI uses `next dev`; it does not. **Strongest evidence (CI run `30345484316`, 2026-07-28): `ui-overlap.spec.ts:199` on `/` asserted `toHaveCount(1)` successfully and then the same `header#search` locator resolved to 2 a statement later, one of them hidden.** A duplicate that appears _after_ a passing count assertion is a stream/hydration artifact by construction, not a static double mount and not something a CSS or component change can cause. That makes four distinct testids across four specs with the identical shape. **Mitigated, not fixed, on `main` (2026-07-28):** `3a8edb93` rewrapped `gotoHome` in `tests/ui-overlap.spec.ts` to retry count-and-visibility together via `toPass`, so a transient second header no longer trips strict mode there — its own note says "checking count then immediately calling waitFor races that flicker into a strict-mode violation". That hardens one helper; the duplicate root itself is unchanged and other specs remain exposed. **Confirmed pre-existing:** at `631d90d2`, the commit before PR #1316's first commit, that spec already documented "two `header#search` nodes" and "a second transient `header#search` can exist briefly" — so this predates that branch. **Next:** with a full-suite repro now available, bisect the preceding specs to find the state that triggers the second mount, then either scope the shared helpers to the visible root once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | +| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | +| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | +| #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | +| #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | +| #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins admission-before-scope (no scope call while the limiter is pending or after a deny) and the client-disconnect abort signal. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`). Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | +| #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | +| #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | +| #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | +| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated unless that `.limit(12)` is made deterministic first. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. **That migration must also carry the health-function change** — `required_indexes` lives inside `search_schema_health()`, which is redefined by `create or replace function` in eleven migrations (copy `20260705180000_reconcile_search_health_indexes.sql:62`); editing `schema.sql:3177` alone moves only the mirror and leaves the indexes unmonitored on hosted (PR #1377 review). Then apply concurrently, confirm `indisvalid`, mirror both the index statements and the identical function body into `schema.sql`, run `npm run drift:manifest` (Docker), and deploy the migration LAST — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` via its own `create or replace function` migration and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | +| #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | +| #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | +| #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Implementation shipped 2026-07-29; browser verification still PENDING:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | +| #106 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | +| #107 | P2 | rec | Component state matrices are the largest untested surface | **Outcome:** loading / empty / error / disabled states on interactive components are covered by executing tests, not only by E2E happy paths. **Detail:** measured 2026-07-29 — production components (excluding mockups) sit at **38.2% lines / 22.8% branch** across 12,602 lines, with **83 of 208 files at zero executed lines**; there are 51 `.dom.test.tsx` files against 195 components. Playwright does visit these routes, so they are smoke-covered, but branch coverage is where the state matrix lives and smoke journeys rarely reach it. Worst by uncovered lines: `global-search-shell.tsx` (7%), `mode-action-popup.tsx` (21%), `answer-content.tsx` (27%), `document-search-results.tsx` (32%), `universal-search-command-surface.tsx` (39%), `master-search-header.tsx` (43%). A concrete first target with clinical meaning: `calculator-ui.tsx` now covers all exported scoring logic, but `seedCheckboxDefaults`, `toggleCheckboxAnswer` and `selectOptionAnswer` stay uncovered because they are module-private and only reachable through React event handlers — `seedCheckboxDefaults` is what makes an all-negative CAGE / SAD PERSONS screen read as a valid 0 rather than incomplete, so a regression there is a false-negative risk. **Next:** treat as a per-PR convention rather than a backfill push — `docs/testing.md` already prescribes the state matrix, so the gap is enforcement. Start with `global-search-shell.tsx`, which `docs/search-chrome-behaviour.md` treats as a contract surface. Keep additions in the jsdom tier (measured ~0.54s per file) instead of new Playwright journeys (~231 production journeys already run serially at `workers: 1` against a 45-minute CI budget). **Stop:** do not chase the coverage percentage by backfilling low-risk components; the re-ratcheted broad floor in `vitest.config.mts` holds the line. | session 2026-07-29 test-coverage analysis | 2026-07-29 | ## Resolved / archive From c11fdde972e82f62681d16e0a5d648a4bb6c4f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:18:01 +0000 Subject: [PATCH 18/25] docs(ledger): record the search_schema_health migration review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 4c33df5045..2b3875da5b 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1276,3 +1276,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 71db10c41de872fca6e400626704a549f145c66c | latency audit implementation (PR #1377) | SUPERSEDES the 78e2beb record: its 'scope-vs-ratelimit overlap with abort' description is stale and describes behaviour that was REVERTED. Codex review raised it P1 and it was correct — an AbortSignal cannot un-execute a statement Postgres already began, and resolveSearchScope only skips the database when there are no filters and no explicit ids (search-scope.ts:242,253), so a throttled caller kept spending DB capacity while collecting 429s. Shipped behaviour is rate-limit admission BEFORE scope evaluation, with request.signal threaded so a client disconnect still cancels scope's paginated queries, pinned by tests/answer-route-preamble.test.ts. Also retracted on this head: the L2-3 'recall is byte-identical' claim, since fetchDocumentTitleAliasRows applies .limit(12) with no ORDER BY. | verify:cheap exit 0 (423 files, 4278 passed/4 skipped); check:branch-review-ledger pass; focused preamble + server-timing suites pass | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 0e937f80f1c4fdfa66a4e5abc2918e763ed51bc1 | PR #1377 latency findings — operator rollback sequencing | Codex P2 confirmed and fixed: the index rollback merged the revert migration into the expectation deploy, so the deployed migration would take ACCESS EXCLUSIVE on documents before the concurrent drop; split into three deployed phases (retract required_indexes -> concurrent live drop -> schema.sql removal + idempotent forward drop migration). Apply-side commit-vs-deploy ordering made explicit. Docs only. | prettier --check clean; docs:check-links 1355; docs:check-scripts 390; check:branch-review-ledger 1232 records | +| 2026-07-29 | claude/latency-findings-impl-s8g01v | 310d0fbc4f08ca88cb097f3e480896e75d0007bf | PR #1377 latency findings — search_schema_health registration is a migration | Codex P2 confirmed and fixed: apply step 5 and rollback phase A described the required_indexes change as a schema.sql edit, but schema.sql is a mirror and search_schema_health() is redefined by create or replace function in 11 migrations (precedent 20260705180000_reconcile_search_health_indexes.sql:62). As written the hosted function never moved, leaving the new indexes unmonitored on apply and, on rollback, letting phase B drop indexes the hosted function still required. Both now specify a create-or-replace-function migration plus matching mirror; apply deploys last. Docs only. | prettier --check clean; docs:check-links 1356; docs:check-scripts 390 | From 39ac6fde537946d0dd94712a206725969689f056 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:24:10 +0000 Subject: [PATCH 19/25] docs: ordering the alias limit does not lift the RAG canary gate Three places described the RAG-path index as canary-gated "until"/"unless"/ "or" fetchDocumentTitleAliasRows' unordered .limit(12) is made deterministic, which reads as ordering lifting the gate. It does not. An unordered LIMIT has no stable selection to preserve, so imposing an order can pick a different twelve than the database happens to return today -- that is an ordering behaviour change on a retrieval surface, which AGENTS.md already requires its own live eval-canary pair for. Sequencing the ordering fix first is still worthwhile, since an unordered LIMIT feeding retrieval candidates is latent nondeterminism regardless of this index, but it yields two canary-gated changes rather than one gate that ordering unlocks. The audit's L2-3 body already said this correctly; its opening clause and the two outstanding-issues rows and the runbook bullet did not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/audit/latency-audit-2026-07-28.md | 2 +- ...r-apply-performance-latency-remediation.md | 15 +- docs/outstanding-issues.md | 166 +++++++++--------- 3 files changed, 93 insertions(+), 90 deletions(-) diff --git a/docs/audit/latency-audit-2026-07-28.md b/docs/audit/latency-audit-2026-07-28.md index bf28d62e2c..3466865e0a 100644 --- a/docs/audit/latency-audit-2026-07-28.md +++ b/docs/audit/latency-audit-2026-07-28.md @@ -168,7 +168,7 @@ Metadata → memory → visual hydration run as three sequential Supabase stages **CORRECTED 2026-07-29 (PR #1377 review) — the "recall is byte-identical" claim was wrong, and it was load-bearing.** The original text argued the indexes are "additive and semantics-neutral, so no query text changes and recall is byte-identical", and used that to keep L2-3 out of canary territory. The premise does not hold on the RAG path: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with **no `ORDER BY`**, so _which_ twelve documents come back is plan-dependent. Adding an index changes the plan, so it can change the title-alias set fed into candidate assembly — a retrieval input, not just a speed-up. "No query text changes" is true; "recall is byte-identical" does not follow from it. -**Revised gating.** The `api/documents/route.ts:193` site is a user-facing document list and carries no retrieval consequence. The `rag-candidate-sources.ts:477` site does. Treat the RAG-path index as **canary-gated**, or make the ordering deterministic first — adding a stable `ORDER BY` to that `.limit(12)` makes selection deterministic **but does not by itself make the change safe** — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve than the database happens to return today, and the resulting recall still needs validating. It is worth doing on its own merits, since an unordered `LIMIT` feeding retrieval candidates is latent nondeterminism regardless of this index, but it converts one unvalidated change into another rather than removing the need for a canary. Tracked in `#102`; do not apply the RAG-path index on the strength of the retracted claim. +**Revised gating.** The `api/documents/route.ts:193` site is a user-facing document list and carries no retrieval consequence. The `rag-candidate-sources.ts:477` site does. Treat the RAG-path index as **canary-gated**, and note that ordering the alias limit first does not lift that gate — adding a stable `ORDER BY` to that `.limit(12)` makes selection deterministic **but does not by itself make the change safe** — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve than the database happens to return today, and the resulting recall still needs validating. It is worth doing on its own merits, since an unordered `LIMIT` feeding retrieval candidates is latent nondeterminism regardless of this index, but it converts one unvalidated change into another rather than removing the need for a canary. Tracked in `#102`; do not apply the RAG-path index on the strength of the retracted claim. **Deliberately no migration file.** The reviewed statements live in [`operator-apply-performance-latency-remediation.md`](../operator-apply-performance-latency-remediation.md). diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md index 3374d2dc5f..6ffc528165 100644 --- a/docs/operator-apply-performance-latency-remediation.md +++ b/docs/operator-apply-performance-latency-remediation.md @@ -63,12 +63,15 @@ not follow from that. - `documents_status_id_idx` and the `documents_title_bare_trgm_idx` benefit to `src/app/api/documents/route.ts:193` are ordering-safe: that path is a user-facing document list with no retrieval consequence. -- The **RAG-path** use of the bare-column trigram indexes is **canary-gated**, or must be preceded - by making that `.limit(12)` deterministic with a stable `ORDER BY`. **Note the ordering fix is - not itself free:** an unordered `LIMIT` has no stable selection to preserve, so imposing an order - can select a different twelve than the database returns today — it is worth doing because - unordered `LIMIT` on a retrieval input is latent nondeterminism, but its own recall impact needs - validating. Either way, do not apply on the retracted semantics-neutral claim. +- The **RAG-path** use of the bare-column trigram indexes is **canary-gated**, full stop. Ordering + that `.limit(12)` with a stable `ORDER BY` does **not** lift the gate: an unordered `LIMIT` has + no stable selection to preserve, so imposing an order can pick a different twelve than the + database happens to return today. That makes it an ordering behaviour change on a retrieval + surface in its own right, which `AGENTS.md` already requires a live eval-canary pair for. It is + worth doing on its own merits — an unordered `LIMIT` feeding retrieval candidates is latent + nondeterminism regardless of this index — but sequencing it first yields **two** canary-gated + changes, not one gate that ordering unlocks. Do not apply on the retracted semantics-neutral + claim. Create them outside a transaction: diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index c208108e16..fc3d9ecda0 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -48,38 +48,38 @@ removed after current-main verification; it is not missing recommended work. database/RAG/clinical/privacy expertise; Operator = named provider/product/legal authority. - **Estimate:** focused active time, excluding approval, hosted runtime, soak, and review waits. -| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition | -| ----: | -------------- | -------- | ------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | `#059` | A1 | Operator security + independent reviewer | Immediate approved security window | 1–3 hours plus verification | Verify every reported exposed credential (GitHub, OpenAI, Supabase service role/database, E2E) is retired; rotate anything still valid and update only intended secret stores. Never record values; stop before provider action without approval. | -| 2 | `#053` | A1 | Operator — legal/privacy | Start now; finish before real patient use/privacy-approved release | 4–8 hours internal; 1–6 weeks elapsed | Execute DPAs; decide ZDR/residency; obtain cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not change public copy before approval. | -| 5 | `#024` | A2 | High — browser/Next diagnostics | Provider-free macOS Safari host available | 1–2 hours | Reproduce document-source fallbacks in Safari/STP without Playwright interception; capture `_rsc` response evidence. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without proof. | -| 6 | `#022` | A2 | Operator — clinical governance + Specialist | Policy implemented locally; hosted apply and human review pending | 1–2 hours apply; 0.5–1 day first ten | The auditable BMJ `third_party_reference_attested` policy, migration and top-ten evidence manifest are prepared without changing `clinical_validation_status=unverified`. A qualified operator must review evidence, apply the migration deliberately, attest eligible records, review the ten visible local documents, then remeasure warnings. | -| 7 | `#023` | A2 | Specialist — RAG/browser diagnostics | After hosted dependency audit is green | 1–2 hours | Capture the skipped Firefox/WebKit scheduled datapoint and disposition the human irrelevant-at-10 labels. Retrieval and answer artifacts are already compared under resolved #051; do not spend on another RAG run. | -| 8 | `#018` | A2 | Specialist — clinical RAG/retrieval | Lithium closed; ADHD/metabolic evidence debt remains | Corpus/operator follow-up | Lithium's bounded subject/row-aware fix passed its targeted answer plus the full 36-case retrieval and 44-case answer canaries. ADHD's expected CAMHS document remains absent and the surfaced chart has no accessible table; metabolic schedule evidence remains unavailable and its standalone classifier candidate was reverted. | -| 10 | `#001` | A2 | Specialist — retrieval/ranking | After rollout approval | 0.5–1 day plus canary | Keep semantic reranking off unless an approved ambiguity comparison preserves 36/36, recall 1.0, zero per-case regressions, and shows measured gain; otherwise record keep-off and stop. | -| 11 | `#025` | A2 | Operator — Railway/GitHub/chat/Supabase | Next approved observability window | 1–3 hours/channel | Choose owned deployment, CI, ingestion, and SLO alerts; mock first, then one approved controlled provider event/channel. The merged Supabase trigger remains inert until its verified inputs are configured. Stop without an accountable responder. | -| 12 | `#055` | A2 | Specialist release owner + Operator | Before next full-confidence release/handoff | 2–4 hours plus runtime | On one exact SHA, run local/provider gates, Firefox/WebKit, required hosted CI, and close actionable GitHub threads. Stop at first failure and rerun only the repaired smallest gate. | -| 13 | `#056` | A2 | Operator — Supabase/Railway + Specialist | Next approved staging schema window | 2–4 hours | Reconcile the existing healthy, empty staging tier's 23-migration history gap using the exact repository migration chain, then re-run indexing, health, identity and data-boundary proof. Never recreate it or copy production clinical documents. | -| 14 | `#057` | A2 | High — release/SRE + Operator | After `#056` | 2–4 hours plus soak | Run documented staging soak and rollback against an exact candidate. Retain latency/error/rollback evidence; stop on unsafe data, identity mismatch, or unowned rollback. | -| 16 | `#011` | A3 | Operator — Supabase capacity | Immediately before first compute scale-up | 30–60 min plus observation | Switch Auth to percentage allocation, record before/after, and run approved advisor/health checks. Stop if no scale-up is planned. | -| 17 | `#017` | A3 | High — performance/browser | Before `#013`/`#016`; approved live-site window | 1–2 hours | Capture reproducible mobile/desktop Lighthouse/Web-Vitals evidence and decide whether payload work is justified. Stop if metrics are acceptable or evidence is too noisy. | -| 18 | `#033` | A3 | Specialist — prompt/source governance | After `#022` and explicit evaluation approval | 1–2 days plus approved eval | Design unknown-vs-adverse metadata wording and prompt tests. Require no supported-grounding drop and zero citation failures; stop on broad over-caveating or degradation. | -| 19 | `#037` | A3 | Operator — clinical/product + Standard | Next trust-policy review | 30–60 min; up to 0.5 day | Decide whether routine claims cap at medium trust. Record policy; if accepted, change only the flag/expectations and run focused tests. | -| 20 | `#013`, `#016` | A3 | High — bundling/runtime performance | After `#017` or equivalent evidence | 0.5–2 days/route | Optimize only a production route with measured payload/render/motion harm. Require material gain plus focused, `verify:cheap`, and browser evidence; stop on small gain. | -| 21 | `#035` | A3 | Specialist — evidence rules | After a demonstrated missed conflict | 0.5–1 day design; code separate | Define a clinically reviewed conflict class with positive and negative fixtures. Stop if no bounded class can be shown; behavior change requires protected review. | -| 22 | `#027` | Optional | Operator — SRE/provider | When an owned external alert path is wanted | 1–2 hours | Decide vendor/cost/privacy/owner; if accepted, prove one non-PHI outage and recovery alert. Stop when no responder owns it. | -| 23 | `#028` | Optional | Specialist privacy/observability + Operator | After privacy/ownership/cost approval | 1–3 days | Define vendor/region/retention/redaction/sampling/source-map envelope before SDK work. Prove no clinical text, identifiers, or secrets leave; stop if unacceptable. | -| 24 | `#038` | Optional | High — product/design architecture | When a new comparison surface is approved | 0.5–1 day | Define a shared interaction contract without flattening mode-specific content. Stop when no concrete new surface exists. | -| 25 | `#040` | Optional | High — visual QA/accessibility | When baseline owner/update workflow exists | 1–2 days | Establish a small stable desktop/mobile/accessibility baseline set. Do not make it blocking if flake or maintenance cost outweighs detection value. | -| 26 | `#039` | Optional | High — frontend architecture | During a concrete catalogue-toolbar project | 0.5–1 day inventory; 1–3 days code | Converge only repeated toolbar behavior without flattening search semantics. Stop when there is no bounded implementation target. | -| 27 | `#065` | A2 | High — document-viewer UI | Only when the user explicitly resumes the paused task | 0.5–1.5 days | Finish the compact source-text accordion, citation/search auto-open, print restoration, and 320/390/1280 px coverage. Keep the preserved branch untouched until explicit resume; no provider calls. | -| 28 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | -| 29 | `#086` | A3 | High — repository structure + Specialist | On explicit go-ahead for X3; later packages own their gates | 1 PR per work order | Ship remaining maturity backlog (X3 rag.ts; X7 src/lib reorg; X6 coverage floors; X5 ACL consolidation; L1 one-shot archive; L4 ledger rotation; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | -| 30 | `#098` | A3 | High — test infrastructure | Before `#099` or `#101`; it is their enabler | 2–4 hours | Generalise the answer-route preamble guard into a counting-proxy round-trip budget harness over the existing offline fixtures. Must enforce admission-before-scope, never the reverse. No providers, no DB. Stop if it would require live credentials. | -| 31 | `#102` | A3 | Operator — Supabase + Specialist | Next approved index window, after the ordering question is settled | 1–2 hours plus apply | Author the migration (operator SQL alone never reaches staging/DR/local replay), then apply → mirror `schema.sql` → regenerate drift manifest → register `required_indexes`. **Stop:** the RAG-path index is canary-gated until `fetchDocumentTitleAliasRows`'s unordered `.limit(12)` is made deterministic — the byte-identical claim was retracted. | -| 32 | `#099` | A3 | Specialist — answer path | After `#098` | Half a day per sub-item | Remaining fixed per-request round trips: the 8 `setCachedSearch` deferrals (abort semantics + mutation window), the anonymous subject+global limiter pair (needs a new atomic RPC first), and proxy→route identity duplication. Stop before hand-authoring locking SQL. | -| 33 | `#103` | A3 | Operator — Supabase schema | Same window as `#102` | 30–60 minutes | Confirm whether the wide `document_table_facts` trigram index from `20260714190000` exists live, then either mirror it into `schema.sql` or record it in `drift-allowlist.json` with the reason. Stop: do not drop it without live scan evidence. | -| 34 | `#105` | Optional | High — browser/UI verification | When the heavy-run lock is free | 20–40 minutes | Run `verify:ui` over the ten `LoadingPanel` fallbacks and confirm the Supabase `preconnect` reaches `` on a live page. Implementation already shipped; this row is the outstanding verification only. | +| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition | +| ----: | -------------- | -------- | ------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `#059` | A1 | Operator security + independent reviewer | Immediate approved security window | 1–3 hours plus verification | Verify every reported exposed credential (GitHub, OpenAI, Supabase service role/database, E2E) is retired; rotate anything still valid and update only intended secret stores. Never record values; stop before provider action without approval. | +| 2 | `#053` | A1 | Operator — legal/privacy | Start now; finish before real patient use/privacy-approved release | 4–8 hours internal; 1–6 weeks elapsed | Execute DPAs; decide ZDR/residency; obtain cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not change public copy before approval. | +| 5 | `#024` | A2 | High — browser/Next diagnostics | Provider-free macOS Safari host available | 1–2 hours | Reproduce document-source fallbacks in Safari/STP without Playwright interception; capture `_rsc` response evidence. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without proof. | +| 6 | `#022` | A2 | Operator — clinical governance + Specialist | Policy implemented locally; hosted apply and human review pending | 1–2 hours apply; 0.5–1 day first ten | The auditable BMJ `third_party_reference_attested` policy, migration and top-ten evidence manifest are prepared without changing `clinical_validation_status=unverified`. A qualified operator must review evidence, apply the migration deliberately, attest eligible records, review the ten visible local documents, then remeasure warnings. | +| 7 | `#023` | A2 | Specialist — RAG/browser diagnostics | After hosted dependency audit is green | 1–2 hours | Capture the skipped Firefox/WebKit scheduled datapoint and disposition the human irrelevant-at-10 labels. Retrieval and answer artifacts are already compared under resolved #051; do not spend on another RAG run. | +| 8 | `#018` | A2 | Specialist — clinical RAG/retrieval | Lithium closed; ADHD/metabolic evidence debt remains | Corpus/operator follow-up | Lithium's bounded subject/row-aware fix passed its targeted answer plus the full 36-case retrieval and 44-case answer canaries. ADHD's expected CAMHS document remains absent and the surfaced chart has no accessible table; metabolic schedule evidence remains unavailable and its standalone classifier candidate was reverted. | +| 10 | `#001` | A2 | Specialist — retrieval/ranking | After rollout approval | 0.5–1 day plus canary | Keep semantic reranking off unless an approved ambiguity comparison preserves 36/36, recall 1.0, zero per-case regressions, and shows measured gain; otherwise record keep-off and stop. | +| 11 | `#025` | A2 | Operator — Railway/GitHub/chat/Supabase | Next approved observability window | 1–3 hours/channel | Choose owned deployment, CI, ingestion, and SLO alerts; mock first, then one approved controlled provider event/channel. The merged Supabase trigger remains inert until its verified inputs are configured. Stop without an accountable responder. | +| 12 | `#055` | A2 | Specialist release owner + Operator | Before next full-confidence release/handoff | 2–4 hours plus runtime | On one exact SHA, run local/provider gates, Firefox/WebKit, required hosted CI, and close actionable GitHub threads. Stop at first failure and rerun only the repaired smallest gate. | +| 13 | `#056` | A2 | Operator — Supabase/Railway + Specialist | Next approved staging schema window | 2–4 hours | Reconcile the existing healthy, empty staging tier's 23-migration history gap using the exact repository migration chain, then re-run indexing, health, identity and data-boundary proof. Never recreate it or copy production clinical documents. | +| 14 | `#057` | A2 | High — release/SRE + Operator | After `#056` | 2–4 hours plus soak | Run documented staging soak and rollback against an exact candidate. Retain latency/error/rollback evidence; stop on unsafe data, identity mismatch, or unowned rollback. | +| 16 | `#011` | A3 | Operator — Supabase capacity | Immediately before first compute scale-up | 30–60 min plus observation | Switch Auth to percentage allocation, record before/after, and run approved advisor/health checks. Stop if no scale-up is planned. | +| 17 | `#017` | A3 | High — performance/browser | Before `#013`/`#016`; approved live-site window | 1–2 hours | Capture reproducible mobile/desktop Lighthouse/Web-Vitals evidence and decide whether payload work is justified. Stop if metrics are acceptable or evidence is too noisy. | +| 18 | `#033` | A3 | Specialist — prompt/source governance | After `#022` and explicit evaluation approval | 1–2 days plus approved eval | Design unknown-vs-adverse metadata wording and prompt tests. Require no supported-grounding drop and zero citation failures; stop on broad over-caveating or degradation. | +| 19 | `#037` | A3 | Operator — clinical/product + Standard | Next trust-policy review | 30–60 min; up to 0.5 day | Decide whether routine claims cap at medium trust. Record policy; if accepted, change only the flag/expectations and run focused tests. | +| 20 | `#013`, `#016` | A3 | High — bundling/runtime performance | After `#017` or equivalent evidence | 0.5–2 days/route | Optimize only a production route with measured payload/render/motion harm. Require material gain plus focused, `verify:cheap`, and browser evidence; stop on small gain. | +| 21 | `#035` | A3 | Specialist — evidence rules | After a demonstrated missed conflict | 0.5–1 day design; code separate | Define a clinically reviewed conflict class with positive and negative fixtures. Stop if no bounded class can be shown; behavior change requires protected review. | +| 22 | `#027` | Optional | Operator — SRE/provider | When an owned external alert path is wanted | 1–2 hours | Decide vendor/cost/privacy/owner; if accepted, prove one non-PHI outage and recovery alert. Stop when no responder owns it. | +| 23 | `#028` | Optional | Specialist privacy/observability + Operator | After privacy/ownership/cost approval | 1–3 days | Define vendor/region/retention/redaction/sampling/source-map envelope before SDK work. Prove no clinical text, identifiers, or secrets leave; stop if unacceptable. | +| 24 | `#038` | Optional | High — product/design architecture | When a new comparison surface is approved | 0.5–1 day | Define a shared interaction contract without flattening mode-specific content. Stop when no concrete new surface exists. | +| 25 | `#040` | Optional | High — visual QA/accessibility | When baseline owner/update workflow exists | 1–2 days | Establish a small stable desktop/mobile/accessibility baseline set. Do not make it blocking if flake or maintenance cost outweighs detection value. | +| 26 | `#039` | Optional | High — frontend architecture | During a concrete catalogue-toolbar project | 0.5–1 day inventory; 1–3 days code | Converge only repeated toolbar behavior without flattening search semantics. Stop when there is no bounded implementation target. | +| 27 | `#065` | A2 | High — document-viewer UI | Only when the user explicitly resumes the paused task | 0.5–1.5 days | Finish the compact source-text accordion, citation/search auto-open, print restoration, and 320/390/1280 px coverage. Keep the preserved branch untouched until explicit resume; no provider calls. | +| 28 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | +| 29 | `#086` | A3 | High — repository structure + Specialist | On explicit go-ahead for X3; later packages own their gates | 1 PR per work order | Ship remaining maturity backlog (X3 rag.ts; X7 src/lib reorg; X6 coverage floors; X5 ACL consolidation; L1 one-shot archive; L4 ledger rotation; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | +| 30 | `#098` | A3 | High — test infrastructure | Before `#099` or `#101`; it is their enabler | 2–4 hours | Generalise the answer-route preamble guard into a counting-proxy round-trip budget harness over the existing offline fixtures. Must enforce admission-before-scope, never the reverse. No providers, no DB. Stop if it would require live credentials. | +| 31 | `#102` | A3 | Operator — Supabase + Specialist | Next approved index window, after the ordering question is settled | 1–2 hours plus apply | Author the migration (operator SQL alone never reaches staging/DR/local replay), then apply → mirror `schema.sql` → regenerate drift manifest → register `required_indexes`. **Stop:** the RAG-path index is canary-gated, and ordering `fetchDocumentTitleAliasRows`'s unordered `.limit(12)` does not lift that — an imposed order can select a different twelve, so it is a second canary-gated change, not a way out of the first. The byte-identical claim was retracted. | +| 32 | `#099` | A3 | Specialist — answer path | After `#098` | Half a day per sub-item | Remaining fixed per-request round trips: the 8 `setCachedSearch` deferrals (abort semantics + mutation window), the anonymous subject+global limiter pair (needs a new atomic RPC first), and proxy→route identity duplication. Stop before hand-authoring locking SQL. | +| 33 | `#103` | A3 | Operator — Supabase schema | Same window as `#102` | 30–60 minutes | Confirm whether the wide `document_table_facts` trigram index from `20260714190000` exists live, then either mirror it into `schema.sql` or record it in `drift-allowlist.json` with the reason. Stop: do not drop it without live scan evidence. | +| 34 | `#105` | Optional | High — browser/UI verification | When the heavy-run lock is free | 20–40 minutes | Run `verify:ui` over the ten `LoadingPanel` fallbacks and confirm the Supabase `preconnect` reaches `` on a live page. Implementation already shipped; this row is the outstanding verification only. | @@ -95,57 +95,57 @@ removed after current-main verification; it is not missing recommended work. > > **Exact-head release confirmation (2026-07-28):** after final review narrowed expanded chunk selection to the measured clozapine blood-count action shape, `output/rag-retrieval-post-exact-head.json` repeated all 36 cases with document/content recall 1.0, MRR 0.8921, content MRR 0.9406, nDCG 0.9308, irrelevant-at-10 0.0917, zero failures and zero per-case document/content reciprocal-rank regressions versus `rag-retrieval-post-final.json`. Median latency rose 13,563 -> 19,729 ms while p90 improved 56,765 -> 55,660 ms; neither run had a latency-failed case, so no ranking or latency gate changed. Cache-bypassed exact-head answer probes for both admission/discharge cases and the clozapine threshold case were substantive, grounded, expected-source-backed and free of citation/numeric/route failures; all used zero provider requests and $0 generation cost. This was the protected behavior-change merge gate, not a rerun for #023. -| ID | Pri | Type | Summary | Detail / next action | Source | Added | -| ---- | --- | ----- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | -| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | -| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | -| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | -| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | -| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | -| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | -| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | -| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | -| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | -| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | -| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them); (e) `src/app/(search-app)/layout.tsx:4` imports 71.6 KB of Therapy-Compass-only CSS in the ROUTE-GROUP layout, making it render-blocking on `/`, `/documents`, `/forms`, `/dsm` and every mode home; (f) `shared-search-app-shell.tsx:8` statically imports the `therapy-compass` barrel, pulling `workspace.tsx` + `bindings.tsx` + `nav.tsx` into every `(search-app)` route; (g) three client waterfalls (`use-app-preferences.ts:156-182`, `ClinicalDashboard.tsx:977-1069`, `signed-image.tsx:60-84` + `use-signed-image-url.ts:39`) and the paint offenders in `globals.css` beyond the sidebar grid — three stacked `backdrop-filter` passes on an always-mounted translating element (`:709-748`), `box-shadow` inside a `transition` list (`:677-684`), and `@keyframes shimmer` animating `background-position` on the shared `Skeleton` (`:2289-2296`). **CORRECTED 2026-07-29 on (c):** the Therapy Compass filenames are unversioned and Next serves `/public` with an ETag, so only the FIRST visit pays 690.6 KB / 2,470 KB — repeat visits pay ~4 revalidation round trips. The fix is content-hashed filenames + `immutable` (touching `scripts/build-therapies-index.mjs` and `check:therapy-data-index`), NOT a bare `Cache-Control` line. See `docs/audit/latency-audit-2026-07-28.md` L3-1/L3-2/L3-3/L3-6/L3-7. | session 2026-07-21 (build route table + design audit) | 2026-07-21 | -| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | -| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | -| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | -| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | -| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | -| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | -| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | -| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | -| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | -| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | -| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | -| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | -| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | -| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | -| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | -| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | -| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | -| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | -| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | -| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | -| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Reproduced locally 2026-07-28** (isolated _production_ build via `run-playwright.mjs`, full `verify:ui`): `ui-tools.spec.ts:563` duplicated `forms-home` and `ui-smoke.spec.ts:3001` duplicated `favourite-row-lithium-monitoring-guideline`; in both, copy 1 is nested under `mobile-composer-reserve-pad`. Both pass when run alone, so it is load/order-dependent, not build-mode dependent — this also corrects an earlier note that CI uses `next dev`; it does not. **Strongest evidence (CI run `30345484316`, 2026-07-28): `ui-overlap.spec.ts:199` on `/` asserted `toHaveCount(1)` successfully and then the same `header#search` locator resolved to 2 a statement later, one of them hidden.** A duplicate that appears _after_ a passing count assertion is a stream/hydration artifact by construction, not a static double mount and not something a CSS or component change can cause. That makes four distinct testids across four specs with the identical shape. **Mitigated, not fixed, on `main` (2026-07-28):** `3a8edb93` rewrapped `gotoHome` in `tests/ui-overlap.spec.ts` to retry count-and-visibility together via `toPass`, so a transient second header no longer trips strict mode there — its own note says "checking count then immediately calling waitFor races that flicker into a strict-mode violation". That hardens one helper; the duplicate root itself is unchanged and other specs remain exposed. **Confirmed pre-existing:** at `631d90d2`, the commit before PR #1316's first commit, that spec already documented "two `header#search` nodes" and "a second transient `header#search` can exist briefly" — so this predates that branch. **Next:** with a full-suite repro now available, bisect the preceding specs to find the state that triggers the second mount, then either scope the shared helpers to the visible root once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | -| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | -| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | -| #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | -| #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | -| #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins admission-before-scope (no scope call while the limiter is pending or after a deny) and the client-disconnect abort signal. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`). Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | -| #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | -| #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | -| #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | -| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated unless that `.limit(12)` is made deterministic first. **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. **That migration must also carry the health-function change** — `required_indexes` lives inside `search_schema_health()`, which is redefined by `create or replace function` in eleven migrations (copy `20260705180000_reconcile_search_health_indexes.sql:62`); editing `schema.sql:3177` alone moves only the mirror and leaves the indexes unmonitored on hosted (PR #1377 review). Then apply concurrently, confirm `indisvalid`, mirror both the index statements and the identical function body into `schema.sql`, run `npm run drift:manifest` (Docker), and deploy the migration LAST — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` via its own `create or replace function` migration and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | -| #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | -| #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | -| #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Implementation shipped 2026-07-29; browser verification still PENDING:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | -| #106 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | -| #107 | P2 | rec | Component state matrices are the largest untested surface | **Outcome:** loading / empty / error / disabled states on interactive components are covered by executing tests, not only by E2E happy paths. **Detail:** measured 2026-07-29 — production components (excluding mockups) sit at **38.2% lines / 22.8% branch** across 12,602 lines, with **83 of 208 files at zero executed lines**; there are 51 `.dom.test.tsx` files against 195 components. Playwright does visit these routes, so they are smoke-covered, but branch coverage is where the state matrix lives and smoke journeys rarely reach it. Worst by uncovered lines: `global-search-shell.tsx` (7%), `mode-action-popup.tsx` (21%), `answer-content.tsx` (27%), `document-search-results.tsx` (32%), `universal-search-command-surface.tsx` (39%), `master-search-header.tsx` (43%). A concrete first target with clinical meaning: `calculator-ui.tsx` now covers all exported scoring logic, but `seedCheckboxDefaults`, `toggleCheckboxAnswer` and `selectOptionAnswer` stay uncovered because they are module-private and only reachable through React event handlers — `seedCheckboxDefaults` is what makes an all-negative CAGE / SAD PERSONS screen read as a valid 0 rather than incomplete, so a regression there is a false-negative risk. **Next:** treat as a per-PR convention rather than a backfill push — `docs/testing.md` already prescribes the state matrix, so the gap is enforcement. Start with `global-search-shell.tsx`, which `docs/search-chrome-behaviour.md` treats as a contract surface. Keep additions in the jsdom tier (measured ~0.54s per file) instead of new Playwright journeys (~231 production journeys already run serially at `workers: 1` against a 45-minute CI budget). **Stop:** do not chase the coverage percentage by backfilling low-risk components; the re-ratcheted broad floor in `vitest.config.mts` holds the line. | session 2026-07-29 test-coverage analysis | 2026-07-29 | +| ID | Pri | Type | Summary | Detail / next action | Source | Added | +| ---- | --- | ----- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | +| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | +| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | +| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | +| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | +| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | +| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | +| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | +| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | +| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | +| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | +| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them); (e) `src/app/(search-app)/layout.tsx:4` imports 71.6 KB of Therapy-Compass-only CSS in the ROUTE-GROUP layout, making it render-blocking on `/`, `/documents`, `/forms`, `/dsm` and every mode home; (f) `shared-search-app-shell.tsx:8` statically imports the `therapy-compass` barrel, pulling `workspace.tsx` + `bindings.tsx` + `nav.tsx` into every `(search-app)` route; (g) three client waterfalls (`use-app-preferences.ts:156-182`, `ClinicalDashboard.tsx:977-1069`, `signed-image.tsx:60-84` + `use-signed-image-url.ts:39`) and the paint offenders in `globals.css` beyond the sidebar grid — three stacked `backdrop-filter` passes on an always-mounted translating element (`:709-748`), `box-shadow` inside a `transition` list (`:677-684`), and `@keyframes shimmer` animating `background-position` on the shared `Skeleton` (`:2289-2296`). **CORRECTED 2026-07-29 on (c):** the Therapy Compass filenames are unversioned and Next serves `/public` with an ETag, so only the FIRST visit pays 690.6 KB / 2,470 KB — repeat visits pay ~4 revalidation round trips. The fix is content-hashed filenames + `immutable` (touching `scripts/build-therapies-index.mjs` and `check:therapy-data-index`), NOT a bare `Cache-Control` line. See `docs/audit/latency-audit-2026-07-28.md` L3-1/L3-2/L3-3/L3-6/L3-7. | session 2026-07-21 (build route table + design audit) | 2026-07-21 | +| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | +| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | +| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | +| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | +| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | +| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | +| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | +| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | +| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | +| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | +| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | +| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | +| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | +| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | +| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | +| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | +| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | +| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | +| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | +| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | +| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Reproduced locally 2026-07-28** (isolated _production_ build via `run-playwright.mjs`, full `verify:ui`): `ui-tools.spec.ts:563` duplicated `forms-home` and `ui-smoke.spec.ts:3001` duplicated `favourite-row-lithium-monitoring-guideline`; in both, copy 1 is nested under `mobile-composer-reserve-pad`. Both pass when run alone, so it is load/order-dependent, not build-mode dependent — this also corrects an earlier note that CI uses `next dev`; it does not. **Strongest evidence (CI run `30345484316`, 2026-07-28): `ui-overlap.spec.ts:199` on `/` asserted `toHaveCount(1)` successfully and then the same `header#search` locator resolved to 2 a statement later, one of them hidden.** A duplicate that appears _after_ a passing count assertion is a stream/hydration artifact by construction, not a static double mount and not something a CSS or component change can cause. That makes four distinct testids across four specs with the identical shape. **Mitigated, not fixed, on `main` (2026-07-28):** `3a8edb93` rewrapped `gotoHome` in `tests/ui-overlap.spec.ts` to retry count-and-visibility together via `toPass`, so a transient second header no longer trips strict mode there — its own note says "checking count then immediately calling waitFor races that flicker into a strict-mode violation". That hardens one helper; the duplicate root itself is unchanged and other specs remain exposed. **Confirmed pre-existing:** at `631d90d2`, the commit before PR #1316's first commit, that spec already documented "two `header#search` nodes" and "a second transient `header#search` can exist briefly" — so this predates that branch. **Next:** with a full-suite repro now available, bisect the preceding specs to find the state that triggers the second mount, then either scope the shared helpers to the visible root once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | +| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | +| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | +| #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | +| #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | +| #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins admission-before-scope (no scope call while the limiter is pending or after a deny) and the client-disconnect abort signal. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`). Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | +| #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | +| #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | +| #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | +| #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated, and making that `.limit(12)` deterministic first does **not** lift the gate — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve and is itself an ordering behaviour change on a retrieval surface, which AGENTS.md requires a canary pair for. Sequencing the ordering fix first is worthwhile (unordered `LIMIT` on a retrieval input is latent nondeterminism regardless) but yields two canary-gated changes, not one (PR #1377 review). **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. **That migration must also carry the health-function change** — `required_indexes` lives inside `search_schema_health()`, which is redefined by `create or replace function` in eleven migrations (copy `20260705180000_reconcile_search_health_indexes.sql:62`); editing `schema.sql:3177` alone moves only the mirror and leaves the indexes unmonitored on hosted (PR #1377 review). Then apply concurrently, confirm `indisvalid`, mirror both the index statements and the identical function body into `schema.sql`, run `npm run drift:manifest` (Docker), and deploy the migration LAST — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` via its own `create or replace function` migration and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | +| #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | +| #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | +| #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Implementation shipped 2026-07-29; browser verification still PENDING:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | +| #106 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | +| #107 | P2 | rec | Component state matrices are the largest untested surface | **Outcome:** loading / empty / error / disabled states on interactive components are covered by executing tests, not only by E2E happy paths. **Detail:** measured 2026-07-29 — production components (excluding mockups) sit at **38.2% lines / 22.8% branch** across 12,602 lines, with **83 of 208 files at zero executed lines**; there are 51 `.dom.test.tsx` files against 195 components. Playwright does visit these routes, so they are smoke-covered, but branch coverage is where the state matrix lives and smoke journeys rarely reach it. Worst by uncovered lines: `global-search-shell.tsx` (7%), `mode-action-popup.tsx` (21%), `answer-content.tsx` (27%), `document-search-results.tsx` (32%), `universal-search-command-surface.tsx` (39%), `master-search-header.tsx` (43%). A concrete first target with clinical meaning: `calculator-ui.tsx` now covers all exported scoring logic, but `seedCheckboxDefaults`, `toggleCheckboxAnswer` and `selectOptionAnswer` stay uncovered because they are module-private and only reachable through React event handlers — `seedCheckboxDefaults` is what makes an all-negative CAGE / SAD PERSONS screen read as a valid 0 rather than incomplete, so a regression there is a false-negative risk. **Next:** treat as a per-PR convention rather than a backfill push — `docs/testing.md` already prescribes the state matrix, so the gap is enforcement. Start with `global-search-shell.tsx`, which `docs/search-chrome-behaviour.md` treats as a contract surface. Keep additions in the jsdom tier (measured ~0.54s per file) instead of new Playwright journeys (~231 production journeys already run serially at `workers: 1` against a 45-minute CI budget). **Stop:** do not chase the coverage percentage by backfilling low-risk components; the re-ratcheted broad floor in `vitest.config.mts` holds the line. | session 2026-07-29 test-coverage analysis | 2026-07-29 | ## Resolved / archive From 0ab16977d52f409ed4fbb0dfefcc94debcac0d1a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:24:32 +0000 Subject: [PATCH 20/25] docs(ledger): record the RAG canary-gate wording review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 2b3875da5b..8b3bb4fffc 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1277,3 +1277,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | claude/latency-findings-impl-s8g01v | 71db10c41de872fca6e400626704a549f145c66c | latency audit implementation (PR #1377) | SUPERSEDES the 78e2beb record: its 'scope-vs-ratelimit overlap with abort' description is stale and describes behaviour that was REVERTED. Codex review raised it P1 and it was correct — an AbortSignal cannot un-execute a statement Postgres already began, and resolveSearchScope only skips the database when there are no filters and no explicit ids (search-scope.ts:242,253), so a throttled caller kept spending DB capacity while collecting 429s. Shipped behaviour is rate-limit admission BEFORE scope evaluation, with request.signal threaded so a client disconnect still cancels scope's paginated queries, pinned by tests/answer-route-preamble.test.ts. Also retracted on this head: the L2-3 'recall is byte-identical' claim, since fetchDocumentTitleAliasRows applies .limit(12) with no ORDER BY. | verify:cheap exit 0 (423 files, 4278 passed/4 skipped); check:branch-review-ledger pass; focused preamble + server-timing suites pass | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 0e937f80f1c4fdfa66a4e5abc2918e763ed51bc1 | PR #1377 latency findings — operator rollback sequencing | Codex P2 confirmed and fixed: the index rollback merged the revert migration into the expectation deploy, so the deployed migration would take ACCESS EXCLUSIVE on documents before the concurrent drop; split into three deployed phases (retract required_indexes -> concurrent live drop -> schema.sql removal + idempotent forward drop migration). Apply-side commit-vs-deploy ordering made explicit. Docs only. | prettier --check clean; docs:check-links 1355; docs:check-scripts 390; check:branch-review-ledger 1232 records | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 310d0fbc4f08ca88cb097f3e480896e75d0007bf | PR #1377 latency findings — search_schema_health registration is a migration | Codex P2 confirmed and fixed: apply step 5 and rollback phase A described the required_indexes change as a schema.sql edit, but schema.sql is a mirror and search_schema_health() is redefined by create or replace function in 11 migrations (precedent 20260705180000_reconcile_search_health_indexes.sql:62). As written the hosted function never moved, leaving the new indexes unmonitored on apply and, on rollback, letting phase B drop indexes the hosted function still required. Both now specify a create-or-replace-function migration plus matching mirror; apply deploys last. Docs only. | prettier --check clean; docs:check-links 1356; docs:check-scripts 390 | +| 2026-07-29 | claude/latency-findings-impl-s8g01v | 39ac6fde537946d0dd94712a206725969689f056 | PR #1377 latency findings — RAG canary gate is not lifted by ordering | Codex P2 confirmed and fixed: the #102 queue row, the #102 detail row and the runbook bullet described the RAG-path index as canary-gated until/unless/or the unordered .limit(12) is ordered, implying ordering lifts the gate. An unordered LIMIT has no stable selection to preserve, so imposing an order can select a different twelve and is itself an ordering behaviour change on a retrieval surface requiring its own canary pair per AGENTS.md. All four sites now state two canary-gated changes rather than one unlockable gate. Docs only; no src/lib/rag change. | prettier --check clean; docs:check-links 1356; docs:check-scripts 390 | From 881b7cfe9f4c9b313e70496968d5a7591f2b594d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:30:30 +0000 Subject: [PATCH 21/25] docs(issues): the drift allowlist cannot reconcile #103 #103's outcome is that the migration chain and schema.sql agree on document_table_facts trigram indexes, but the row offered drift-allowlist.json as an alternative to mirroring. It is not one. The allowlist's own header scopes it to "Known live-vs-schema.sql divergence" -- it suppresses a live drift finding and cannot make the two schema sources agree. A fresh `supabase db reset` still runs 20260714190000 and creates document_table_facts_text_trgm_idx while schema.sql still omits it, so the divergence survives the allowlist entirely. There are exactly two routes: mirror it into schema.sql and regenerate the manifest if retained, or drop it via a forward migration if the live scan evidence shows it redundant. Also records that no offline gate catches this -- the migration/schema.sql parity test only asserts one migration's schema_drift_snapshot function definition, not an index inventory. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/outstanding-issues.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index fc3d9ecda0..9b3c634a04 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -78,7 +78,7 @@ removed after current-main verification; it is not missing recommended work. | 30 | `#098` | A3 | High — test infrastructure | Before `#099` or `#101`; it is their enabler | 2–4 hours | Generalise the answer-route preamble guard into a counting-proxy round-trip budget harness over the existing offline fixtures. Must enforce admission-before-scope, never the reverse. No providers, no DB. Stop if it would require live credentials. | | 31 | `#102` | A3 | Operator — Supabase + Specialist | Next approved index window, after the ordering question is settled | 1–2 hours plus apply | Author the migration (operator SQL alone never reaches staging/DR/local replay), then apply → mirror `schema.sql` → regenerate drift manifest → register `required_indexes`. **Stop:** the RAG-path index is canary-gated, and ordering `fetchDocumentTitleAliasRows`'s unordered `.limit(12)` does not lift that — an imposed order can select a different twelve, so it is a second canary-gated change, not a way out of the first. The byte-identical claim was retracted. | | 32 | `#099` | A3 | Specialist — answer path | After `#098` | Half a day per sub-item | Remaining fixed per-request round trips: the 8 `setCachedSearch` deferrals (abort semantics + mutation window), the anonymous subject+global limiter pair (needs a new atomic RPC first), and proxy→route identity duplication. Stop before hand-authoring locking SQL. | -| 33 | `#103` | A3 | Operator — Supabase schema | Same window as `#102` | 30–60 minutes | Confirm whether the wide `document_table_facts` trigram index from `20260714190000` exists live, then either mirror it into `schema.sql` or record it in `drift-allowlist.json` with the reason. Stop: do not drop it without live scan evidence. | +| 33 | `#103` | A3 | Operator — Supabase schema | Same window as `#102` | 30–60 minutes | Confirm whether the wide `document_table_facts` trigram index from `20260714190000` exists live, then either mirror it into `schema.sql` (retained) or drop it via a forward migration (redundant). **Not the allowlist** — it suppresses live-vs-`schema.sql` findings only and cannot make the migration chain and the mirror agree. Stop: do not drop it without live scan evidence. | | 34 | `#105` | Optional | High — browser/UI verification | When the heavy-run lock is free | 20–40 minutes | Run `verify:ui` over the ten `LoadingPanel` fallbacks and confirm the Supabase `preconnect` reaches `` on a live page. Implementation already shipped; this row is the outstanding verification only. | @@ -141,7 +141,7 @@ removed after current-main verification; it is not missing recommended work. | #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | | #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. The documents-list and `(status,id)` uses stay ordering-safe; the RAG-path index is canary-gated, and making that `.limit(12)` deterministic first does **not** lift the gate — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve and is itself an ordering behaviour change on a retrieval surface, which AGENTS.md requires a canary pair for. Sequencing the ordering fix first is worthwhile (unordered `LIMIT` on a retrieval input is latent nondeterminism regardless) but yields two canary-gated changes, not one (PR #1377 review). **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. **That migration must also carry the health-function change** — `required_indexes` lives inside `search_schema_health()`, which is redefined by `create or replace function` in eleven migrations (copy `20260705180000_reconcile_search_health_indexes.sql:62`); editing `schema.sql:3177` alone moves only the mirror and leaves the indexes unmonitored on hosted (PR #1377 review). Then apply concurrently, confirm `indisvalid`, mirror both the index statements and the identical function body into `schema.sql`, run `npm run drift:manifest` (Docker), and deploy the migration LAST — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` via its own `create or replace function` migration and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | -| #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then either add it to `schema.sql` or record it in `supabase/drift-allowlist.json` with the reason. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | +| #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then take one of exactly two routes — **retained:** mirror `document_table_facts_text_trgm_idx` into `supabase/schema.sql` beside the narrow one and regenerate `drift-manifest.json`; **redundant:** drop it through a new forward migration, never by deleting `20260714190000`. **`drift-allowlist.json` is NOT a third option** (PR #1377 review): its own header scopes it to _"Known live-vs-`schema.sql` divergence"_, so it can silence a live drift finding but cannot reconcile the migration chain with the mirror — a fresh `supabase db reset` still runs `20260714190000` and creates the index while `schema.sql` still omits it, leaving this row's stated outcome unmet. **No offline gate catches this today:** the migration↔`schema.sql` parity test (`tests/drift-detection.test.ts:59-68`) only asserts one migration's `schema_drift_snapshot` function definition, not an index inventory — which is why this sits open rather than red in CI, and why a replay-to-schema inventory comparison is the check that would have caught it. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | | #104 | P3 | rec | CORRECTION — the worker's triple image read is deliberate, not debt | **Outcome:** a future audit does not re-file this a third time. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` `readFile`s each extracted image up to 3x per document — hash, caption on cache miss, upload) as "CONFIRMED with no fix evidence", carried forward from the 2026-07-01 audit's `L11`. **That was wrong.** The 2026-07-01 disposition table already recorded it as a deliberate peak-memory trade-off, and the rationale is documented in place at `worker/main.ts:866-869`: holding every extracted image Buffer for a document with hundreds of multi-MB page images would multiply the worker's peak memory, and disk I/O is the cheaper resource for a background pipeline. The three reads (`:872`, `:1034`, `:1129`) are real but accepted. **Next:** none — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not "fix" this by caching buffers; that trades a decided memory ceiling for disk I/O nobody has measured as a problem. | `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table; `docs/audit/latency-audit-2026-07-28.md` L4-2 retraction | 2026-07-29 | | #105 | P3 | task | Verify the `#017`-exempt client latency wins in a browser | **Outcome:** the two zero-payload client fixes are confirmed in a real browser. `#017` gates _payload_ decisions (#012/#013/#016 are all byte-count items); a `loading` fallback ships zero bytes and a resource hint ships ~60, so neither can be justified or refuted by a Lighthouse number — that is why these were not held behind #017. **Implementation shipped 2026-07-29; browser verification still PENDING:** 10 of 11 `ssr:false` dashboard surfaces had NO `loading` fallback and rendered nothing between HTML arrival and chunk execution — all now use the shared `LoadingPanel` (`role="status"` + accessible label); Supabase `preconnect`/`dns-prefetch` added, since `AuthProvider` awaits a cross-origin `getUser()` on mount that every auth-gated fetch queues behind and there were no resource hints anywhere in `src/`. Shipped with `verify:cheap` + `verify:pr-local` only. **Next:** run `npm run verify:ui` once the heavy-run lock is free, and confirm the preconnect appears in `` on a live page. **Stop:** the two sidebar dialogs are intentionally excluded — they mount on open, so a fallback would render into a closed dialog. | `docs/audit/latency-audit-2026-07-28.md` L3-4/L3-5; `src/components/clinical-dashboard/clinical-dashboard-lazy.tsx` | 2026-07-29 | | #106 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | From 2757cf713624c9e03bb03a63890757d3abd60572 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:30:44 +0000 Subject: [PATCH 22/25] docs(ledger): record the #103 drift-allowlist review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 8b3bb4fffc..3405944d5d 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1278,3 +1278,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | claude/latency-findings-impl-s8g01v | 0e937f80f1c4fdfa66a4e5abc2918e763ed51bc1 | PR #1377 latency findings — operator rollback sequencing | Codex P2 confirmed and fixed: the index rollback merged the revert migration into the expectation deploy, so the deployed migration would take ACCESS EXCLUSIVE on documents before the concurrent drop; split into three deployed phases (retract required_indexes -> concurrent live drop -> schema.sql removal + idempotent forward drop migration). Apply-side commit-vs-deploy ordering made explicit. Docs only. | prettier --check clean; docs:check-links 1355; docs:check-scripts 390; check:branch-review-ledger 1232 records | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 310d0fbc4f08ca88cb097f3e480896e75d0007bf | PR #1377 latency findings — search_schema_health registration is a migration | Codex P2 confirmed and fixed: apply step 5 and rollback phase A described the required_indexes change as a schema.sql edit, but schema.sql is a mirror and search_schema_health() is redefined by create or replace function in 11 migrations (precedent 20260705180000_reconcile_search_health_indexes.sql:62). As written the hosted function never moved, leaving the new indexes unmonitored on apply and, on rollback, letting phase B drop indexes the hosted function still required. Both now specify a create-or-replace-function migration plus matching mirror; apply deploys last. Docs only. | prettier --check clean; docs:check-links 1356; docs:check-scripts 390 | | 2026-07-29 | claude/latency-findings-impl-s8g01v | 39ac6fde537946d0dd94712a206725969689f056 | PR #1377 latency findings — RAG canary gate is not lifted by ordering | Codex P2 confirmed and fixed: the #102 queue row, the #102 detail row and the runbook bullet described the RAG-path index as canary-gated until/unless/or the unordered .limit(12) is ordered, implying ordering lifts the gate. An unordered LIMIT has no stable selection to preserve, so imposing an order can select a different twelve and is itself an ordering behaviour change on a retrieval surface requiring its own canary pair per AGENTS.md. All four sites now state two canary-gated changes rather than one unlockable gate. Docs only; no src/lib/rag change. | prettier --check clean; docs:check-links 1356; docs:check-scripts 390 | +| 2026-07-29 | claude/latency-findings-impl-s8g01v | 881b7cfe9f4c9b313e70496968d5a7591f2b594d | PR #1377 latency findings — #103 drift allowlist is not a reconciliation route | Codex P2 confirmed and fixed: the #103 queue and detail rows offered drift-allowlist.json as an alternative to mirroring document_table_facts_text_trgm_idx into schema.sql. The allowlist header scopes it to live-vs-schema.sql divergence, so it cannot reconcile migrations with the mirror; a fresh db reset still runs 20260714190000 while schema.sql omits the index, leaving the row outcome unmet. Both rows now give exactly two routes (mirror, or forward-migration drop after live scan evidence) and record that no offline gate catches this. Docs only. | prettier --check clean; docs:check-links 1356; docs:check-scripts 390 | From 58d94e5293804d9bbf006bbac4d257932e85ce5d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:58:39 +0000 Subject: [PATCH 23/25] docs(ledger): drop four union-merge duplicates from the main merge Merge 9f7a629 brought origin/main into this branch, and merge=union kept both copies of four records this branch already carried from an earlier main merge. Static PR checks went red on the ledger guard: - 4 exact duplicate review record(s) found at line(s) 1277, 1279, 1280, 1281. - 4 record(s) repeat the same ref/HEAD/scope (line pairs 1270 and 1277; 1275 and 1279; 1272 and 1280; 1273 and 1281). This is the #088 watch condition exactly. All four pairs are byte-identical, so only the later copies are removed -- the one mutation the append-only contract permits. No record is edited and none is lost: 4 deletions, 0 additions, and the unique-row sets before and after are identical. The rows belong to three other branches (clinical-design-system-update-e34ca9, test-coverage-analysis-2vcd8a, document-reader-condensed-view), not to this one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b8268b6cb4..84bd3ec48a 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1274,11 +1274,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | PR #1377 / claude/latency-findings-impl-s8g01v | 0badfedac1a8bd2ad32587ad90dbea68617f64e9 | PR #1377 CI/review babysit | Synced #1382/#1383. Real conflict only in outstanding-issues: kept corrected latency #098-#105 + added #106/#107. CI green on prior tip 07c6aaeb. 0 unresolved threads; no Bugbot findings. | vitest preamble+calculator-scoring+private-access-routes 208/208; check:branch-review-ledger pass; prior tip PR-required SUCCESS | | 2026-07-29 | codex/document-reader-condensed-view | 7cefb24e99f9745a61843c7e48c4889f7324ec42 | pr-1380-main-merge-coderabbit-density | merged origin/main; resolved source-panels conflict (kept condensed details + tracking-eyebrow); density in-memory fallback when storage blocked; summary keys + search/plain compact tests; local vitest/lint/typecheck/format/playwright condensed pass; awaiting hosted CI | vitest document suites 20/20; lint; typecheck; format:check; playwright condensed 4/4; merge-tree clean | | 2026-07-29 | codex/remove-source-overlays | c3feb4cea34dd1a0d0d675df3e063c95174f2504 | PR #1378 babysit | MERGED via squash auto-merge after Codex P1 governance restore + overlay removal. Hosted required checks green; unresolved threads 0; Bugbot no open findings. | Hosted PR required/Production UI/Static/Unit/Build/Safety/PR policy SUCCESS; verify:cheap 4273 pass; Bugbot no open findings | -| 2026-07-29 | claude/clinical-design-system-update-e34ca9 | 0cdae091ad92f40e0ad7335b3e2d396c44188a4f | PR #1375 conflict fix + Bugbot | FIXED second CONFLICTING after #1378: took main removal of SelectedDocumentEvidencePanel; retained tracking-eyebrow on surviving document-search-results. Prior DocumentViewerRail + form-detail settlement retained. MERGEABLE; CI re-running. | local: document-search-record-fault + design-token tests; merge-tree CLEAN; prior Production UI PASS on 6903f51f; form-detail e2e 2/2. | | 2026-07-29 | PR #1378 / codex/remove-source-overlays (squash) | 6f2f1aa259ad7b554b3bac4e6c24adf2f8d28436 | PR #1378 babysit | MERGED via squash auto-merge. Supersedes prior closeout row that recorded pre-squash tip c3feb4cea34dd1a0d0d675df3e063c95174f2504 (unreachable after squash). Hosted required checks green; unresolved threads 0; Bugbot no open findings. | Hosted PR required/Production UI/Static/Unit/Build/Safety/PR policy SUCCESS; verify:cheap 4273 pass; squash SHA 6f2f1aa2 resolvable | -| 2026-07-29 | codex/document-reader-condensed-view | 7cefb24e99f9745a61843c7e48c4889f7324ec42 | pr-1380-main-merge-coderabbit-density | merged origin/main; resolved source-panels conflict (kept condensed details + tracking-eyebrow); density in-memory fallback when storage blocked; summary keys + search/plain compact tests; local vitest/lint/typecheck/format/playwright condensed pass; awaiting hosted CI | vitest document suites 20/20; lint; typecheck; format:check; playwright condensed 4/4; merge-tree clean | -| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks | -| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change | | 2026-07-29 | agent/document-topbar-actions (PR #1381) | 9da8ccfb19ff81b876a9bfff4e6b5870641e44d8 | PR #1381 CI triage | merged via squash auto-merge after main sync; all required checks green; no product code fix; no Bugbot/review threads | hosted CI pr-required pass; Production UI pass; CircleCI pass; lint; typecheck; document-viewer-shell.dom; Bugbot none | | 2026-07-29 | codex/chat-document-header-overlay-document-header-overlay-20260729 | 48ed6cc95f886837f4ddbb369fdbc611a0958f17 | document phone header overlay | No high-confidence findings; physical iPhone acceptance remains | verify:pr-local unit 4373 pass; build PASS; focused Playwright 2 pass; phone gate contended | | 2026-07-29 | codex/document-reader-condensed-view | 5678e878d4fe681d33bb58df5b5b3468a138a1c8 | pr-1380-ci-green-resync | hosted CI green on 7150899a (Static/Build/Unit/Advisory/Production UI/PR required/CircleCI); CodeRabbit density fallback + summary keys + search/plain compact tests landed; unresolved review threads none; resynced main after tip went BEHIND by 1 | hosted CI success on 7150899a; merge-tree clean; bugbot no P0/P1 | From 9e2ee65ca0bcce45a3cb6a0539e265ec8d961582 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:03:51 +0000 Subject: [PATCH 24/25] docs(issues): point #098 at the executable offline RAG suites The #098 row still named test-cache-path.mjs and check-rag-fixtures.mjs as the fixtures to build the round-trip counting harness over. Neither can count a RAG request: the first computes Vitest/TypeScript cache paths, the second only validates fixture manifests. A harness wired to them would have pinned nothing. The audit document already carried this retraction (:358); this row did not, so the stale references survived in the one place an implementer would actually read them. Now names scripts/eval-rag-offline.mjs, scripts/test-rag-offline.mjs, scripts/rag-offline-contract.mjs and scripts/fixtures/rag-offline-contract-tests.json, all four verified present, and records the correction inline so it is not reintroduced a third time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/outstanding-issues.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index f4c10ff6a6..f38ecc4aea 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -136,7 +136,7 @@ removed after current-main verification; it is not missing recommended work. | #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | | #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | | #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | -| #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins admission-before-scope (no scope call while the limiter is pending or after a deny) and the client-disconnect abort signal. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline fixtures (`scripts/eval-rag-offline.mjs`, `test-cache-path.mjs`, `check-rag-fixtures.mjs`). Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | +| #098 | P2 | task | Offline round-trip budget harness for the hot routes | **Outcome:** per-scenario Supabase round-trip counts are pinned by a test, so an extra round trip on a hot path is a red gate rather than an inference. **Done 2026-07-29:** the measurement gap is closed — `Server-Timing` now covers `auth`/`ratelimit`/`scope` on `/api/answer`, `auth`/`ratelimit`/`search`/`total` on `/api/search`, and `auth`/`ratelimit` on `/api/answer/stream` (previously the route the UI actually calls emitted no header at all). Headers flush before the first SSE frame, so in-stream stages cannot reach a header and must NOT be routed through the governed `progress`/`final` contract. `tests/answer-route-preamble.test.ts` pins admission-before-scope (no scope call while the limiter is pending or after a deny) and the client-disconnect abort signal. **Next:** generalise it — wrap the Supabase client in a counting proxy and assert per-scenario query budgets over the existing offline suites — `scripts/eval-rag-offline.mjs`, `scripts/test-rag-offline.mjs`, `scripts/rag-offline-contract.mjs` and the contract fixture `scripts/fixtures/rag-offline-contract-tests.json`. **An earlier version of this row named `test-cache-path.mjs` and `check-rag-fixtures.mjs`** (corrected 2026-07-29, PR #1377 review, matching the audit's own retraction): neither exercises a RAG request — the first computes Vitest/TypeScript cache paths, the second only validates fixture manifests — so building the harness on them would have counted nothing. Sequence before #099 and #101: it is the enabler and the standing guard. No providers, no DB. | `docs/audit/latency-audit-2026-07-28.md` measurement plan; `src/lib/server-timing.ts`; `src/lib/answer-stream-contract.ts:18-21` | 2026-07-29 | | #099 | P2 | task | Remove the remaining fixed per-request round trips | **Outcome:** the answer path stops paying avoidable per-request Supabase round trips. **Done 2026-07-29:** shared-cache-hit promotion deferred off the response path with its mid-request staleness guard intact and documented (`rag.ts:3234`, `rag-cache.ts`); scope resolution overlapped with the rate-limit RPC, signal threaded so a client disconnect finally cancels its paginated queries (`answer/route.ts`). **REFUTED on PR #1377 review — do not retry:** the same pass also overlapped scope with the rate-limit RPC and aborted it on deny, claiming the limiter could "deny for free". It cannot. With caller-supplied `filters` or explicit ids, scope passes its zero-query early returns (`search-scope.ts:242,253`) into the paginated `documents` loop at `:269`, and an `AbortSignal` cancels the client request without un-executing a statement Postgres already began — so throttled traffic kept burning database capacity while collecting 429s, against `capacity-review.md:106-113`'s first-soft-failure warning. Scope is behind admission again, pinned by `tests/answer-route-preamble.test.ts`. Re-attempting the overlap requires a non-database admission gate ahead of the durable limiter first. **Remaining:** (a) the 8 `setCachedSearch` awaits — deferring changes `throwIfAborted` semantics and widens a real mutation window because the clone happens after an `await`, so each branch needs discharging individually; (b) batch the anonymous subject+global rate-limit pair, which needs a NEW atomic RPC modelled on `consume_summary_rate_limits_atomic` and cannot be called until the operator applies it — `Promise.all` is the WRONG fix because it consumes the global bucket even when the subject bucket already denied; (c) stop the proxy and route handler resolving identity twice per authenticated request — no in-process memo can do this (different `Request` objects), so the proxy must forward unspoofable verified claims via a header it controls. Cross-references #011: halving auth resolutions eases the ~10-connection Auth cap that `capacity-review.md:106-113` calls the first hard failure. | `docs/audit/latency-audit-2026-07-28.md` L1-1/L1-3/L1-4; `src/lib/api-rate-limit.ts:276-282`; `src/proxy.ts:125` | 2026-07-29 | | #100 | P2 | rec | Buffered answer generation has no incremental verified delivery | **Outcome:** a clinician sees verified answer content before the whole generation completes. Highest-leverage latency finding in the 2026-07-28 audit: generation is buffered (`openai.ts:465`) and delivered in ONE `final` SSE frame, so time-to-first-content equals total latency — a strong answer inside its 25 s SLO still shows a blank panel for 25 s. The 15 s `sse-heartbeat` exists because that silence routinely exceeds 15 s; it instruments the defect rather than fixing it. **Naive token streaming is REFUTED, not merely unbuilt:** `answer-stream-contract.ts:18-21` removed `token`/`revising` deliberately because a rolling deployment would "re-expose unvalidated clinical prose", and raw tokens bypass the numeric-faithfulness gate the 2026-07-01 audit filed as H1. **Only admissible shape:** progressive disclosure of already-verified units (evidence/sources at retrieval-complete, then per-section after that section clears verification) over the existing whitelisted `progress` event. Needs a clinical-governance decision plus a canary pair. Also add the refutation to `docs/rag-behaviour/refuted-approaches.md`. Cross-references #021. **Stop:** do not re-land `token` streaming. | `docs/audit/latency-audit-2026-07-28.md` L0-1; `src/lib/answer-stream-contract.ts:18-21`; `src/lib/sse-heartbeat.ts` | 2026-07-29 | | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | From 14e0232a218953e93324278f6510d192c0f15316 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:04:04 +0000 Subject: [PATCH 25/25] docs(ledger): record the #098 offline-harness reference review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01361jh3eYVjJCzXWjAhdZiF --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 0404aa4017..8d4560f5fc 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1291,3 +1291,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | codex/document-reader-condensed-view | b5cdbf301d517239ffe9ed941b9ebe809aea0bfd | branch-cleanup-deletion-pending | DELETION PENDING — content proven fully on main. Merge-base with main is 855aa291 and tree(merge-base) equals tree(tip): git diff --name-only 855aa291 b5cdbf30 reports 0 files, so the tip introduces nothing beyond a state already in main. Its work landed as main's tip via squash. Backs no open PR. Deletion blocked by HTTP 403; HEAD recorded here so the later branch-cleanup row can be keyed to it after the ref is gone. Supersedes the aggregate main-keyed row at 855aa291, which recorded no candidate HEADs. | local git only for the content proof: git merge-base, git diff --name-only (0 files), git diff --name-only origin/main... (0 files), full history after git fetch --unshallow. PROVIDER-BACKED evidence obtained (GitHub reads, authorised repo-scope): open-PR head cross-check via GitHub API against PRs #1374/#1377/#1384/#1385/#1386/#1387, and GitHub MCP capability inspection. PROVIDER-BACKED MUTATION ATTEMPTED AND REJECTED: git push origin --delete returned HTTP 403; no branch was deleted. No OpenAI/Supabase/hosted-CI calls. | | 2026-07-29 | cursor/page-anchored-search-composer-30ee | 7ff134ca7f614db527b8d142676640305533669d | branch-cleanup-deletion-pending | DELETION PENDING — content proven fully on main. Merge-base with main is 79d1c879 and tree(merge-base) equals tree(tip): git diff --name-only 79d1c879 7ff134ca reports 0 files, so the tip introduces nothing beyond a state already in main. Backs no open PR. Deletion blocked by HTTP 403; HEAD recorded here so the later branch-cleanup row can be keyed to it after the ref is gone. Supersedes the aggregate main-keyed row at 855aa291, which recorded no candidate HEADs. | local git only for the content proof: git merge-base, git diff --name-only (0 files), git diff --name-only origin/main... (0 files), full history after git fetch --unshallow. PROVIDER-BACKED evidence obtained (GitHub reads, authorised repo-scope): open-PR head cross-check via GitHub API against PRs #1374/#1377/#1384/#1385/#1386/#1387, and GitHub MCP capability inspection. PROVIDER-BACKED MUTATION ATTEMPTED AND REJECTED: git push origin --delete returned HTTP 403; no branch was deleted. No OpenAI/Supabase/hosted-CI calls. | | 2026-07-29 | cursor/pr-1379-babysit-ledger-9365 | be2de03f855cb7fdfccea4bb74d05eb4c9bf6c61 | branch-cleanup-deletion-pending | DELETION PENDING — content proven fully on main. Merge-base with main is b2740480 and tree(merge-base) equals tree(tip): git diff --name-only b2740480 be2de03f reports 0 files, so the tip introduces nothing beyond a state already in main. Backs no open PR. Deletion blocked by HTTP 403; HEAD recorded here so the later branch-cleanup row can be keyed to it after the ref is gone. Supersedes the aggregate main-keyed row at 855aa291, which recorded no candidate HEADs. | local git only for the content proof: git merge-base, git diff --name-only (0 files), git diff --name-only origin/main... (0 files), full history after git fetch --unshallow. PROVIDER-BACKED evidence obtained (GitHub reads, authorised repo-scope): open-PR head cross-check via GitHub API against PRs #1374/#1377/#1384/#1385/#1386/#1387, and GitHub MCP capability inspection. PROVIDER-BACKED MUTATION ATTEMPTED AND REJECTED: git push origin --delete returned HTTP 403; no branch was deleted. No OpenAI/Supabase/hosted-CI calls. | +| 2026-07-29 | claude/latency-findings-impl-s8g01v | 9e2ee65ca0bcce45a3cb6a0539e265ec8d961582 | PR #1377 latency findings — #098 stale offline-harness references | Codex P2 confirmed and fixed: the #098 row in docs/outstanding-issues.md still named test-cache-path.mjs and check-rag-fixtures.mjs as the offline fixtures for the round-trip counting harness. Neither exercises a RAG request (cache paths; fixture-manifest validation), so a harness built on them would count nothing. The audit doc carried the retraction at :358 but this row did not - the same local-retraction pattern flagged in two prior rounds. Now names eval-rag-offline.mjs, test-rag-offline.mjs, rag-offline-contract.mjs and the contract fixture, all verified present, with the correction recorded inline. Docs only. | prettier --check clean; docs:check-links 1363; docs:check-scripts 390; grep confirms no stale refs remain |