diff --git a/bundle-budget.json b/bundle-budget.json index 84feae937..9ebae9854 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,6 +1,7 @@ { - "$comment": "Client JS bundle-size budget. Warn-only until a baseline is captured: after a known-good `npm run build`, run `npm run check:bundle-budget -- --update` to set totalGzipBytes, then flip enforce to true so CI fails on >tolerancePct growth. See scripts/check-bundle-budget.mjs.", - "enforce": false, + "$comment": "Client JS bundle-size budget captured from a known-good production build. CI fails when total gzip size grows beyond tolerancePct; refresh intentionally with `npm run check:bundle-budget -- --update`.", + "enforce": true, "tolerancePct": 10, - "totalGzipBytes": null + "totalGzipBytes": 1363382, + "updatedAt": "2026-07-17T12:11:55.267Z" } diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 43cd46874..e3e7258e9 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,44 +18,46 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD ## Review Records -| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | -| ---------- | ------------------------------------------------------ | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-07-13 | codex/lithium-answer-recovery-pr | c5fde11e64d8976e1c163d1b8618f58a52e0b8ff | lithium answer recovery and source governance | Fixed the provider-failure path with a grounded Australian source-backed fallback, a public-safe progress lifecycle, centralised Australian authority/context selection, and fail-closed locality repair. The live audit exposed and the branch fixed hierarchy identity false conflicts plus registry projections entering clinical metadata gates. The corrected audit/backfill found zero proposals, so no production write was made. No high-confidence defect remains. Residual risk is provider latency; live generation timed out but the grounded fallback completed. | `npm run verify:pr-local` passed: 1,988 tests passed/1 skipped, build/client scan, 36 fixtures, and 267 offline RAG tests. After review fixes, `npm run verify:cheap` passed with 1,992 tests passed/1 skipped. `npm run check:production-readiness` passed 5/5. `npm run test:e2e:critical` passed 9/9. `node scripts/run-playwright.mjs tests/answer-progress-ui-smoke.spec.ts --project=chromium` passed 2/2. `node scripts/run-eval-safe.mjs scripts/eval-rag.ts --question "Lithium dosing" --expect-australian --fail-on-threshold --json` exited 0 with one grounded FSH citation and no threshold/safety failures. `npm run audit:source-governance` reported 0 gaps/conflicts/proposals across 2,851 rows. `npm run backfill:source-metadata -- --locality-only` reported 0 changes. `git diff --check` passed. Full `npm run verify:ui` was not rerun after the aggregate runner lost its local server. | -| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval | -| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` | -| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | -| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | -| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI | -| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained | -| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | -| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | -| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` | -| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check | -| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | -| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. | -| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | -| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | -| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | -| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | -| 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. | -| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. | -| 2026-07-13 | codex/repository-review-remediation | b72cefd2f5c0da79788cc0f8d0d40837c711ae92 | live-drift reconciliation and release review | Reconciled production migration history and live-ahead governance/retrieval definitions without mutating live; removed the migration-version collision; made captured OUT-signature changes fresh-replay-safe; preserved production ACLs; added a forward lexical-score correction; and reduced read-only live drift from 27 differences to five changes fully explained by the unapplied remediation migrations. No remaining high-confidence source defect was found in the reviewed scope. | Docker schema replay and regenerated manifest; isolated full Supabase migration reset; focused Vitest 74/74; full Vitest 1,712 passed/1 skipped; lint; typecheck; production build and client-bundle secret scan; configured production-readiness READY; targeted Chromium scope/modal/control QA 4/4; read-only live drift; Supabase security advisor clear. Live apply not run because authorization remained read-only. | -| 2026-07-13 | codex/repository-review-remediation | 452275824294564a1e08e6bec169bd4af744d09a | live migration apply and post-apply review | Applied the four reviewed forward migrations to `Clinical KB Database`, aligned repository filenames to the generated production versions, and corrected the schema snapshot so the legacy unfenced commit overload remains inaccessible to `service_role`. Live drift is clean and no active ingestion/enrichment overlap or duplicate open ingestion group was found. | Ran `npm run check:drift`: passed clean. Ran `npm run check:production-readiness`: READY. Ran Docker schema replay: passed. Ran focused concurrency/retrieval Vitest: 166/166 passed. Ran offline RAG: 36 fixtures and 60/60 contract tests passed. Ran M13, retrieval-owner, schema-health, lexical-retrieval, concurrency, and ACL live probes: passed; lexical retrieval returned 12 truthfully scored results. Not completed: full provider retrieval-quality evaluation exceeded the local command window; deterministic live retrieval checks passed. | -| 2026-07-13 | codex/fix-48h-review-findings-current | 49735663370735a60870d065ed0de3b9d34e077f | last-48-hours PR remediation | Revalidated the last-48-hours findings on current main after PRs #538 and #540; retained only unique fixes across auth/cache isolation, stale-response protection, upload/routing/UI behavior, RAG coalescing, telemetry, worktree tooling, and SAST enforcement. No remaining high-confidence local defect was found in the changed scope. The approved live drift check reported only the five differences already explained by unapplied migrations from #540. | Focused Vitest 107/107; `npm run verify:pr-local` (1,762 passed, 1 skipped; production build and client-bundle scan; offline RAG 60/60); critical Chromium 8/8; live `check:drift`; `git diff --check`. Full Chromium remains advisory after the earlier runner hang; the required critical subset passed on current main. | -| 2026-07-13 | codex/public-anonymous-access | 7f3eded3d17c9daf6a443c9cac3f0553e4e9321b | production UI design and accessibility review | Fixed the fullscreen clinical-table focus leak and divergent modal implementation, removed the non-native table-surface control, and lifted meaningful production metadata from 8-10px to the 11px floor with stronger muted contrast. No remaining high-confidence defect was found in the reviewed visual scope. | Baseline/final screenshots at 1440x1000 and 390x820; focused Chromium table expansion 3/3; focused Vitest 6/6; `npm run typecheck`; targeted ESLint; type-scale and focused Prettier checks; `git diff --check`. `verify:cheap` timed out in full lint/test execution; full `verify:ui` deferred under the API confirmation boundary. | -| 2026-07-13 | main (PR #570 squash, glass header) | cc6bfc1c80902ca3c91e5ba2ebe78a80f3fd9e14 | post-merge review: CSS/visual/a11y/perf + logic/regression | No P0/P1. P2s confirmed and fixed in follow-up: build pipeline dropped ALL hand-authored backdrop-filter declarations (manual -webkit- duplicates confused Lightning CSS — header scrim, bottom dock, and composer pill were tint-only in every engine); scrim retuned to carry the bar's frost alone (header backdrop-root removed) with masks fading to true zero; private-scope alert made sticky inside main (was scroll-away in non-answer modes); scroll-hide reporter reset on breakpoint-gate change; non-forced fallback backgrounds layered to preserve the utility-wins contract. | Ran custom Playwright probe (`node scratchpad/probe-blur.mjs`, Chromium 390x844): baseline showed `getComputedStyle(.edge-glass-header-backdrop).backdropFilter === "none"` on all three passes, after fix `blur(14px)/blur(20px)/blur(26px)` — passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium -g "glass header\|collapse hide\|private-scope alert\|phone (short\|long) answer stays"`: 6/6 passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium` (full file): 71 passed, 4 failed (pre-existing `/privacy` heading test, fails identically on clean main); Ran `npm run verify:cheap`: exit 0 (1935 unit tests passed); Ran `npm run format:check`: passed; Not run: WebKit/Safari real-device check (no WebKit runner in this environment — served client chunk verified to pair `-webkit-backdrop-filter` with each declaration for Safari <= 17) | -| 2026-07-13 | codex/rag-review-followup | 755ac9e517a3b81f8e12a119f80f3769dd58ae4e | PR #575 post-merge review finding remediation | Fixed the P1 path that could combine a medication amount and route from separate chunks, expanded the shared explicit amount/route/frequency intent detector, corrected route-only failure classification, and added microgram-symbol coverage. Requested attributes must now be co-located with the medication subject before the text fast path is accepted. No additional high-confidence defect was found in the changed scope after integrating the production answer-budget fix from PR #580. | Focused Vitest 143/143; `npm run eval:rag:offline` (21 files, 265/265); `npm run typecheck`; targeted ESLint; full `npm test` (211 files passed, 1 skipped; 1,946 tests passed, 1 skipped); PR-local dry-run selected runtime, format, lint, typecheck, full tests, build, and offline RAG; `git diff --check`. `verify:cheap` passed all pre-test stages but its 10-minute host bound expired during the full suite; the same suite then passed independently with a longer bound. | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | -------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-17 | PR #718 / codex/performance-latency-remediation-20260717 | b5f509744d4f4bac74d414644cd1802f64b97fa9 | CodeRabbit performance and SQL correctness follow-up | Resolved nine confirmed findings and dispositioned one stale test comment: document downloads revalidate signed URLs on every action; committed-generation filtering precedes detail pagination; enrichment fallback errors preserve identity; caller cancellation leaves the shared classifier flight alive; registry seeding preserves its cache signal; aliases emit canonical corrections; rate-limit success metadata is coherent; ambiguous upserts use named constraints; and grantable default ACLs fail closed. The proxy mock duplicate was not present. No remaining high-confidence P0-P2 defect was found. | Integrated focused Vitest 122/122; post-format Vitest 71/71; `npm run verify:cheap` passed runtime/policy/static guards, ESLint, TypeScript, and 2,684/2,684 tests; focused Prettier and `git diff --check`; disposable Docker replay, regenerated drift manifest, and transactional local SQL probes. No OpenAI calls, live Supabase DDL/migration/data write, deployment, or production mutation ran. | +| 2026-07-17 | PR #718 / codex/performance-latency-remediation-20260717 | d47ef7a329256687a615c33dd311806c2c1214a8 | hosted UI and migration-order merge-blocker follow-up | Fixed both integration defects exposed by the exact-head UI run: the document scope surface now triggers the deferred, deduplicated catalogue load, and viewer navigation closes unrelated disclosures before opening or scrolling to the selected section. Updated SSR-aware browser fixtures without restoring the removed detail request. Renumbered all three new migrations after the latest production migration and regenerated the drift manifest. No remaining high-confidence P0-P2 defect was found in the follow-up diff. | Focused Vitest 65/65; scoped ESLint and Prettier; `git diff --check`; isolated production Webpack build and TypeScript; focused Chromium 7/7 including both stress viewports; Docker schema replay and drift-manifest regeneration passed in 15 seconds with unchanged schema SHA. No OpenAI calls or live Supabase DDL, migration, rate-limit RPC, or data write ran. | +| 2026-07-13 | codex/lithium-answer-recovery-pr | c5fde11e64d8976e1c163d1b8618f58a52e0b8ff | lithium answer recovery and source governance | Fixed the provider-failure path with a grounded Australian source-backed fallback, a public-safe progress lifecycle, centralised Australian authority/context selection, and fail-closed locality repair. The live audit exposed and the branch fixed hierarchy identity false conflicts plus registry projections entering clinical metadata gates. The corrected audit/backfill found zero proposals, so no production write was made. No high-confidence defect remains. Residual risk is provider latency; live generation timed out but the grounded fallback completed. | `npm run verify:pr-local` passed: 1,988 tests passed/1 skipped, build/client scan, 36 fixtures, and 267 offline RAG tests. After review fixes, `npm run verify:cheap` passed with 1,992 tests passed/1 skipped. `npm run check:production-readiness` passed 5/5. `npm run test:e2e:critical` passed 9/9. `node scripts/run-playwright.mjs tests/answer-progress-ui-smoke.spec.ts --project=chromium` passed 2/2. `node scripts/run-eval-safe.mjs scripts/eval-rag.ts --question "Lithium dosing" --expect-australian --fail-on-threshold --json` exited 0 with one grounded FSH citation and no threshold/safety failures. `npm run audit:source-governance` reported 0 gaps/conflicts/proposals across 2,851 rows. `npm run backfill:source-metadata -- --locality-only` reported 0 changes. `git diff --check` passed. Full `npm run verify:ui` was not rerun after the aggregate runner lost its local server. | +| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval | +| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` | +| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | +| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | +| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI | +| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained | +| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | +| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | +| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` | +| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check | +| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | +| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. | +| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | +| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | +| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | +| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | +| 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. | +| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. | +| 2026-07-13 | codex/repository-review-remediation | b72cefd2f5c0da79788cc0f8d0d40837c711ae92 | live-drift reconciliation and release review | Reconciled production migration history and live-ahead governance/retrieval definitions without mutating live; removed the migration-version collision; made captured OUT-signature changes fresh-replay-safe; preserved production ACLs; added a forward lexical-score correction; and reduced read-only live drift from 27 differences to five changes fully explained by the unapplied remediation migrations. No remaining high-confidence source defect was found in the reviewed scope. | Docker schema replay and regenerated manifest; isolated full Supabase migration reset; focused Vitest 74/74; full Vitest 1,712 passed/1 skipped; lint; typecheck; production build and client-bundle secret scan; configured production-readiness READY; targeted Chromium scope/modal/control QA 4/4; read-only live drift; Supabase security advisor clear. Live apply not run because authorization remained read-only. | +| 2026-07-13 | codex/repository-review-remediation | 452275824294564a1e08e6bec169bd4af744d09a | live migration apply and post-apply review | Applied the four reviewed forward migrations to `Clinical KB Database`, aligned repository filenames to the generated production versions, and corrected the schema snapshot so the legacy unfenced commit overload remains inaccessible to `service_role`. Live drift is clean and no active ingestion/enrichment overlap or duplicate open ingestion group was found. | Ran `npm run check:drift`: passed clean. Ran `npm run check:production-readiness`: READY. Ran Docker schema replay: passed. Ran focused concurrency/retrieval Vitest: 166/166 passed. Ran offline RAG: 36 fixtures and 60/60 contract tests passed. Ran M13, retrieval-owner, schema-health, lexical-retrieval, concurrency, and ACL live probes: passed; lexical retrieval returned 12 truthfully scored results. Not completed: full provider retrieval-quality evaluation exceeded the local command window; deterministic live retrieval checks passed. | +| 2026-07-13 | codex/fix-48h-review-findings-current | 49735663370735a60870d065ed0de3b9d34e077f | last-48-hours PR remediation | Revalidated the last-48-hours findings on current main after PRs #538 and #540; retained only unique fixes across auth/cache isolation, stale-response protection, upload/routing/UI behavior, RAG coalescing, telemetry, worktree tooling, and SAST enforcement. No remaining high-confidence local defect was found in the changed scope. The approved live drift check reported only the five differences already explained by unapplied migrations from #540. | Focused Vitest 107/107; `npm run verify:pr-local` (1,762 passed, 1 skipped; production build and client-bundle scan; offline RAG 60/60); critical Chromium 8/8; live `check:drift`; `git diff --check`. Full Chromium remains advisory after the earlier runner hang; the required critical subset passed on current main. | +| 2026-07-13 | codex/public-anonymous-access | 7f3eded3d17c9daf6a443c9cac3f0553e4e9321b | production UI design and accessibility review | Fixed the fullscreen clinical-table focus leak and divergent modal implementation, removed the non-native table-surface control, and lifted meaningful production metadata from 8-10px to the 11px floor with stronger muted contrast. No remaining high-confidence defect was found in the reviewed visual scope. | Baseline/final screenshots at 1440x1000 and 390x820; focused Chromium table expansion 3/3; focused Vitest 6/6; `npm run typecheck`; targeted ESLint; type-scale and focused Prettier checks; `git diff --check`. `verify:cheap` timed out in full lint/test execution; full `verify:ui` deferred under the API confirmation boundary. | +| 2026-07-13 | main (PR #570 squash, glass header) | cc6bfc1c80902ca3c91e5ba2ebe78a80f3fd9e14 | post-merge review: CSS/visual/a11y/perf + logic/regression | No P0/P1. P2s confirmed and fixed in follow-up: build pipeline dropped ALL hand-authored backdrop-filter declarations (manual -webkit- duplicates confused Lightning CSS — header scrim, bottom dock, and composer pill were tint-only in every engine); scrim retuned to carry the bar's frost alone (header backdrop-root removed) with masks fading to true zero; private-scope alert made sticky inside main (was scroll-away in non-answer modes); scroll-hide reporter reset on breakpoint-gate change; non-forced fallback backgrounds layered to preserve the utility-wins contract. | Ran custom Playwright probe (`node scratchpad/probe-blur.mjs`, Chromium 390x844): baseline showed `getComputedStyle(.edge-glass-header-backdrop).backdropFilter === "none"` on all three passes, after fix `blur(14px)/blur(20px)/blur(26px)` — passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium -g "glass header\|collapse hide\|private-scope alert\|phone (short\|long) answer stays"`: 6/6 passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium` (full file): 71 passed, 4 failed (pre-existing `/privacy` heading test, fails identically on clean main); Ran `npm run verify:cheap`: exit 0 (1935 unit tests passed); Ran `npm run format:check`: passed; Not run: WebKit/Safari real-device check (no WebKit runner in this environment — served client chunk verified to pair `-webkit-backdrop-filter` with each declaration for Safari <= 17) | +| 2026-07-13 | codex/rag-review-followup | 755ac9e517a3b81f8e12a119f80f3769dd58ae4e | PR #575 post-merge review finding remediation | Fixed the P1 path that could combine a medication amount and route from separate chunks, expanded the shared explicit amount/route/frequency intent detector, corrected route-only failure classification, and added microgram-symbol coverage. Requested attributes must now be co-located with the medication subject before the text fast path is accepted. No additional high-confidence defect was found in the changed scope after integrating the production answer-budget fix from PR #580. | Focused Vitest 143/143; `npm run eval:rag:offline` (21 files, 265/265); `npm run typecheck`; targeted ESLint; full `npm test` (211 files passed, 1 skipped; 1,946 tests passed, 1 skipped); PR-local dry-run selected runtime, format, lint, typecheck, full tests, build, and offline RAG; `git diff --check`. `verify:cheap` passed all pre-test stages but its 10-minute host bound expired during the full suite; the same suite then passed independently with a longer bound. | | 2026-07-13 | origin/main (detached review worktree) | c523cabeae4b68ebdf569ecbc18d9f5a7b5afbf1 | repo-wide audit | Changes requested: one P1 clinical-answer trust cluster; three P2 tenancy/reindex guardrail issues; three P3 information-disclosure, dead-code, and transitive-deprecation cleanup items. No P0 found. | `npm run verify:cheap` (1,721 passed, 1 skipped); `npm run test:coverage` (thresholds passed); `npm run build`; `npm run verify:ui` (137/137); `npm run eval:rag:offline` (36 fixtures, 60 tests); format, Edge Function, Codex workflow, import/secret/dead-reference scans. Provider-backed checks skipped. | | 2026-07-13 | HEAD / origin/main (detached worktree) | 04c1d0b036cae8af4dabfc692055c7aab93d5888 | OpenAI-facing API and integration review | Read-only review found one P1 clinical-streaming defect and four P2 reliability/API-contract issues: provisional clinical prose is exposed before validation; mid-stream failure can silently trigger a second buffered generation; answer caches are not model/prompt fingerprinted; OpenAI access/model errors are under-classified; and table-fact route IDs are not validated before database access. GPT-5.6 migration also requires replacing the legacy prompt-cache parameter. No application code was changed. | Static call-flow, prompt, model, schema, streaming, cache, error, route, test, and governance inspection; official OpenAI model/Responses/structured-output/streaming/prompt-cache guidance reviewed. Provider-backed checks were not run. Local tests were not run because this worktree has no installed dependencies (`openai`, `vitest`). | @@ -576,5 +578,6 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-17 | PR #713 / codex/chat-workflow-ideas-0916 | b52112df6aa36311d7420189064acd79dcf2c3f5 + reviewed follow-up diff | workflow toolkit review follow-up | Fixed all 14 actionable Codex and CodeRabbit threads: cross-platform path fixtures, installation-managed preflight guidance, complete Supabase-backed API database scoping, per-command approval boundaries, plugin-ignore narrowing, isolated CI-scope proof, remote-Git command guarding, repository-skill verification classification, `TypeError` diagnosis, strict CLI option values, machine-parseable JSON evidence output, and preservation of baseline database/clinical approval gates in the RAG lab. No unresolved actionable finding remains in the reviewed scope. | `npm run verify:cheap` passed with 273 files and 2,599 tests; focused toolkit Vitest 20/20; CI-scope self-test; plugin-ignore proof; `git diff --check`. Exact-head hosted CI remains required after the follow-up push. No Supabase, OpenAI, or other live product-provider command was run. | | 2026-07-17 | PR #699 / codex/test-reliability-hardening | 6202835ab1cf3703af311b9afdf372f74c63e040 | branch-cleanup-superseded | Closed as fully superseded by merged PR #705 (`e5caaa46c`). Range-diff maps the original implementation commit to #705's first integration commit; #705 then adds seven focused reliability fixes, while #699's remaining commit is merge-only and contributes no unique relevant patch. The exact-SHA remote ref and the unregistered local predecessor ref (`b518c1de9`) were deleted after final rechecks. | Fresh GitHub PR/head/status inventory; exact `ls-remote` and local-ref checks; cherry-pick-aware log; range-diff against PR #705's merged head; merge-parent verification; exact leased remote deletion and exact-old-value local `update-ref` deletion. No Supabase/OpenAI/product-provider checks run. | | 2026-07-17 | PR #716 / codex/pr-process-hardening-20260717 | 220de891f10f82df11eb2e8137367514c139a206 + reviewed implementation/follow-up diff | PR metadata, CI triage, review routing, Actions permissions, secret scanning, and branch-hygiene hardening | No remaining high-confidence P0-P2 defect in the reviewed process diff. Added a trusted base-SHA PR metadata/risk policy with draft and merge-queue handling; corrected CI triage to compare only the same workflow's latest completed `main` run; wired self-tests into local/hosted gates; documented the policy; created the missing durable Codex routing labels; removed four unused per-head routing labels; and applied read-only default Actions tokens, immutable Action pinning, no Actions-authored approvals, push protection, and automatic merged-branch deletion. Four policy defects were fixed before merge: API-only paths no longer trigger UI evidence, slash-bearing outcome titles are accepted, all seven governance items are required exactly, and placeholder risk/rollback text is rejected. | Offline `check:pr-policy`, `check:ci-triage`, `check:ci-scope`, action-pin check, scoped Prettier, and `git diff --check` passed. Initial hosted CI passed static, safety, coverage, build, images, Semgrep, Gitleaks, GitGuardian, and the aggregate gate; exact-head CI is required after the review fix. Broader local gates were deferred because other registered worktrees repeatedly held the heavyweight lock. GitHub provider inspection/settings changes were explicitly authorized. No Supabase/OpenAI/product-provider command ran. | +| 2026-07-17 | codex/performance-latency-remediation-20260717 | f277d13512e85dcaae4f9b94d028a8087e41375a | end-to-end database, network, middleware, client rendering, migration, privacy, and merge-readiness review | Fixed all confirmed performance findings plus three final review blockers: title-vocabulary backfill now installs its privacy trigger first, canonical schema replay preserves hardened trigger-function ACLs, and medication auto-seeding no longer aborts its own owner-cache flight. The isolated worktree also uses Next's supported Webpack fallback only when shared dependencies resolve outside the project. No remaining high-confidence P0-P2 defect was found in the reviewed diff. | Docker scratch replay and drift-manifest regeneration passed; live read-only plans selected lexical, table-fact trigram, and chunk HNSW indexes; exact TypeScript passed; focused Vitest owner-cache 9/9, runner safety 12/12, bundle budget 10/10, offline RAG 291/291, and earlier aggregate unit run 2,657 passed with its sole corrected schema assertion subsequently validated by direct ordering/ACL checks; production build and bundle budget passed at 1,363,382 gzip bytes; targeted Chromium dashboard deferral and NDJSON search passed, while the viewer trace reached all hydration/preview assertions and its corrected download-control selector remains for exact-head hosted UI. No OpenAI calls, live Supabase writes/migrations, deployment, or production mutation ran. | | 2026-07-17 | codex/mobile-search-phone-refresh-20260717 (supersedes PR #700) | 42a3e3ce65dc5a0e1dce386e0b91fccd23d13d6c + reviewed follow-up diff | phone universal-search command-panel recovery and merge-readiness review | Recovered the still-useful behavior from PR #700 onto current `main`, including its hydration fix and wide-touch regression coverage. Hosted Production UI then exposed one desktop focus race: capability state intentionally initializes false for hydration safety, but an input could receive focus before the post-hydration effect synchronized the real browser state. The follow-up recomputes the same guarded predicate synchronously on focus; it requires the placement breakpoint plus either a fine pointer or a zero-touch desktop fallback, so wide touch devices remain suppressed while desktop keeps the first command-panel interaction. No remaining high-confidence P0-P2 defect was found in the scoped diff. | Focused Vitest 7/7; `npm run ensure` verified the project at `http://localhost:3751`; hosted static, safety, coverage, build, advisory UI, Semgrep, Gitleaks, and GitGuardian passed; the first hosted Production UI run isolated the nine desktop regressions. The focused browser proof reproduced the desktop race while the wide-touch regression passed, and exact-head hosted Production UI remains required after the focus fix. `format:changed -- --check` and `git diff --check` passed before the final follow-up. No Supabase/OpenAI/product-provider command ran. | | 2026-07-17 | final historical branch/worktree cleanup against `origin/main` | 5d195d7ca8752b2ae4006725c6b145c5662bb687 | branch-cleanup | Merged PRs #716 and #717, closed superseded PR #700, and removed the three clean task worktrees. Deleted exact remote refs for `codex/mobile-search-phone-fix-20260717` (`590f32b73`), `claude/audit-findings-review-phgz92` (`ea3b8f95b8`), `codex/chat-forms-import-6914` (`b05da82f82`), `codex/chat-supabase-migration-preflight-b463` (`1ef0faee95`), and `codex/dsm-diagnosis-mode` (`f6cda83ca6`); the merged #716/#717 branches were deleted automatically. Deleted 14 unregistered local refs only after direct-main ancestry, exact ledger deletion-pending proof, or exact merged-PR commit provenance. Final inventory found zero remote branches without an open PR or registered worktree, zero locally merged or exact deletion-pending orphan refs, and no retired target refs or paths. Thirteen non-ancestor local refs and 25 registered worktrees remain preserved because they are backups, patch-unique/unresolved, open-PR-owned, or ownership could not be safely disproved. | Fresh fetch/prune; exact GitHub PR/head/merge associations; cherry-pick-aware logs; DSM PR #661 exact commit/file provenance; exact leased remote deletes; exact-old-value local `update-ref` deletes; clean-worktree and path/process checks; worktree prune; final zero-orphan inventory. The Codex task registry lookup timed out, so ambiguous registered worktrees were conservatively retained. No Supabase, OpenAI, production-data, or live clinical workflow ran. | diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md new file mode 100644 index 000000000..9cb1faf3a --- /dev/null +++ b/docs/operator-apply-performance-latency-remediation.md @@ -0,0 +1,25 @@ +# Operator apply — performance and latency remediation + +This worktree does not apply migrations. Production rollout remains a separate, +explicitly authorized operation after local replay, review, and backups. + +## Registry projection index on a busy database + +`20260717170000_registry_projection_cleanup.sql` creates +`documents_registry_projection_lookup_idx` transactionally so clean local +replay remains deterministic. On a busy production database, pre-create the +exact index outside a transaction: + +```sql +create index concurrently if not exists documents_registry_projection_lookup_idx + on public.documents ( + (metadata->>'registry_record_kind'), + (metadata->>'registry_record_id') + ) + where metadata->>'source_kind' = 'registry_record'; +``` + +After the index is valid and ready, the migration's `create index if not +exists` is a no-op. Do not mark the migration applied merely because the index +exists: the cleanup function, hardened privileges, and three lifecycle triggers +must still be installed through the normal authorized migration rollout. diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 5bc236c26..c692dc6f4 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -320,7 +320,7 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu ## 2026-07-13 audit remediation batch (branch claude/audit-remediation-2026-07-13) - **Lexical retrieval rewrite (audit finding 1):** `20260713100000_index_friendly_lexical_retrieval.sql` splits `match_document_chunks_text`'s OR-across-relations candidate search into two GIN-index probes unioned by chunk id (same contract, same scores; the `_v2` wrapper inherits the speedup). Parity + plan + timing harness: `scripts/sql/lexical-rpc-parity-check.sql` (scratch databases only; run it against the drift-manifest container kept with `--keep`). Re-run it whenever either lexical body changes. -- **supabase_admin default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies the intended defaults before migrations on fresh databases. Supabase CLI reads that file as the unprivileged local `postgres` role, so the migration-replay job starts an empty emulator without migrations, applies the file once as `supabase_admin`, and then applies every migration with `supabase migration up --local` in the same fresh database. This preserves the database-scoped defaults without granting reserved role membership. `20260717161000_assert_supabase_admin_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. The migration attempts the hardening statements and then fails closed if the effective postcondition is unsafe, returning the exact six-statement operator remediation in its error hint. Existing hosted databases do not receive `roles.sql` through ordinary migration deployment; after an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. The future-object probe in `20260713102000_revoke_supabase_admin_default_privileges.sql` remains useful in environments that can assume `supabase_admin`. +- **supabase_admin default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies the intended defaults before migrations on fresh databases. Supabase CLI reads that file as the unprivileged local `postgres` role, so the migration-replay job starts an empty emulator without migrations, applies the file once as `supabase_admin`, and then applies every migration with `supabase migration up --local` in the same fresh database. This preserves the database-scoped defaults without granting reserved role membership. `20260717161000_assert_supabase_admin_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_supabase_admin_default_privileges.sql` repeats that fail-closed postcondition after the later performance migrations and must remain the final migration. Existing hosted databases do not receive `roles.sql` through ordinary migration deployment; after an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. The future-object probe in `20260713102000_revoke_supabase_admin_default_privileges.sql` remains useful in environments that can assume `supabase_admin`. - **Legacy rag query text scrub (audit finding 5):** `20260713103000_scrub_legacy_rag_query_text.sql` performs four redaction/deletion operations for pre-HMAC plaintext query text: (1) scrubs `rag_queries.query` rows not matching `redacted-query:%`, replacing them with salted `redacted-query:legacy:` placeholders; (2) scrubs both `rag_query_misses.query` and `rag_query_misses.normalized_query` not matching `redacted-query:%`; (3) scrubs both `rag_retrieval_logs.query` and `rag_retrieval_logs.normalized_query` not matching `redacted-query:%` (nullable); (4) deletes `rag_response_cache` rows where `normalized_query` does not match `redacted-cache:%` (cache entries, not re-keyed). **Operator verification after live apply:** for each affected table/operation, count rows not matching the expected redacted pattern (expect 0 unless `RAG_PERSIST_RAW_QUERY_TEXT` is deliberately enabled): `select count(*) from rag_queries where query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_query_misses where query not like 'redacted-query:%' or normalized_query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_retrieval_logs where query not like 'redacted-query:%' or (normalized_query is not null and normalized_query not like 'redacted-query:%');` (expect 0), `select count(*) from rag_response_cache where normalized_query not like 'redacted-cache:%';` (expect 0). The migration includes a post-apply assertion block that enforces these exact checks and fails the migration if any unscrubbed/undeleted rows remain. - Remaining operator items from the 2026-07-13 audit (confirmation-required, not automated): apply this batch's migrations live, re-run Supabase advisors, trigger the Eval Canary and require two consecutive green scheduled runs, repair the invalid `storage.idx_objects_bucket_id_name_lower` index via dashboard/support, and dedupe response-cache purge cron jobs if `cron.job` shows duplicates. diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index c1a17349c..47e6a7113 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -25,6 +25,30 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const CHUNKS_DIR = path.join(root, ".next", "static", "chunks"); const BUDGET_PATH = path.join(root, "bundle-budget.json"); +const APP_BUILD_MANIFEST_PATH = path.join(root, ".next", "app-build-manifest.json"); +const BUILD_MANIFEST_PATH = path.join(root, ".next", "build-manifest.json"); +const ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH = path.join( + root, + ".next", + "server", + "app", + "page_client-reference-manifest.js", +); + +const fixtureSnapshotMarkerGroups = [ + { + name: "services snapshot", + markers: ["deep_research_citation_tokens", "canonical_name_key", "source_table_lines"], + }, + { + name: "forms fixture catalogue", + markers: ["transport-crisis-form", "extension-transport-order", "detention-examination-movement"], + }, + { + name: "differentials snapshot", + markers: ["redFlagFlows", "searchAliases", "exportedAt"], + }, +]; function walkJsFiles(dir) { const out = []; @@ -74,6 +98,45 @@ export function compareToBudget(current, budget) { }; } +/** Resolve the JavaScript chunks required by the root App Router dashboard. */ +export function initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest) { + const pages = appBuildManifest?.pages ?? {}; + const pageClientChunks = Object.values(pageClientReferenceManifest?.clientModules ?? {}).flatMap((module) => + Array.isArray(module?.chunks) ? module.chunks : [], + ); + const names = new Set([ + ...(appBuildManifest?.rootMainFiles ?? []), + ...(pages["/layout"] ?? []), + ...(pages["/page"] ?? []), + ...pageClientChunks, + ]); + return [...names] + .filter((name) => typeof name === "string" && name.endsWith(".js")) + .map((name) => name.replace(/^\/?static\/chunks\//, "")); +} + +function loadRootPageClientReferenceManifest() { + if (!existsSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH)) return null; + const source = readFileSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH, "utf8"); + const marker = 'globalThis.__RSC_MANIFEST["/page"]='; + const start = source.indexOf(marker); + if (start < 0) return null; + const jsonStart = start + marker.length; + const jsonEnd = source.lastIndexOf(";"); + if (jsonEnd <= jsonStart) return null; + return JSON.parse(source.slice(jsonStart, jsonEnd)); +} + +/** Identify large fixture payloads from stable groups of serialized keys/slugs. + * Requiring every marker in a group avoids failing on ordinary UI copy that + * happens to mention one fixture term. */ +export function findFixtureSnapshotsInChunks(files) { + const content = files.map(({ buffer }) => buffer.toString("utf8")).join("\n"); + return fixtureSnapshotMarkerGroups + .filter((group) => group.markers.every((marker) => content.includes(marker))) + .map((group) => group.name); +} + function kb(bytes) { return `${(bytes / 1024).toFixed(1)} KiB`; } @@ -101,6 +164,30 @@ function main() { name: path.relative(CHUNKS_DIR, full), buffer: readFileSync(full), })); + const manifestPath = existsSync(APP_BUILD_MANIFEST_PATH) + ? APP_BUILD_MANIFEST_PATH + : existsSync(BUILD_MANIFEST_PATH) + ? BUILD_MANIFEST_PATH + : null; + if (!manifestPath) { + console.error("[bundle-budget] FAIL — no build manifest is available; cannot verify initial dashboard chunks."); + process.exit(1); + } + const appBuildManifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const pageClientReferenceManifest = loadRootPageClientReferenceManifest(); + const initialChunkNames = new Set(initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest)); + const initialDashboardChunks = files.filter((file) => initialChunkNames.has(file.name.replace(/\\/g, "/"))); + if (initialDashboardChunks.length === 0) { + console.error("[bundle-budget] FAIL — no root dashboard JavaScript chunks were resolved from the build manifest."); + process.exit(1); + } + const fixtureViolations = findFixtureSnapshotsInChunks(initialDashboardChunks); + if (fixtureViolations.length > 0) { + console.error( + `[bundle-budget] FAIL — initial dashboard chunks contain fixture payloads: ${fixtureViolations.join(", ")}.`, + ); + process.exit(1); + } const current = measureChunks(files); const budget = loadBudget(); @@ -113,7 +200,7 @@ function main() { const verdict = compareToBudget(current, budget); if (asJson) { - console.log(JSON.stringify({ current, verdict }, null, 2)); + console.log(JSON.stringify({ current, verdict, initialDashboardChunks: initialDashboardChunks.length }, null, 2)); } else { console.log( `[bundle-budget] client chunks: ${current.files} files, ${kb(current.totalGzipBytes)} gzip (${kb(current.totalRawBytes)} raw).`, @@ -122,6 +209,9 @@ function main() { console.log(`[bundle-budget] baseline ${kb(verdict.baseline)} gzip; ${verdict.reason}.`); console.log("[bundle-budget] largest chunks (gzip):"); for (const c of current.largest) console.log(` ${kb(c.gzipBytes).padStart(12)} ${c.name}`); + console.log( + `[bundle-budget] initial dashboard fixture assertion passed (${initialDashboardChunks.length} chunks).`, + ); } if (verdict.status === "fail") { diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs index 6c3bbcf66..b5ba23ba0 100644 --- a/scripts/dev-free-port.mjs +++ b/scripts/dev-free-port.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -60,6 +61,24 @@ function removePortArgs(args) { return cleaned; } +function dependencyBundlerArgs(command, args) { + if (command !== "dev" || args.some((arg) => ["--webpack", "--turbopack", "--turbo"].includes(arg))) { + return []; + } + + try { + const dependenciesPath = fs.realpathSync(path.join(projectRoot, "node_modules")); + const relativeDependenciesPath = path.relative(projectRoot, dependenciesPath); + const dependenciesAreExternal = + relativeDependenciesPath === ".." || + relativeDependenciesPath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDependenciesPath); + return dependenciesAreExternal ? ["--webpack"] : []; + } catch { + return []; + } +} + function canListenOnHost(port, host) { return new Promise((resolve) => { const server = net.createServer(); @@ -140,6 +159,7 @@ const child = spawn( "--port", String(freePort), ...removePortArgs(forwardedArgs), + ...dependencyBundlerArgs(parsedCommand.command, forwardedArgs), ], { cwd: projectRoot, diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index eff39611a..843ee7153 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -5,6 +5,7 @@ import { isDemoMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; import { allowRateLimitInMemoryFallbackOnUnavailable, + consumeSummaryRateLimits, consumeSubjectApiRateLimit, rateLimitJsonResponse, type ApiRateLimitResult, @@ -272,24 +273,24 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); - const rateLimit = await consumeSubjectApiRateLimit({ - supabase, - subject: access.rateLimitSubject, - bucket: "answer", - allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), - }); - if (rateLimit.limited) return rateLimitStream(rateLimit); - if (body.summaryMode) { - // Streamed full-document summaries use the same paid provider path as the - // legacy summary endpoint. Preserve the general answer ceiling, then also - // enforce the stricter summary quota before the SSE stream can start. - const summaryRateLimit = await consumeSubjectApiRateLimit({ + const decision = await consumeSummaryRateLimits({ + supabase, + subject: access.rateLimitSubject, + }); + if (decision.rateLimit.limited) { + return decision.bucket === "document_summarize" + ? documentSummaryRateLimitStream(decision.rateLimit) + : rateLimitStream(decision.rateLimit); + } + } else { + const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, - bucket: "document_summarize", + bucket: "answer", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); - if (summaryRateLimit.limited) return documentSummaryRateLimitStream(summaryRateLimit); + if (rateLimit.limited) return rateLimitStream(rateLimit); } return streamAnswer(body, resolveRetrievalAccessScope(access.ownerId), request.signal); diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index e61e7fcf1..25fd4e50f 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -17,6 +17,7 @@ import { import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { getDifferentialDetailContext, getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { safeErrorLogDetails } from "@/lib/privacy"; import { publicAccessContext } from "@/lib/public-api-access"; @@ -30,10 +31,13 @@ const differentialDetailQuerySchema = z.object({ kind: z.enum(["presentation", "diagnosis"]).optional().default("diagnosis"), }); -function differentialResponse(payload: Record, init?: { status?: number }) { +function differentialResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -53,20 +57,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (kind === "presentation") { const workflow = getPresentationWorkflow(normalizedSlug); if (!workflow) return notFoundResponse(normalizedSlug); - return differentialResponse({ - workflow, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - demoMode: true, - }); + return differentialResponse( + { + workflow, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + demoMode: true, + }, + { request, fixture: true }, + ); } const record = getDifferentialRecord(normalizedSlug); if (!record) return notFoundResponse(normalizedSlug); - return differentialResponse({ - record, - detailContext: getDifferentialDetailContext(record), - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - demoMode: true, - }); + return differentialResponse( + { + record, + detailContext: getDifferentialDetailContext(record), + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -91,20 +101,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (kind === "presentation") { const workflow = getPresentationWorkflow(normalizedSlug); if (!workflow) return notFoundResponse(normalizedSlug); - return differentialResponse({ - workflow, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - publicAccess: true, - }); + return differentialResponse( + { + workflow, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + publicAccess: true, + }, + { request, fixture: true }, + ); } const record = getDifferentialRecord(normalizedSlug); if (!record) return notFoundResponse(normalizedSlug); - return differentialResponse({ - record, - detailContext: getDifferentialDetailContext(record), - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - publicAccess: true, - }); + return differentialResponse( + { + record, + detailContext: getDifferentialDetailContext(record), + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchRecord = async () => { diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index afc883800..a4ccc4744 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -16,6 +16,7 @@ import { import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { safeErrorLogDetails } from "@/lib/privacy"; import { publicAccessContext } from "@/lib/public-api-access"; @@ -24,10 +25,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; -function differentialResponse(payload: Record, init?: { status?: number }) { +function differentialResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -50,12 +54,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!record) return []; return [{ ...candidate, record }]; }); - return differentialResponse({ - workflow, - candidates, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - demoMode: true, - }); + return differentialResponse( + { + workflow, + candidates, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -84,12 +91,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!record) return []; return [{ ...candidate, record }]; }); - return differentialResponse({ - workflow, - candidates, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - publicAccess: true, - }); + return differentialResponse( + { + workflow, + candidates, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchPresentation = async () => { diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index 256d6a187..ae4559303 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -22,6 +22,7 @@ import { type DifferentialRecordMatch, } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -43,8 +44,11 @@ const differentialListQuerySchema = z.object({ limit: queryInteger({ fallback: 100, min: 1, max: 200 }), }); -function differentialResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +function differentialResponse( + payload: Record, + options: { request?: Request; fixture?: boolean } = {}, +) { + return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) }); } function recordMatchesPayload(matches: DifferentialRecordMatch[]) { @@ -82,10 +86,13 @@ export async function GET(request: Request) { const { kind, q, limit } = parseRequestQuery(request, differentialListQuerySchema, "Invalid differential query."); if (isDemoMode() || isLocalNoAuthMode()) { - return differentialResponse({ - ...publicDifferentialPayload(kind, q, limit), - demoMode: true, - }); + return differentialResponse( + { + ...publicDifferentialPayload(kind, q, limit), + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit: publicAccessContext skips the @@ -105,10 +112,13 @@ export async function GET(request: Request) { } if (!access.ownerId) { - return differentialResponse({ - ...publicDifferentialPayload(kind, q, limit), - publicAccess: true, - }); + return differentialResponse( + { + ...publicDifferentialPayload(kind, q, limit), + publicAccess: true, + }, + { request, fixture: true }, + ); } const rows = await fetchOwnerDifferentialRowsWithSeed(supabase, access.ownerId, kind, DIFFERENTIAL_MAX_RECORDS); diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts index dde68684b..6cd574841 100644 --- a/src/app/api/documents/[id]/labels/route.ts +++ b/src/app/api/documents/[id]/labels/route.ts @@ -145,7 +145,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: .single(); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ label, labels: await selectLabels(supabase, id) }, { status: 201 }); } catch (error) { if (error instanceof AuthenticationError) { @@ -200,7 +200,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .single(); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ label }); } @@ -243,7 +243,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .single(); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ label, labels: await selectLabels(supabase, id) }); } catch (error) { if (error instanceof AuthenticationError) { @@ -288,7 +288,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i .eq("source", "manual"); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ deleted: true, labelId: parsed.labelId, labels: await selectLabels(supabase, id) }); } catch (error) { if (error instanceof AuthenticationError) { diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 87bdf5f18..abaa39321 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -2,19 +2,21 @@ import { NextResponse } from "next/server"; import type { Json } from "@/lib/supabase/database.types"; import { z } from "zod"; import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; -import { getDemoDocumentPayload } from "@/lib/demo-data"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { buildStorageCleanupJobUpdate } from "@/lib/ingestion"; import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; -import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; -import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; +import { + DocumentDetailRateLimitError, + documentDetailQuerySchema, + loadAuthorizedDocumentDetail, +} from "@/lib/document-detail"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; -import { optionalQueryString, parseRequestQuery, queryInteger } from "@/lib/validation/query"; +import { parseRequestQuery } from "@/lib/validation/query"; export const runtime = "nodejs"; @@ -42,85 +44,10 @@ const deleteDocumentResultSchema = z.discriminatedUnion("outcome", [ }), ]); -const defaultPageWindow = 9; -const maxPageWindow = 40; -const defaultChunkWindow = 16; -const maxChunkWindow = 80; -const selectedChunkNeighborCount = 3; - -const documentDetailQuerySchema = z.object({ - chunk: optionalQueryString({ maxLength: 80 }), - page: queryInteger({ fallback: 1, min: 1, max: 1_000_000 }), - pageLimit: queryInteger({ fallback: defaultPageWindow, min: 1, max: maxPageWindow }), - chunkLimit: queryInteger({ fallback: defaultChunkWindow, min: 1, max: maxChunkWindow }), - chunkOffset: queryInteger({ fallback: 0, min: 0, max: 1_000_000 }), -}); - -function pageWindowAround(pageNumber: number, limit: number, maxPage?: number | null) { - const half = Math.floor(limit / 2); - const max = Math.max(1, maxPage ?? Number.MAX_SAFE_INTEGER); - const from = Math.max(1, Math.min(pageNumber - half, Math.max(1, max - limit + 1))); - const to = Math.min(max, from + limit - 1); - return { from, to }; -} - function safeMetadata(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } -function metadataText(metadata: Record, key: string) { - const value = metadata[key]; - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function compactTableText(value: string | null, limit = 500) { - if (!value) return null; - const compact = value.replace(/\s+/g, " ").trim(); - if (!compact) return null; - return compact.length > limit ? `${compact.slice(0, limit - 3).trim()}...` : compact; -} - -function metadataStringArrayRows(metadata: Record, key: string) { - const value = metadata[key]; - if (!Array.isArray(value)) return null; - const rows = value - .filter((row): row is unknown[] => Array.isArray(row)) - .map((row) => row.map((cell) => String(cell ?? "").trim())); - return rows.length ? rows : null; -} - -function metadataStringArray(metadata: Record, key: string) { - const value = metadata[key]; - if (!Array.isArray(value)) return null; - const items = value.map((item) => String(item ?? "").trim()).filter(Boolean); - return items.length ? items : null; -} - -function withImageTableMetadata(image: T) { - const metadata = safeMetadata(image.metadata); - const rawTableText = metadataText(metadata, "table_text"); - const tableText = rawTableText ?? metadataText(metadata, "table_text_snippet"); - const publicImage = { ...image }; - delete publicImage.metadata; - return { - ...publicImage, - tableLabel: metadataText(metadata, "table_label"), - tableTitle: metadataText(metadata, "table_title"), - tableRole: metadataText(metadata, "table_role"), - tableTextSnippet: compactTableText(tableText), - clinicalUseClass: metadataText(metadata, "clinical_use_class"), - clinicalUseReason: metadataText(metadata, "clinical_use_reason"), - accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText, - tableRows: metadataStringArrayRows(metadata, "table_rows"), - tableColumns: metadataStringArray(metadata, "table_columns"), - }; -} - -function committedRows(document: { metadata?: unknown }, rows: T[]) { - const committedGeneration = committedIndexGeneration(document.metadata); - return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration })); -} - function storageWarningsFrom(error: unknown, label: string) { const message = error && typeof error === "object" && "message" in error ? String(error.message) : "Storage cleanup failed."; @@ -188,185 +115,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: try { const { id: rawId } = await params; const detailQuery = parseRequestQuery(request, documentDetailQuerySchema, "Invalid document detail query."); - if (isDemoMode()) { - const payload = getDemoDocumentPayload(rawId, detailQuery.chunk ?? null); - if (!payload) { - return NextResponse.json({ error: "Demo document not found." }, { status: 404 }); - } - return NextResponse.json({ ...payload, demoMode: true }); - } - - const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); - const supabase = createAdminClient(); - const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); - if (rateLimit.limited) { - return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); - } - const { data: document, error } = await withOwnerReadScope( - supabase.from("documents").select("*").eq("id", id), - access.ownerId, - ).maybeSingle(); - - if (error) throw new Error(error.message); - if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); - - // withOwnerReadScope also returns PUBLIC (owner_id IS NULL) documents to an authenticated - // caller. The owner-only projection (raw metadata, storage_path/content_hash, index health, - // image storage paths) must be gated on OWNERSHIP, not merely on being authenticated, so an - // authed non-owner viewing a shared public document gets the same redacted view as an - // anonymous caller (S1/D1). - const isOwner = callerOwnsDocumentRow(document, access.ownerId); - - const chunkId = detailQuery.chunk ?? null; - const requestedPage = Math.min(detailQuery.page, Math.max(1, document.page_count ?? 1)); - const pageLimit = detailQuery.pageLimit; - const chunkLimit = detailQuery.chunkLimit; - const chunkOffset = detailQuery.chunkOffset; - - let selectedChunk: { - id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids: string[]; - } | null = null; - - if (chunkId) { - const { data, error: selectedChunkError } = await supabase - .from("document_chunks") - .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata") - .eq("document_id", id) - .eq("id", chunkId) - .maybeSingle(); - - if (selectedChunkError) throw new Error(selectedChunkError.message); - selectedChunk = data && committedRows(document, [data]).length > 0 ? data : null; - } - - const effectivePage = selectedChunk?.page_number ?? requestedPage; - const pageWindow = pageWindowAround(effectivePage, pageLimit, document.page_count); - const { data: pages, error: pagesError } = await supabase - .from("document_pages") - .select("id,page_number,text,ocr_used,metadata") - .eq("document_id", id) - .gte("page_number", pageWindow.from) - .lte("page_number", pageWindow.to) - .order("page_number", { ascending: true }); - - if (pagesError) throw new Error(pagesError.message); - - const { data: images, error: imagesError } = await supabase - .from("document_images") - .select( - "id,page_number,storage_path,caption,bbox,mime_type,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata", - ) - .eq("document_id", id) - .neq("image_type", "logo_decorative") - .or("searchable.eq.true,source_kind.eq.table_crop") - .order("page_number", { ascending: true }); - - if (imagesError) throw new Error(imagesError.message); - - const chunkQuery = supabase - .from("document_chunks") - .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata") - .eq("document_id", id) - .order("chunk_index", { ascending: true }); - - const chunkRangeStart = selectedChunk - ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount) - : chunkOffset; - const chunkRangeEnd = selectedChunk - ? selectedChunk.chunk_index + selectedChunkNeighborCount - : chunkOffset + chunkLimit - 1; - - const { data: chunks, error: chunksError } = selectedChunk - ? await chunkQuery.gte("chunk_index", chunkRangeStart).lte("chunk_index", chunkRangeEnd) - : await chunkQuery.range(chunkRangeStart, chunkRangeEnd); - - if (chunksError) throw new Error(chunksError.message); - - const [labelsResult, summaryResult, tableFactsResult] = await Promise.all([ - supabase.from("document_labels").select("*").eq("document_id", id).order("confidence", { ascending: false }), - supabase.from("document_summaries").select("*").eq("document_id", id).maybeSingle(), - supabase - .from("document_table_facts") - .select("*") - .eq("document_id", id) - .order("page_number", { ascending: true }) - .limit(200), - ]); - - if (labelsResult.error) throw new Error(labelsResult.error.message); - if (summaryResult.error) throw new Error(summaryResult.error.message); - if (tableFactsResult.error) throw new Error(tableFactsResult.error.message); - - const omitPublicInternalFields = (row: Record) => { - const internalKeys = new Set([ - "owner_id", - "storage_path", - "content_hash", - "source_path", - "import_batch_id", - "error_message", - "metadata", - // Summary provenance: only present on document_summaries rows (fetched with select("*")). - // A non-owner viewing a public document's summary must not see the owner's chunk/image - // source IDs or the generation model, matching the list route's PUBLIC_SUMMARY projection. - "source_chunk_ids", - "source_image_ids", - "model", - ]); - return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key))); - }; - const publicRows = >(rows: T[]) => - isOwner ? rows : rows.map(omitPublicInternalFields); - const responseDocument = isOwner ? document : omitPublicInternalFields(document as Record); - - return NextResponse.json({ - document: { - ...responseDocument, - labels: publicRows((labelsResult.data ?? []) as Record[]), - summary: - isOwner || !summaryResult.data - ? (summaryResult.data ?? null) - : omitPublicInternalFields(summaryResult.data as Record), - }, - pages: publicRows((pages ?? []) as Record[]), - images: publicRows( - committedRows(document, images ?? []).map(withImageTableMetadata) as Record[], - ), - tableFacts: publicRows(committedRows(document, tableFactsResult.data ?? []) as Record[]), - chunks: publicRows(committedRows(document, chunks ?? []) as Record[]), - pageWindow: { - from: pageWindow.from, - to: pageWindow.to, - limit: pageLimit, - total: document.page_count ?? null, - hasBefore: pageWindow.from > 1, - hasAfter: Boolean(document.page_count && pageWindow.to < document.page_count), - }, - chunkWindow: { - offset: chunkRangeStart, - limit: selectedChunk ? chunkRangeEnd - chunkRangeStart + 1 : chunkLimit, - total: document.chunk_count ?? null, - hasBefore: chunkRangeStart > 0, - hasAfter: Boolean(document.chunk_count && chunkRangeEnd + 1 < document.chunk_count), - selectedChunkId: selectedChunk?.id ?? null, - }, - ...(isOwner - ? { - indexHealth: { - extractionQuality: safeMetadata(document.metadata).extraction_quality ?? null, - indexedAt: safeMetadata(document.metadata).indexed_at ?? null, - indexVersion: safeMetadata(document.metadata).rag_indexing_version ?? null, - warnings: safeMetadata(document.metadata).extraction_warnings ?? [], - }, - } - : {}), - }); + const payload = await loadAuthorizedDocumentDetail({ request, rawId, query: detailQuery }); + return NextResponse.json(payload); } catch (error) { + if (error instanceof DocumentDetailRateLimitError) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", error.rateLimit); + } if (error instanceof AuthenticationError) { return unauthorizedResponse(); } @@ -418,7 +172,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .single(); if (updateError) throw new Error(updateError.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); await writeAuditLog(supabase, { ownerId: user.id, action: "document_rename", @@ -481,7 +235,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i }); if (ledgerWarning) cleanup.storageWarnings.push(ledgerWarning); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); await writeAuditLog(supabase, { ownerId: user.id, action: "document_delete", diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index 48de02a16..a02dfaebe 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -6,6 +6,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { getMedicationRecord } from "@/lib/medication-snapshot"; import { ensureMedicationsSeeded } from "@/lib/medication-seed"; @@ -23,10 +24,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; -function medicationResponse(payload: Record, init?: { status?: number }) { +function medicationResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -55,10 +59,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (isDemoMode() || isLocalNoAuthMode()) { const payload = publicMedicationDetailPayload(normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return medicationResponse({ - ...payload, - demoMode: true, - }); + return medicationResponse( + { + ...payload, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -80,10 +87,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!access.ownerId) { const payload = publicMedicationDetailPayload(normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return medicationResponse({ - ...payload, - publicAccess: true, - }); + return medicationResponse( + { + ...payload, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchRecord = async () => { diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index adb6e9743..7562cf418 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -7,6 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; import { @@ -61,8 +62,8 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { })); } -function medicationResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +function medicationResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) { + return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) }); } function matchesPayload(matches: MedicationSearchMatch[]) { @@ -99,10 +100,13 @@ export async function GET(request: Request) { const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query."); if (isDemoMode() || isLocalNoAuthMode()) { - return medicationResponse({ - ...publicMedicationPayload(q, limit, fields), - demoMode: true, - }); + return medicationResponse( + { + ...publicMedicationPayload(q, limit, fields), + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit: publicAccessContext skips the @@ -122,10 +126,13 @@ export async function GET(request: Request) { } if (!access.ownerId) { - return medicationResponse({ - ...publicMedicationPayload(q, limit, fields), - publicAccess: true, - }); + return medicationResponse( + { + ...publicMedicationPayload(q, limit, fields), + publicAccess: true, + }, + { request, fixture: true }, + ); } const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index e3bb5a6be..44d8f372d 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -7,6 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; @@ -28,10 +29,13 @@ const registryDetailQuerySchema = z.object({ kind: z.enum(["service", "form"]), }); -function registryResponse(payload: Record, init?: { status?: number }) { +function registryResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -59,10 +63,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (isDemoMode() || isLocalNoAuthMode()) { const payload = publicRegistryDetailPayload(kind, normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return registryResponse({ - ...payload, - demoMode: true, - }); + return registryResponse( + { + ...payload, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -84,10 +91,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!access.ownerId) { const payload = publicRegistryDetailPayload(kind, normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return registryResponse({ - ...payload, - publicAccess: true, - }); + return registryResponse( + { + ...payload, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchRecord = async () => { diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 309981079..8326c168d 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -7,6 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; @@ -45,8 +46,8 @@ function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query: return kind === "form" ? rankFormRecords(records, query, limit) : rankServiceRecords(records, query, limit); } -function registryResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +function registryResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) { + return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) }); } function matchesPayload(matches: ServiceSearchMatch[]) { @@ -74,10 +75,13 @@ export async function GET(request: Request) { const { kind, q, limit } = parseRequestQuery(request, registryListQuerySchema, "Invalid registry query."); if (isDemoMode() || isLocalNoAuthMode()) { - return registryResponse({ - ...publicRegistryPayload(kind, q, limit), - demoMode: true, - }); + return registryResponse( + { + ...publicRegistryPayload(kind, q, limit), + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit: publicAccessContext skips the @@ -97,10 +101,13 @@ export async function GET(request: Request) { } if (!access.ownerId) { - return registryResponse({ - ...publicRegistryPayload(kind, q, limit), - publicAccess: true, - }); + return registryResponse( + { + ...publicRegistryPayload(kind, q, limit), + publicAccess: true, + }, + { request, fixture: true }, + ); } const rows = await fetchOwnerRegistryRows(supabase, access.ownerId, kind, REGISTRY_MAX_RECORDS); diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts index ef4b0541b..32d4d1702 100644 --- a/src/app/api/search/universal/route.ts +++ b/src/app/api/search/universal/route.ts @@ -15,6 +15,7 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { runUniversalSearch, universalSearchDomains, + type RunUniversalSearchArgs, type UniversalSearchDomain, type UniversalSearchResponse, } from "@/lib/universal-search"; @@ -35,6 +36,7 @@ const universalSearchQuerySchema = z.object({ q: z.string().trim().min(2).max(200), limit: queryInteger({ fallback: 5, min: 1, max: 10 }), mode: z.enum(appModeIds).optional(), + stream: z.enum(["ndjson"]).optional(), domains: z .string() .trim() @@ -64,22 +66,84 @@ function universalResponse( return NextResponse.json(payload, { headers }); } +function universalStreamResponse( + request: Request, + searchArgs: RunUniversalSearchArgs, + decoration: { demoMode?: true; publicAccess?: true } = {}, +) { + const encoder = new TextEncoder(); + const searchController = new AbortController(); + const abortFromRequest = () => searchController.abort(request.signal.reason); + if (request.signal.aborted) abortFromRequest(); + else request.signal.addEventListener("abort", abortFromRequest, { once: true }); + + const body = new ReadableStream({ + start(controller) { + const enqueue = (event: Record) => { + if (!searchController.signal.aborted) { + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + } + }; + + void runUniversalSearch({ + ...searchArgs, + signal: searchController.signal, + onGroup: (group) => enqueue({ type: "group", query: searchArgs.query, group }), + }) + .then((response) => { + if (searchController.signal.aborted) return; + enqueue({ type: "complete", response: { ...response, ...decoration } }); + controller.close(); + }) + .catch((error: unknown) => { + if (searchController.signal.aborted) { + try { + controller.close(); + } catch { + // The consumer may already have cancelled the stream. + } + return; + } + controller.error(error); + }) + .finally(() => request.signal.removeEventListener("abort", abortFromRequest)); + }, + cancel(reason) { + searchController.abort( + reason instanceof Error ? reason : new DOMException("The search stream was cancelled.", "AbortError"), + ); + request.signal.removeEventListener("abort", abortFromRequest); + }, + }); + + return new Response(body, { + headers: { + "Cache-Control": "private, no-store", + "Content-Type": "application/x-ndjson; charset=utf-8", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }); +} + export async function GET(request: Request) { try { - const { q, limit, domains, mode } = parseRequestQuery( + const { q, limit, domains, mode, stream } = parseRequestQuery( request, universalSearchQuerySchema, "Invalid universal query.", ); if (isDemoMode() || isLocalNoAuthMode()) { - const payload = await runUniversalSearch({ + const searchArgs: RunUniversalSearchArgs = { query: q, limitPerDomain: limit, domains, contextMode: mode, demo: true, - }); + }; + if (stream === "ndjson") return universalStreamResponse(request, searchArgs, { demoMode: true }); + const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal }); return universalResponse({ ...payload, demoMode: true }); } @@ -99,7 +163,7 @@ export async function GET(request: Request) { // demo:false + supabase always run the live pipeline. An anonymous caller (ownerId // undefined) is scoped to the public corpus via allowGlobalSearch and the real default // catalogues — never the synthetic demo fixtures. - const payload = await runUniversalSearch({ + const searchArgs: RunUniversalSearchArgs = { query: q, limitPerDomain: limit, domains, @@ -107,7 +171,11 @@ export async function GET(request: Request) { supabase, ownerId: access.ownerId, demo: false, - }); + }; + if (stream === "ndjson") { + return universalStreamResponse(request, searchArgs, access.ownerId ? {} : { publicAccess: true }); + } + const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal }); return universalResponse(access.ownerId ? payload : { ...payload, publicAccess: true }); } catch (error) { if (error instanceof AuthenticationError) { diff --git a/src/app/documents/[id]/page.tsx b/src/app/documents/[id]/page.tsx index 19a7cf04b..10413f356 100644 --- a/src/app/documents/[id]/page.tsx +++ b/src/app/documents/[id]/page.tsx @@ -1,4 +1,11 @@ +import { headers } from "next/headers"; import { DocumentViewerLazy as DocumentViewer } from "@/components/document-viewer-lazy"; +import { + documentDetailQuerySchema, + loadAuthorizedDocumentDetail, + sanitizeDocumentDetailError, +} from "@/lib/document-detail"; +import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; export default async function DocumentPage({ params, @@ -10,5 +17,32 @@ export default async function DocumentPage({ const [{ id }, query] = await Promise.all([params, searchParams]); const parsedPage = Number.parseInt(query.page ?? "", 10); const initialPage = Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1; - return ; + let initialDetail: DocumentDetailPayload | undefined; + let initialError: string | undefined; + + try { + const detailQuery = documentDetailQuerySchema.parse({ + page: initialPage, + chunk: query.chunk, + assetScope: "window", + }); + const requestHeaders = new Headers(await headers()); + const request = new Request(`http://document-detail.local/documents/${encodeURIComponent(id)}`, { + headers: requestHeaders, + }); + initialDetail = await loadAuthorizedDocumentDetail({ request, rawId: id, query: detailQuery }); + } catch (error) { + initialError = sanitizeDocumentDetailError(error); + } + + return ( + + ); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 931335a1a..322216daf 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -187,8 +187,8 @@ import { import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope"; import { parseApiErrorResponse } from "@/lib/api-client-error"; import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle"; -import { rankFormRecords } from "@/lib/forms"; -import { rankServiceRecords } from "@/lib/services"; +import { rankFormRecords } from "@/lib/form-ranker"; +import { rankServiceRecords } from "@/lib/service-ranker"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; import { @@ -232,6 +232,7 @@ const stagedDashboardExtraction = { type RefreshOptions = { includeSetup?: boolean; includeDashboardData?: boolean; + includeAdministrationData?: boolean; includeDocumentMeta?: boolean; }; type PollHint = { @@ -446,16 +447,23 @@ export function ClinicalDashboard({ const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); const autoRunSearchSignatureRef = useRef(null); - const refreshInFlightRef = useRef<{ epoch: number; promise: Promise } | null>(null); + const refreshInFlightRef = useRef<{ + epoch: number; + dataScope: number; + promise: Promise; + } | null>(null); + const dashboardDataLoadedRef = useRef(false); + const administrationDataLoadedRef = useRef(false); const nextWorkStatePollRef = useRef(0); const urlSearchBootstrappedRef = useRef(false); const urlDocumentSearchBootstrappedRef = useRef(false); const lastSyncedSearchParamsRef = useRef(searchParams.toString()); const modeChangeFromUiRef = useRef(false); const [documents, setDocuments] = useState([]); + const documentsRef = useRef(documents); const [documentsPagination, setDocumentsPagination] = useState(null); const indexedDocumentTotal = documentsPagination?.total ?? documents.length; - const [dashboardDataLoading, setDashboardDataLoading] = useState(true); + const [dashboardDataLoading, setDashboardDataLoading] = useState(false); const [loadingMoreDocuments, setLoadingMoreDocuments] = useState(false); const [jobs, setJobs] = useState([]); const [batches, setBatches] = useState([]); @@ -640,6 +648,7 @@ export function ClinicalDashboard({ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); + const [documentScopeOpen, setDocumentScopeOpen] = useState(false); const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library"); const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false); const [uploadMobileTab, setUploadMobileTab] = useState("upload"); @@ -664,6 +673,7 @@ export function ClinicalDashboard({ ); const [indexingActionId, setIndexingActionId] = useState(null); const [indexingActive, setIndexingActive] = useState(false); + const [userStartedIngestion, setUserStartedIngestion] = useState(false); const [nextRefreshDelayMs, setNextRefreshDelayMs] = useState(null); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); @@ -743,6 +753,9 @@ export function ClinicalDashboard({ setJobs([]); setBatches([]); setQualityItems([]); + dashboardDataLoadedRef.current = false; + administrationDataLoadedRef.current = false; + setUserStartedIngestion(false); setSelectedDocumentIds([]); setDocumentMatches([]); setSearchScope(null); @@ -987,6 +1000,10 @@ export function ClinicalDashboard({ answerThreadOwnerId, ]); + useEffect(() => { + documentsRef.current = documents; + }, [documents]); + useEffect(() => { jobsRef.current = jobs; }, [jobs]); @@ -995,10 +1012,22 @@ export function ClinicalDashboard({ batchesRef.current = batches; }, [batches]); - const refresh = useCallback( + const refresh: (options?: RefreshOptions) => Promise = useCallback( async (options: RefreshOptions = {}) => { - if (refreshInFlightRef.current?.epoch === authEpoch) { - return refreshInFlightRef.current.promise; + const includeDashboardData = options.includeDashboardData ?? true; + const includeAdministrationData = options.includeAdministrationData ?? includeDashboardData; + const requestedDataScope = (includeDashboardData ? 1 : 0) | (includeAdministrationData ? 2 : 0); + while (refreshInFlightRef.current?.epoch === authEpoch) { + const activeRefresh = refreshInFlightRef.current; + const needsFollowUp = (requestedDataScope & ~activeRefresh.dataScope) !== 0; + await activeRefresh.promise; + // A setup-only refresh cannot satisfy a data request that arrived + // while it was in flight. Run one follow-up request; same-scope calls + // stay coalesced on the original promise. + if (!needsFollowUp) return; + // The promise is complete, so release its coalescing slot now. The + // owning call still releases its auth request in its own finally. + if (refreshInFlightRef.current === activeRefresh) refreshInFlightRef.current = null; } const controller = new AbortController(); @@ -1006,12 +1035,11 @@ export function ClinicalDashboard({ const canCommit = () => isAuthEpochCurrent(authRequest.epoch) && !controller.signal.aborted; const promise = (async () => { - const trackDashboardLoading = options.includeDashboardData ?? true; + const trackDashboardLoading = requestedDataScope !== 0; await Promise.resolve(); if (trackDashboardLoading) setDashboardDataLoading(true); const includeSetup = options.includeSetup ?? true; - const includeDashboardData = options.includeDashboardData ?? true; const includeDocumentMeta = options.includeDocumentMeta ?? true; let nextDemoMode = clientDemoMode; let routeIndexingActive = false; @@ -1078,7 +1106,7 @@ export function ClinicalDashboard({ return; } - if (!includeDashboardData) { + if (requestedDataScope === 0) { setIndexingActive(routeIndexingActive); setNextRefreshDelayMs(routePollDelayMs); return; @@ -1091,14 +1119,17 @@ export function ClinicalDashboard({ } const now = Date.now(); - const shouldRefreshWorkState = now >= nextWorkStatePollRef.current; + const shouldRefreshWorkState = + includeAdministrationData && (!administrationDataLoadedRef.current || now >= nextWorkStatePollRef.current); if (shouldRefreshWorkState) nextWorkStatePollRef.current = now + indexingWorkDetailsPollMs; const [documentsResponse, jobsResponse, batchesResponse, qualityResponse] = await Promise.all([ - fetch(`/api/documents?${documentParams.toString()}`, { - headers: protectedHeaders, - signal: controller.signal, - }), + includeDashboardData + ? fetch(`/api/documents?${documentParams.toString()}`, { + headers: protectedHeaders, + signal: controller.signal, + }) + : Promise.resolve(null as Response | null), shouldRefreshWorkState ? fetch("/api/ingestion/jobs", { headers: protectedHeaders, signal: controller.signal }) : Promise.resolve(null as Response | null), @@ -1110,9 +1141,8 @@ export function ClinicalDashboard({ : Promise.resolve(null as Response | null), ]); if (!canCommit()) return; - if ( - documentsResponse.status === 401 || + (documentsResponse !== null && documentsResponse.status === 401) || (jobsResponse !== null && jobsResponse.status === 401) || (batchesResponse !== null && batchesResponse.status === 401) || (qualityResponse !== null && qualityResponse.status === 401) @@ -1128,22 +1158,23 @@ export function ClinicalDashboard({ return; } - let nextDocuments: ClinicalDocument[] = []; + let nextDocuments: ClinicalDocument[] = includeDashboardData ? [] : documentsRef.current; let nextJobs: IngestionJob[] = shouldRefreshWorkState ? [] : jobsRef.current; let nextBatches: ImportBatch[] = shouldRefreshWorkState ? [] : batchesRef.current; - if (documentsResponse.ok) { + if (documentsResponse?.ok) { const payload = (await documentsResponse.json()) as DocumentsPayload; nextDocuments = payload.documents ?? []; setDocuments((current) => includeDocumentMeta ? nextDocuments : mergeDocumentRefresh(current, nextDocuments), ); setDocumentsPagination(payload.pagination ?? null); + dashboardDataLoadedRef.current = true; routeIndexingActive ||= Boolean(payload.indexing?.active); routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.indexing?.pollAfterMs); if (payload.demoMode) setDemoMode(true); if (payload.setupRequired) setSetupWarning(payload.error ?? null); - } else { + } else if (includeDashboardData) { setApiUnavailable(true); } @@ -1178,17 +1209,21 @@ export function ClinicalDashboard({ setApiUnavailable(true); } + if (jobsResponse?.ok && batchesResponse?.ok && qualityResponse?.ok) { + administrationDataLoadedRef.current = true; + } + const activeWork = hasActiveIndexingWork(nextDocuments, nextJobs, nextBatches, routeIndexingActive); setIndexingActive(activeWork); setNextRefreshDelayMs(routePollDelayMs ?? (activeWork ? activeIndexingPollFallbackMs : null)); })(); - refreshInFlightRef.current = { epoch: authRequest.epoch, promise }; + refreshInFlightRef.current = { epoch: authRequest.epoch, dataScope: requestedDataScope, promise }; try { return await promise; } finally { authRequest.release(); - if ((options.includeDashboardData ?? true) === true && canCommit()) setDashboardDataLoading(false); + if (requestedDataScope !== 0 && canCommit()) setDashboardDataLoading(false); if (refreshInFlightRef.current?.promise === promise) { refreshInFlightRef.current = null; } @@ -1267,6 +1302,8 @@ export function ClinicalDashboard({ if (!response.ok) { throw new Error(typeof payload.error === "string" ? payload.error : "Job retry could not be started."); } + setUserStartedIngestion(true); + setIndexingActive(true); setActionNotice({ tone: "success", message: "Ingestion job retry queued.", @@ -1312,6 +1349,8 @@ export function ClinicalDashboard({ : "Document reindex could not be started.", ); } + setUserStartedIngestion(true); + setIndexingActive(true); setActionNotice({ tone: "success", message: mode === "enrichment" ? "Document enrichment refreshed." : "Document reindex queued.", @@ -1475,32 +1514,79 @@ export function ClinicalDashboard({ [documents, jobs, batches, indexingActive], ); const needsSetupRecheck = useMemo(() => setupNeedsSlowRecheck(setupChecks), [setupChecks]); + const dashboardDataSurfaceVisible = documentScopeOpen || documentsDrawerOpen || uploadDrawerOpen; + const administrationSurfaceVisible = uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin"); useEffect(() => { - refresh({ includeSetup: true, includeDashboardData: true, includeDocumentMeta: true }).catch(() => undefined); + dashboardDataLoadedRef.current = false; + administrationDataLoadedRef.current = false; + }, [authEpoch]); + + useEffect(() => { + refresh({ includeSetup: true, includeDashboardData: false, includeDocumentMeta: false }).catch(() => undefined); }, [authStatus, authorizationHeader, clientDemoMode, refresh]); useEffect(() => { - const hasScheduledWork = activeIndexingWork || needsSetupRecheck; - if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) { + const includeDashboardData = dashboardDataSurfaceVisible && !dashboardDataLoadedRef.current; + const includeAdministrationData = administrationSurfaceVisible && !administrationDataLoadedRef.current; + if (!includeDashboardData && !includeAdministrationData) return; + refresh({ + includeSetup: false, + includeDashboardData, + includeAdministrationData, + includeDocumentMeta: includeDashboardData, + }).catch(() => undefined); + }, [administrationSurfaceVisible, authEpoch, dashboardDataSurfaceVisible, refresh]); + + useEffect(() => { + if (!userStartedIngestion || !dashboardDataLoadedRef.current || activeIndexingWork) return; + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) setUserStartedIngestion(false); + }); + return () => { + cancelled = true; + }; + }, [activeIndexingWork, userStartedIngestion]); + + useEffect(() => { + const visibleSurfaceHasActiveWork = dashboardDataSurfaceVisible && activeIndexingWork; + const userOperationHasActiveWork = userStartedIngestion && activeIndexingWork; + const shouldPollDashboardData = visibleSurfaceHasActiveWork || userOperationHasActiveWork; + const hasScheduledWork = shouldPollDashboardData || needsSetupRecheck; + const pollingAllowed = + userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork); + if (!pollingAllowed) { return; } - const delay = activeIndexingWork ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs; + const delay = shouldPollDashboardData ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs; const timeout = window.setTimeout(() => { - if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) { + const stillAllowed = + userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork); + if (!stillAllowed) { return; } refresh({ - includeSetup: !activeIndexingWork, - includeDashboardData: activeIndexingWork, + includeSetup: !shouldPollDashboardData, + includeDashboardData: shouldPollDashboardData, + includeAdministrationData: shouldPollDashboardData && (administrationSurfaceVisible || userStartedIngestion), includeDocumentMeta: false, }).catch(() => undefined); }, delay); return () => window.clearTimeout(timeout); - }, [activeIndexingWork, demoMode, needsSetupRecheck, nextRefreshDelayMs, refresh]); + }, [ + activeIndexingWork, + administrationSurfaceVisible, + dashboardDataSurfaceVisible, + demoMode, + needsSetupRecheck, + nextRefreshDelayMs, + refresh, + userStartedIngestion, + ]); useEffect(() => { const refreshVisibleDashboard = () => { @@ -1510,7 +1596,8 @@ export function ClinicalDashboard({ refresh({ includeSetup: true, - includeDashboardData: activeIndexingWork || canUsePrivateApis || clientDemoMode, + includeDashboardData: dashboardDataSurfaceVisible || (userStartedIngestion && activeIndexingWork), + includeAdministrationData: administrationSurfaceVisible || (userStartedIngestion && activeIndexingWork), includeDocumentMeta: false, }).catch(() => undefined); }; @@ -1521,7 +1608,7 @@ export function ClinicalDashboard({ document.removeEventListener("visibilitychange", refreshVisibleDashboard); window.removeEventListener("focus", refreshVisibleDashboard); }; - }, [activeIndexingWork, canUsePrivateApis, clientDemoMode, refresh]); + }, [activeIndexingWork, administrationSurfaceVisible, dashboardDataSurfaceVisible, refresh, userStartedIngestion]); useEffect(() => { const updateOnline = () => setIsOnline(navigator.onLine); @@ -2567,6 +2654,8 @@ export function ClinicalDashboard({ const payload = await response.json().catch(() => ({})); if (!isAuthEpochCurrent(requestEpoch)) return; if (!response.ok) throw new Error(payload.error || errorCopy.bulkReindexFailed); + setUserStartedIngestion(true); + setIndexingActive(true); setBulkActionStatus( `${payload.results?.filter((result: { ok: boolean }) => result.ok).length ?? 0} selected documents updated.`, ); @@ -3174,6 +3263,8 @@ export function ClinicalDashboard({ }, ]; const handleUploadQueued = () => { + setUserStartedIngestion(true); + setIndexingActive(true); setUploadMobileTab("jobs"); void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); }; @@ -3313,6 +3404,7 @@ export function ClinicalDashboard({ onClearScope={() => setSelectedDocumentIds([])} onQueryModeChange={setQueryMode} onScopeFiltersChange={setScopeFilters} + onScopeOpenChange={setDocumentScopeOpen} onToggleScope={toggleDocumentScope} onOpenUpload={openUploadDrawer} onOpenEvidence={openEvidenceDrawer} diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 2ac217e8c..178f93939 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -103,6 +103,7 @@ import { } from "@/lib/document-summary-formatting"; import { buildDocumentSummaryBadges } from "@/lib/document-summary-badges"; import { documentSummaryQuestion } from "@/lib/answer-contract"; +import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; type PageRow = { id: string; @@ -230,39 +231,6 @@ async function requestSignedUrlPayload( return payload; } -// Fetch the preview + download signed URLs together (serving the client cache -// first when allowed). Returns their settled results for the caller to apply. -function fetchSignedUrlPair( - signedUrlEndpoint: string, - downloadSignedUrlEndpoint: string, - options: { - signal: AbortSignal; - headers: HeadersInit | undefined; - onUnauthorized: () => void; - useCache: boolean; - }, -) { - const cachedSigned = options.useCache ? getCachedSignedUrl(signedUrlEndpoint) : null; - const cachedDownload = options.useCache ? getCachedSignedUrl(downloadSignedUrlEndpoint) : null; - const signedUrlRequest: Promise = cachedSigned - ? Promise.resolve(cachedSigned) - : requestSignedUrlPayload(signedUrlEndpoint, { - signal: options.signal, - headers: options.headers, - onUnauthorized: options.onUnauthorized, - errorMessage: "Source preview could not be loaded.", - }); - const signedDownloadUrlRequest: Promise = cachedDownload - ? Promise.resolve(cachedDownload) - : requestSignedUrlPayload(downloadSignedUrlEndpoint, { - signal: options.signal, - headers: options.headers, - onUnauthorized: options.onUnauthorized, - errorMessage: "Download URL could not be loaded.", - }); - return Promise.allSettled([signedUrlRequest, signedDownloadUrlRequest]); -} - function getInitialPdfViewerMode() { if (typeof window === "undefined") { return { @@ -290,6 +258,12 @@ function getInitialPdfViewerMode() { }; } +function rowsById(incoming: T[]) { + const rows = new Map(); + for (const row of incoming) rows.set(row.id, row); + return Array.from(rows.values()); +} + function hasProfileItems(items: unknown): items is DocumentSummaryProfileItem[] { return Array.isArray(items) && items.some((item) => item && typeof item === "object" && "text" in item); } @@ -602,7 +576,7 @@ function DocumentViewerAnchors({ className, }: { evidenceHref: "#source-evidence" | "#source-evidence-rail"; - textHref: "#source-text-mobile" | "#source-text-desktop"; + textHref: "#source-text"; className?: string; }) { const anchors = [ @@ -626,6 +600,11 @@ function DocumentViewerAnchors({ href={anchor.href} onClick={() => { const target = window.document.querySelector(anchor.href); + window.document + .querySelectorAll('details[name="document-viewer-section"]') + .forEach((disclosure) => { + if (disclosure !== target) disclosure.open = false; + }); if (target instanceof HTMLDetailsElement) target.open = true; }} className="inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-[color:var(--clinical-accent)] shadow-[var(--shadow-tight)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" @@ -897,7 +876,7 @@ const IndexedTextPanel = memo(function IndexedTextPanel({ searchingDocument: boolean; documentSearchError: string | null; idPrefix: string; - sectionId?: "source-text-mobile" | "source-text-desktop"; + sectionId?: "source-text"; selectedChunkId?: string; onSearchChange: (value: string) => void; }) { @@ -1412,12 +1391,33 @@ function documentKeySections(document: ClinicalDocument) { return Array.from(new Set(labels)).slice(0, 3); } -function DocumentPagePreview({ href, pageNumber }: { href: string; pageNumber: number | null }) { +function DocumentPagePreview({ + href, + pageNumber, + onNavigate, +}: { + href: string; + pageNumber: number | null; + onNavigate: (page: number) => void; +}) { // A real "jump to page" chip rather than a fake wireframe thumbnail that looks // like a skeleton that never resolves. return ( { + if ( + pageNumber === null || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + onNavigate(pageNumber); + }} className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/40 hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)]" >
on phones. - // Scroll the copy the user can actually see: skip display:none matches and - // expand the mobile
when the pinned chunk only exists inside it. - const matches = Array.from( - window.document.querySelectorAll(`[data-source-chunk-id="${CSS.escape(chunkId)}"]`), - ); - const isDisplayed = (element: HTMLElement) => element.offsetParent !== null || element.getClientRects().length > 0; - const inClosedDetails = (element: HTMLElement) => Boolean(element.closest("details:not([open])")); - let target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element)); - if (!target) { - const collapsed = matches - .map((element) => element.closest("details")) - .find((node): node is HTMLDetailsElement => node instanceof HTMLDetailsElement && !node.open); - if (collapsed) collapsed.open = true; - target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element)) ?? matches[0]; - } - target?.scrollIntoView({ block: "center", behavior: "smooth" }); - }, [chunkId, loadingDocument, chunks.length]); + if (!activeChunkId || loadingDocument) return; + window.document + .querySelector(`[data-source-chunk-id="${CSS.escape(activeChunkId)}"]`) + ?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [activeChunkId, loadingDocument, chunks.length]); const retryPreview = () => { setViewerError(null); setPreviewError(null); + setDownloadError(null); + // Re-open the guarded load path after a transient identity failure; the + // cleared identity promise is still revalidated before any API request. + setLocalProjectReady(true); setLoadingDocument(true); setPreviewAttempt((current) => current + 1); }; @@ -2300,8 +2415,8 @@ export function DocumentViewer({ signedUrlRefreshCountRef.current = 0; }, [documentId]); // The PDF signed URL has a 10-min TTL and pdf.js holds a dead reference once it - // expires. When the canvas reports an expiry, drop the cached URLs and re-run - // the fetch pipeline to mint fresh ones (bounded so a broken URL can't loop). + // expires. When the canvas reports an expiry, drop cached URLs and mint a fresh + // preview only (bounded so a broken URL can't loop). Download remains click-gated. // Stable identity (useCallback) so the memoised PdfCanvasViewer isn't re-rendered // — and its page re-rastered — every time an unrelated parent state (source-search // keystroke, composer focus, online/offline) changes. @@ -2311,6 +2426,7 @@ export function DocumentViewer({ const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`; clearCachedSignedUrl(signedUrlEndpoint); clearCachedSignedUrl(`${signedUrlEndpoint}?download=true`); + setDownloadSignedUrl(null); refreshSignedUrls(); }, [documentId, refreshSignedUrls]); // A successful reload means the refreshed URL was accepted, so the recovery @@ -2478,19 +2594,22 @@ export function DocumentViewer({ Open original PDF )} - {downloadSignedUrl ? ( - setMobileActionsOpen(false)} - className={cn(secondaryButton, "min-h-12 justify-start text-xs")} - > + )} @@ -2641,16 +2770,19 @@ export function DocumentViewer({ )} {downloadSignedUrl && ( - void openSourceDownload()} + disabled={downloadingSource} className={secondaryButton} > - + {downloadingSource ? ( +