diff --git a/docs/README.md b/docs/README.md index 395309d2a3..6ff5abdd4c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,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/branch-review-ledger.md b/docs/branch-review-ledger.md index f295b9fb2d..af59f08424 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -171,14 +171,17 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | claude/outstanding-issues-triage-24c8ow | 8d2710fd6cbdc84e8c50a6c9bc0a1e1a0cd612c8 | open PR changed-scope review | APPROVE: completed items 095, 096, 104, 109, and 115 move to archive with no deletion, duplicate ID, or stale next-id. | check:outstanding-issues PASS; check:branch-review-ledger PASS; diff review; no unresolved threads | | 2026-07-30 | claude/latency-findings-impl-s8g01v | e7ff5e933ba1f34d5adbd46dd77c38aced11ed44 | open PR changed-scope review | APPROVE: ordering-risk documentation is accurate and the near-bottom refusal guard now proves its geometry is non-vacuous before asserting no hide. | diff check PASS; focused test review; no unresolved threads; exact-head Production UI required | | 2026-07-30 | claude/ci-testing-review-2l8klp | 2e2160bc8b9d2d824209c217c67cb9cac1be3a8d | open PR changed-scope review | APPROVE: three-way UI sharding, critical-first gating, measured drag travel, and gate-manifest updates preserve required-check aggregation and deterministic Playwright settings. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS; ledger guards PASS; exact-head sharded Production UI required | +| 2026-07-30 | codex/docs-sync-automation | 76d7372d8aa886008e2fb637e5911e9c00bb33e3 | documentation synchronization automation review | APPROVE after deletion-path fix; no remaining P0-P2 findings | docs/update and static gates pass; focused Vitest admission blocked | +| 2026-07-30 | PR #1430 | a9ae22ac4915e86d51ee05787059382a39bd8ba8 | phone chrome diagnostics and merge repair | fixed and ready for CI | issues guard; ledger guard; 37 focused tests; phone-chrome dry-run | +| 2026-07-30 | PR-1442 | 35fc11a2665ecd0464a23949babbbddba8055dcd | PR #1442 documentation synchronization automation | hook is fail-closed for mixed staged inputs and does not auto-stage; generated inventories remain deterministic; no findings | docs update/checks pass; focused Vitest 4 passed; issue and ledger guards pass | +| 2026-07-30 | PR-1440 | f7260cc6a0da87cb4df1ac95ef962967e667c3f0 | PR #1440 issue #102 ordering correction | accurately restores the two canary-gated retrieval ordering constraints; no findings | outstanding-issues and ledger guards pass; documentation-only diff | +| 2026-07-30 | PR-1445 | 07933e08cff6c7d81345e02c03727032ccf522b6 | PR #1445 close duplicate issue | correctly archives duplicate #140 while preserving #133 as the surviving open conflict-frequency record; no findings | outstanding-issues and docs-link guards pass; docs-only diff | +| 2026-07-30 | PR-1434 | f6bebf2a8c658df8b3840c1b1133be5c94a977b0 | PR #1434 Codex Cloud setup consolidation and prompt perfector | fixed Cloud runtime verification gaps and reconciled duplicate implementation after #1438; no remaining findings | check:codex-cloud pass; codex-cloud-setup Vitest 4/4; outstanding-issues and ledger guards pass | | 2026-07-30 | PR #1432 | a2b53c815b3c060dec2619af2855a63f9f496858 | Playwright browser preflight review and repair | fixed; focused tests pending coordinator | Prettier PASS; issues guard PASS; focused Vitest blocked by active Playwright lease | | 2026-07-30 | PR #1432 | f85995ade3a19513a531713724813adc742c360d | Playwright browser preflight verification | focused tests pass; typecheck lease-blocked | 16 focused tests PASS; Prettier PASS; typecheck admission blocked | -| 2026-07-30 | PR #1430 | a9ae22ac4915e86d51ee05787059382a39bd8ba8 | phone chrome diagnostics and merge repair | fixed and ready for CI | issues guard; ledger guard; 37 focused tests; phone-chrome dry-run | | 2026-07-30 | PR-1432 | 7c7b63cf40d59652954e539ce1b3027005916bf1 | PR #1432 Playwright browser preflight final exact-head review | fixed existing project-isolation contract after preflight refactor; no remaining findings | preflight and isolation Vitest 9/9; typecheck pass; Prettier and diff checks pass | -| 2026-07-30 | PR-1440 | f7260cc6a0da87cb4df1ac95ef962967e667c3f0 | PR #1440 issue #102 ordering correction | accurately restores the two canary-gated retrieval ordering constraints; no findings | outstanding-issues and ledger guards pass; documentation-only diff | | 2026-07-30 | PR-1432 | a5d234302b57be6f7ce5d1957c9ec00bc7f191f0 | PR #1432 Playwright preflight and phone-scroll reliability | cross-platform preflight fails closed and production focus-restore race is removed from the phone-scroll proof; no remaining findings | preflight tests 9 passed; focused Chromium journey 2 passed; formatting and ledger guards pass | | 2026-07-30 | PR-1432 | 330086eff76f704ce6b9cf5405aeecfdd375027c | PR #1432 visual-config preflight follow-up | visual runs now preflight chromium-artifacts instead of the unrelated main browser matrix; unknown configs fail closed | config-selection tests added; formatting passes; exact-head CI pending | -| 2026-07-30 | PR-1445 | 07933e08cff6c7d81345e02c03727032ccf522b6 | PR #1445 close duplicate issue | correctly archives duplicate #140 while preserving #133 as the surviving open conflict-frequency record; no findings | outstanding-issues and docs-link guards pass; docs-only diff | | 2026-07-30 | claude/organize-local-worktree-d22bc3 | 2f26a53b5aeb3df451cf7b1d04f80b07edf0d6fe | docs organisation: dated-record filing, docs index gaps, orientation maps | PR #1436 opened — 5 dated docs filed into docs/audit and docs/archive, root codex-cloud-review moved under docs/prompts, 17 docs README index gaps closed, root data/ documented in CLAUDE.md + codebase-index; no product code, schema or RAG surface touched | docs:check-links 1368 refs pass; docs:check-scripts 378 pass; docs:check-index OK; format:check whole-tree clean; verify:cheap 26 static gates + lint + typecheck pass, unit 4562 pass / 1 pre-existing Windows path-separator failure in tests/repo-hygiene.test.ts | | 2026-07-30 | PR-1436 | 9d8e081f3e7003d4f2210b00a7b7e54bf7ca2f0b | PR #1436 documentation organization and link repair | fixed stale no-driver wording and renumbered three union-collided issue records; no remaining findings | docs index, links, scripts, outstanding-issues, and ledger guards pass | | 2026-07-30 | cursor/ci-hygiene-gates-1bf5 | b660dbc5a10d7ca3da03541028017f0abc6b5bd3 | ci-hygiene-gates-merge-readiness | NOT READY: cancel-to-green behavior still allowed required PR CI to pass incorrectly; fixed at subsequent head 8f3283d00da274dee507a1b8e9b611321d1f35be | check:ci-scope; check:gitleaks-pinned; scope-classify PR files ui_changed=false; cancelled-as-neutral simulation exposed #095 | @@ -187,12 +190,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | PR #1446 / claude/ci-testing-review-2l8klp | 8be4f703d5729b4aa10e73ee8fbc77e03f400b8b | ci-testing-review-capture | Withdraws an invalid inference from the earlier records for this PR, on a correct Codex finding. Those rows argued that because the sibling documentScrollTop assertion did not fail, the scroll position held and scroll-restoration causes were ruled out. Playwright aborts a test at the first failing expect, so once anchorTop threw, documentScrollTop NEVER EXECUTED - its absence from the output shows nothing. The #142 row now says so and the class is not ruled out. The capture itself stands: the Services viewport-anchor failure is real, intermittent on byte-identical code (pass/pass/fail/pass-on-rerun), and distinct from #127. Separately CodeRabbit flagged :973 vs :1133 as inconsistent and then withdrew it: :973 is the test declaration and :1133 the thrown assertion, both reported by Playwright, and declaration lines drift (898 / 973 / 1041 across three tree states) which is why the exact title is the durable identity. | check:outstanding-issues PASS (140 rows, unique ids, next-id=143). Lesson: reasoning from an assertion that never ran is the same verified-vs-assumed error this session already hit twice in the other direction. | | 2026-07-30 | claude/x3-rag-coverage-gate-qx9j7d (PR #1454, squashed as 102bb1f) | 102bb1f5edf09e666d1be5934ff5dfb2aa5abcf0 | X3/#086 evidence coverage gate extraction from rag.ts into rag-coverage-gate.ts | clean and landed — byte-identical move verified against pre-merge main, rag.ts 5030->4780, budget ratcheted to 4780, no back-edge, public re-export preserved; squash captured 100% of branch content, nothing orphaned | workflow:rag-lab, focused vitest 81/81, check:maintainability-budgets 4780/4780, check:rag:fixtures 36 golden, eval:rag:offline 567/567, typecheck, lint, check:knip, format:check, verify:cheap 4569 passed, npm test 4569 passed, verify:pr-local build+bundle-scan, post-merge npm test on main 4574 passed | | 2026-07-30 | PR #1432 | 74adc5aa3f8a4dad659c7a40490288ef8efcb82e | Playwright browser preflight and phone-sheet focus repair | APPROVE after current-main sync: browser-project resolution fails closed, phone-sheet focus is stable, and no stale issue-ledger state remains. | 3 focused files 45 passed; phone-chrome dry-run; installed-lock parity; docs and ledger guards; formatting | -| 2026-07-30 | codex/outstanding-local-batch-final | 330d964a04406a9e123c674409f167746f7b9a28 | outstanding local task batch merge readiness | Reviewed changed scope; fixed the env-file bypass in the upload-limit parity guard. No unresolved findings. | Focused Vitest: 7 files, 125 tests passed; exact-head verify:cheap static gates and lint passed; typecheck/full unit pending coordinator availability. | -| 2026-07-30 | codex/outstanding-local-batch-final | 46ebd3f13a3e8b843026dd7d3d4024440970d7ae | upload-limit env regression type correction | Reviewed the test-only ProcessEnv annotation; no unresolved finding. | Focused test and typecheck awaiting repository coordinator; prior exact-head static gates and lint passed. | -| 2026-07-30 | codex/outstanding-local-batch-final | 7b63c28ca6ff9ab2f3599197ee6811292958e2c2 | final upload env fixture correction | Reviewed the contextual ProcessEnv construction after hosted readonly-property failure; no unresolved finding. | Prior hosted Build, Unit coverage, Production UI critical, containers and lint passed; exact-head typecheck rerun pending. | | 2026-07-30 | PR-1470 | 1932e81ece9361c08607d2ef01ad653a7df0ac8d | PR #1470 full diff vs origin/main | PASS after repair: #013 remains open and measurement-gated | check:outstanding-issues passed; docs:check-links 1408 passed; Prettier passed; git diff --check | -| 2026-07-30 | codex/outstanding-local-batch-final | 112ac9ba71f78df6e8d05d0e3fa2e3adf6ba4705 | PR #1480 Docker upload-limit review finding | FIXED P1: the Docker build now receives both server and browser limits, and container CI proves a matched lowered 50 MB pair. | check:upload-limits and check:github-actions passed; focused/typecheck/container rerun pending exact head. | -| 2026-07-30 | PR-1434 | f6bebf2a8c658df8b3840c1b1133be5c94a977b0 | PR #1434 Codex Cloud setup consolidation and prompt perfector | fixed Cloud runtime verification gaps and reconciled duplicate implementation after #1438; no remaining findings | check:codex-cloud pass; codex-cloud-setup Vitest 4/4; outstanding-issues and ledger guards pass | | 2026-07-30 | PR-1441 | c298432cffd2a1aee1b96edda9d32deb31be7f00 | PR #1441 upload-limit parity and issue-ledger closures | upload limit guard is fail-closed and safely wired; archived rows retain their dispositions; no findings | upload parity self-test/runtime pass; issue, ledger, gate-manifest, and docs-script guards pass | | 2026-07-30 | PR #1441 | d8bd22192ce974d4d2340ff26959ee41480218e9 | PR readiness: issue ledger, upload parity, CircleCI cleanup | FIXED: review found the Docker build lacked MAX_UPLOAD_MB input and open issue #119 still requested obsolete CircleCI investigation; both are repaired, with no remaining P0-P2 findings in scope. | upload parity default and 50/50 pass; 50/40 mismatch fails; outstanding-issues, branch-review-ledger, and gate-manifest guards pass | | 2026-07-30 | PR #1441 | 0b17e849406a03b87af20e627da6500a7cd03c2e | PR readiness: issue ledger, upload parity, CircleCI cleanup | FIXED: review repaired Docker build-time server parity, archived stale CircleCI row #119, and made the #095 aggregate harness portable; no remaining P0-P2 findings in scope. | verify:pr-local PASS on parent code tree 727ed55a7: 435 files, 4570 passed, build 1694 pages, client scan pass, RAG 36 cases/21 suites; latest-main docs, issue, review-ledger, and upload guards pass | @@ -204,19 +202,22 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | codex/issue-ledger-upload-parity | f35a4ca178724ff59e7a876c4d819bed0b786662 | PR #1441 final current-main sync | approved after merging #1457 without overlap; upload parity and repository guards remain green | upload self-test pass; issues 141; ledger 141+1206; actions pin, format, diff pass | | 2026-07-30 | codex/issue-ledger-upload-parity | dc8068590d5be469ff30789b8b345896a3f1cdb9 | PR #1441 sync after PR #1470 | approved; catalogue payload disposition and upload-limit closures both preserved | upload self-test, issues, ledger, diff pass | | 2026-07-30 | codex/cloud-readiness-consolidation-20260730 | 8ff0a7ec309c80379bd8a9a76ab107a65ac7b837 | PR #1434 Codex Cloud setup and isolation tooling | approved after current-main sync, helper typing repair, static Cloud contracts, and isolation review | codex-cloud, skills, docs, maintainability, issues, ledger, format, isolation 14/14 pass; focused Vitest coordinator-blocked; shell runtime acceptance deferred to hosted Linux | -| 2026-07-30 | codex/outstanding-local-batch-final | 43de3c910ea1a361458586cfb0e5861e8e2d5ee6 | post-main reconciliation merge readiness | APPROVE: retained main's stronger #1441 upload guard, removed the duplicate checker/test, and preserved the six non-overlapping fixes; no unresolved findings. | Upload parity self-test/runtime, GitHub Actions, docs scripts, review ledger, outstanding issues, and diff checks pass; parent exact-head hosted suite fully green; final hosted rerun pending. | -| 2026-07-30 | codex/docs-sync-automation | 76d7372d8aa886008e2fb637e5911e9c00bb33e3 | documentation synchronization automation review | APPROVE after deletion-path fix; no remaining P0-P2 findings | docs/update and static gates pass; focused Vitest admission blocked | -| 2026-07-30 | PR-1442 | 35fc11a2665ecd0464a23949babbbddba8055dcd | PR #1442 documentation synchronization automation | hook is fail-closed for mixed staged inputs and does not auto-stage; generated inventories remain deterministic; no findings | docs update/checks pass; focused Vitest 4 passed; issue and ledger guards pass | | 2026-07-30 | codex/docs-sync-automation | fba8ab4af465c96b8aa318f25d792fefd10e9ada | PR #1442 documentation synchronization automation | approved after current-main conflict resolution, generated inventory refresh, and dirty-output preservation review | docs inventory 194/209; focused 5/5; index, links 1417, scripts 405, issues, ledger, format, diff pass; pre-commit executed successfully | | 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 | +| 2026-07-30 | pr/1467 | 6b84090a7c4a57a19521a820bf4c488090fb6062 | docs: close rejected Playwright cache proposal | approved; measured rejection archived on current main | check:outstanding-issues; check:branch-review-ledger; docs inventory/links/scripts; Prettier; diff-check | | 2026-07-30 | codex/issue-ledger-upload-parity-v3 | 95b0e289f03afc46d45def9ed1a165cd614684fd | Replacement PR: issue closures, upload-limit parity, production env precedence | No findings; intended replacement scope preserved on current main | verify:pr-local PASS pre-rebase; exact-head runtime/install/format/lint PASS; focused guards PASS; typecheck rerun blocked by unrelated Playwright lease | | 2026-07-30 | codex/issue-ledger-upload-parity-v3 | 9dff07f85bcce7822eb2b2701b82a80d1e0a145e | PR #1482 Docker-context CI repair | No findings; hosted ENOENT fixed without weakening effective parity | hosted app-image log inspected; normal 150/150 PASS; Docker-context 50/50 PASS; Docker-context 50/40 rejected | | 2026-07-30 | codex/issue-ledger-upload-parity-v3 | 9867f72eddf51e25322028af6ff232dba3560871 | PR #1482 post-#1441 ledger-only salvage | No findings; duplicate implementation dropped and only four resolved issue records remain | diff vs origin/main two docs files; outstanding-issues PASS; branch-review-ledger PASS; main implementation byte-identical | | 2026-07-30 | codex/issue-ledger-upload-parity-v3 | 0a074d9b2572ee06f663d8bbee3b50fb6d4fbd9a | PR #1482 final current-main ledger-only review | No findings; current main preserved and PR diff is ledger-only | three-dot diff two docs files; issue and review ledger guards PASS; four resolutions verified against merged main | | 2026-07-30 | codex/issue-ledger-upload-parity-v3 | fc116cfa0cf06d359548c1bc00d383b872fb1a48 | PR #1482 upload-parity deployment-input repair | No findings; checker changes now trigger build, container, and Railway app paths | check:ci-scope PASS with single-file assertion; upload parity PASS; Railway config test queued behind primary live-provider lease | | 2026-07-30 | codex/issue-ledger-upload-parity-v3 | b4e68aa9e4892d4031479240f7783b7c22bd4bbb | PR #1482 final current-main review | PASS - no P0-P2 findings; ledger archives preserved and deployment inputs repaired | issues, ledger, docs links/scripts, ci-scope self-test, diff-check; hosted full unit pending | +| 2026-07-30 | pr/1467 | fc7abe7f7e7ecb97dc7896c16b5553256da3ad80 | docs: close rejected Playwright cache proposal | approved after current-main reconciliation; archive entry preserved | issue/ledger guards; docs inventory/links/scripts; Prettier; diff-check | +| 2026-07-30 | pr/1476 | 79822031e696cd3906ce01284ec9736938c40a74 | docs: record ESLint 10 ecosystem blocker | approved; blocker matches installed peer ranges and current main | runtime/install parity; issue/ledger; docs inventory/links/scripts; Prettier; diff-check | +| 2026-07-30 | codex/reopen-issue-105 | b94a8f5a693cc44e8aaa0fe3ec5bb65a7c313a3b | Correct #105 status after PR #1482 | No findings; restores the withdrawn verification evidence and leaves the task open | outstanding issues PASS 146 rows 69 open 77 archived next-id 149; docs links and scripts PASS | +| 2026-07-30 | codex/reopen-issue-105 | 65635235c91527c57d33dd8311d062d28ccff6d9 | PR #1483 current-main reconciliation | No findings; #105 remains open and main's #136 archival is preserved | issues PASS 146 rows 68 open 78 archived next-id 149; ledger PASS 161 live 1206 archived | | 2026-07-30 | 0b01c56fe539598279b9393db5e207bc78fa41c9 | 0b01c56fe539598279b9393db5e207bc78fa41c9 | branch-cleanup-deletion-pending | redundant clean detached head already on origin/main; removal deferred by primary-dirty lease | clean status; merge-base ancestor of origin/main; no open PR | | 2026-07-30 | 018673ff5cadadbd799815cea8d742de28922754 | 018673ff5cadadbd799815cea8d742de28922754 | branch-cleanup-deletion-pending | redundant clean detached review base already on origin/main; removal deferred by primary-dirty lease | clean status; merge-base ancestor of origin/main; no open PR | | 2026-07-30 | codex/review-pr1438 | f13c261ee96405dc338db184ec74009bcd3699a3 | branch-cleanup-deletion-pending | redundant exact head merged in PR 1438; removal deferred by primary-dirty lease | clean status; GitHub merged exact head; zero cherry-pick-unique commits | @@ -239,52 +240,46 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | codex/review-pr1445 | 483a1c6190dfbd1a5895ef2c419a73f0f2162f05 | branch-cleanup-deletion-pending | redundant exact head merged in PR 1445; removal deferred by primary-dirty lease | clean status; GitHub merged exact head; no active process | | 2026-07-30 | PR #1462 | 5146ae94e226a6e55968d80ecefa53c7cd5df9c3 | bounded inactive-work cleanup documentation | APPROVE after fix: both cleanup batches remain deferred behind the primary-checkout lease, and the resume instruction now names the executable repository command. | outstanding-issues guard; ledger guard; diff review; one review finding fixed | | 2026-07-30 | codex/organize-inactive-20260730 | e7b248e215714fbf823c784857f521f89d86fa3d | PR #1462 changed-scope review | PASS - no P0-P2 findings; exact-lock formatter resolution fails closed | guard-push self-test and focused contracts previously green; issues, ledger, docs, diff-check; hosted full unit pending | -| 2026-07-30 | pr/1467 | 6b84090a7c4a57a19521a820bf4c488090fb6062 | docs: close rejected Playwright cache proposal | approved; measured rejection archived on current main | check:outstanding-issues; check:branch-review-ledger; docs inventory/links/scripts; Prettier; diff-check | -| 2026-07-30 | pr/1467 | fc7abe7f7e7ecb97dc7896c16b5553256da3ad80 | docs: close rejected Playwright cache proposal | approved after current-main reconciliation; archive entry preserved | issue/ledger guards; docs inventory/links/scripts; Prettier; diff-check | -| 2026-07-30 | PR-1473 | a2b2820c13a47425cfc0ea751e57ee35e9bd1105 | PR #1473 full diff vs origin/main | PASS after review repair: governance refusal and error-state contracts are consistent | outstanding-issues guard passed; docs links 1412 passed; docs index passed; Prettier passed; git diff --check | -| 2026-07-30 | codex/reopen-issue-105 | b94a8f5a693cc44e8aaa0fe3ec5bb65a7c313a3b | Correct #105 status after PR #1482 | No findings; restores the withdrawn verification evidence and leaves the task open | outstanding issues PASS 146 rows 69 open 77 archived next-id 149; docs links and scripts PASS | -| 2026-07-30 | codex/reopen-issue-105 | 65635235c91527c57d33dd8311d062d28ccff6d9 | PR #1483 current-main reconciliation | No findings; #105 remains open and main's #136 archival is preserved | issues PASS 146 rows 68 open 78 archived next-id 149; ledger PASS 161 live 1206 archived | | 2026-07-30 | pr/1483 | 76393b9a0c6603e2551898c89a33396f52949da3 | docs: reopen issue 105 after withdrawn verification | approved; restores pending LoadingPanel verification without disturbing PR 1462 | runtime/install parity; issue/ledger; docs inventory/links/scripts; Prettier; diff-check | +| 2026-07-30 | PR-1473 | a2b2820c13a47425cfc0ea751e57ee35e9bd1105 | PR #1473 full diff vs origin/main | PASS after review repair: governance refusal and error-state contracts are consistent | outstanding-issues guard passed; docs links 1412 passed; docs index passed; Prettier passed; git diff --check | | 2026-07-30 | pr/1483 | a84fa60eebdbe7a00193c268b401f7abd3cc554e | docs: reopen issue 105 after withdrawn verification | approved after PR 1473 sync; issue 105 remains correctly open | issue/ledger; docs inventory/links/scripts; Prettier; diff-check | -| 2026-07-30 | pr/1476 | 79822031e696cd3906ce01284ec9736938c40a74 | docs: record ESLint 10 ecosystem blocker | approved; blocker matches installed peer ranges and current main | runtime/install parity; issue/ledger; docs inventory/links/scripts; Prettier; diff-check | | 2026-07-30 | pr/1476 | 0fd5a3cdba612d30cfd75ea997177f6e29c34bd3 | docs: record ESLint 10 ecosystem blocker | approved after PR 1483 sync; ESLint blocker and issue 105 correction preserved | issue/ledger; docs inventory/links/scripts; Prettier; diff-check | | 2026-07-30 | pr/1465 | 4e34d97bb9eb5122b9d8f8e54c42793c727f5085 | issues: record fresh #133 evidence | approved; duplicate-ID race and Prettier prerequisite accurately recorded | issue/ledger; docs inventory/links; Prettier; diff-check | | 2026-07-30 | PR #1477 | 26d713922006c1af8187994edfa76669dc14cd46 | PR #1477 fork-safe Codex autofix routing | Fixed fork routing to the PR head repository, added fail-closed metadata handling, reconciled current main, and found no remaining actionable defects. | check:codex-autofix-workflow; check:github-actions; check:pr-policy; check:outstanding-issues; check:branch-review-ledger; docs:check-inventory; docs:check-links; docs:check-scripts; typecheck; focused Vitest 53 passed; Prettier | | 2026-07-30 | PR #1477 | 20f795da2d9d0adafa6cb3117429ab3665129c0d | PR #1477 fork-safe Codex autofix routing | Refreshed onto current main after #1465; issue and ledger reconciliation remained clean and no new actionable defects were introduced. | check:outstanding-issues; check:branch-review-ledger; check:codex-autofix-workflow; focused Vitest 53 passed | +| 2026-07-30 | codex/outstanding-local-batch-final | 330d964a04406a9e123c674409f167746f7b9a28 | outstanding local task batch merge readiness | Reviewed changed scope; fixed the env-file bypass in the upload-limit parity guard. No unresolved findings. | Focused Vitest: 7 files, 125 tests passed; exact-head verify:cheap static gates and lint passed; typecheck/full unit pending coordinator availability. | +| 2026-07-30 | codex/outstanding-local-batch-final | 46ebd3f13a3e8b843026dd7d3d4024440970d7ae | upload-limit env regression type correction | Reviewed the test-only ProcessEnv annotation; no unresolved finding. | Focused test and typecheck awaiting repository coordinator; prior exact-head static gates and lint passed. | +| 2026-07-30 | codex/outstanding-local-batch-final | 7b63c28ca6ff9ab2f3599197ee6811292958e2c2 | final upload env fixture correction | Reviewed the contextual ProcessEnv construction after hosted readonly-property failure; no unresolved finding. | Prior hosted Build, Unit coverage, Production UI critical, containers and lint passed; exact-head typecheck rerun pending. | +| 2026-07-30 | codex/outstanding-local-batch-final | 112ac9ba71f78df6e8d05d0e3fa2e3adf6ba4705 | PR #1480 Docker upload-limit review finding | FIXED P1: the Docker build now receives both server and browser limits, and container CI proves a matched lowered 50 MB pair. | check:upload-limits and check:github-actions passed; focused/typecheck/container rerun pending exact head. | +| 2026-07-30 | codex/outstanding-local-batch-final | 43de3c910ea1a361458586cfb0e5861e8e2d5ee6 | post-main reconciliation merge readiness | APPROVE: retained main's stronger #1441 upload guard, removed the duplicate checker/test, and preserved the six non-overlapping fixes; no unresolved findings. | Upload parity self-test/runtime, GitHub Actions, docs scripts, review ledger, outstanding issues, and diff checks pass; parent exact-head hosted suite fully green; final hosted rerun pending. | | 2026-07-30 | PR #1480 | 6c1e76f53aee87be8408cebc295744fbdce05367 | PR #1480 bounded outstanding reliability fixes | Fixed both review findings: documented the dark accent role and added partial favourites retry without hiding valid counts; no other actionable defects found. | focused Vitest 119 passed; docs index; issue and ledger guards; Actions and Codex workflow guards; Prettier; diff check; typecheck coordinator-blocked | | 2026-07-30 | codex/close-pr1480-issues | bf8ac88b024642eb45d1fead86f4ee30fce3f98d | archive PR 1480 issue resolutions | approved: five resolved rows moved intact to archive | check:outstanding-issues; prettier check; diff check | | 2026-07-30 | codex/close-issue-127 | 9bbb8486d399ed31b9bf43364579f466a4e66c67 | archive issue 127 after post-fix runs | approved: close condition satisfied with no post-fix recurrence | check:outstanding-issues; prettier check; diff check | | 2026-07-30 | codex/close-issue-127 | d5fd28f75b404046f13507aa3705d55898d7669c | review finding wording correction | approved: causal wording now matches PR 1427 implementation | check:outstanding-issues; prettier check; diff check | | 2026-07-30 | PR-1469 | 02108d5424f8a3ab50f45808a6cc3cbd872e7555 | PR #1469 component state matrix coverage | PASS after current-main sync; tests execute enabled and disabled popup transitions plus document search loading, empty, and fault states | focused Vitest 2 files, 10 tests passed; outstanding-issues and branch-review-ledger guards passed; no unresolved review threads | | 2026-07-30 | claude/global-search-mockups-mrgmzl | e79e499839e56bff66aecc37d1c915ac3127e995 | prlanded | merged and verified by content | verify:cheap exit 0 (437 files, 4576 passed); verify:pr-local exit 0; CI pr-required green on a6f2281; squash e79e499 content diff vs branch tip empty; late aria-live/role=alert commit confirmed present on main | -| 2026-07-30 | codex/close-issue-105 | 09950abc8cf2d23455a6cbab3521bdec921ef272 | archive issue 105 after driven browser proof | approved: specific lazy fallback observed in driven desktop and phone Chromium | browser chunk-delay proof; check:outstanding-issues; prettier check; diff check | | 2026-07-30 | PR-1475 | 6de5c321beac55860cc4b6fc7d26ef5a7e088f38 | PR #1475 ingestion behavioral extraction | PASS after current-main reconciliation; extracted decisions preserve entrypoint behavior and replace the matching source-grep assertion with executable coverage | focused Vitest 3 files, 27 tests passed; typecheck passed; outstanding-issues and branch-review-ledger guards passed; provider-backed ingestion not run | | 2026-07-30 | PR-1458 | 8c1975b178c67e4c54acffc395d85e38c43d39f5 | PR #1458 superseded root-gate reconciliation | PASS: retained only unique documentation corrections after PR #1480 landed the stronger tracked-root gate; archived resolved shared-hook issue #143 | docs index and links passed; outstanding-issues and branch-review-ledger guards passed; diff check passed | +| 2026-07-30 | codex/close-issue-105 | 09950abc8cf2d23455a6cbab3521bdec921ef272 | archive issue 105 after driven browser proof | approved: specific lazy fallback observed in driven desktop and phone Chromium | browser chunk-delay proof; check:outstanding-issues; prettier check; diff check | | 2026-07-30 | PR-1448 | 8ece7f345e93170c6bd242701eaff05f5504d98b | PR #1448 authenticated live workflow | PASS after review repair: protected-main-only checkout, explicit bounded mutations, scoped secrets, and static dispatch confirmation; no live provider workflow dispatched | GitHub Actions and PR-policy guards passed; focused Vitest 3 passed; docs links and scripts, issue and ledger guards, Prettier and diff checks passed | -| 2026-07-30 | claude/capture-session-followups | bbc5d4625adcbdc32aee2f9b4fb4b0d4365d0e99 | outstanding-issues capture: unreadable CI token, at-risk worktree work, unpushed hook fix | PR #1490 opened. Ledger-only: adds #149 (PAT lacks Checks: Read so no PR verdict is readable; the working status endpoint returns total:0 rather than erroring), #150 (four already-merged worktrees hold uncommitted work existing in no branch or PR, largest +395/-200 over 19 files incl CI config), #151 (the #143 pre-commit fail-open d2fd16d54 lives only on a never-pushed branch, 17 behind main, conflicting on the file main's docs:update generator now owns). Also records that PR #1458 is superseded by #1480 and should be closed after owner confirmation | check:outstanding-issues 149 rows 60 open unique ids next-id=152; docs:check-links 1415 refs resolve; docs:check-index 49 roots/modules/routes; prettier clean | | 2026-07-30 | codex/outstanding-deletion-guard | da1bed4bcb05f2b975823c76bab0913278cfaea6 | issue 148 deletion guard | approved: no P0-P2 findings; base comparison is fail-closed in CI and preserves archive moves | verify:cheap; deletion self-test; unreadable-base proof; CI scope; GitHub Actions pin guard | -| 2026-07-30 | claude/capture-session-followups | a026c0bfe70f0e9fe290abbdd3660f4c458e4115 | PR #1490 #143/#151/#149 reconciliation | corrected archived #143 fail-open claim; #151 owns remaining half; #149 separates Checks:Read from missing-gh; merged main #1491 | check:outstanding-issues; docs:check-links | -| 2026-07-30 | claude/capture-session-followups | f18dc1fb25f4687006be897f2c11a4bb1f583f41 | PR #1490 #143/#151/#149 reconciliation | reconciled after parallel remote main-sync; #143 corrected; #151 owns fail-open; #149 separates Checks vs missing-gh | check:outstanding-issues; docs:check-links | +| 2026-07-30 | codex/ingestion-fixes-current-main-20260731 | fe68a0a817213ef33ddeee35b45034656986d089 | ingestion fixes replacement release readiness | ready after fixing unreachable partial-batch handling and invalid recovery owner lookup | 4 focused files 183 tests passed; typecheck passed; production-readiness READY; git diff --check passed | | 2026-07-30 | codex/sync-ci-anti-churn | 4f99c6d6dbcd4d2c16d5ec58183003c64d989ac8 | issue 145 anti-churn guidance | approved: guidance now covers both pushes and sync mutations without weakening cancellation | check:outstanding-issues; prettier AGENTS; diff check | | 2026-07-30 | PR-1492 | a50640970a4e4197c64fba7239aeae073445fed9 | PR #1492 branch-sync churn review | FIXED P2: exact-head queued or in-progress workflows now block automated branch updates; Run PR guidance matches the executable guard | focused Vitest 1 file, 8 tests passed; Prettier passed; sync dry-run passed on 19 open PRs; diff check passed; no provider-backed application checks run | -| 2026-07-30 | codex/ingestion-fixes-current-main-20260731 | fe68a0a817213ef33ddeee35b45034656986d089 | ingestion fixes replacement release readiness | ready after fixing unreachable partial-batch handling and invalid recovery owner lookup | 4 focused files 183 tests passed; typecheck passed; production-readiness READY; git diff --check passed | | 2026-07-30 | codex/archive-advisory-ui-scope | c8d50c7ac275212cbebf5c53fe859d863e800bbf | archive issue 137 after current-main verification | approved: implementation and fail-open self-tests remain green on current main | check:ci-scope; check:gate-manifest; check:outstanding-issues; diff check | | 2026-07-30 | PR-1494 | 807a3a09f5afc12e8db4f9158abe09d9c7b336c9 | PR #1494 pre-commit fail-open review | FIXED P2: legacy worktrees may skip a genuinely absent generator, while a staged deletion or rename now fails closed | docs-inventory Vitest 5 passed; shell syntax passed; Prettier test check passed; diff check passed | | 2026-07-30 | claude/x3-rag-coverage-gate-qx9j7d (PR #1463, squashed as dba7356f) | dba7356fc8dc926d951d6de6f019d5b8e000be21 | X3/#101 per-request hydration extraction from rag.ts into rag-hydration.ts | clean and landed — byte-identical move verified against pre-merge main, rag.ts 4780->4543, budget ratcheted to 4543, no back-edge (cluster referenced zero rag.ts-local symbols), both public re-exports preserved; squash captured 100% of branch content | typecheck, lint, check:knip, check:maintainability-budgets 4543/4543, focused vitest 83/83 incl rag-query-concurrency, eval:rag:offline 572/572 36 golden, format:check, verify:cheap, verify:pr-local build+bundle-scan, post-merge content verification on main | | 2026-07-30 | dba7356fc8dc926d951d6de6f019d5b8e000be21 | dba7356fc8dc926d951d6de6f019d5b8e000be21 | X3 hydration unit: per-request hydration extraction from rag.ts into rag-hydration.ts (PR #1463) | clean and landed — byte-identical move verified against pre-merge main, rag.ts 4780->4543, budget ratcheted to 4543, no back-edge, both public re-exports preserved. Supersedes the earlier row for this HEAD, which was keyed only to the slash-form branch token and so returned NOT REVIEWED on a landed-SHA lookup; it also mislabelled the unit as #101, which is the unrelated open canary-gated retrieval-parallelisation recommendation | typecheck, lint, check:knip, check:maintainability-budgets 4543/4543, focused vitest 83/83, eval:rag:offline 572/572 36 golden, format:check, verify:cheap 442 files 4625 passed, verify:pr-local, post-merge content verification on main | -| 2026-07-30 | claude/capture-session-followups | 4a041fcd2ac8f12e4ebb0ab68e0151722db65bcf | PR #1490 main sync + #151 close | merged origin/main (clean tree; GitHub DIRTY was ledger-driver staleness); archived #151 via #1494; #143 fully resolved; review threads already addressed | check:outstanding-issues; docs:check-links; merge-tree clean | -| 2026-07-30 | claude/capture-session-followups | adc4e2e86edce33849ec9c8080b8f0be86155734 | PR #1490 main sync after #1496 id collision | merged c8e53d57; kept main #149/#150; archived #151 via #1494; renumbered this PR's open rows to #152/#153; #143 fully resolved | check:outstanding-issues; docs:check-links; merge-tree clean | -| 2026-07-30 | PR-1433 | e7a8102620b5b4847894b3df21f438304ea581bd | PR #1433 document result cards and action menu final review | fixed clipped menu, clipboard fallback focus loss, and inherited action typography; no remaining findings | DOM Vitest 4/4; focused Chromium 1/1; typecheck, Prettier, and diff checks pass | -| 2026-07-30 | PR #1474 | 098186866932394d2cc17983e566ae6c44b063b4 | PR #1474 full diff vs origin/main | approved | verify:cheap; eval:rag:offline; live canary 30578169116 -> 30579534353 | | 2026-07-30 | codex/coverage-scope-policy | 94f97cdb1d0543724de408f19e79d64e61c8b31a | issue 139 coverage scope policy | approved: workflow coverage breadth is deliberate and test-pinned; docs-like skills remain static-only | check:ci-scope; check:gate-manifest; check:outstanding-issues; prettier; diff check | | 2026-07-30 | codex/coverage-scope-policy | 4da2a003bc2254507662d1b8b6e9768e94371abd | issue 139 coverage scope policy post-sync | approved: late main sync preserves deliberate workflow coverage and static-only skill policy | check:ci-scope; check:outstanding-issues; check:branch-review-ledger; diff check | | 2026-07-30 | codex/archive-completed-ci-tasks | 5c902f422ceee78ef68132900fda734c1d5bc1f8 | archive issues 133 and 135 | approved: both rows were already resolved on current main and focused guards prove their contracts | check:ci-scope; check:outstanding-issues; check:branch-review-ledger; diff check | | 2026-07-30 | codex/next-local-task | 3e6d6d69c15fc056773657e15879ba2283fa2899 | archive issues 129 and 132 | approved: documented constraints satisfy both explicit outcomes without overstating client-side enforcement | guard:push:self-test; focused vitest 24/24; check:github-actions; check:outstanding-issues; diff check | +| 2026-07-30 | PR #1474 | 098186866932394d2cc17983e566ae6c44b063b4 | PR #1474 full diff vs origin/main | approved | verify:cheap; eval:rag:offline; live canary 30578169116 -> 30579534353 | +| 2026-07-30 | PR-1433 | e7a8102620b5b4847894b3df21f438304ea581bd | PR #1433 document result cards and action menu final review | fixed clipped menu, clipboard fallback focus loss, and inherited action typography; no remaining findings | DOM Vitest 4/4; focused Chromium 1/1; typecheck, Prettier, and diff checks pass | | 2026-07-30 | claude/design-visual-baselines | b57432facb7ded1e9605d1076e0d8c9d661efa2c | open PR changed-scope review | APPROVE after fix: platform-scoped baseline guidance matches the candidate-path and AWAITING_BASELINE adoption contract. | Prettier PASS; docs:check-links PASS; check:ci-scope PASS; review thread resolved; exact-head visual CI required | | 2026-07-30 | pr/1431 | 74e10087eb20a81279fb56d18f28a2475d895fab | docs: visual baseline platform layout | approved; candidate adoption and Linux baseline guidance match implementation | runtime/install parity; ledger; CI scope; docs inventory/links; Prettier; diff-check | | 2026-07-30 | pr/1431 | b4848aa92e890193a4a41744b611746673f3b058 | docs: visual baseline platform layout | approved after remote-head reconciliation; guidance unchanged | ledger; CI scope; docs inventory/links; Prettier; diff-check | | 2026-07-30 | pr/1431 | 897de9b1b7fc243006c1a71e67a6333681272ac6 | docs: visual baseline platform layout | approved after PR 1462 base sync; visual guidance unchanged | ledger; CI scope; docs inventory; Prettier; diff-check | -| 2026-07-30 | claude/capture-session-followups | 7954c044dd16e0669d417e09d6b6192a4df0e72d | PR #1490 main sync | merged origin/main 9af15e1f (clean tree; GitHub DIRTY was merge=ledger staleness); kept #152/#153 and clarified #153 snapshot wording; #151/#143 remain archived | check:outstanding-issues; docs:check-links; merge-tree clean | | 2026-07-30 | codex/ledger-next-20260730 | 268b201a1dbaed7cca6dff4a146b0319a5275216 | issue ledger closures, favourites partial-source status, CI and ledger guards | FIXED. No remaining P0-P2 findings. Reconciled current main row by row, retained current-main #105 and #136 dispositions, rejected the unsafe Playwright cache, and fixed favourites partial-source masking plus the indexed-search hydration race. | format PASS; static gates, lint, typecheck PASS; unit 4597 PASS, 3 SKIP, 1 stale donor failure repaired by exact-lock dependency; brace cap direct PASS; focused Chromium 1 PASS; production build PASS; ledger guards PASS | | 2026-07-30 | codex/ledger-next-20260730 | 1ee749bd71da45be2d6b9d3eb4913331696996a6 | issue ledger closures, favourites partial-source status, CI and ledger guards | FIXED. Supersedes the prior review after cleanly merging current main. No remaining P0-P2 findings; current-main #079 was retained, verified closures remained archived, and no product behavior changed during sync. | merge reconciliation + ledger:dedupe PASS; outstanding and branch ledger guards PASS; whole-tree format PASS; prior static, lint, typecheck, unit, build, RAG fixture, and focused Chromium evidence retained | | 2026-07-30 | codex/ledger-next-20260730 | ee66a39b63fd7e39448ab13a5a5b72441f5321be | issue ledger closures, favourites partial-source status, CI and ledger guards | FIXED. Supersedes prior reviews after merging current main answer-delivery design. No remaining P0-P2 findings; current-main #100 governance text was retained exactly, verified closures remained archived, and the sync added documentation only. | merge reconciliation + ledger:dedupe PASS; outstanding and branch ledger guards PASS; prior format, static, lint, typecheck, unit, build, RAG fixture, and focused Chromium evidence retained | @@ -319,6 +314,12 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | PR-1507 | 12e5c8c977a873a7b900596fdc310a1a78d863f0 | PR #1507 final current-main review | APPROVE; architecture-specific container lookup and immutable-root fail-closed fallback are preserved after current-main reconciliation; no remaining P0-P2 findings. | focused Vitest 2 files / 40 tests PASS; tsc --noEmit PASS; issue/ledger/format/diff guards PASS | | 2026-07-30 | PR-1507 | 12e5c8c977a873a7b900596fdc310a1a78d863f0 | PR #1507 current-main merge and ledger normalization | APPROVED — no findings after current-main sync; feature diff unchanged and ledger reduced to three unique append-only review rows. | focused Vitest 2 files/40 tests PASS; outstanding-issues PASS; branch-review-ledger PASS; diff check PASS; merge-tree f8f88bd79706c2fb36637014a25e93af6e08eb99 | | 2026-07-30 | PR-1507 | 5e22b89f7bdb73335d12a0cf4091915615b20dd7 | PR #1507 remote ancestry reconciliation | APPROVED — identical-tree remote merge ancestry reconciled without content change; no remaining findings. | focused Vitest 2 files/40 tests PASS on identical tree; issue and ledger guards PASS; diff check PASS; merge-tree d6594063a4aa2c5f8b7a9ec72c1c41c94e6937fa | +| 2026-07-30 | claude/capture-session-followups | bbc5d4625adcbdc32aee2f9b4fb4b0d4365d0e99 | outstanding-issues capture: unreadable CI token, at-risk worktree work, unpushed hook fix | PR #1490 opened. Ledger-only: adds #149 (PAT lacks Checks: Read so no PR verdict is readable; the working status endpoint returns total:0 rather than erroring), #150 (four already-merged worktrees hold uncommitted work existing in no branch or PR, largest +395/-200 over 19 files incl CI config), #151 (the #143 pre-commit fail-open d2fd16d54 lives only on a never-pushed branch, 17 behind main, conflicting on the file main's docs:update generator now owns). Also records that PR #1458 is superseded by #1480 and should be closed after owner confirmation | check:outstanding-issues 149 rows 60 open unique ids next-id=152; docs:check-links 1415 refs resolve; docs:check-index 49 roots/modules/routes; prettier clean | +| 2026-07-30 | claude/capture-session-followups | a026c0bfe70f0e9fe290abbdd3660f4c458e4115 | PR #1490 #143/#151/#149 reconciliation | corrected archived #143 fail-open claim; #151 owns remaining half; #149 separates Checks:Read from missing-gh; merged main #1491 | check:outstanding-issues; docs:check-links | +| 2026-07-30 | claude/capture-session-followups | f18dc1fb25f4687006be897f2c11a4bb1f583f41 | PR #1490 #143/#151/#149 reconciliation | reconciled after parallel remote main-sync; #143 corrected; #151 owns fail-open; #149 separates Checks vs missing-gh | check:outstanding-issues; docs:check-links | +| 2026-07-30 | claude/capture-session-followups | 4a041fcd2ac8f12e4ebb0ab68e0151722db65bcf | PR #1490 main sync + #151 close | merged origin/main (clean tree; GitHub DIRTY was ledger-driver staleness); archived #151 via #1494; #143 fully resolved; review threads already addressed | check:outstanding-issues; docs:check-links; merge-tree clean | +| 2026-07-30 | claude/capture-session-followups | adc4e2e86edce33849ec9c8080b8f0be86155734 | PR #1490 main sync after #1496 id collision | merged c8e53d57; kept main #149/#150; archived #151 via #1494; renumbered this PR's open rows to #152/#153; #143 fully resolved | check:outstanding-issues; docs:check-links; merge-tree clean | +| 2026-07-30 | claude/capture-session-followups | 7954c044dd16e0669d417e09d6b6192a4df0e72d | PR #1490 main sync | merged origin/main 9af15e1f (clean tree; GitHub DIRTY was merge=ledger staleness); kept #152/#153 and clarified #153 snapshot wording; #151/#143 remain archived | check:outstanding-issues; docs:check-links; merge-tree clean | | 2026-07-30 | claude/capture-session-followups | 6bd0c3f85743c5406d49474bb7a92956fa44c0d2 | PR #1490 merge conflict | merged origin/main; resolved outstanding-issues against #1508 IDs; kept pre-snapshot wording | check:outstanding-issues,docs:check-links | | 2026-07-30 | claude/capture-session-followups | bdd27597e9b9d72d56940cd9a55c8000f9bbe1fc | PR #1490 merge conflict | merged origin/main; resolved outstanding-issues against #1508 IDs; kept pre-snapshot wording | check:outstanding-issues,docs:check-links | | 2026-07-30 | claude/capture-session-followups | e47c9d410a2eaaffd73af9e41dc57113f8cc9ef1 | PR #1490 merge conflict | merged origin/main; resolved outstanding-issues against #1508 IDs; kept pre-snapshot wording | check:outstanding-issues,docs:check-links | @@ -329,7 +330,7 @@ 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 | 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 | 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 | | 2026-07-30 | PR-1490 | 0a44df55532fcea3cf3b8cad28526ff8805d803b | PR #1490 consolidated session follow-ups | reviewed; consolidated accurate provider-token, preserved-worktree, install-parity, CodeRabbit, hook, and physical-device findings; resolved concurrent documentation conflict without lost rows | check:outstanding-issues pass; check:branch-review-ledger pass; docs:check-links pass; git diff --check pass | | 2026-07-30 | PR-1492 | 4fdc4ba99f94a369702c747b104fa4eaf48cb53e | PR #1492 exact-head branch-sync anti-churn review | approved after P2 repair; current helper fails closed on Actions lookup errors and defers only behind branches with queued or running exact-head CI; operator guidance and tests match | focused Vitest 9 tests passed on reviewed implementation; hosted static checks passed; exact-head coverage in progress at review; merge-tree audit clean | | 2026-07-30 | PR-1495 | 99c62cf3bd6f2a47d13b6602d54de1f8f73123e1 | PR #1495 hydration documentation correction | approved after correcting unrelated issue #101 label and appending a resolvable landed-SHA hydration review record; content consolidated into PR #1490 | outstanding-issues and ledger guards previously passed; documentation-only diff reviewed; no provider checks required | @@ -346,3 +347,5 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-31 | PR-1510 | eed59358ffdb588c3015ef317de4587d14ea00cf | PR #1510 reopen-ready evidence correction | FIXED: removed false #098 canary attribution; NOTES #154->#157 and restored 7/4/3 token accounting; mockup tokens unchanged | check:outstanding-issues PASS; check:branch-review-ledger PASS; check:design-system-contract PASS; format:changed PASS; canary ancestry vs origin/main/work verified | | 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 | diff --git a/docs/comparison-behaviour.md b/docs/comparison-behaviour.md new file mode 100644 index 0000000000..d6c0f3e120 --- /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 636d60830c..ee12629815 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 84f1a16998..ef3dfc165a 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 b63ffebc48..70deeaec5a 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. | 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. | +| 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. | @@ -100,11 +96,10 @@ 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 | -| #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 | @@ -153,6 +148,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 | 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 | | #158 | issue | Misreading the results band's muted live region as a defect (withdrawn finding, kept as a guard rail) | NOT A DEFECT — recorded 2026-07-30 so it is not re-filed. `search-results-header-band.tsx` sets `aria-live={faulted ? "off" : "polite"}` on its count/status span, which reads like a silenced failure announcement. It is not. The band mounts a separate fault panel with `role="alert"` carrying the failure title, body and Retry, and the mute is deliberate so the two do not both speak — the reasoning sits in a comment directly above the attribute, and `tests/search-results-header-band.dom.test.tsx` ("keeps exactly one status region and one alert while faulted") pins it with singular role queries that throw on duplicates. **Why this row exists:** the misreading was filed as a real P2 defect during session 2026-07-30 (PR #1481) on the strength of the attribute alone, and the proposed fix — escalating the count span to `role="alert"`/`aria-live="assertive"` — would have produced a DUPLICATE announcement and a red test, making it worse than no change. Codex caught it. An earlier withdrawal row was itself lost to the squash that merged #1481, which is the row-deletion shape `#148` now guards. **Stop:** do not "fix" this, and do not port the mockup's escalation to production. `search-refine-adaptive-mockups.tsx` legitimately escalates because it has no fault panel, so there the count span is the only announcement channel; that does not transfer. Verify which node owns an announcement before calling a live region a defect. | 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 | @@ -246,8 +244,6 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | #133 | rec | Conflict-resistant outstanding-issues ledger tables | RESOLVED 2026-07-30. The stock union driver is removed, all three tables use compact canonical rows, and current main now excludes this file from whole-tree Prettier so a max-width row cannot re-pad 144 unrelated lines. The measured mechanism is narrower than the original report: an edit within existing column maxima changes two lines either way, while an append that raises a maximum changes 144 padded lines versus two compact lines—and appends are the common operation. This branch also retains scoped `prettier-ignore` markers and a guard that rejects padded or unprotected tables. The file deliberately has no merge driver, so genuinely overlapping edits conflict instead of silently concatenating duplicates. Source: PRs #1444, #1451, #1479; concurrent #141 allocation evidence. | 2026-07-30 | | #126 | task | Quarterly branch-review ledger rotation reminder | RESOLVED 2026-07-30. The recurring UTC-quarter rotation procedure now lives in `docs/codex-review-protocol.md`: dry-run first, rotate only the intended completed records, commit live and archive together, and stop on unexpected movement or archive collision. A perpetual open reminder is no longer needed. | 2026-07-30 | | #110 | task | Design-system project token manifest lags its stylesheet | RESOLVED 2026-07-30. The full `/design-sync` re-sync ran in a session that had the skill, so `_ds_manifest.json` regenerates server-side instead of being hand-authored: `resync.mjs --remote` rebuilt the bundle from the current `src/app/globals.css`, `package-validate.mjs` exited 0 (`render check: 10/10 previews render cleanly`), and the upload re-armed the `_ds_needs_recompile` sentinel that makes the app rebuild the manifest and `_adherence.oxlintrc.json` from the uploaded source on next open. 66 files written, 0 deletes, design-side `templates/**` preserved; `_ds_sync.json` written last. Also corrected two stale claims in the conventions header the port had invalidated (PR #1443). | 2026-07-30 | -| #092 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | CLOSED 2026-07-30 as deliberately deferred to preserve the identity-clearing privacy invariant. Auth-backed registries continue clearing prior records on loading rather than retaining a refetch count across a possible identity transition. Reopen only with identity-and-query equality guards plus a test proving an identity change clears immediately. | 2026-07-30 | -| #038 | rec | Consolidate shared comparison behavior | CLOSED 2026-07-30 as a future design principle rather than current outstanding implementation. No approved new comparison surface presently needs a shared contract, and existing mode-specific behavior remains unchanged. Reopen when a concrete new comparison surface is approved. | 2026-07-30 | | #037 | rec | D5 trust-cap-all-claims flag parked OFF | CLOSED 2026-07-30 by product disposition: retain `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` OFF as the accepted policy. Routine supported claims are not globally capped to medium trust; both flag states remain test-pinned. No code, environment, clinical-output, or provider change was made. Reopen only after an explicit clinical/product trust-policy decision. | 2026-07-30 | | #021 | rec | E-3d H2 residual strong/comparison generation discards | CLOSED 2026-07-30 as deliberately parked. The conservative quality gate continues to fall back to extractive output for the residual hard cases; no generation or provider behaviour changed. Reopen only if measured latency or generation-waste complaints justify an approved evaluation wave, or a materially cheaper lever appears. | 2026-07-30 | | #005 | rec | `finalScore` saturates at clamp ceiling | CLOSED 2026-07-30 as not a current defect. Result ordering already uses the unbounded pre-clamp `rankScore`; the `[0,1]` clamp applies only to reported confidence, and the current golden set has no defect. RAG impact: no retrieval behaviour change — ledger disposition only. Reopen only within an approved ranking initiative with the required canary. | 2026-07-30 | diff --git a/docs/staging-setup.md b/docs/staging-setup.md index 6ff40b5ddb..9516481f59 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 @@ -33,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/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 668dbb5980..763d235dc6 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 c72eabd040..952ab2dbd9 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -832,7 +832,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" @@ -1017,7 +1017,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 585bf640b4..ef01eb930b 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 f650873b99..6f6a487a70 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +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"; @@ -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,33 @@ 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 [requestLifecycle] = useState(() => createAuthRequestLifecycle()); + 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 (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 { @@ -139,25 +162,42 @@ 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. const [retryAttempt, setRetryAttempt] = useState(0); const refetch = useCallback(() => { if (!requestKey) return; - setState({ 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, setState]); useEffect(() => { if (!requestKey || !cacheKey) return undefined; - if (peekDifferentialCache(cacheKey)) { + // 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; } 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([ @@ -171,7 +211,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(); @@ -193,7 +233,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 ?? [], @@ -203,7 +243,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); @@ -211,13 +256,23 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { return () => { window.clearTimeout(timer); controller.abort(); + registration.release(); }; - }, [requestKey, cacheKey, authStatus, authorizationHeader, markSessionExpired, retryAttempt]); + }, [ + requestKey, + cacheKey, + authStatus, + authorizationHeader, + markSessionExpired, + retryAttempt, + state.status, + requestLifecycle, + ]); 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 746f26a180..c5cd5aef0b 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; import { useAuthSession } from "@/lib/supabase/client"; @@ -61,28 +62,43 @@ 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 [requestLifecycle] = useState(() => createAuthRequestLifecycle()); 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 }, + ); } + 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); @@ -92,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, @@ -107,8 +128,9 @@ export function useMedicationCatalog( return () => { window.clearTimeout(timer); controller.abort(); + registration.release(); }; - }, [trimmed, enabled, fields, debounceMs, authorizationHeader]); + }, [trimmed, enabled, fields, debounceMs, authIdentity, authorizationHeader, requestLifecycle]); return state; } diff --git a/src/components/document-viewer/source-panels.tsx b/src/components/document-viewer/source-panels.tsx index b5bc77fdbe..47144e9582 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 55769e80a8..e09a70f4d7 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 8826eaaa8d..d727dbd4c0 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 1f41d1ec22..3ea6d58001 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 fd4865775e..1a536f22fa 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 85bb03624e..d923213422 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -1,12 +1,13 @@ "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, createAuthRequestLifecycle } 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,24 +75,59 @@ 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 [requestLifecycle] = useState(() => createAuthRequestLifecycle()); + + 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. + // 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(() => { - 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]); 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 @@ -116,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; @@ -130,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 new file mode 100644 index 0000000000..ce9d38549e --- /dev/null +++ b/tests/catalog-refetch-privacy.dom.test.tsx @@ -0,0 +1,113 @@ +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; + 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 }); + + 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 () => { + 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 }); + + 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/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 2c0091cced..058aa7a5b1 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3593,6 +3593,10 @@ 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 waitForReactEventHandler(sourceSearch, "onChange"); @@ -3600,6 +3604,11 @@ test.describe("Clinical KB UI smoke coverage", () => { 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"); @@ -3608,6 +3617,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); }); @@ -3846,6 +3858,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, }) => { @@ -3873,6 +3915,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); @@ -3894,6 +3938,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"); @@ -3916,6 +3964,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" }); @@ -3923,11 +3974,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 c60f6d7c12..615b3b8d50 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,267 @@ 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("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" }, + 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" }, + 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" },