From 4bcf98d25eb2e49ae64658eecc5096b61bcaa650 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:37:13 +0800 Subject: [PATCH 01/11] feat: complete document and privacy follow-ups --- docs/README.md | 1 + docs/comparison-behaviour.md | 70 +++++ docs/design-system.md | 4 + docs/operator-backlog.md | 25 +- docs/outstanding-issues.md | 8 +- docs/staging-setup.md | 10 +- .../clinical-dashboard/differentials-home.tsx | 4 +- .../document-search-results.tsx | 6 +- .../medication-prescribing-workspace.tsx | 12 +- .../use-differential-catalog.ts | 36 ++- .../use-medication-catalog.ts | 23 +- .../document-viewer/source-panels.tsx | 277 ++++++++++++------ src/components/forms/forms-home-page.tsx | 2 +- .../forms/forms-search-results-page.tsx | 10 +- .../services/services-home-page.tsx | 2 +- .../services/services-navigator-page.tsx | 10 +- src/lib/use-registry-records.ts | 36 ++- tests/catalog-refetch-privacy.dom.test.tsx | 95 ++++++ tests/ui-smoke.spec.ts | 59 ++++ tests/use-differential-search.dom.test.tsx | 48 +++ 20 files changed, 600 insertions(+), 138 deletions(-) create mode 100644 docs/comparison-behaviour.md create mode 100644 tests/catalog-refetch-privacy.dom.test.tsx diff --git a/docs/README.md b/docs/README.md index 3aa536f58..ae6d14cc5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ npm run docs:check-links - [deployment-architecture.md](deployment-architecture.md) — app/worker/Supabase deployment topology - [ingestion-state-machine.md](ingestion-state-machine.md) — ingestion job lifecycle and states - [design-system.md](design-system.md) — tokens, primitives, styling conventions +- [comparison-behaviour.md](comparison-behaviour.md) — shared selection, state, responsive, and accessibility contract for comparison surfaces - [clinical-chat-ui-component-map.md](clinical-chat-ui-component-map.md) — chat UI component inventory - [clinical-badge-system-guide.md](clinical-badge-system-guide.md) — clinical badge semantics - [multi-user-auth-setup.md](multi-user-auth-setup.md) — auth, sessions, owner scoping diff --git a/docs/comparison-behaviour.md b/docs/comparison-behaviour.md new file mode 100644 index 000000000..d6c0f3e12 --- /dev/null +++ b/docs/comparison-behaviour.md @@ -0,0 +1,70 @@ +# Comparison behaviour + +This is the shared interaction contract for side-by-side comparison surfaces. It standardises +selection, state, navigation, and accessibility without standardising the clinical fields or the +meaning of a comparison. + +Existing reference surfaces are differential diagnosis, Services Navigator, and Therapy Compass. +New comparison work should reuse this behaviour before introducing another interaction model. + +## Selection contract + +- Comparison is always an explicit user action. A mode may suggest or initially select likely + candidates, but every selected item remains visibly removable before comparison begins. +- Zero selected items shows an instructional empty state. One selected item asks for one more. + Two or more enables the comparison action. The mode owns any upper limit and states it before + the limit is reached and when an add is refused. +- Selection controls state both actions: `Add to comparison` and `Remove from +comparison`. Selected state is visually apparent and exposed with the appropriate native or + ARIA state. +- Selection contains stable item identifiers, never copied clinical records. Remove identifiers + that no longer exist in the active result set or authorised catalogue. +- A query, mode, organisation, or authenticated-user change must not silently carry selections + into a different scope. Clear them unless the surface has a deliberate, tested, shareable URL + contract for that exact scope. + +## Entry and exit + +- The compare affordance includes the current selected count. While fewer than two items are + selected it is disabled or rendered as an explicit instructional action; it is never inert. +- Desktop placement belongs near the selection controls or summary rail. On phones, a docked or + composer-adjacent action is allowed only when its owner and content reserve follow + `search-chrome-behaviour.md`. +- Opening comparison preserves enough context to return to the originating results. Removing an + item in comparison updates the originating selection if both views share client state. +- A clear-all action is available once anything is selected. It is disabled at zero and does not + delete, mutate, or hide source records. + +## Comparison states + +- With two or more items, align equivalent fields so a user can scan one field across all items. + A narrow viewport may use a labelled stacked layout or horizontal scrolling; it must not reorder + an item's fields or detach values from their item and field labels. +- Loading or background refetch preserves the last authorised comparison and labels it as + refreshing. An identity or comparison-scope change clears it synchronously before new data is + requested. +- Missing, unknown, not applicable, and failed-to-load are distinct states. Do not render a blank + cell where the distinction affects interpretation. +- Source, review, freshness, or confidence context stays attached to the item or field it qualifies. + A summary may highlight differences, but it must not replace the underlying source context. +- Copy, print, and share actions operate only on the visible selected set and are disabled until the + set is valid. Shared URLs must validate every identifier and apply the same access checks as the + underlying record routes. + +## Mode-owned content + +The shared contract does not define comparison fields, clinical recommendations, rankings, +thresholds, evidence weighting, or generated prose. Each mode owns those through its existing data, +governance, and safety contracts. Adding a new comparison surface must document: + +1. the record type and stable identifier; +2. the minimum and maximum selection count; +3. when selection is cleared or restored; +4. the field order and missing-value semantics; +5. source/review context and any clinical owner; +6. phone layout, keyboard order, and return path; and +7. focused tests for zero, one, valid, over-limit, stale-record, and identity-change states. + +Do not create a shared clinical comparison component until at least two modes use the same field +semantics. Shared selection helpers or layout primitives are acceptable when they preserve each +mode's content ownership. diff --git a/docs/design-system.md b/docs/design-system.md index 636d60830..ee1262981 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -12,6 +12,10 @@ Design direction is **settled**. Work on the UI is convergence — closing the g contract and the code — not reinvention. If a change genuinely needs a new direction, update `permanent-colour-direction.md` first, then the code. +Comparison surfaces also follow [`comparison-behaviour.md`](comparison-behaviour.md). That contract +standardises selection and interaction states while leaving clinical fields and meaning with each +mode. + ## 1. Non-negotiables - **Tokens only.** Every colour comes from a CSS custom property defined in diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index 84f1a1699..ef3dfc165 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -18,15 +18,15 @@ Findings inventory for handover: [audit/audit-handover-2026-07-14.md](audit/audi ## Launch-gating actions -| Action | Status | Blocked by | Verify command | Runbook | -| ----------------------------------------------------- | ---------- | --------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Apply July-8 migration batch (a–g) to live | ✅ done | — | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` (2026-07-13: 6 live, apply=no-op) | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) | -| Apply drift-codify forward migration (step 1h) | ✅ done | — | Applied and drift/readiness verified 2026-07-13; verify only unless new reviewed drift is found | [database-drift-detection.md](database-drift-detection.md) | -| Apply repo-ahead migrations to live (post-2026-07-13) | ✅ done | — | Zero unsafe title-word rows; `npm run check:drift`; then `eval:retrieval:quality` (36/36) for the corrector | [deploy-corrector-public-titles.md](deploy-corrector-public-titles.md) · [operator-apply-performance-latency-remediation.md](operator-apply-performance-latency-remediation.md) | -| Full release gate (bounded OpenAI spend) | ⏳ pending | hosted audit/browser | Let Dependabot PRs #1268/#1269 clear the production audit, then run one exact-SHA release/browser gate | [launch-operator-runbook.md §2](launch-operator-runbook.md) | -| Reconcile existing staging Supabase schema | ⏳ pending | DB CLI credential | Apply the exact 23-version repository chain after `20260719055623`; then `npm run check:indexing` | [staging-setup.md](staging-setup.md) | -| Staging soak + rollback rehearsal on Railway | ⏳ pending | staging schema parity | Re-run tenancy proof, then `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) | -| Production deploy to Railway | ✅ done | — | App deployment recorded live 2026-07-14; re-verify with `GET /api/health` and deployment readiness | [deployment-architecture.md](deployment-architecture.md) | +| Action | Status | Blocked by | Verify command | Runbook | +| ----------------------------------------------------- | ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Apply July-8 migration batch (a–g) to live | ✅ done | — | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` (2026-07-13: 6 live, apply=no-op) | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) | +| Apply drift-codify forward migration (step 1h) | ✅ done | — | Applied and drift/readiness verified 2026-07-13; verify only unless new reviewed drift is found | [database-drift-detection.md](database-drift-detection.md) | +| Apply repo-ahead migrations to live (post-2026-07-13) | ✅ done | — | Zero unsafe title-word rows; `npm run check:drift`; then `eval:retrieval:quality` (36/36) for the corrector | [deploy-corrector-public-titles.md](deploy-corrector-public-titles.md) · [operator-apply-performance-latency-remediation.md](operator-apply-performance-latency-remediation.md) | +| Full release gate (bounded OpenAI spend) | ⏳ pending | hosted audit/browser | Let Dependabot PRs #1268/#1269 clear the production audit, then run one exact-SHA release/browser gate | [launch-operator-runbook.md §2](launch-operator-runbook.md) | +| Reconcile existing staging Supabase schema | ⏳ pending | reviewed full-chain scope | 2026-07-30 live list shows 24 local-only versions, including ten earlier history holes and fourteen after `20260719055623`; apply only the complete reviewed chain, then `npm run check:indexing` | [staging-setup.md](staging-setup.md) | +| Staging soak + rollback rehearsal on Railway | ⏳ pending | staging schema parity | Re-run tenancy proof, then `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) | +| Production deploy to Railway | ✅ done | — | App deployment recorded live 2026-07-14; re-verify with `GET /api/health` and deployment readiness | [deployment-architecture.md](deployment-architecture.md) | ## Post-deploy actions @@ -53,6 +53,13 @@ dashboard/CLI action, never committed. | Supabase schedules / Vault names | ✅ present | `Clinical KB Database` | Read-only 2026-07-27 proof found active retention/performance/auto-toggle schedules and the `cron_ingestion_jwt` / `indexing_v3_agent_secret` Vault names. The deliberately absent document-change webhook activation secret remains tracked by #025. | | OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | App endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md). This legal/provider decision remains #053 and is not implied by config presence. | +**Webhook activation preflight (2026-07-30):** names-only checks found neither chat webhook URL in +the designated local env, GitHub Actions secrets, Railway production app, or Railway staging app. +`RAILWAY_WEBHOOK_SECRET` and `SUPABASE_INGESTION_WEBHOOK_SECRET` were also absent from both Railway +app environments. Activation stopped without generating or setting partial secrets because no +accountable chat endpoint/responder was available; no webhook provider mutation or controlled event +was dispatched. This remains #025. + ## Disaster-recovery re-creation (does NOT survive a schema restore) Per [disaster-recovery-runbook.md](disaster-recovery-runbook.md) — config & secrets are the layer a schema diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index f767c0718..91c5d2656 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -69,10 +69,8 @@ removed after current-main verification; it is not missing recommended work. | 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; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. L4 ledger rotation shipped in #1418. 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. | @@ -99,7 +97,6 @@ removed after current-main verification; it is not missing recommended work. | 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 | @@ -122,7 +119,6 @@ removed after current-main verification; it is not missing recommended work. | #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 | @@ -130,7 +126,6 @@ removed after current-main verification; it is not missing recommended work. | #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 (in progress); 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; M1 repo-host hardening (maintainer, audit §8). **Shipped:** L4 ledger rotation (#1418 — `ledger:rotate`, live/archive corpus, `merge=ledger`). **X3 progress:** the evidence coverage gate shipped as `src/lib/rag/rag-coverage-gate.ts` (PR #1454, squashed `102bb1f`) — `evaluateEvidenceCoverageGate` + `applyCoverageGateTelemetry` moved byte-identically, `rag.ts` 5,030 → 4,780, budget ratcheted to 4,780, no back-edge, `evaluateEvidenceCoverageGate` still re-exported from `@/lib/rag/rag`. **Next X3 unit — `rag-hydration.ts`:** the hydration cluster is `createDocumentRankingMetadataCache` / `attachDocumentRankingMetadata` / `withCachedIndexQuality` / `attachIndexQualityMetadata` / `attachPageVisualEvidence` plus the `DocumentRankingMetadataCache` type (`rag.ts:1487-1718` as of `102bb1f`). **It does NOT on its own unblock `prepareCoverageGateResults`** (corrected 2026-07-30 after a Codex finding on PR #1461 — the earlier claim that it re-homed all five dependencies was wrong). That function needs five `rag.ts`-only runtime symbols, and hydration accounts for only two of them (`attachDocumentRankingMetadata`, `attachPageVisualEvidence`). The other three sit outside the cluster and are a separate seam: `selectRankedRetrievalResults` (`rag.ts:1825`, retrieval selection), `applySecondStageRerankIfNeeded` (`rag.ts:679`, second-stage ranking), and `measureSearchPhase` (`rag.ts:1975`, the shared pipeline timing wrapper — 21 references across the file, of which only `metadata_hydration` and `visual_hydration` are hydration phases, so it belongs with the search orchestrator/telemetry, not with hydration). So `prepareCoverageGateResults` can only move after BOTH hydration and that ranking/timing seam are re-homed; it stayed in `rag.ts` for #086 because a back-edge and a signature change were both refused. Verify with a symbol-location plus call-site search before planning the boundary — do not treat hydration alone as sufficient. **Next:** remaining X3 units 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 | | #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 | | #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. **Done 2026-07-30 (PR #1450, `1bff4c78`):** the counting proxy exists and the answer path is budgeted. `tests/helpers/supabase-round-trip-counter.ts` counts on **execution, not construction** — a builder that is never awaited costs zero, one awaited twice costs two — which is the distinction that makes the count mean "requests issued". `tests/rag-round-trip-budget.test.ts` pins two offline answer-path scenarios (a single-source source-only answer, and that trips do not scale with the number of retrieved sources) plus three self-tests of the counter, and is registered in `scripts/fixtures/rag-offline-contract-tests.json` so it runs inside the offline contract rather than only on demand. Verified locally, provider-free: `Test Files 1 passed (1)`, `Tests 5 passed (5)`. Its documented blind spot is worth repeating before anyone cites a budget as total cost: it sees only traffic through the wrapped client, so a trip issued via another client instance, a direct `fetch`, or a provider SDK is invisible to it. **Next:** two gaps remain from the original scope. (a) `/api/search` has no budget — this row named the hot routes plural and only the answer path is pinned, so an added round trip on search is still an inference. (b) `scripts/eval-rag-offline.mjs` and `scripts/test-rag-offline.mjs` were not wired; decide whether the offline contract runner is the single right home for budgets or whether those suites need their own, and record the decision here rather than leaving both plausible. | `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 | @@ -175,6 +170,9 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | ID | Type | Summary | Outcome | Resolved | | ---- | ----- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| #065 | task | Complete the paused compact document source-text accordion | Resolved after reconciling only the missing behavior with current `DocumentViewer`: extracted page text and passages are compact nested disclosures; citation/search opens the active passage; manual peers are exclusive; print expands and restores state. Focused Chromium proof passed at 320, 390, and 1280 px with no overflow, plus deep-link/search and print restoration paths. The preserved historical branch was not changed. | 2026-07-31 | +| #092 | task | Refetch pulse deferred on auth-backed registries | Resolved with identity- and resource-keyed background refresh in registry, medication, and differential catalogues. Same-user credential refreshes retain authorised rows under `refetching`; query, mode, or user identity changes clear synchronously. Focused DOM tests pin same-user preservation and immediate user-change clearing, and existing result surfaces now expose the shared refetch pulse without replacing records with skeletons. | 2026-07-31 | +| #038 | rec | Consolidate shared comparison behavior | Resolved with `docs/comparison-behaviour.md`: one interaction contract now covers selection scope, zero/one/valid/over-limit states, phone entry, background refresh, identity changes, missing values, source context, sharing, and accessibility while explicitly leaving clinical fields, rankings, evidence weighting, and generated prose with each mode. No RAG or comparison-generation behavior changed. | 2026-07-30 | | #095 | issue | `PR required` reports failure for concurrency-cancelled jobs | RESOLVED 2026-07-30. The `pr-required` aggregate now distinguishes a cancelled job from a failed one. `require_success` / `require_skipped_or_success` are thin wrappers over one `record()` collector that reads **each job's own `result`** and appends to a `failures` or `cancellations` array; both arrays are filled before anything is reported. **Genuine failures win:** every failure is emitted as its own `::error::` and a concurrent cancellation is demoted to a `::warning::`, so a run that is cancelled AND broken cannot read as an excuse (refinement reported by Codex on PR #1409). Cancelled with nothing failing stays **RED**, and the message states the two possibilities rather than asserting supersession: it points the reader at a newer `PR required` run on the PR's current head SHA, and says that if there is none the run was cancelled by hand and must be re-run rather than merged past. It deliberately does NOT read the workflow-level cancelled status function — an earlier revision passed that through an `env:` value, which is invalid because GitHub allows those functions only in `if:` conditions, so the whole file failed to parse, ran as `.github/workflows/ci.yml` instead of `CI`, and created zero jobs; valid YAML but invalid Actions schema, so prettier and every local gate passed it. **The tempting fix was rejected as unsafe:** treating `cancelled` as neutral, or skipping the aggregate via a not-cancelled condition, would make the red disappear, but GitHub counts a SKIPPED required check as PASSING, so a hand-cancelled run on the current head would become mergeable with nothing verified — `if: always()` is therefore deliberate. Guarded by ten cases in `tests/ci-cache-safety.test.ts` that EXECUTE the extracted aggregate script under synthetic job results rather than grepping the YAML. **Corrected in review (Codex, PR #1428):** the first version of this record described a shared `cancelled_error` helper that never existed and claimed the error names the newest run; both were carried over from an obsolete sentence in the open row and neither matched the shipped implementation. Source: PR #1316 runs 30340972329 / 30341225585; PR #1409 | 2026-07-30 | | #096 | task | PR #1316 review follow-ups — adoption-gate coverage closed | RESOLVED 2026-07-30. Every sub-item is dispositioned. The band adoption gate's root-path gap closed on PR #1394 — root and href-less modes now resolve to `src/app/(search-app)/page.tsx` — and closing it surfaced two further defects in the same gate the original finding did not name: the hand-rolled walk was capped at two import hops where the root route's real chain is four, and it followed neither `layout.tsx` (where that route's band actually comes from) nor `dynamic(() => import(...))` (how the dashboard code-splits its mode workspaces). All three were fixed together with a bounded BFS, each verified load-bearing by reverting it and watching the gate fail. Four findings — favourites hub counts, the document-search status derivation, the 401 session-expiry path, the record-path duplicate notice — were already fixed independently. The Therapy Compass retry-waiter finding was corrected to NOT a live defect: `retryWaitersRef` is genuinely unscoped but no caller observes it. The seven Codex follow-up SHAs remain unreachable and were never pushed; the PR #1316 review threads are the durable source. Archived by the 2026-07-30 triage pass. Source: PR #1316 review sweep | 2026-07-30 | | #104 | rec | Worker's triple image read is deliberate, not debt | NOT DEBT — archived so a fourth audit does not re-file it. The 2026-07-28 latency audit listed L4-2 (`worker/main.ts` reads 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. This row carried no next action, so it is archived rather than left open — revisit only if ingestion throughput becomes a measured complaint AND a bounded-buffer design is proposed. **Stop:** do not re-file this as debt. Source: `docs/audit/repo-audit-2026-07-01.md` L11 + disposition table | 2026-07-30 | diff --git a/docs/staging-setup.md b/docs/staging-setup.md index 6ff40b5dd..c84a2aa7c 100644 --- a/docs/staging-setup.md +++ b/docs/staging-setup.md @@ -10,8 +10,14 @@ Staging is two independent tiers: a **staging Supabase project** (data) and a > **Current state (verified 2026-07-27):** Supabase project `ikoiolksxqxfxgiyqpnu` > and the Railway staging app already exist and are healthy. The app is in offline-provider mode, > the staging corpus is empty, and `search_schema_health()` passes. Do not create replacements. -> The remaining data-tier work is to apply the 23 repository migrations after -> `20260719055623`, then repeat the identity, indexing, health, and empty-data-boundary proof. +> **Revalidated 2026-07-30:** the staging project and app are still healthy, correctly identify as +> staging, run with `RAG_PROVIDER_MODE=offline`, and have no OpenAI key. Linked migration history +> has **24** local-only versions: ten holes before/at `20260719053533` (four are historical +> placeholders) and fourteen versions after `20260719055623`. `supabase db push --linked +--include-all --dry-run` prints that exact 24-version chain. Do not run a normal or partial push: +> history is divergent, and the full chain currently ends in the separately governed BMJ +> attestation migration `20260727010000`. Reconcile the entire reviewed chain only in an approved +> scope, then repeat the identity, indexing, health, and empty-data-boundary proof. The identity guard is already staging-aware (`src/lib/supabase/project.ts`): it accepts a second project **only** when you explicitly declare it via diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 668dbb598..763d235dc 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -914,7 +914,9 @@ function SearchResultsView({ : "error" : loading || catalogLoading ? "loading" - : "ready" + : catalog.status === "refetching" + ? "refetching" + : "ready" } faultTitle={ catalog.status === "unauthorized" diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index bfd0dd641..ba4f96794 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -598,7 +598,7 @@ function SearchRecordResults({ } function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus; mode: SearchRecordMode }) { - if (status === "ready") return null; + if (status === "ready" || status === "refetching") return null; const noun = mode === "forms" ? "forms" : "services"; const config = status === "loading" @@ -783,7 +783,9 @@ function DocumentSearchResultsPanelImpl({ ? "error" : recordStatus === "loading" ? "loading" - : "ready" + : recordStatus === "refetching" + ? "refetching" + : "ready" : (unavailable?.status ?? (loading ? "loading" : "ready")) } faultBody={showRecordMatches ? undefined : (unavailableMessage ?? undefined)} diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 585bf640b..ef01eb930 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -461,6 +461,8 @@ function MedicationResults({ // "Exact clinical fit" rows when every visible row says the same thing. const showMatchBadge = useMemo(() => new Set(rows.map((row) => row.result.match)).size > 1, [rows]); const activeFilterLabel = medicationResultFilters.find((filter) => filter.id === activeFilter)?.label ?? "filtered"; + const initialCatalogLoading = catalog.loading && !catalog.data; + const catalogRefetching = catalog.loading && Boolean(catalog.data); return (
@@ -468,7 +470,9 @@ function MedicationResults({ modeId="prescribing" query={query} matchCount={resultCount} - status={catalog.error ? "error" : catalog.loading ? "loading" : "ready"} + status={ + catalog.error ? "error" : initialCatalogLoading ? "loading" : catalogRefetching ? "refetching" : "ready" + } faultBody={catalog.error ?? undefined} filterLabel="Filter medication results" mobileControls={ @@ -491,13 +495,13 @@ function MedicationResults({ {/* The error branch moved into the band's fault panel, which carries the same message and announces it once. Loading copy stays here. */} - {catalog.loading ? ( + {initialCatalogLoading ? (

Loading medication catalogue…

) : null} - {!catalog.loading && !catalog.error && resultCount === 0 ? ( + {!initialCatalogLoading && !catalog.error && resultCount === 0 ? ( totalAvailable > 0 ? (
0 ? ( + {!initialCatalogLoading && !catalog.error && resultCount > 0 ? (
Medication diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index f650873b9..e6f4d8bca 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; +import { authSessionFingerprint } from "@/lib/auth-request-lifecycle"; import type { DifferentialDetailContext } from "@/lib/differential-detail"; import type { DifferentialSourceStatus, DifferentialValidationStatus } from "@/lib/differential-records"; import type { DifferentialPresentationWorkflow, DifferentialRecord } from "@/lib/differentials"; @@ -13,7 +14,7 @@ export type DifferentialSearchMatches = { }; export type DifferentialSearchState = { - status: "loading" | "ready" | "unauthorized" | "error"; + status: "loading" | "refetching" | "ready" | "unauthorized" | "error"; matches: DifferentialSearchMatches; demoMode: boolean; }; @@ -107,9 +108,9 @@ export function clearDifferentialSearchCacheForTests() { export type DifferentialSearchResult = DifferentialSearchState & { refetch: () => void }; export function useDifferentialSearch(query: string): DifferentialSearchResult { - const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); + const { authorizationHeader, markSessionExpired, session, status: authStatus } = useAuthSession(); const requestKey = query.trim().toLowerCase(); - const authSignature = JSON.stringify(authorizationHeader ?? {}); + const authSignature = authSessionFingerprint(authStatus, session?.user.id); const cacheKey = requestKey ? differentialCacheKey(requestKey, authSignature) : null; const cached = cacheKey ? peekDifferentialCache(cacheKey) : undefined; @@ -127,11 +128,22 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { // Auth must clear prior identity's matches immediately (parity with useUniversalSearch). const [lastRequestKey, setLastRequestKey] = useState(requestKey); const [lastAuthSignature, setLastAuthSignature] = useState(authSignature); - if (lastRequestKey !== requestKey || lastAuthSignature !== authSignature) { + const [lastAuthorizationHeader, setLastAuthorizationHeader] = useState(authorizationHeader); + const requestChanged = lastRequestKey !== requestKey; + const identityChanged = lastAuthSignature !== authSignature; + const credentialChanged = lastAuthorizationHeader !== authorizationHeader; + if (requestChanged || identityChanged || credentialChanged) { setLastRequestKey(requestKey); setLastAuthSignature(authSignature); - if (!requestKey) { - setState({ status: "ready", matches: emptyDifferentialMatches, demoMode: false }); + setLastAuthorizationHeader(authorizationHeader); + if (!requestKey || identityChanged) { + setState( + requestKey + ? { status: "loading", matches: emptyDifferentialMatches, demoMode: false } + : { status: "ready", matches: emptyDifferentialMatches, demoMode: false }, + ); + } else if (!requestChanged && credentialChanged && state.status === "ready") { + setState({ ...state, status: "refetching" }); } else if (cached) { setState({ status: "ready", matches: cached.matches, demoMode: cached.demoMode }); } else { @@ -145,14 +157,18 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { const [retryAttempt, setRetryAttempt] = useState(0); const refetch = useCallback(() => { if (!requestKey) return; - setState({ status: "loading", matches: emptyDifferentialMatches, demoMode: false }); + setState((current) => + current.status === "ready" || current.status === "refetching" + ? { ...current, status: "refetching" } + : { status: "loading", matches: emptyDifferentialMatches, demoMode: false }, + ); setRetryAttempt((attempt) => attempt + 1); }, [requestKey]); useEffect(() => { if (!requestKey || !cacheKey) return undefined; - if (peekDifferentialCache(cacheKey)) { + if (peekDifferentialCache(cacheKey) && state.status !== "refetching") { touchDifferentialCache(cacheKey); return undefined; } @@ -212,12 +228,12 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { window.clearTimeout(timer); controller.abort(); }; - }, [requestKey, cacheKey, authStatus, authorizationHeader, markSessionExpired, retryAttempt]); + }, [requestKey, cacheKey, authStatus, authorizationHeader, markSessionExpired, retryAttempt, state.status]); if (!requestKey) { return { status: "ready", matches: emptyDifferentialMatches, demoMode: false, refetch }; } - if (cached && state.status !== "unauthorized" && state.status !== "error") { + if (cached && state.status !== "unauthorized" && state.status !== "error" && state.status !== "refetching") { return { status: "ready", matches: cached.matches, demoMode: cached.demoMode, refetch }; } return { ...state, refetch }; diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 746f26a18..66ab194e8 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; +import { authSessionFingerprint } from "@/lib/auth-request-lifecycle"; import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; import { useAuthSession } from "@/lib/supabase/client"; @@ -61,23 +62,31 @@ export function useMedicationCatalog( const trimmed = query?.trim() ?? ""; // Auth-aware like use-registry-records: without the header an authenticated owner was // silently served the public fixture catalogue instead of their seeded records. - const { authorizationHeader } = useAuthSession(); + const { authorizationHeader, session, status: authStatus } = useAuthSession(); + const authIdentity = authSessionFingerprint(authStatus, session?.user.id); const [prevQuery, setPrevQuery] = useState(trimmed); const [prevEnabled, setPrevEnabled] = useState(enabled); + const [prevAuthIdentity, setPrevAuthIdentity] = useState(authIdentity); + const [prevAuthorizationHeader, setPrevAuthorizationHeader] = useState(authorizationHeader); const [state, setState] = useState>({ data: null, loading: enabled, error: null, }); - if (trimmed !== prevQuery || enabled !== prevEnabled) { + const resourceChanged = trimmed !== prevQuery || enabled !== prevEnabled; + const identityChanged = authIdentity !== prevAuthIdentity; + const credentialChanged = authorizationHeader !== prevAuthorizationHeader; + if (resourceChanged || identityChanged || credentialChanged) { setPrevQuery(trimmed); setPrevEnabled(enabled); - setState({ - data: null, - loading: enabled, - error: null, - }); + setPrevAuthIdentity(authIdentity); + setPrevAuthorizationHeader(authorizationHeader); + setState((current) => + !resourceChanged && !identityChanged && credentialChanged && current.data + ? { ...current, loading: true, error: null } + : { data: null, loading: enabled, error: null }, + ); } useEffect(() => { diff --git a/src/components/document-viewer/source-panels.tsx b/src/components/document-viewer/source-panels.tsx index b5bc77fdb..47144e958 100644 --- a/src/components/document-viewer/source-panels.tsx +++ b/src/components/document-viewer/source-panels.tsx @@ -14,7 +14,16 @@ import { Target, type LucideIcon, } from "lucide-react"; -import { memo, useEffect, useMemo, useState, type MouseEventHandler, type ReactNode } from "react"; +import { + memo, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type MouseEventHandler, + type ReactNode, +} from "react"; import { AccessibleTable, hasRenderableAccessibleTable } from "@/components/AccessibleTable"; import { SignedImage } from "@/components/clinical-dashboard/signed-image"; import { SafeBoldText } from "@/components/SafeBoldText"; @@ -42,6 +51,7 @@ import { } from "@/lib/source-text-sanitizer"; import { smartEvidenceTags } from "@/lib/evidence-tags"; import { flowIndexedText, parseIndexedSourceText } from "@/lib/indexed-source-formatting"; +import { resolveScrollBehavior } from "@/lib/scroll-behavior"; import type { ClinicalDocumentSummaryProfile, DocumentSummaryProfileItem } from "@/lib/types"; import type { FormattedDocumentSummary as FormattedDocumentSummaryModel } from "@/lib/document-summary-formatting"; import type { ChunkRow, DocumentSearchResult, ImageRow, PageRow, TableFactRow } from "./types"; @@ -660,6 +670,17 @@ function highlightTermsFor(terms: string[], fallback: string) { return Array.from(new Set((terms.length ? terms : fallbackTerms).map((term) => term.toLowerCase()).filter(Boolean))); } +function sourcePassageTeaser(value: string) { + return flowIndexedText(value).replace(/\s+/g, " ").trim(); +} + +function openNestedSourceDisclosure(container: HTMLDetailsElement | null, disclosure: HTMLDetailsElement) { + container?.querySelectorAll("[data-source-nested-disclosure]").forEach((peer) => { + if (peer !== disclosure) peer.open = false; + }); + disclosure.open = true; +} + function HighlightedSearchText({ text, terms }: { text: string; terms: string[] }) { if (!text.trim() || terms.length === 0) return <>{text}; const escaped = terms @@ -768,6 +789,15 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ .map(([page, count]) => `p${page}: ${count}`) .join(" · "); const selectedPageText = selectedPage ? sourceTextForIndexedPage(selectedPage.text) : ""; + const topLevelDisclosureRef = useRef(null); + const activeHitId = activeHit?.id; + const autoOpenTargetId = activeHitId ?? selectedChunkId; + const autoOpenDriver = activeHitId ? `search:${activeHitId}` : selectedChunkId ? `citation:${selectedChunkId}` : null; + const targetAvailability = `${searchingDocument ? "loading" : "ready"}:${visibleChunks + .map((chunk) => chunk.id) + .join(",")}`; + const previousAutoOpenDriverRef = useRef(null); + const manualClosedDriverRef = useRef(null); const [compactOpen, setCompactOpen] = useState(Boolean(selectedChunkId)); // Deep-linked chunks and in-document search must keep the panel revealed even // when the exclusive accordion briefly closes it (section jumps / sibling @@ -780,17 +810,47 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ } useEffect(() => { - if (!activeHit) return; - document.getElementById(`${idPrefix}-${activeHit.id}`)?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, [activeHit, idPrefix]); + if (previousAutoOpenDriverRef.current !== autoOpenDriver) { + previousAutoOpenDriverRef.current = autoOpenDriver; + manualClosedDriverRef.current = null; + } + if (!autoOpenDriver || !autoOpenTargetId || manualClosedDriverRef.current === autoOpenDriver) return; + const targetDisclosure = document.getElementById(`${idPrefix}-${autoOpenTargetId}`); + if (!(targetDisclosure instanceof HTMLDetailsElement)) return; + if (topLevelDisclosureRef.current) topLevelDisclosureRef.current.open = true; + const wasOpen = targetDisclosure.open; + openNestedSourceDisclosure(topLevelDisclosureRef.current, targetDisclosure); + if (!wasOpen) targetDisclosure.scrollIntoView({ block: "nearest", behavior: resolveScrollBehavior() }); + }, [autoOpenDriver, autoOpenTargetId, idPrefix, targetAvailability]); function moveHit(delta: number) { if (visibleChunks.length === 0) return; setActiveHitIndex((current) => (current + delta + visibleChunks.length) % visibleChunks.length); } + function handleNestedSummaryClick(event: ReactMouseEvent) { + const disclosure = event.currentTarget.parentElement; + if (!(disclosure instanceof HTMLDetailsElement)) return; + event.preventDefault(); + const isDriverDisclosure = disclosure.id === `${idPrefix}-${autoOpenTargetId}`; + if (disclosure.open) { + disclosure.open = false; + if (isDriverDisclosure && autoOpenDriver) manualClosedDriverRef.current = autoOpenDriver; + return; + } + if (isDriverDisclosure && autoOpenDriver) manualClosedDriverRef.current = null; + if (!isDriverDisclosure && autoOpenDriver && autoOpenTargetId) { + const driverDisclosure = document.getElementById(`${idPrefix}-${autoOpenTargetId}`); + if (driverDisclosure instanceof HTMLDetailsElement && driverDisclosure.open) { + manualClosedDriverRef.current = autoOpenDriver; + } + } + openNestedSourceDisclosure(topLevelDisclosureRef.current, disclosure); + } + return (
{loading ? ( - ) : selectedPage ? ( - ) : ( -

No extracted text has been indexed for this page yet.

+
+
+ + + Full extracted page text + + Page {selectedPage?.page_number ?? "n/a"} + + + +
+ {selectedPage ? ( + + ) : ( +

+ No extracted text has been indexed for this page yet. +

+ )} +
+
+
)}
@@ -905,81 +995,104 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ ) : documentSearchError ? null : visibleChunks.length === 0 ? (

No indexed passage matched that search.

) : ( - visibleChunks.map((chunk) => ( -
-
-

- {selectedChunkId === chunk.id - ? "Highlighted quoted passage" - : activeHit?.id === chunk.id - ? "Active search hit" - : "Source passage"} -

-

- Page {chunk.page_number ?? "n/a"} · chunk {chunk.chunk_index} - {chunk.serverRanked ? " · full-document search" : ""} -

- {chunk.section_heading && ( -

{chunk.section_heading}

+ visibleChunks.map((chunk) => { + const selected = selectedChunkId === chunk.id; + const active = activeHit?.id === chunk.id; + const status = selected + ? "Highlighted quoted passage" + : active + ? "Active search hit" + : "Source passage"; + const teaser = sourcePassageTeaser(chunk.displayContent); + return ( +
- {chunk.matchedTerms.slice(0, 5).map((term) => ( - - {term} + > + + + + {status} + + + Page {chunk.page_number ?? "n/a"} · chunk {chunk.chunk_index} + {chunk.serverRanked ? " · full-document search" : ""} + + {chunk.section_heading ? ( + + {chunk.section_heading} - ))} -
- ) : null} -
-
-

- Excerpt -

- {normalizedSearch ? ( -

- -

- ) : ( - + {teaser || "No displayable clinical text was available for this indexed passage."} + + +
- - )) + +
+ {chunk.matchedTerms.length ? ( +
+ {chunk.matchedTerms.slice(0, 5).map((term) => ( + + {term} + + ))} +
+ ) : null} +

+ Excerpt +

+ {normalizedSearch ? ( +

+ +

+ ) : ( + + )} +
+
+ ); + }) )}
diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 55769e80a..e09a70f4d 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -85,7 +85,7 @@ export function FormsHomePage({ defaultFormSlug = null }: { defaultFormSlug?: st const taskCards = buildTaskCards(defaultFormSlug); const registry = useRegistryRecords("form"); const verifiedCount = countVerifiedRegistryRecords(registry); - const registryReady = registry.status === "ready"; + const registryReady = registry.status === "ready" || registry.status === "refetching"; const hasRegistryRecords = registryReady && registry.total > 0; const registryNotice = registry.status === "loading" ? ( diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 8826eaaa8..d727dbd4c 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -569,7 +569,7 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { const [sortValue, setSortValue] = useResultSort(); const command = useSearchCommand(); const registry = useRegistryRecords("form"); - const registryReady = registry.status === "ready"; + const registryReady = registry.status === "ready" || registry.status === "refetching"; const [refineOpen, setRefineOpen] = useState(false); const refinePanelId = useId(); const deferredQuery = useDeferredValue(query); @@ -606,9 +606,11 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { ? "unauthorized" : registry.status === "ready" ? "ready" - : registry.status === "loading" - ? "loading" - : "error" + : registry.status === "refetching" + ? "refetching" + : registry.status === "loading" + ? "loading" + : "error" } faultTitle={registry.status === "unauthorized" ? "Session expired" : "Could not load forms"} faultBody={ diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 1f41d1ec2..3ea6d5800 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -86,7 +86,7 @@ export function ServicesHomePage({ defaultServiceSlug = null }: { defaultService const taskCards = buildTaskCards(defaultServiceSlug); const registry = useRegistryRecords("service"); const verifiedCount = countVerifiedRegistryRecords(registry); - const registryReady = registry.status === "ready"; + const registryReady = registry.status === "ready" || registry.status === "refetching"; const hasRegistryRecords = registryReady && registry.total > 0; const registryNotice = registry.status === "loading" ? ( diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index fd4865775..1a536f22f 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -559,14 +559,12 @@ export function ServicesNavigatorPage() { const deferredQuery = useDeferredValue(query); const registry = useRegistryRecords("service"); const registryLoading = registry.status === "loading"; + const registryReady = registry.status === "ready" || registry.status === "refetching"; // Demo mode is served by the registry API as status "ready" with fixture // records, so unauthorized/error must not silently fall back to fixtures — // the home and detail pages surface the same conditions as notices. const registryBlocked = registry.status === "unauthorized" || registry.status === "error"; - const searchableRecords = useMemo( - () => (registry.status === "ready" ? registry.records : []), - [registry.records, registry.status], - ); + const searchableRecords = useMemo(() => (registryReady ? registry.records : []), [registry.records, registryReady]); const matches = useMemo(() => { // Cleared live query should restore the full catalogue immediately, even if // deferredQuery still holds the previous term for a frame. @@ -642,7 +640,9 @@ export function ServicesNavigatorPage() { : "error" : registryLoading ? "loading" - : "ready" + : registry.status === "refetching" + ? "refetching" + : "ready" } faultTitle={registry.status === "unauthorized" ? "Session expired" : "Could not load services"} faultBody={ diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts index 85bb03624..c0e6c0bd0 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -4,9 +4,10 @@ import { useCallback, useEffect, useState } from "react"; import type { RegistryRecordKind, RegistrySourceStatus, RegistryValidationStatus } from "@/lib/registry-records"; import type { ServiceRecord } from "@/lib/services"; +import { authSessionFingerprint } from "@/lib/auth-request-lifecycle"; import { useAuthSession } from "@/lib/supabase/client"; -export type RegistryRequestStatus = "loading" | "ready" | "unauthorized" | "not_found" | "error"; +export type RegistryRequestStatus = "loading" | "refetching" | "ready" | "unauthorized" | "not_found" | "error"; export type RegistryRecordsState = { status: RegistryRequestStatus; @@ -74,15 +75,40 @@ export function useRegistryRecords( options: { enabled?: boolean } = {}, ): RegistryRecordsResult { const enabled = options.enabled ?? true; - const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); + const { authorizationHeader, markSessionExpired, session, status: authStatus } = useAuthSession(); + const authIdentity = authSessionFingerprint(authStatus, session?.user.id); const [state, setState] = useState(recordsState("loading", kind)); const [attempt, setAttempt] = useState(0); + const [lastRequestIdentity, setLastRequestIdentity] = useState({ authIdentity, authorizationHeader, enabled, kind }); + + const resourceChanged = lastRequestIdentity.kind !== kind || lastRequestIdentity.enabled !== enabled; + const identityChanged = lastRequestIdentity.authIdentity !== authIdentity; + const credentialChanged = lastRequestIdentity.authorizationHeader !== authorizationHeader; + if (resourceChanged || identityChanged || credentialChanged) { + setLastRequestIdentity({ authIdentity, authorizationHeader, enabled, kind }); + setState((current) => { + if ( + !resourceChanged && + !identityChanged && + credentialChanged && + current.kind === kind && + (current.status === "ready" || current.status === "refetching") + ) { + return { ...current, status: "refetching" }; + } + return recordsState("loading", kind); + }); + } const visibleState: RegistryRecordsState = state.kind === kind ? state : recordsState("loading", kind); - // Re-run the request from a Retry control: reset to loading and bump a counter - // the effect depends on. Recovery otherwise required a full page reload. + // A same-identity refresh keeps already-authorized rows visible. Resource or + // identity changes clear synchronously above, before another owner can paint. const refetch = useCallback(() => { - setState(recordsState("loading", kind)); + setState((current) => + current.kind === kind && (current.status === "ready" || current.status === "refetching") + ? { ...current, status: "refetching" } + : recordsState("loading", kind), + ); setAttempt((value) => value + 1); }, [kind]); diff --git a/tests/catalog-refetch-privacy.dom.test.tsx b/tests/catalog-refetch-privacy.dom.test.tsx new file mode 100644 index 000000000..4818b01ad --- /dev/null +++ b/tests/catalog-refetch-privacy.dom.test.tsx @@ -0,0 +1,95 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; +import { useRegistryRecords } from "@/lib/use-registry-records"; + +const authSession = vi.hoisted(() => ({ + authorizationHeader: { Authorization: "Bearer user-a-token" }, + markSessionExpired: vi.fn(), + session: { user: { id: "user-a" } }, + status: "authenticated" as const, +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +let fetchMock: ReturnType>; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +beforeEach(() => { + authSession.authorizationHeader = { Authorization: "Bearer user-a-token" }; + authSession.markSessionExpired.mockReset(); + authSession.session = { user: { id: "user-a" } }; + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("auth-backed catalogue background refresh", () => { + it("preserves registry rows for a same-user refresh and clears them immediately on identity change", async () => { + const record = { slug: "cmht", title: "Community Mental Health Team" }; + fetchMock.mockResolvedValueOnce(jsonResponse({ records: [record], total: 1, governance: {} })); + + const { result, rerender } = renderHook(() => useRegistryRecords("service")); + await flushMicrotasks(); + expect(result.current).toMatchObject({ status: "ready", records: [record], total: 1 }); + + let resolveRefresh!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveRefresh = resolve))); + authSession.authorizationHeader = { Authorization: "Bearer user-a-refreshed" }; + rerender(); + expect(result.current).toMatchObject({ status: "refetching", records: [record], total: 1 }); + + await act(async () => resolveRefresh(jsonResponse({ records: [record], total: 1, governance: {} }))); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + + fetchMock.mockImplementationOnce(() => new Promise(() => undefined)); + authSession.authorizationHeader = { Authorization: "Bearer user-b-token" }; + authSession.session = { user: { id: "user-b" } }; + rerender(); + expect(result.current).toMatchObject({ status: "loading", records: [], total: 0 }); + }); + + it("preserves medication data only while query and identity are unchanged", async () => { + vi.useFakeTimers(); + const payload = { records: [{ slug: "clozapine", name: "Clozapine" }], total: 1 }; + fetchMock.mockResolvedValueOnce(jsonResponse(payload)); + + const { result, rerender } = renderHook(() => useMedicationCatalog("clozapine", { debounceMs: 0 })); + await act(async () => vi.runOnlyPendingTimersAsync()); + await flushMicrotasks(); + expect(result.current).toMatchObject({ data: payload, loading: false, error: null }); + + fetchMock.mockImplementationOnce(() => new Promise(() => undefined)); + authSession.authorizationHeader = { Authorization: "Bearer user-a-refreshed" }; + rerender(); + expect(result.current).toMatchObject({ data: payload, loading: true, error: null }); + + authSession.authorizationHeader = { Authorization: "Bearer user-b-token" }; + authSession.session = { user: { id: "user-b" } }; + rerender(); + expect(result.current).toMatchObject({ data: null, loading: true, error: null }); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index aedb59cea..ad23bf639 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3551,12 +3551,21 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect( page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), ).toBeVisible(); + await expect(page.locator("#source-text")).toHaveJSProperty("open", true); + await expect( + page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), + ).toHaveJSProperty("open", true); const sourceSearch = page.getByLabel("Search within indexed source text").last(); await sourceSearch.fill("safety plan include"); const desktopTextPanel = page.getByTestId("source-chunk-indexed-text-panel"); await expect(desktopTextPanel.getByText("Hit 1 of 2").first()).toBeVisible(); await expect(desktopTextPanel.locator("mark").filter({ hasText: "safety" }).first()).toBeVisible(); + const initialActiveHit = desktopTextPanel.locator('details[data-source-active-hit="true"]'); + await expect(initialActiveHit).toHaveJSProperty("open", true); + const initialActiveHitId = await initialActiveHit.getAttribute("data-source-chunk-id"); + expect(initialActiveHitId).toBeTruthy(); + const initialActiveDisclosure = desktopTextPanel.locator(`details[data-source-chunk-id="${initialActiveHitId}"]`); const previousHit = desktopTextPanel.getByRole("button", { name: "Previous document search hit" }); const nextHit = desktopTextPanel.getByRole("button", { name: "Next document search hit" }); await expect(previousHit).toHaveAttribute("title", "Previous document search hit"); @@ -3565,6 +3574,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(nextHit).toHaveText(""); await nextHit.click(); await expect(desktopTextPanel.getByText("Hit 2 of 2")).toBeVisible(); + const nextActiveHit = desktopTextPanel.locator('details[data-source-active-hit="true"]'); + await expect(nextActiveHit).toHaveJSProperty("open", true); + await expect(initialActiveDisclosure).toHaveJSProperty("open", false); await expectNoPageHorizontalOverflow(page); }); @@ -3803,6 +3815,36 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("document source text accordion stays compact at 320, 390, and 1280 pixels", async ({ page }) => { + await mockDemoApi(page); + for (const width of [320, 390, 1280]) { + await page.setViewportSize({ width, height: 900 }); + await gotoApp(page, "/documents/11111111-1111-4111-8111-111111111111?page=1"); + await expect(page.getByRole("heading", { level: 1, name: "Synthetic lithium monitoring protocol" })).toBeVisible({ + timeout: 30_000, + }); + + const indexedText = page.locator("#source-text"); + const pageText = indexedText.getByTestId("indexed-page-text-disclosure"); + const passages = indexedText.locator("details[data-source-chunk-id]"); + await expect(indexedText).toHaveJSProperty("open", false); + await expect(passages).toHaveCount(2); + for (const disclosure of [pageText, passages.nth(0), passages.nth(1)]) { + await expect(disclosure).toHaveJSProperty("open", false); + } + + await indexedText.locator("summary").first().click(); + await expect(indexedText).toHaveJSProperty("open", true); + await passages.nth(0).locator("summary").click(); + await expect(passages.nth(0)).toHaveJSProperty("open", true); + await expect(passages.nth(1)).toHaveJSProperty("open", false); + await passages.nth(1).locator("summary").click(); + await expect(passages.nth(1)).toHaveJSProperty("open", true); + await expect(passages.nth(0)).toHaveJSProperty("open", false); + await expectNoPageHorizontalOverflow(page); + } + }); + test("document viewer content disclosures are naturally closed and mutually exclusive by default", async ({ page, }) => { @@ -3830,6 +3872,8 @@ test.describe("Clinical KB UI smoke coverage", () => { const summary = page.getByTestId("high-yield-summary"); const images = page.locator("#source-images"); const indexingDetails = page.getByTestId("indexing-details"); + const pageText = indexedText.getByTestId("indexed-page-text-disclosure"); + const passages = indexedText.locator("details[data-source-chunk-id]"); const sectionTrigger = page.getByTestId("document-section-trigger"); const clickSectionNav = async (label: RegExp) => { await revealPhoneHeaderControl(page, sectionTrigger); @@ -3851,6 +3895,10 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(indexedText).toBeVisible(); await expect(indexedText).toHaveJSProperty("open", false); + await expect(passages).toHaveCount(2); + for (const disclosure of [pageText, passages.nth(0), passages.nth(1)]) { + await expect(disclosure).toHaveJSProperty("open", false); + } await sectionTrigger.click(); const densitySheet = page.getByTestId("document-section-sheet"); const densityToggle = densitySheet.getByTestId("document-view-density-toggle"); @@ -3873,6 +3921,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await openImagesDisclosure(); await page.evaluate(() => window.dispatchEvent(new Event("beforeprint"))); await expect(indexedText).toHaveJSProperty("open", true); + await expect(pageText).toHaveJSProperty("open", true); + await expect(passages.nth(0)).toHaveJSProperty("open", true); + await expect(passages.nth(1)).toHaveJSProperty("open", true); await page.emulateMedia({ media: "print" }); await expect(summaryContent).toBeVisible(); await page.emulateMedia({ media: "screen" }); @@ -3880,11 +3931,19 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(summaryContent).toBeHidden(); await expect(images).toHaveJSProperty("open", true); await expect(indexedText).toHaveJSProperty("open", false); + await expect(pageText).toHaveJSProperty("open", false); + await expect(passages.nth(0)).toHaveJSProperty("open", false); + await expect(passages.nth(1)).toHaveJSProperty("open", false); await clickSectionNav(/Indexed source text/); await expect(indexedText).toBeInViewport(); await expect(indexedText).toHaveJSProperty("open", true); await expect(images).toHaveJSProperty("open", false); + await passages.nth(0).locator("summary").click(); + await expect(passages.nth(0)).toHaveJSProperty("open", true); + await passages.nth(1).locator("summary").click(); + await expect(passages.nth(1)).toHaveJSProperty("open", true); + await expect(passages.nth(0)).toHaveJSProperty("open", false); await clickSectionNav(/High-yield summary/); await expect(summary).toHaveJSProperty("open", true); diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx index c60f6d7c1..81d2cca33 100644 --- a/tests/use-differential-search.dom.test.tsx +++ b/tests/use-differential-search.dom.test.tsx @@ -9,6 +9,7 @@ import { const authSession = vi.hoisted(() => ({ authorizationHeader: { Authorization: "Bearer differential-search-test" }, markSessionExpired: vi.fn(), + session: { user: { id: "user-a" } }, status: "authenticated" as const, })); @@ -23,6 +24,7 @@ beforeEach(() => { clearDifferentialSearchCacheForTests(); authSession.markSessionExpired.mockReset(); authSession.authorizationHeader = { Authorization: "Bearer differential-search-test" }; + authSession.session = { user: { id: "user-a" } }; authSession.status = "authenticated"; fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -158,6 +160,7 @@ describe("useDifferentialSearch debounce/abort/cache", () => { expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); authSession.authorizationHeader = { Authorization: "Bearer other-user" }; + authSession.session = { user: { id: "user-b" } }; rerender(); expect(result.current.status).toBe("loading"); expect(result.current.matches).toEqual({ diagnoses: [], presentations: [] }); @@ -167,6 +170,51 @@ describe("useDifferentialSearch debounce/abort/cache", () => { expect(result.current.status).toBe("ready"); }); + it("keeps same-query matches visible while a same-user token refresh refetches", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + fetchMock.mockImplementation((input) => + Promise.resolve( + jsonResponse( + String(input).includes("kind=diagnosis") + ? { matches: [diagnosisMatch], demoMode: false } + : { matches: [], demoMode: false }, + ), + ), + ); + + const { result, rerender } = renderHook(() => useDifferentialSearch("depression")); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + + let resolveDiagnosis!: (response: Response) => void; + let resolvePresentation!: (response: Response) => void; + fetchMock.mockImplementation( + (input) => + new Promise((resolve) => { + if (String(input).includes("kind=diagnosis")) resolveDiagnosis = resolve; + else resolvePresentation = resolve; + }), + ); + + authSession.authorizationHeader = { Authorization: "Bearer refreshed-same-user" }; + rerender(); + expect(result.current.status).toBe("refetching"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + + await advanceDebounce(); + await act(async () => { + resolveDiagnosis(jsonResponse({ matches: [diagnosisMatch], demoMode: false })); + resolvePresentation(jsonResponse({ matches: [], demoMode: false })); + }); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + }); + it("clears the search LRU on 401 so prior authorized hits cannot resurface", async () => { const diagnosisMatch = { record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, From 1addcece5a2b7122c5898584830109f617421a3a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:35:46 +0800 Subject: [PATCH 02/11] fix: reject stale catalogue responses across identities --- .../use-differential-catalog.ts | 34 +++++++++-- .../use-medication-catalog.ts | 23 ++++++-- src/lib/use-registry-records.ts | 25 +++++++-- tests/catalog-refetch-privacy.dom.test.tsx | 30 ++++++++-- tests/use-differential-search.dom.test.tsx | 56 +++++++++++++++++++ 5 files changed, 145 insertions(+), 23 deletions(-) diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index e6f4d8bca..0296bfed2 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; -import { authSessionFingerprint } from "@/lib/auth-request-lifecycle"; +import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import type { DifferentialDetailContext } from "@/lib/differential-detail"; import type { DifferentialSourceStatus, DifferentialValidationStatus } from "@/lib/differential-records"; import type { DifferentialPresentationWorkflow, DifferentialRecord } from "@/lib/differentials"; @@ -129,6 +129,7 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { const [lastRequestKey, setLastRequestKey] = useState(requestKey); const [lastAuthSignature, setLastAuthSignature] = useState(authSignature); const [lastAuthorizationHeader, setLastAuthorizationHeader] = useState(authorizationHeader); + const [requestLifecycle] = useState(() => createAuthRequestLifecycle()); const requestChanged = lastRequestKey !== requestKey; const identityChanged = lastAuthSignature !== authSignature; const credentialChanged = lastAuthorizationHeader !== authorizationHeader; @@ -151,6 +152,10 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { } } + useLayoutEffect(() => { + requestLifecycle.invalidate(); + }, [authSignature, authorizationHeader, requestKey, requestLifecycle]); + // Retry bumps this so the fetch effect re-runs on an unchanged query. Without // it a Retry button is inert: the hook keys on query + auth identity, neither // of which changes when the reader asks to try again. @@ -174,6 +179,8 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { } const controller = new AbortController(); + const registration = requestLifecycle.register(controller); + const isCurrentRequest = () => requestLifecycle.isCurrent(registration.epoch); const timer = window.setTimeout(() => { const encoded = encodeURIComponent(requestKey); Promise.all([ @@ -187,7 +194,7 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { }), ]) .then(async ([diagnosisResponse, presentationResponse]) => { - if (controller.signal.aborted) return; + if (controller.signal.aborted || !isCurrentRequest()) return; if (diagnosisResponse.status === 401 || presentationResponse.status === 401) { if (authStatus === "loading") return; if (authStatus === "authenticated") markSessionExpired(); @@ -209,7 +216,7 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { matches?: DifferentialSearchMatches["presentations"]; demoMode?: boolean; }; - if (controller.signal.aborted) return; + if (controller.signal.aborted || !isCurrentRequest()) return; const matches: DifferentialSearchMatches = { diagnoses: diagnosisPayload.matches ?? [], presentations: presentationPayload.matches ?? [], @@ -219,7 +226,12 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { setState({ status: "ready", matches, demoMode }); }) .catch((error: unknown) => { - if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return; + if ( + controller.signal.aborted || + !isCurrentRequest() || + (error instanceof DOMException && error.name === "AbortError") + ) + return; setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false }); }); }, debounceMs); @@ -227,8 +239,18 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { return () => { window.clearTimeout(timer); controller.abort(); + registration.release(); }; - }, [requestKey, cacheKey, authStatus, authorizationHeader, markSessionExpired, retryAttempt, state.status]); + }, [ + requestKey, + cacheKey, + authStatus, + authorizationHeader, + markSessionExpired, + retryAttempt, + state.status, + requestLifecycle, + ]); if (!requestKey) { return { status: "ready", matches: emptyDifferentialMatches, demoMode: false, refetch }; diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 66ab194e8..6725959b1 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; -import { authSessionFingerprint } from "@/lib/auth-request-lifecycle"; +import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; import { useAuthSession } from "@/lib/supabase/client"; @@ -68,6 +68,7 @@ export function useMedicationCatalog( const [prevEnabled, setPrevEnabled] = useState(enabled); const [prevAuthIdentity, setPrevAuthIdentity] = useState(authIdentity); const [prevAuthorizationHeader, setPrevAuthorizationHeader] = useState(authorizationHeader); + const [requestLifecycle] = useState(() => createAuthRequestLifecycle()); const [state, setState] = useState>({ data: null, loading: enabled, @@ -89,9 +90,15 @@ export function useMedicationCatalog( ); } + useLayoutEffect(() => { + requestLifecycle.invalidate(); + }, [authIdentity, authorizationHeader, enabled, fields, requestLifecycle, trimmed]); + useEffect(() => { if (!enabled) return; const controller = new AbortController(); + const registration = requestLifecycle.register(controller); + const isCurrentRequest = () => requestLifecycle.isCurrent(registration.epoch); const params = new URLSearchParams(); if (trimmed) params.set("q", trimmed); if (fields) params.set("fields", fields); @@ -101,10 +108,15 @@ export function useMedicationCatalog( const timer = window.setTimeout(() => { fetchJson(url, authorizationHeader, controller.signal) .then((data) => { - if (!controller.signal.aborted) setState({ data, loading: false, error: null }); + if (!controller.signal.aborted && isCurrentRequest()) setState({ data, loading: false, error: null }); }) .catch((error) => { - if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return; + if ( + controller.signal.aborted || + !isCurrentRequest() || + (error instanceof DOMException && error.name === "AbortError") + ) + return; setState({ data: null, loading: false, @@ -116,8 +128,9 @@ export function useMedicationCatalog( return () => { window.clearTimeout(timer); controller.abort(); + registration.release(); }; - }, [trimmed, enabled, fields, debounceMs, authorizationHeader]); + }, [trimmed, enabled, fields, debounceMs, authorizationHeader, requestLifecycle]); return state; } diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts index c0e6c0bd0..d92321342 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -1,10 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import type { RegistryRecordKind, RegistrySourceStatus, RegistryValidationStatus } from "@/lib/registry-records"; import type { ServiceRecord } from "@/lib/services"; -import { authSessionFingerprint } from "@/lib/auth-request-lifecycle"; +import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import { useAuthSession } from "@/lib/supabase/client"; export type RegistryRequestStatus = "loading" | "refetching" | "ready" | "unauthorized" | "not_found" | "error"; @@ -80,6 +80,7 @@ export function useRegistryRecords( const [state, setState] = useState(recordsState("loading", kind)); const [attempt, setAttempt] = useState(0); const [lastRequestIdentity, setLastRequestIdentity] = useState({ authIdentity, authorizationHeader, enabled, kind }); + const [requestLifecycle] = useState(() => createAuthRequestLifecycle()); const resourceChanged = lastRequestIdentity.kind !== kind || lastRequestIdentity.enabled !== enabled; const identityChanged = lastRequestIdentity.authIdentity !== authIdentity; @@ -101,6 +102,12 @@ export function useRegistryRecords( } const visibleState: RegistryRecordsState = state.kind === kind ? state : recordsState("loading", kind); + // Abort prior-identity work during commit, before paint and before passive + // effects can start the replacement request. + useLayoutEffect(() => { + requestLifecycle.invalidate(); + }, [authIdentity, authorizationHeader, enabled, kind, requestLifecycle]); + // A same-identity refresh keeps already-authorized rows visible. Resource or // identity changes clear synchronously above, before another owner can paint. const refetch = useCallback(() => { @@ -115,9 +122,12 @@ export function useRegistryRecords( useEffect(() => { if (!enabled) return undefined; let active = true; - fetch(`/api/registry/records?kind=${kind}`, { headers: authorizationHeader }) + const controller = new AbortController(); + const registration = requestLifecycle.register(controller); + const isCurrentRequest = () => active && requestLifecycle.isCurrent(registration.epoch); + fetch(`/api/registry/records?kind=${kind}`, { headers: authorizationHeader, signal: controller.signal }) .then(async (response) => { - if (!active) return; + if (!isCurrentRequest()) return; if (response.status === 401) { // In real auth deployments the first request can race AuthProvider's // session load. Keep loading until the auth status changes and this @@ -142,6 +152,7 @@ export function useRegistryRecords( demoMode?: boolean; governance?: Record; }; + if (!isCurrentRequest()) return; const governance: Record = {}; for (const [slug, entry] of Object.entries(payload.governance ?? {})) { if (entry?.validationStatus) governance[slug] = entry.validationStatus; @@ -156,12 +167,14 @@ export function useRegistryRecords( ); }) .catch(() => { - if (active) setState(recordsState("error", kind)); + if (isCurrentRequest()) setState(recordsState("error", kind)); }); return () => { active = false; + controller.abort(); + registration.release(); }; - }, [enabled, kind, authStatus, authorizationHeader, markSessionExpired, attempt]); + }, [enabled, kind, authStatus, authorizationHeader, markSessionExpired, attempt, requestLifecycle]); return { ...visibleState, refetch }; } diff --git a/tests/catalog-refetch-privacy.dom.test.tsx b/tests/catalog-refetch-privacy.dom.test.tsx index 4818b01ad..ce9d38549 100644 --- a/tests/catalog-refetch-privacy.dom.test.tsx +++ b/tests/catalog-refetch-privacy.dom.test.tsx @@ -56,20 +56,25 @@ describe("auth-backed catalogue background refresh", () => { expect(result.current).toMatchObject({ status: "ready", records: [record], total: 1 }); let resolveRefresh!: (response: Response) => void; + let resolveNextIdentity!: (response: Response) => void; fetchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveRefresh = resolve))); + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveNextIdentity = resolve))); authSession.authorizationHeader = { Authorization: "Bearer user-a-refreshed" }; rerender(); expect(result.current).toMatchObject({ status: "refetching", records: [record], total: 1 }); - await act(async () => resolveRefresh(jsonResponse({ records: [record], total: 1, governance: {} }))); - await flushMicrotasks(); - expect(result.current.status).toBe("ready"); - - fetchMock.mockImplementationOnce(() => new Promise(() => undefined)); authSession.authorizationHeader = { Authorization: "Bearer user-b-token" }; authSession.session = { user: { id: "user-b" } }; rerender(); expect(result.current).toMatchObject({ status: "loading", records: [], total: 0 }); + + await act(async () => resolveRefresh(jsonResponse({ records: [record], total: 1, governance: {} }))); + await flushMicrotasks(); + expect(result.current).toMatchObject({ status: "loading", records: [], total: 0 }); + + await act(async () => resolveNextIdentity(jsonResponse({ records: [], total: 0, governance: {} }))); + await flushMicrotasks(); + expect(result.current).toMatchObject({ status: "ready", records: [], total: 0 }); }); it("preserves medication data only while query and identity are unchanged", async () => { @@ -82,14 +87,27 @@ describe("auth-backed catalogue background refresh", () => { await flushMicrotasks(); expect(result.current).toMatchObject({ data: payload, loading: false, error: null }); - fetchMock.mockImplementationOnce(() => new Promise(() => undefined)); + let resolveRefresh!: (response: Response) => void; + let resolveNextIdentity!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveRefresh = resolve))); + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveNextIdentity = resolve))); authSession.authorizationHeader = { Authorization: "Bearer user-a-refreshed" }; rerender(); expect(result.current).toMatchObject({ data: payload, loading: true, error: null }); + await act(async () => vi.runOnlyPendingTimersAsync()); authSession.authorizationHeader = { Authorization: "Bearer user-b-token" }; authSession.session = { user: { id: "user-b" } }; rerender(); expect(result.current).toMatchObject({ data: null, loading: true, error: null }); + await act(async () => vi.runOnlyPendingTimersAsync()); + + await act(async () => resolveRefresh(jsonResponse(payload))); + await flushMicrotasks(); + expect(result.current).toMatchObject({ data: null, loading: true, error: null }); + + await act(async () => resolveNextIdentity(jsonResponse({ records: [], total: 0 }))); + await flushMicrotasks(); + expect(result.current).toMatchObject({ data: { records: [], total: 0 }, loading: false, error: null }); }); }); diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx index 81d2cca33..890046c04 100644 --- a/tests/use-differential-search.dom.test.tsx +++ b/tests/use-differential-search.dom.test.tsx @@ -215,6 +215,62 @@ describe("useDifferentialSearch debounce/abort/cache", () => { expect(result.current.status).toBe("ready"); }); + it("ignores a late previous-user refresh after the auth identity changes", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + fetchMock.mockImplementation((input) => + Promise.resolve( + jsonResponse( + String(input).includes("kind=diagnosis") + ? { matches: [diagnosisMatch], demoMode: false } + : { matches: [], demoMode: false }, + ), + ), + ); + + const { result, rerender } = renderHook(() => useDifferentialSearch("depression")); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + + const pending: Array<(response: Response) => void> = []; + fetchMock.mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + authSession.authorizationHeader = { Authorization: "Bearer refreshed-same-user" }; + rerender(); + await advanceDebounce(); + expect(pending).toHaveLength(2); + + authSession.authorizationHeader = { Authorization: "Bearer user-b" }; + authSession.session = { user: { id: "user-b" } }; + rerender(); + expect(result.current).toMatchObject({ + status: "loading", + matches: { diagnoses: [], presentations: [] }, + }); + await advanceDebounce(); + expect(pending).toHaveLength(4); + + await act(async () => { + pending[0](jsonResponse({ matches: [diagnosisMatch], demoMode: false })); + pending[1](jsonResponse({ matches: [], demoMode: false })); + }); + await flushMicrotasks(); + expect(result.current).toMatchObject({ + status: "loading", + matches: { diagnoses: [], presentations: [] }, + }); + + await act(async () => { + pending[2](jsonResponse({ matches: [], demoMode: false })); + pending[3](jsonResponse({ matches: [], demoMode: false })); + }); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + }); + it("clears the search LRU on 401 so prior authorized hits cannot resurface", async () => { const diagnosisMatch = { record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, From d95563ec56cfb82bc45c9f32112cd92687af1c01 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:36:21 +0800 Subject: [PATCH 03/11] docs: record moderate batch review --- 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 9a81fcb72..347213919 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -206,3 +206,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | codex/docs-sync-automation | 1c80a938f27af80df323fb1d6063ef9077f55fdf | PR #1442 hosted gate-count follow-up | approved after static CI exposed and corrected verify:cheap count drift | gate manifest, docs inventory, format, diff pass | | 2026-07-30 | claude/latency-findings-impl-s8g01v | fd3753b4c8cc041889c39f74a0f763edc358c183 | PR #1459 final docs issue review | PASS - no P0-P2 findings; restores open issue 105 and records deletion-guard gap | outstanding-issues, ledger guard, docs links, diff-check | | 2026-07-30 | codex/docs-sync-automation | e1c514f289a864bc741841accae5352a419fbb59 | PR #1442 sync after PR #1459 | approved; issue-evidence correction and docs automation closures preserved | issues, ledger, inventory, gate manifest, diff pass | +| 2026-07-30 | codex/moderate-batch-20260730 | 1addcece5a2b7122c5898584830109f617421a3a | document accordion, auth-safe catalogue refetch, comparison contract, operator preflight | P1 late identity response race fixed; no remaining findings | verify:cheap static through owner-scope; lint; typecheck; full Vitest; Chromium UI; production-readiness | From e0d429f3375b15a96220f3da724c50ad566a1f27 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:24:19 +0800 Subject: [PATCH 04/11] docs: format merged issue ledger --- docs/outstanding-issues.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index b8b7ae656..5be242838 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -163,16 +163,16 @@ removed after current-main verification; it is not missing recommended work. Move resolved rows here with the resolution date and a one-line outcome. Keep them — do not delete. -| ID | Type | Summary | Outcome | Resolved | -| ---- | ----- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| #038 | rec | Consolidate shared comparison behavior | Resolved with `docs/comparison-behaviour.md`: one interaction contract now covers selection scope, zero/one/valid/over-limit states, phone entry, background refresh, identity changes, missing values, source context, sharing, and accessibility while explicitly leaving clinical fields, rankings, evidence weighting, and generated prose with each mode. No RAG or comparison-generation behavior changed. | 2026-07-30 | -| #065 | task | Complete the paused compact document source-text accordion | Resolved after reconciling only the missing behavior with current `DocumentViewer`: extracted page text and passages are compact nested disclosures; citation/search opens the active passage; manual peers are exclusive; print expands and restores state. Focused Chromium proof passed at 320, 390, and 1280 px with no overflow, plus deep-link/search and print restoration paths. The preserved historical branch was not changed. | 2026-07-31 | -| #092 | task | Refetch pulse deferred on auth-backed registries | Resolved with identity- and resource-keyed background refresh in registry, medication, and differential catalogues. Same-user credential refreshes retain authorised rows under `refetching`; query, mode, or user identity changes clear synchronously. Focused DOM tests pin same-user preservation and immediate user-change clearing, and existing result surfaces now expose the shared refetch pulse without replacing records with skeletons. | 2026-07-31 | -| #091 | issue | Results band could not express a partial-source failure | Resolved by PR #1480. Favourites and shared result bands now retain only the honest loaded count, visibly report unavailable sources, and expose a tested Retry recovery path without masking partial failures as ready. | 2026-07-30 | -| #128 | issue | Draft-to-ready alone did not retrigger required CI | Resolved by PR #1480. The CI pull-request trigger now includes ready_for_review alongside opened, synchronize, and reopened, with the workflow contract and exact-head Actions runs passing. | 2026-07-30 | -| #134 | issue | Ledger merge driver could be absent when install was skipped | Resolved by PR #1480. The branch-review-ledger guard now fails loudly when merge=ledger is declared but merge.ledger.driver is not configured, and its self-test covers the missing-driver case. | 2026-07-30 | -| #141 | issue | Production --clinical-accent-strong token was undefined | Resolved by PR #1480. The token is defined for light, dark, and forced-colors themes, documented in the permanent colour direction, and protected by focused design-token tests. | 2026-07-30 | -| #144 | issue | check:docs-index lacked repository-root directory coverage | Resolved by PR #1480. The checker now discovers tracked top-level directories, applies an explicit infrastructure/output allowlist, verifies both orientation maps, and reports all 49 covered roots/modules/routes plus schema tables. | 2026-07-30 | +| ID | Type | Summary | Outcome | Resolved | +| ---- | ----- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| #038 | rec | Consolidate shared comparison behavior | Resolved with `docs/comparison-behaviour.md`: one interaction contract now covers selection scope, zero/one/valid/over-limit states, phone entry, background refresh, identity changes, missing values, source context, sharing, and accessibility while explicitly leaving clinical fields, rankings, evidence weighting, and generated prose with each mode. No RAG or comparison-generation behavior changed. | 2026-07-30 | +| #065 | task | Complete the paused compact document source-text accordion | Resolved after reconciling only the missing behavior with current `DocumentViewer`: extracted page text and passages are compact nested disclosures; citation/search opens the active passage; manual peers are exclusive; print expands and restores state. Focused Chromium proof passed at 320, 390, and 1280 px with no overflow, plus deep-link/search and print restoration paths. The preserved historical branch was not changed. | 2026-07-31 | +| #092 | task | Refetch pulse deferred on auth-backed registries | Resolved with identity- and resource-keyed background refresh in registry, medication, and differential catalogues. Same-user credential refreshes retain authorised rows under `refetching`; query, mode, or user identity changes clear synchronously. Focused DOM tests pin same-user preservation and immediate user-change clearing, and existing result surfaces now expose the shared refetch pulse without replacing records with skeletons. | 2026-07-31 | +| #091 | issue | Results band could not express a partial-source failure | Resolved by PR #1480. Favourites and shared result bands now retain only the honest loaded count, visibly report unavailable sources, and expose a tested Retry recovery path without masking partial failures as ready. | 2026-07-30 | +| #128 | issue | Draft-to-ready alone did not retrigger required CI | Resolved by PR #1480. The CI pull-request trigger now includes ready_for_review alongside opened, synchronize, and reopened, with the workflow contract and exact-head Actions runs passing. | 2026-07-30 | +| #134 | issue | Ledger merge driver could be absent when install was skipped | Resolved by PR #1480. The branch-review-ledger guard now fails loudly when merge=ledger is declared but merge.ledger.driver is not configured, and its self-test covers the missing-driver case. | 2026-07-30 | +| #141 | issue | Production --clinical-accent-strong token was undefined | Resolved by PR #1480. The token is defined for light, dark, and forced-colors themes, documented in the permanent colour direction, and protected by focused design-token tests. | 2026-07-30 | +| #144 | issue | check:docs-index lacked repository-root directory coverage | Resolved by PR #1480. The checker now discovers tracked top-level directories, applies an explicit infrastructure/output allowlist, verifies both orientation maps, and reports all 49 covered roots/modules/routes plus schema tables. | 2026-07-30 | | ID | Type | Summary | Outcome | Resolved | | ---- | ----- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | From 6f20fdd854cc48c43c3c65e1b3d72e46c53c636b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 23:48:54 +0000 Subject: [PATCH 05/11] fix: retain catalog refetch across credential pulses Keep differential catalogs in refetching across back-to-back same-identity credential refreshes, include authIdentity in the medication fetch effect, align staging migration counts and setup guidance, and pin the regression with a DOM test. Co-authored-by: BigSimmo --- docs/outstanding-issues.md | 30 ++++------ docs/staging-setup.md | 14 +++-- .../use-differential-catalog.ts | 2 +- .../use-medication-catalog.ts | 2 +- tests/use-differential-search.dom.test.tsx | 60 +++++++++++++++++++ 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index f9a44c618..b5e2beb6b 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -59,7 +59,7 @@ removed after current-main verification; it is not missing recommended work. | 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. | +| 13 | `#056` | A2 | Operator — Supabase/Railway + Specialist | Next approved staging schema window | 2–4 hours | Reconcile the existing healthy, empty staging tier's 24-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 | Ranking done; only the raw-JSON cross-check remains | 1–2 hours | Ranking complete 2026-07-30 — `#147` owns CLS, `#117` owns the LCP outlier. What is left here is narrow: cross-check the emitted table against raw Lighthouse JSON artifact `8762211043` (30-day retention) and confirm the INP clause from CrUX field data, which Lighthouse cannot measure in lab conditions. Neither blocks `#147` or `#117`. **Stop:** do not re-dispatch the live workflow; CLS now reproduces offline for free. | @@ -70,18 +70,14 @@ removed after current-main verification; it is not missing recommended work. | 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; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. L4 ledger rotation shipped in #1418. 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` (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 a driven-browser session is available | 30–60 minutes | Decide how the ten `LoadingPanel` fallbacks can be observed at all, then verify or re-scope. A cold-load grep cannot see them — every lazy surface mounts behind interaction state, so nothing renders them on first paint — and a claimed cold-load verification was withdrawn 2026-07-30 after it turned out to be matching `ModeHomePageSkeleton`. Either drive the interaction in a browser and assert the surface's specific label, or record that they are unobservable on cold load. The preconnect half is already verified; do not redo it. **Stop:** do not close this on a cold-load grep. | -| 35 | `#126` | Optional | Standard — repository hygiene | Once per UTC calendar quarter, or when the live ledger grows large | 5–15 minutes | Run `npm run ledger:rotate -- --dry-run`, then `npm run ledger:rotate` if the preview looks right; commit the live+archive diff. Stop if dry-run shows unexpected mass moves or archive path collisions. | +| 24 | `#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. | +| 25 | `#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. | +| 26 | `#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. | +| 27 | `#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; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. L4 ledger rotation shipped in #1418. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | +| 28 | `#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. | +| 29 | `#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. | +| 30 | `#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. | +| 31 | `#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. | @@ -103,7 +99,7 @@ removed after current-main verification; it is not missing recommended work. | #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 | +| #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 24 repository migrations behind (ten earlier history holes plus fourteen after `20260719055623`). 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 | | #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 | Keep this recommendation open and measurement-gated. `build:analyze` still finds route-scoped catalogue modules: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), and `/formulation` ships `formulation-content.json` (~52 KB; client-side local search needs an index/full split or a search endpoint). The approved Lighthouse sample covered `/forms`, but `/specifiers` and `/formulation` remain unmeasured, so the precommitted `#017` rule does not permit archiving their payload work. Development-only `*-mockups.tsx` chunks are not an initial production bundle and production returns 404 for `/mockups/*`; do not restructure them without deploy-artifact or cold-start evidence. **Next:** collect route-specific LCP/CLS and CrUX INP evidence for `/specifiers` and `/formulation`, then close only the routes that meet every `#017` threshold. | session 2026-07-21 (`build:analyze`); PR #1470 review | 2026-07-21 | @@ -149,9 +145,9 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | ID | Type | Summary | Outcome | Resolved | | ---- | ----- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| #038 | rec | Consolidate shared comparison behavior | Resolved with `docs/comparison-behaviour.md`: one interaction contract now covers selection scope, zero/one/valid/over-limit states, phone entry, background refresh, identity changes, missing values, source context, sharing, and accessibility while explicitly leaving clinical fields, rankings, evidence weighting, and generated prose with each mode. No RAG or comparison-generation behavior changed. | 2026-07-30 | -| #065 | task | Complete the paused compact document source-text accordion | Resolved after reconciling only the missing behavior with current `DocumentViewer`: extracted page text and passages are compact nested disclosures; citation/search opens the active passage; manual peers are exclusive; print expands and restores state. Focused Chromium proof passed at 320, 390, and 1280 px with no overflow, plus deep-link/search and print restoration paths. The preserved historical branch was not changed. | 2026-07-31 | -| #092 | task | Refetch pulse deferred on auth-backed registries | Resolved with identity- and resource-keyed background refresh in registry, medication, and differential catalogues. Same-user credential refreshes retain authorised rows under `refetching`; query, mode, or user identity changes clear synchronously. Focused DOM tests pin same-user preservation and immediate user-change clearing, and existing result surfaces now expose the shared refetch pulse without replacing records with skeletons. | 2026-07-31 | +| #038 | rec | Consolidate shared comparison behavior | Supersedes the earlier 2026-07-30 closed-as-future-principle disposition. Resolved with `docs/comparison-behaviour.md`: one interaction contract now covers selection scope, zero/one/valid/over-limit states, phone entry, background refresh, identity changes, missing values, source context, sharing, and accessibility while explicitly leaving clinical fields, rankings, evidence weighting, and generated prose with each mode. No RAG or comparison-generation behavior changed. | 2026-07-30 | +| #065 | task | Complete the paused compact document source-text accordion | Resolved after reconciling only the missing behavior with current `DocumentViewer`: extracted page text and passages are compact nested disclosures; citation/search opens the active passage; manual peers are exclusive; print expands and restores state. Focused Chromium proof passed at 320, 390, and 1280 px with no overflow, plus deep-link/search and print restoration paths. The preserved historical branch was not changed. | 2026-07-30 | +| #092 | task | Refetch pulse deferred on auth-backed registries | Supersedes the earlier 2026-07-30 deferred/closed disposition that retained identity-clearing over a refetch pulse. Resolved with identity- and resource-keyed background refresh in registry, medication, and differential catalogues. Same-user credential refreshes retain authorised rows under `refetching`; query, mode, or user identity changes clear synchronously. Focused DOM tests pin same-user preservation and immediate user-change clearing, and existing result surfaces now expose the shared refetch pulse without replacing records with skeletons. | 2026-07-30 | | #153 | issue | Pre-commit hook aborted commits in worktrees lacking the sync script | Resolved 2026-07-31 by PR #1494. `core.hooksPath` is an absolute path to the primary checkout, so `.githooks/pre-commit` runs from every linked worktree, including ones whose branch predates the docs-sync tooling and so lacks `scripts/update-docs-inventory.mjs`; those commits died with `MODULE_NOT_FOUND`. PR #1442 had already tracked the hook and script onto `main`, leaving only the guard, which #1494 added to `main`'s committed hook: the inventory task drops itself when its script is missing, and the all-tasks-empty early exit is re-checked (without that, an empty `docs_to_check` makes the trailing diff match every modified file and fail the commit for unrelated reasons). Verified in an isolated repository where the script genuinely does not exist — deleting it from a real worktree does not exercise this path, because the mixed-inputs guard sees the unstaged deletion and fails first. `codex/docs-sync-automation-pr` is superseded and can be abandoned. | 2026-07-31 | | #105 | task | Verify the #017-exempt client latency wins in a browser | Resolved 2026-07-30. The preconnect/dns-prefetch half was already proved in SSR and the live DOM. The remaining fallback was verified in driven Chromium by switching the dashboard from Answer to Documents while delaying the exact document-search-results chunk by 1.6 s: role=status with aria-label=Loading document results appeared after 315 ms at 1440x900 and 123 ms at 390x844, then disappeared when the chunk executed. The phone pass used keyboard activation, reduced motion, and forced colors, retained focus on Mode Documents, and had zero horizontal overflow. | 2026-07-30 | | #127 | issue | Document-route phone header intermittently failed to hide | Resolved 2026-07-30 by PR #1427, which fixed the short/clamped drag mechanism. Its runway polling and delivered-travel assertions then passed across the four recorded post-fix runs and the later PR #1480 exact-head browser/PWA document-header journeys, with no recurrence. | 2026-07-30 | diff --git a/docs/staging-setup.md b/docs/staging-setup.md index c84a2aa7c..9516481f5 100644 --- a/docs/staging-setup.md +++ b/docs/staging-setup.md @@ -39,13 +39,17 @@ those vars are unset. ```bash supabase link --project-ref - supabase db push # applies supabase/migrations/* → matches schema.sql + # Unavailable until the divergent history is reconciled in an approved window: + # do not run a normal `supabase db push`. Preview the full reviewed chain first: + supabase db push --linked --include-all --dry-run + # Only after explicit approval for the complete 24-version chain: + # supabase db push --linked --include-all ``` - Preserve the repository migration versions exactly. Do not replay the missing chain through a - helper that records new timestamps, because that would make staging history diverge while - appearing current. If the staging database credential is unavailable, stop and retain the - migration gap as operator debt instead of substituting a different apply mechanism. + Preserve the repository migration versions exactly. Do not run a normal or partial push against + the current divergent history, and do not replay the missing chain through a helper that records + new timestamps. If the staging database credential is unavailable, stop and retain the migration + gap as operator debt instead of substituting a different apply mechanism. Then confirm health: `npm run check:indexing` (runs `search_schema_health()` over the hybrid RPCs) should report ok. diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index 0296bfed2..6f912089d 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -143,7 +143,7 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { ? { status: "loading", matches: emptyDifferentialMatches, demoMode: false } : { status: "ready", matches: emptyDifferentialMatches, demoMode: false }, ); - } else if (!requestChanged && credentialChanged && state.status === "ready") { + } else if (!requestChanged && credentialChanged && (state.status === "ready" || state.status === "refetching")) { setState({ ...state, status: "refetching" }); } else if (cached) { setState({ status: "ready", matches: cached.matches, demoMode: cached.demoMode }); diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 6725959b1..c5cd5aef0 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -130,7 +130,7 @@ export function useMedicationCatalog( controller.abort(); registration.release(); }; - }, [trimmed, enabled, fields, debounceMs, authorizationHeader, requestLifecycle]); + }, [trimmed, enabled, fields, debounceMs, authIdentity, authorizationHeader, requestLifecycle]); return state; } diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx index 890046c04..941822882 100644 --- a/tests/use-differential-search.dom.test.tsx +++ b/tests/use-differential-search.dom.test.tsx @@ -215,6 +215,66 @@ describe("useDifferentialSearch debounce/abort/cache", () => { expect(result.current.status).toBe("ready"); }); + it("keeps refetching across back-to-back same-identity credential refreshes", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + const refreshedMatch = { + record: { slug: "persistent-depressive-disorder", title: "Persistent depressive disorder" }, + score: 10, + reasons: ["title"], + }; + fetchMock.mockImplementation((input) => + Promise.resolve( + jsonResponse( + String(input).includes("kind=diagnosis") + ? { matches: [diagnosisMatch], demoMode: false } + : { matches: [], demoMode: false }, + ), + ), + ); + + const { result, rerender } = renderHook(() => useDifferentialSearch("depression")); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + + const pending: Array<(response: Response) => void> = []; + fetchMock.mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + + authSession.authorizationHeader = { Authorization: "Bearer refreshed-same-user" }; + rerender(); + expect(result.current.status).toBe("refetching"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + await advanceDebounce(); + expect(pending).toHaveLength(2); + + authSession.authorizationHeader = { Authorization: "Bearer refreshed-same-user-again" }; + rerender(); + expect(result.current.status).toBe("refetching"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + await advanceDebounce(); + expect(pending).toHaveLength(4); + + await act(async () => { + pending[0](jsonResponse({ matches: [diagnosisMatch], demoMode: false })); + pending[1](jsonResponse({ matches: [], demoMode: false })); + }); + await flushMicrotasks(); + expect(result.current.status).toBe("refetching"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + + await act(async () => { + pending[2](jsonResponse({ matches: [refreshedMatch], demoMode: false })); + pending[3](jsonResponse({ matches: [], demoMode: false })); + }); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + expect(result.current.matches.diagnoses).toEqual([refreshedMatch]); + }); + it("ignores a late previous-user refresh after the auth identity changes", async () => { const diagnosisMatch = { record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, From aa7eb993b83d36d8c5af70e908ab0576b130146b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 00:22:06 +0000 Subject: [PATCH 06/11] fix: revalidate differential cache after error and credential pulses Delete warm LRU entries on Retry and non-retaining credential refreshes, keep query+credential warm hits in refetching so the new Authorization header is revalidated, and pin both paths with DOM regression tests. Co-authored-by: BigSimmo --- .../use-differential-catalog.ts | 33 ++++-- tests/use-differential-search.dom.test.tsx | 100 ++++++++++++++++++ 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index 6f912089d..14a855ccb 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -143,8 +143,18 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { ? { status: "loading", matches: emptyDifferentialMatches, demoMode: false } : { status: "ready", matches: emptyDifferentialMatches, demoMode: false }, ); - } else if (!requestChanged && credentialChanged && (state.status === "ready" || state.status === "refetching")) { + } else if (credentialChanged && !requestChanged && (state.status === "ready" || state.status === "refetching")) { setState({ ...state, status: "refetching" }); + } else if (credentialChanged && !requestChanged) { + // Error/unauthorized/loading: drop the same-identity LRU entry so the + // render/effect cache short-circuits cannot paint stale ready matches + // without revalidating the new Authorization header. + if (cacheKey) differentialSearchCache.delete(cacheKey); + setState({ status: "loading", matches: emptyDifferentialMatches, demoMode: false }); + } else if (credentialChanged && cached) { + // Query changed in the same pulse as the credential: show the warm hit + // but stay in refetching so the new Authorization header is revalidated. + setState({ status: "refetching", matches: cached.matches, demoMode: cached.demoMode }); } else if (cached) { setState({ status: "ready", matches: cached.matches, demoMode: cached.demoMode }); } else { @@ -162,18 +172,25 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { const [retryAttempt, setRetryAttempt] = useState(0); const refetch = useCallback(() => { if (!requestKey) return; - setState((current) => - current.status === "ready" || current.status === "refetching" - ? { ...current, status: "refetching" } - : { status: "loading", matches: emptyDifferentialMatches, demoMode: false }, - ); + setState((current) => { + if (current.status === "ready" || current.status === "refetching") { + return { ...current, status: "refetching" }; + } + // Retry after error/unauthorized must not soft-succeed from a warm LRU + // entry that survived the failed attempt. Use refetching (not loading): + // the render short-circuit still promotes loading+cache → ready. + if (cacheKey) differentialSearchCache.delete(cacheKey); + return { status: "refetching", matches: emptyDifferentialMatches, demoMode: false }; + }); setRetryAttempt((attempt) => attempt + 1); - }, [requestKey]); + }, [cacheKey, requestKey]); useEffect(() => { if (!requestKey || !cacheKey) return undefined; - if (peekDifferentialCache(cacheKey) && state.status !== "refetching") { + // Only a settled ready hit may skip the network. loading/refetching must + // revalidate so Retry and credential pulses cannot soft-succeed offline. + if (state.status === "ready" && peekDifferentialCache(cacheKey)) { touchDifferentialCache(cacheKey); return undefined; } diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx index 941822882..615b3b8d5 100644 --- a/tests/use-differential-search.dom.test.tsx +++ b/tests/use-differential-search.dom.test.tsx @@ -215,6 +215,106 @@ describe("useDifferentialSearch debounce/abort/cache", () => { expect(result.current.status).toBe("ready"); }); + it("retries over the network after a warm-cache error instead of soft-succeeding", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + fetchMock.mockImplementation((input) => + Promise.resolve( + jsonResponse( + String(input).includes("kind=diagnosis") + ? { matches: [diagnosisMatch], demoMode: false } + : { matches: [], demoMode: false }, + ), + ), + ); + + const { result } = renderHook(() => useDifferentialSearch("depression")); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ error: "boom" }, 500))); + await act(async () => { + result.current.refetch(); + }); + await advanceDebounce(); + await flushMicrotasks(); + await flushMicrotasks(); + expect(result.current.status).toBe("error"); + + const pending: Array<(response: Response) => void> = []; + fetchMock.mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + await act(async () => { + result.current.refetch(); + }); + expect(result.current.status).toBe("refetching"); + expect(result.current.matches.diagnoses).toEqual([]); + await advanceDebounce(); + expect(pending).toHaveLength(2); + + await act(async () => { + pending[0](jsonResponse({ matches: [diagnosisMatch], demoMode: false })); + pending[1](jsonResponse({ matches: [], demoMode: false })); + }); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + }); + + it("refetches after an error when the same identity refreshes credentials", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + fetchMock.mockImplementation((input) => + Promise.resolve( + jsonResponse( + String(input).includes("kind=diagnosis") + ? { matches: [diagnosisMatch], demoMode: false } + : { matches: [], demoMode: false }, + ), + ), + ); + + const { result, rerender } = renderHook(() => useDifferentialSearch("depression")); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ error: "boom" }, 500))); + await act(async () => { + result.current.refetch(); + }); + expect(result.current.status).toBe("refetching"); + await advanceDebounce(); + await flushMicrotasks(); + await flushMicrotasks(); + expect(result.current.status).toBe("error"); + expect(result.current.matches.diagnoses).toEqual([]); + + const pending: Array<(response: Response) => void> = []; + fetchMock.mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + authSession.authorizationHeader = { Authorization: "Bearer refreshed-after-error" }; + rerender(); + expect(result.current.status).toBe("loading"); + expect(result.current.matches.diagnoses).toEqual([]); + await advanceDebounce(); + expect(pending).toHaveLength(2); + + await act(async () => { + pending[0](jsonResponse({ matches: [diagnosisMatch], demoMode: false })); + pending[1](jsonResponse({ matches: [], demoMode: false })); + }); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + }); + it("keeps refetching across back-to-back same-identity credential refreshes", async () => { const diagnosisMatch = { record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, From 27aacfdd2249f6140f280c13f033404d86610280 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 00:22:49 +0000 Subject: [PATCH 07/11] docs: record PR #1485 accordion/catalogue review Append the Bugbot/CodeRabbit triage approval for the differential cache revalidation head so the branch review ledger matches the queued tip. Co-authored-by: BigSimmo --- 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 ed6617e0e..56f3c1c5b 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -330,3 +330,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-31 | claude/root-dir-coverage-gate-v2 | 398660144d93aeefc2e5649c156948a68925cb64 | docs:check-index repo-root coverage, stale script counts, ledger correction | MERGED as PR #1458 (squash 907fd9f4a). Root-directory coverage pass for docs:check-index, red-then-green proven (flagged .cursor/.design-sync/.vscode, then 49 entries vs 31). Main landed an equivalent pass independently in #1480, so the two overlapped; no duplication reached main. Row not recorded at the time - appended retrospectively | verify:cheap exit 0, 435 test files / 4574 tests pass; codebase-index-coverage 10/10 incl 4 new root cases; eslint clean; docs gates green; prettier clean | | 2026-07-31 | claude/pre-commit-fail-open | 7b96a09b8500adc917cf5549b1c61142b2244b39 | pre-commit hook fail-open when the inventory script is absent | MERGED as PR #1494 (squash 387c3b653). Resolves ledger #153: core.hooksPath is absolute to the primary checkout, so the hook ran in worktrees lacking scripts/update-docs-inventory.mjs and aborted with MODULE_NOT_FOUND. Guard drops the inventory task and re-checks the all-tasks-empty exit; grep carries \|\| true because set -e treats a fully-filtering grep as failure | isolated-repo probe with the script genuinely absent: prints skipping inventory sync, commit succeeds; sh -n clean; no-op when the script is present; prettier does not parse shell so format:check skips it | | 2026-07-31 | claude/ledger-relanding | 30ec06964e4235d9f0b4bb782f357e6b4fb59430 | re-land the three session findings lost when PR #1490 was closed | MERGED as PR #1508 (squash 7b551abc4). Ledger-only: #151 corrects the claim that CI is unreadable (PAT has Actions:read though not Checks:read), #152 re-lands the at-risk worktree inventory with the four preservation snapshots, #153 archives the hook fix. Verified landed by content on main, not by PR state or row id | CI, PR Policy, PR mergeability, SAST, Secret Scan all completed/success via the Actions API; check:outstanding-issues 151 rows 45 open unique ids next-id=154; docs:check-links 1414 refs; prettier clean | +| 2026-07-31 | codex/moderate-batch-20260730 | d582c49fe3a2f01bad179d06f84484754b639458 | PR #1485 accordion/catalogues | APPROVE after Bugbot/CodeRabbit triage; fixed differential LRU soft-success on Retry and credential/error pulses; no open review threads; merge-tree clean vs main | vitest catalog DOM 12/12; check:outstanding-issues; merge-tree clean; Bugbot: no cursor[bot] threads; CodeRabbit threads resolved | From ba44b0cb77d6e0c3a9396a79a18ef24ff4cc7085 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 00:56:33 +0000 Subject: [PATCH 08/11] fix: satisfy React Compiler deps on differential refetch Include setState in the useCallback dependency list so Static PR lint preserves the manual memoization for Retry/credential revalidation. Co-authored-by: BigSimmo --- src/components/clinical-dashboard/use-differential-catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index 14a855ccb..6f6a487a7 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -183,7 +183,7 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { return { status: "refetching", matches: emptyDifferentialMatches, demoMode: false }; }); setRetryAttempt((attempt) => attempt + 1); - }, [cacheKey, requestKey]); + }, [cacheKey, requestKey, setState]); useEffect(() => { if (!requestKey || !cacheKey) return undefined; From c7b57f1b5082d8bd18757b277185362feae864d4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:57:36 +0800 Subject: [PATCH 09/11] fix: preserve differential refetch memoization --- src/components/clinical-dashboard/use-differential-catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index 14a855ccb..20f87e461 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -183,7 +183,7 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { return { status: "refetching", matches: emptyDifferentialMatches, demoMode: false }; }); setRetryAttempt((attempt) => attempt + 1); - }, [cacheKey, requestKey]); + }, [cacheKey, requestKey, setState, setRetryAttempt]); useEffect(() => { if (!requestKey || !cacheKey) return undefined; From 4d7a5c4e1d14b731b7137ae69ae741534f7768ab Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:03:23 +0800 Subject: [PATCH 10/11] docs: resolve outstanding issue allocation table --- docs/outstanding-issues.md | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index d27a3ae87..70deeaec5 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -63,23 +63,6 @@ removed after current-main verification; it is not missing recommended work. | 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 | Ranking done; only the raw-JSON cross-check remains | 1–2 hours | Ranking complete 2026-07-30 — `#147` owns CLS, `#117` owns the LCP outlier. What is left here is narrow: cross-check the emitted table against raw Lighthouse JSON artifact `8762211043` (30-day retention) and confirm the INP clause from CrUX field data, which Lighthouse cannot measure in lab conditions. Neither blocks `#147` or `#117`. **Stop:** do not re-dispatch the live workflow; CLS now reproduces offline for free. | -<<<<<<< HEAD -| 18 | `#147` | A3 | High — frontend layout/performance | Now; provider-free and locally reproducible | 2–4 hours | Attribute the mobile CLS shifts to elements, worst first: `/dsm` 0.363, `/documents/search` 0.220, `/forms` 0.212, `/therapy-compass` 0.142. Reproduce offline with `CHROME_PATH=/opt/pw-browsers/chromium npm run verify:lighthouse` — it matches live production to three decimals, so no provider spend is needed. Lighthouse's element audit is empty, so use a `PerformanceObserver` on `layout-shift`. **Stop:** do not re-dispatch the live workflow, and do not read LCP from local runs — only CLS reproduces. | -| 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 | `#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. | -| 25 | `#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. | -| 26 | `#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. | -| 27 | `#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; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. L4 ledger rotation shipped in #1418. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | -| 28 | `#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. | -| 29 | `#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. | -| 30 | `#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. | -| 31 | `#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. | -======= | 18 | `#147` | A3 | High — frontend layout/performance | Attribution done; the fix is next and is phone-chrome governed | 2–4 hours | Stop `usePhoneOverlayChromeReserve` publishing a stale 200px reserve it revises to 72px 15-60ms later — that round trip moves all main content down 128px and back, and is 100% of `/documents/search`'s CLS and ~75% of `/dsm`'s. Defer the first publish until the stack settles, or let the ResizeObserver be the only writer and trust the CSS seed (which is already correct) until it fires. Phone-chrome surface: read `docs/search-chrome-behaviour.md`, run `npm run verify:phone-chrome`, and produce a before/after CLS pair from the offline harness. **Stop:** do not re-dispatch the live workflow, do not read LCP from local runs, and do not change the CSS seed — it is not the cause. | | 19 | `#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. | | 20 | `#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. | @@ -87,19 +70,14 @@ removed after current-main verification; it is not missing recommended work. | 22 | `#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. | | 23 | `#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. | | 24 | `#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. | -| 25 | `#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. | -| 26 | `#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. | -| 27 | `#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. | -| 28 | `#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. | -| 29 | `#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. | -| 30 | `#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; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. L4 ledger rotation shipped in #1418. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | -| 31 | `#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. | -| 32 | `#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. | -| 33 | `#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. | -| 34 | `#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. | -| 35 | `#105` | Optional | High — browser/UI verification | When a driven-browser session is available | 30–60 minutes | Decide how the ten `LoadingPanel` fallbacks can be observed at all, then verify or re-scope. A cold-load grep cannot see them — every lazy surface mounts behind interaction state, so nothing renders them on first paint — and a claimed cold-load verification was withdrawn 2026-07-30 after it turned out to be matching `ModeHomePageSkeleton`. Either drive the interaction in a browser and assert the surface's specific label, or record that they are unobservable on cold load. The preconnect half is already verified; do not redo it. **Stop:** do not close this on a cold-load grep. | -| 36 | `#126` | Optional | Standard — repository hygiene | Once per UTC calendar quarter, or when the live ledger grows large | 5–15 minutes | Run `npm run ledger:rotate -- --dry-run`, then `npm run ledger:rotate` if the preview looks right; commit the live+archive diff. Stop if dry-run shows unexpected mass moves or archive path collisions. | ->>>>>>> origin/main +| 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 | `#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. | +| 28 | `#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; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. L4 ledger rotation shipped in #1418. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | +| 29 | `#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. | +| 30 | `#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. | +| 31 | `#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. | +| 32 | `#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. | From cf5cefa7acfa3371931b818dd1a21da1a1d17577 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:14:47 +0800 Subject: [PATCH 11/11] docs: record exact PR 1485 review --- 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 0f4d8b26e..af59f0842 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -348,3 +348,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-31 | PR-1510 | 61d25fd7727c2345fabb9631d604b1632bc0df6d | post-1513 concurrency-note reconciliation | no actionable findings; preserved main 155, renumbered withdrawn guard to 158, and advanced next-id to 159 | outstanding-issues, branch-review-ledger, design-system-contract, changed-format, diff-check | | 2026-07-31 | PR-1510 | 2e8821c77fcadaa283d8a0033b1a6af815458d79 | PR #1510 CodeRabbit + evidence reopen-ready | FIXED: CodeRabbit computed-value-time wording, unique #033 queue order, deduped #098 Done block; prior false canary attribution already corrected | check:outstanding-issues PASS; format:changed PASS; contains origin/main | | 2026-07-31 | claude/ci-testing-review-2l8klp | fa304a5332443f544a676bdf35d813797154f87c | PR #1466 reopen-prep | READY: main merged (clean), phoneContract sibling arm fixed+pinned, Codex Cloud origin inspect uses configured URL (insteadOf-safe), prior Codex/Copilot/CodeRabbit threads resolved, no cursor[bot] Bugbot findings, PR left CLOSED | verify:cheap PASS (444 files / 4652 passed, 4 skipped); prettier --check . PASS; check:ci-scope PASS; verify-phone-chrome+codex-cloud-setup+test-runner-safety+playwright-project-isolation 59/59; merge-tree clean before merge; Bugbot none | +| 2026-07-31 | PR-1485 | f4f42fbc5b4a73d0037c8c275a358d265727e0fc | post-review document accordion and catalogue sync | APPROVE; post-review changes limited to differential refetch memoization and current-main sync; no remaining findings | installed-lock parity; focused catalogue/document suites 3 files 19 tests PASS; typecheck PASS; issue and review-ledger guards PASS; zero unresolved threads |