diff --git a/.env.example b/.env.example index ac2d9744e..778152180 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,8 @@ SUPABASE_IMAGE_BUCKET=clinical-images # Conservative local-first defaults. Raise only after testing your worker machine, # Supabase plan limits, and confidentiality policy. MAX_UPLOAD_MB=150 +# Next Proxy has a fixed 151 MiB transport envelope (150 MiB file plus +# multipart framing), so values above 150 are intentionally rejected. MAX_IMPORT_JOBS_PER_RUN=5 MAX_IMPORT_BYTES_PER_RUN=157286400 CHUNK_SIZE=2000 diff --git a/.github/workflows/eval-canary.yml b/.github/workflows/eval-canary.yml index dcf871e53..f8f48becb 100644 --- a/.github/workflows/eval-canary.yml +++ b/.github/workflows/eval-canary.yml @@ -91,7 +91,14 @@ jobs: run: npm run eval:retrieval:quality -- --fail-on-threshold - name: Answer-quality subset (live generation) - run: npm run eval:quality -- --rag-only --limit ${{ github.event.inputs.answer_case_limit || '8' }} --fail-on-threshold + env: + ANSWER_CASE_LIMIT: ${{ github.event.inputs.answer_case_limit || '8' }} + run: | + if [[ ! "$ANSWER_CASE_LIMIT" =~ ^[0-9]+$ ]] || (( ANSWER_CASE_LIMIT < 1 || ANSWER_CASE_LIMIT > 100 )); then + echo "answer_case_limit must be an integer from 1 to 100" >&2 + exit 2 + fi + npm run eval:quality -- --rag-only --limit "$ANSWER_CASE_LIMIT" --fail-on-threshold - name: Open or update regression issue if: failure() && github.event_name == 'schedule' diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ac8343eff..15dd0df13 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,33 +18,35 @@ 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-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/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. | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | ------------------------------------------------------ | ---------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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. | diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index 5ef219879..b89cedd76 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -173,6 +173,11 @@ comparable (~200 ms) from Singapore or Sydney and does not favour either host. `/api/health`. - No secret is ever baked into a layer. `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY`, etc. are injected at run time by Railway's variable store. +- Request bodies are bounded twice: Next Proxy buffers at most 151 MiB, and + `/api/upload` rejects declared multipart bodies above `MAX_UPLOAD_MB` plus + 1 MiB framing overhead before authentication or `request.formData()`. Keep + the managed host's request-body limit at 151 MiB or lower as a third ingress + fence; `MAX_UPLOAD_MB` is capped at 150 MiB by environment validation. ### Config as code (`railway.app.json`) diff --git a/docs/forward-codify-retrieval-rpcs-workorder.md b/docs/forward-codify-retrieval-rpcs-workorder.md index 686c04ad4..b225a38db 100644 --- a/docs/forward-codify-retrieval-rpcs-workorder.md +++ b/docs/forward-codify-retrieval-rpcs-workorder.md @@ -1,7 +1,11 @@ # Work-order — forward-codify live-ahead retrieval RPC bodies (drift backlog #0) -**Status: staged, not executed.** This is plan task 1.2. It is deliberately **not** shipped as a -migration yet because the two safety preconditions were unavailable at authoring time (2026-07-12): +**Status: reconciled in source on 2026-07-13; live apply remains pending explicit write approval.** +The production-current definitions were captured read-only, preserved under their actual migration +versions, replayed successfully in scratch PostgreSQL, and removed from the drift allowlist. The +four forward remediation migrations remain unapplied to live. + +Historical blockers at authoring time (2026-07-12): 1. **Byte-faithful Docker-replay validation is required and Docker was down.** The established method (see [supabase-migration-reconciliation.md](supabase-migration-reconciliation.md) and the diff --git a/next.config.ts b/next.config.ts index 89fcad349..2654b9e1d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -26,6 +26,11 @@ const nextConfig: NextConfig = { experimental: { cpus: 1, optimizePackageImports: ["lucide-react"], + // Proxy is on every API route. Bound its buffered client body so a + // chunked multipart upload cannot grow without limit before route code + // reaches request.formData(). MAX_UPLOAD_MB is capped at 150 below this + // 151 MiB transport envelope (1 MiB reserved for multipart framing). + proxyClientMaxBodySize: "151mb", }, poweredByHeader: false, turbopack: { diff --git a/scripts/check-drift.ts b/scripts/check-drift.ts index 965dc064b..a6b7a3004 100644 --- a/scripts/check-drift.ts +++ b/scripts/check-drift.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { loadEnvConfig } from "@next/env"; loadEnvConfig(process.cwd()); @@ -229,7 +229,9 @@ async function main() { postgres_image: string; snapshot: Snapshot; }; - const allowlistFile = JSON.parse(read("supabase/drift-allowlist.json")) as { entries: AllowlistEntry[] }; + const allowlistFile = JSON.parse(read("supabase/drift-allowlist.json")) as Record & { + entries: AllowlistEntry[]; + }; const allowlist = allowlistFile.entries ?? []; const schemaSha = normalizedSchemaSha256(read("supabase/schema.sql")); @@ -261,6 +263,20 @@ async function main() { const { findings: remaining, allowed, staleEntries, infos } = compareDriftSnapshots(expected, live, allowlist); + if (process.argv.includes("--prune-stale") && remaining.length === 0 && staleEntries.length > 0) { + const staleSet = new Set(staleEntries); + const nextAllowlist = { + ...allowlistFile, + entries: allowlist.filter((entry) => !staleSet.has(entry)), + }; + writeFileSync( + new URL("../supabase/drift-allowlist.json", import.meta.url), + `${JSON.stringify(nextAllowlist, null, 2)}\n`, + "utf8", + ); + console.log(`Pruned ${staleEntries.length} stale allowlist entr${staleEntries.length === 1 ? "y" : "ies"}.`); + } + console.log(`Drift manifest: generated ${manifestFile.generated_at} from schema.sql ${schemaSha.slice(0, 12)}…`); console.log( `Compared ${Object.keys(categoryKeys) diff --git a/scripts/sql/capture-live-retrieval-rpcs.sql b/scripts/sql/capture-live-retrieval-rpcs.sql index 8476af969..c3536f3b7 100644 --- a/scripts/sql/capture-live-retrieval-rpcs.sql +++ b/scripts/sql/capture-live-retrieval-rpcs.sql @@ -1,7 +1,12 @@ -- capture-live-retrieval-rpcs.sql (READ-ONLY) -- --- Captures the VERBATIM live bodies of the retrieval RPCs that are still flagged --- "LIVE IS AHEAD" in supabase/drift-allowlist.json, so their definitions can be +-- Captures the VERBATIM live bodies of retrieval RPCs still flagged +-- "LIVE IS AHEAD" in supabase/drift-allowlist.json. The target set is currently +-- empty because the 20260712 live-ahead definitions have been forward-codified. +-- If drift reappears, add only the newly allowlisted signatures between the +-- guard markers below so the test and capture query stay synchronized. +-- +-- Definitions are forward-codified into a migration + supabase/schema.sql without -- forward-codified into a migration + supabase/schema.sql WITHOUT hand-authoring -- them. These bodies are complex, have diverged in both directions (e.g. the -- fail-closed retrieval_owner_matches predicate was applied to live while live @@ -27,11 +32,7 @@ -- pg_proc. Run it via the Supabase Dashboard SQL editor, an approved -- service-role SQL path, or the Supabase MCP execute_sql tool. -- --- Expect EXACTLY 7 rows back (one per target). A target that no longer resolves --- on live (renamed, dropped, or already reconciled) is filtered out and simply --- yields FEWER rows — the query does not error. If you get fewer than 7, find --- which with the "missing signatures" snippet in the work-order and reconcile --- the target set + allowlist before continuing. +-- Expect zero rows while the drift allowlist has no LIVE IS AHEAD targets. -- -- search_path is pinned to '' so oid::regprocedure renders fully-qualified and -- matches the allowlist keys / schema_drift_snapshot() signatures exactly, and @@ -43,25 +44,8 @@ set search_path = ''; -select - r.signature, - pg_get_functiondef(r.proc) as definition -from ( - select t.signature, to_regprocedure(t.signature) as proc - from ( - -- >>> forward-codify targets >>> (kept in lockstep with the "LIVE IS AHEAD" - -- retrieval entries in supabase/drift-allowlist.json — see - -- tests/forward-codify-retrieval-targets.test.ts) - values - ('public.get_related_document_metadata(uuid[],uuid)'), - ('public.match_document_chunks(extensions.vector,integer,double precision,uuid,uuid)'), - ('public.match_document_chunks_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)'), - ('public.match_document_chunks_text(text,integer,uuid[],uuid)'), - ('public.match_document_table_facts_text(text,integer,uuid[],uuid)'), - ('public.match_documents_for_query(text,integer,uuid)'), - ('public.repair_strict_enrichment_gate_batch(integer)') - -- <<< forward-codify targets <<< - ) as t(signature) -) as r -where r.proc is not null -order by r.signature; +-- >>> forward-codify targets >>> (kept in lockstep with the LIVE IS AHEAD +-- retrieval entries in supabase/drift-allowlist.json; currently empty) +-- <<< forward-codify targets <<< +select null::text as signature, null::text as definition +where false; diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 0c902a48a..4b226f8e4 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -143,6 +143,20 @@ export async function POST(request: Request) { // (refused) sources/smartPanel/smartApiPlan would still reach the client and // defeat the refusal. Keep only the safe "unsupported" contract fields, matching // the empty-scope branch above. + void logAnswerDiagnostics({ + supabase, + query: answerBody.query, + ownerId: access.ownerId, + answer: { + ...answer, + grounded: false, + confidence: "unsupported", + sources: [], + responseMode: "evidence_gap", + fallbackReason: "source_governance_refusal", + routingReason: [answer.routingReason, "source_governance_refusal"].filter(Boolean).join("; "), + }, + }); return NextResponse.json({ answer: sourceGovernanceRefusalAnswer, grounded: false, diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 288bd9aa2..d11ac106f 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -201,6 +201,22 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, if (shouldUseSourceGovernanceRefusal && hasDangerSourceGovernanceWarning(warnings)) { // Explicit refusal payload — do not spread ...answer (see /api/answer): // the refused sources/smartPanel/smartApiPlan must not reach the client. + if (!isDemoMode()) { + void logAnswerDiagnostics({ + supabase: createAdminClient(), + query: body.query, + ownerId, + answer: { + ...answer, + grounded: false, + confidence: "unsupported", + sources: [], + responseMode: "evidence_gap", + fallbackReason: "source_governance_refusal", + routingReason: [answer.routingReason, "source_governance_refusal"].filter(Boolean).join("; "), + }, + }); + } send("final", { answer: sourceGovernanceRefusalAnswer, grounded: false, diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 1d1ddde96..f6e3f2374 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -1,25 +1,17 @@ import { NextResponse } from "next/server"; -import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import { env, isDemoMode } from "@/lib/env"; -import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; -import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; import { jsonError } from "@/lib/http"; import { activeIngestionJobColumns, buildActiveJobsSafetyResult, checkIngestionMutationSafety, - hasActiveAgentEnrichmentJob, ingestionMutationSafetyPayload, ingestionRollbackFenceStamp, type IngestionJobRow, } from "@/lib/ingestion-mutation-safety"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; -import { - committedIndexGeneration, - isAtomicReindexCandidate, - isCommittedGenerationMetadata, -} from "@/lib/reindex-pipeline"; +import { isAtomicReindexCandidate } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBodyOrDefault } from "@/lib/validation/body"; @@ -27,7 +19,6 @@ import { parseRouteParams } from "@/lib/validation/params"; export const runtime = "nodejs"; -const reindexPageSize = 1000; const reindexModeSchema = z .object({ mode: z.preprocess((value) => (value === "enrichment" ? "enrichment" : "full"), z.enum(["full", "enrichment"])), @@ -37,73 +28,11 @@ const reindexRouteParamsSchema = z.object({ id: z.string().uuid(), }); -type ReindexChunk = { - id: string; - document_id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids?: string[] | null; - metadata?: Record | null; -}; - -type ReindexImage = { - id: string; - page_number: number | null; - caption: string | null; - image_type: string | null; - labels?: string[] | null; - source_kind?: string | null; - clinical_relevance_score?: number | null; - metadata?: Record | null; -}; - -function committedReindexRows(document: { metadata?: unknown }, rows: T[]) { - const committedGeneration = committedIndexGeneration(document.metadata); - return rows.filter((row) => - isCommittedGenerationMetadata({ - rowMetadata: row.metadata, - committedGeneration, - }), - ); -} - async function readMode(request: Request) { const parsed = await parseJsonBodyOrDefault(request, reindexModeSchema, { mode: "full" }); return parsed.mode; } -async function selectReindexRowsInPages(args: { - supabase: ReturnType; - table: "document_chunks" | "document_images"; - select: string; - documentId: string; - searchableOnly?: boolean; -}) { - const rows: T[] = []; - if (args.searchableOnly && args.table !== "document_images") { - throw new Error("searchableOnly reindex paging only supports the document_images table."); - } - for (let offset = 0; ; offset += reindexPageSize) { - const dynamicSupabase = args.supabase as unknown as SupabaseClient; - const query = args.searchableOnly - ? dynamicSupabase - .from("document_images") - .select(args.select) - .eq("document_id", args.documentId) - .eq("searchable", true) - : dynamicSupabase.from(args.table).select(args.select).eq("document_id", args.documentId); - const { data, error } = await query.range(offset, offset + reindexPageSize - 1); - if (error) throw new Error(error.message); - - const page = (data ?? []) as T[]; - rows.push(...page); - if (page.length < reindexPageSize) break; - } - return rows; -} - export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { if (isDemoMode()) return NextResponse.json({ error: "Reindex is unavailable in demo mode." }, { status: 400 }); @@ -139,74 +68,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); if (mode === "enrichment") { - // Audit R24d: route-mode enrichment and the enrichment agent both - // delete-then-insert the same artifact families with no shared lock. If - // the agent is mid-pass, the interleaved deletes can strand a - // "completed/good" document with zero enrichment artifacts (no repair - // path exists). Refuse to run while a live agent pass holds the document. - const agentBusy = await hasActiveAgentEnrichmentJob({ - supabase, - documentId: id, - staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, + const { data: queued, error: queueError } = await supabase.rpc("request_indexing_v3_enrichment", { + p_document_id: id, + p_owner_id: user.id, }); - if (agentBusy) { + if (queueError) { return NextResponse.json( - { - error: - "The enrichment agent is currently processing this document. Wait for it to finish before re-running enrichment.", - }, + { error: "Enrichment is already active or could not be queued safely." }, { status: 409 }, ); } - - const [chunks, images] = await Promise.all([ - selectReindexRowsInPages({ - supabase, - table: "document_chunks", - select: "id,document_id,page_number,chunk_index,section_heading,content,image_ids,metadata", - documentId: id, - }), - selectReindexRowsInPages({ - supabase, - table: "document_images", - select: "id,page_number,caption,image_type,labels,source_kind,clinical_relevance_score,metadata", - documentId: id, - searchableOnly: true, - }), - ]); - - const committedChunks = committedReindexRows(document, chunks); - const committedImages = committedReindexRows(document, images); - - committedChunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); - committedImages.sort((a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0)); - - if (!committedChunks.length) { - return NextResponse.json({ error: "Document has no indexed chunks to enrich." }, { status: 400 }); - } - - const enrichment = await upsertDocumentEnrichment({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - }); - const deepMemory = await upsertDocumentDeepMemory({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - summary: enrichment.summary.summary, - }); - return NextResponse.json({ - mode, - enrichment, - deepMemory: { - sectionCount: deepMemory.sections.length, - memoryCardCount: deepMemory.memoryCards.length, - indexUnitCount: deepMemory.indexUnits.length, - }, - }); + return NextResponse.json({ mode, queued }, { status: 202 }); } const atomicReindex = isAtomicReindexCandidate(document); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index c339e642c..35bc57b16 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -381,16 +381,39 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: 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", + ]); + return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key))); + }; + const publicRows = >(rows: T[]) => + access.authenticated ? rows : rows.map(omitPublicInternalFields); + const responseDocument = access.authenticated + ? document + : omitPublicInternalFields(document as Record); + return NextResponse.json({ document: { - ...document, - labels: labelsResult.data ?? [], - summary: summaryResult.data ?? null, + ...responseDocument, + labels: publicRows((labelsResult.data ?? []) as Record[]), + summary: + access.authenticated || !summaryResult.data + ? (summaryResult.data ?? null) + : omitPublicInternalFields(summaryResult.data as Record), }, - pages: pages ?? [], - images: committedRows(document, images ?? []).map(withImageTableMetadata), - tableFacts: committedRows(document, tableFactsResult.data ?? []), - chunks: committedRows(document, chunks ?? []), + 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, @@ -407,12 +430,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: hasAfter: Boolean(document.chunk_count && chunkRangeEnd + 1 < document.chunk_count), selectedChunkId: selectedChunk?.id ?? null, }, - 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 ?? [], - }, + ...(access.authenticated + ? { + 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 ?? [], + }, + } + : {}), }); } catch (error) { if (error instanceof AuthenticationError) { diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 2d146277f..27b099407 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -1,8 +1,5 @@ import { NextResponse } from "next/server"; -import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; -import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; -import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { @@ -15,11 +12,7 @@ import { } from "@/lib/ingestion-mutation-safety"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { invalidateRagCachesForOwner } from "@/lib/rag"; -import { - committedIndexGeneration, - isAtomicReindexCandidate, - isCommittedGenerationMetadata, -} from "@/lib/reindex-pipeline"; +import { isAtomicReindexCandidate } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBody } from "@/lib/validation/body"; @@ -31,69 +24,6 @@ const bulkReindexSchema = z.object({ mode: z.enum(["enrichment", "full", "retry_failed"]).default("enrichment"), }); -const pageSize = 1000; - -type ReindexChunk = { - id: string; - document_id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids?: string[] | null; - metadata?: Record | null; -}; - -type ReindexImage = { - id: string; - page_number: number | null; - caption: string | null; - image_type: string | null; - labels?: string[] | null; - source_kind?: string | null; - clinical_relevance_score?: number | null; - metadata?: Record | null; -}; - -function committedReindexRows(document: { metadata?: unknown }, rows: T[]) { - const committedGeneration = committedIndexGeneration(document.metadata); - return rows.filter((row) => - isCommittedGenerationMetadata({ - rowMetadata: row.metadata, - committedGeneration, - }), - ); -} - -async function selectRowsInPages(args: { - supabase: ReturnType; - table: "document_chunks" | "document_images"; - select: string; - documentId: string; - searchableOnly?: boolean; -}) { - const rows: T[] = []; - if (args.searchableOnly && args.table !== "document_images") { - throw new Error("searchableOnly reindex paging only supports the document_images table."); - } - for (let offset = 0; ; offset += pageSize) { - const dynamicSupabase = args.supabase as unknown as SupabaseClient; - const query = args.searchableOnly - ? dynamicSupabase - .from("document_images") - .select(args.select) - .eq("document_id", args.documentId) - .eq("searchable", true) - : dynamicSupabase.from(args.table).select(args.select).eq("document_id", args.documentId); - const { data, error } = await query.range(offset, offset + pageSize - 1); - if (error) throw new Error(error.message); - const page = (data ?? []) as T[]; - rows.push(...page); - if (page.length < pageSize) break; - } - return rows; -} - export async function POST(request: Request) { try { if (isDemoMode()) return NextResponse.json({ error: "Bulk reindex is unavailable in demo mode." }, { status: 400 }); @@ -140,46 +70,16 @@ export async function POST(request: Request) { } if (parsed.mode === "enrichment") { - const [chunks, images] = await Promise.all([ - selectRowsInPages({ - supabase, - table: "document_chunks", - select: "id,document_id,page_number,chunk_index,section_heading,content,image_ids,metadata", - documentId: document.id, - }), - selectRowsInPages({ - supabase, - table: "document_images", - select: "id,page_number,caption,image_type,labels,source_kind,clinical_relevance_score,metadata", - documentId: document.id, - searchableOnly: true, - }), - ]); - const committedChunks = committedReindexRows(document, chunks); - const committedImages = committedReindexRows(document, images); - if (!committedChunks.length) throw new Error("Document has no indexed chunks to enrich."); - committedChunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); - committedImages.sort( - (a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0), - ); - const enrichment = await upsertDocumentEnrichment({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - }); - const memory = await upsertDocumentDeepMemory({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - summary: enrichment.summary.summary, + const { data: queued, error: queueError } = await supabase.rpc("request_indexing_v3_enrichment", { + p_document_id: document.id, + p_owner_id: user.id, }); + if (queueError) throw new Error("Enrichment is already active or could not be queued safely."); results.push({ documentId: document.id, mode: parsed.mode, ok: true, - jobId: `${enrichment.labels.length}:${memory.memoryCards.length}:${memory.indexUnits.length}`, + jobId: queued && typeof queued === "object" && "job_id" in queued ? String(queued.job_id ?? "") : undefined, }); continue; } diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index eb6640817..27c38c423 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -13,17 +13,14 @@ export const runtime = "nodejs"; const PUBLIC_DOCUMENT_LIST_COLUMNS = [ "id", - "owner_id", "title", "description", "file_name", "file_type", - "file_size", "status", "page_count", "chunk_count", "image_count", - "metadata", "created_at", "updated_at", ].join(","); @@ -57,7 +54,6 @@ const PUBLIC_LABEL_LIST_COLUMNS = [ "label_type", "source", "confidence", - "metadata", "created_at", "updated_at", ].join(","); diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index 83067157b..e45fe0e9a 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -3,6 +3,7 @@ import { env, isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { allowDeepHealthProbe } from "@/lib/deep-probe-auth"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, requireAuthenticatedUser } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -163,7 +164,7 @@ async function readSearchSchemaStatus(supabase: AdminClient | null) { if (!supabase) throw new Error("Supabase admin client is unavailable."); const { data, error } = await supabase.rpc("search_schema_health"); if (error) { - return check("search", label, "needs_setup", `Search health RPC is unavailable or failed: ${error.message}`); + return check("search", label, "needs_setup", "Search health checks are temporarily unavailable."); } const health = (data ?? {}) as SearchSchemaHealth; const missing = Array.isArray(health.missing) ? health.missing : []; @@ -439,11 +440,20 @@ export async function GET(request: Request) { const payload = await readSetupStatusPayload(); // Whether the caller may see raw per-check detail (raw Supabase error text / project posture). - // Gate on a TRUSTED server-side runtime signal, never on request.url's host — behind a proxy the - // Host header is client-controlled, so a spoofed `localhost:` must not unlock - // detail. In a real production runtime, only the operator deep-probe token unlocks it; local dev - // and single-instance local-no-auth keep full detail (their `detail` is not an internet leak). - const requiresOperatorToken = process.env.NODE_ENV === "production" && !isDemoMode() && !isLocalNoAuthMode(); - const authorizedForDetail = !requiresOperatorToken || allowDeepHealthProbe(request); + // Gate on trusted server-side signals, never request.url's client-controlled host. + const requiresAuthorization = process.env.NODE_ENV === "production" && !isDemoMode() && !isLocalNoAuthMode(); + let authorizedForDetail = !requiresAuthorization || allowDeepHealthProbe(request); + + if (!authorizedForDetail && request.headers.has("authorization")) { + try { + await requireAuthenticatedUser(request, createAdminClient()); + authorizedForDetail = true; + } catch (error) { + if (!(error instanceof AuthenticationError)) throw error; + // Invalid or expired credentials receive the same coarse posture as an + // anonymous caller; setup status is not an authentication oracle. + } + } + return setupStatusResponse(authorizedForDetail ? payload : coarseSetupStatusPayload(payload)); } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index ab1d0f1f0..f017cfbc4 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -115,6 +115,12 @@ export async function POST(request: Request) { ); } + const declaredLength = Number(request.headers.get("content-length")); + const multipartBudget = env.MAX_UPLOAD_MB * 1024 * 1024 + 1024 * 1024; + if (Number.isFinite(declaredLength) && declaredLength > multipartBudget) { + throw new PublicApiError("Upload request is too large.", 413, { code: "payload_too_large" }); + } + const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 4c4fe3b23..0bb03e6ae 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { FileQuestion, Search } from "lucide-react"; import { cn, primaryControl } from "@/components/ui-primitives"; diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx index 5c7da24ab..dd9fb916f 100644 --- a/src/components/AccessibleTable.tsx +++ b/src/components/AccessibleTable.tsx @@ -355,8 +355,8 @@ export function AccessibleTable({ lowConfidenceFallback?: ReactNode; }) { const dialogId = useId(); - const dialogRef = useRef(null); const closeButtonRef = useRef(null); + const dialogRef = useRef(null); const restoreFocusRef = useRef(null); const [open, setOpen] = useState(false); const canExpand = useMobileTableExpansion(expandOnMobile); @@ -392,7 +392,6 @@ export function AccessibleTable({ return; } if (event.key !== "Tab") return; - const focusable = Array.from( dialogRef.current?.querySelectorAll( 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])', diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f6b343f77..7190ebe88 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -804,38 +804,6 @@ export function ClinicalDashboard({ if (!answerThreadBootstrappedRef.current) return; if (answer === null) latestAnswerTurnRef.current = null; }, [answer]); - useEffect(() => { - queueMicrotask(() => { - const persisted = loadPersistedAnswerThread(); - if (persisted) { - restoredThreadFromStorageRef.current = true; - setPriorAnswerTurns(persisted.priorTurns); - setLatestAnswerQuery(persisted.latestTurn?.query ?? null); - if (persisted.latestTurn) { - latestAnswerTurnRef.current = persisted.latestTurn; - setAnswer(persisted.latestTurn.answer); - setSources(persisted.latestTurn.sources); - setModeSearchSubmitted(true); - setQuery(""); - const restoredQuery = persisted.latestTurn.query.trim(); - if (restoredQuery) { - autoRunSearchSignatureRef.current = `answer:${restoredQuery}`; - } - } - answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { - const match = /^answer-turn-(\d+)$/.exec(turn.id); - return match ? Math.max(max, Number(match[1])) : max; - }, 0); - setCollapsedTurnIds( - persisted.collapsedTurnIds.length - ? new Set(persisted.collapsedTurnIds) - : new Set(persisted.priorTurns.map((turn) => turn.id)), - ); - } - answerThreadBootstrappedRef.current = true; - setAnswerThreadBootstrapped(true); - }); - }, []); useEffect(() => { if ( !answerThreadBootstrappedRef.current || @@ -909,7 +877,19 @@ export function ClinicalDashboard({ const [apiUnavailable, setApiUnavailable] = useState(false); const [localProjectReady, setLocalProjectReady] = useState(true); const [isOnline, setIsOnline] = useState(true); - const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); + const routedDocumentId = searchParams.get("documentId"); + const scopedDocumentIds = useMemo( + () => + routedDocumentId && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(routedDocumentId) + ? [routedDocumentId] + : [], + [routedDocumentId], + ); + const [selectedDocumentIds, setSelectedDocumentIds] = useState(scopedDocumentIds); + useEffect(() => { + queueMicrotask(() => setSelectedDocumentIds(scopedDocumentIds)); + }, [scopedDocumentIds]); const [copiedAction, setCopiedAction] = useState(null); const [pendingFeedback, setPendingFeedback] = useState(null); const [actionNotice, setActionNotice] = useState<{ tone: "success" | "warning"; message: string } | null>(null); @@ -1036,6 +1016,56 @@ export function ClinicalDashboard({ const localNoAuthMode = isLocalNoAuthMode(); const explicitDemoMode = demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true"; const clientDemoMode = explicitDemoMode || browserAuthUnavailableDemoFallback || localNoAuthMode; + const answerThreadOwnerId = auth.session?.user.id ?? (clientDemoMode ? "local-demo-session" : null); + const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId); + useEffect(() => { + const previousOwnerId = previousAnswerThreadOwnerIdRef.current; + previousAnswerThreadOwnerIdRef.current = answerThreadOwnerId; + if (!previousOwnerId || previousOwnerId === answerThreadOwnerId) return; + answerThreadBootstrappedRef.current = false; + queueMicrotask(() => { + setPriorAnswerTurns([]); + setLatestAnswerQuery(null); + setCollapsedTurnIds(new Set()); + setAnswer(null); + setSources([]); + latestAnswerTurnRef.current = null; + setAnswerThreadBootstrapped(false); + }); + }, [answerThreadOwnerId]); + useEffect(() => { + if (authStatus === "loading" || answerThreadBootstrappedRef.current) return; + queueMicrotask(() => { + const persisted = answerThreadOwnerId ? loadPersistedAnswerThread(answerThreadOwnerId) : null; + if (persisted) { + restoredThreadFromStorageRef.current = true; + setPriorAnswerTurns(persisted.priorTurns); + setLatestAnswerQuery(persisted.latestTurn?.query ?? null); + if (persisted.latestTurn) { + latestAnswerTurnRef.current = persisted.latestTurn; + setAnswer(persisted.latestTurn.answer); + setSources(persisted.latestTurn.sources); + setModeSearchSubmitted(true); + setQuery(""); + const restoredQuery = persisted.latestTurn.query.trim(); + if (restoredQuery) autoRunSearchSignatureRef.current = `answer:${restoredQuery}`; + } + answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { + const match = /^answer-turn-(\d+)$/.exec(turn.id); + return match ? Math.max(max, Number(match[1])) : max; + }, 0); + setCollapsedTurnIds( + persisted.collapsedTurnIds.length + ? new Set(persisted.collapsedTurnIds) + : new Set(persisted.priorTurns.map((turn) => turn.id)), + ); + } else if (!answerThreadOwnerId) { + clearPersistedAnswerThread(); + } + answerThreadBootstrappedRef.current = true; + setAnswerThreadBootstrapped(true); + }); + }, [answerThreadOwnerId, authStatus]); const uploadReadOnlyMode = demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || browserAuthUnavailableDemoFallback; const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks); @@ -1134,10 +1164,16 @@ export function ClinicalDashboard({ }, [prefetchApplications]); useEffect(() => { + if (!answerThreadOwnerId) { + queueMicrotask(() => setRecentQueries([])); + return; + } let cancelled = false; const frame = window.requestAnimationFrame(() => { try { - const stored = JSON.parse(window.localStorage.getItem(recentQueryStorageKey) ?? "[]"); + const stored = JSON.parse( + window.sessionStorage.getItem(`${recentQueryStorageKey}:${answerThreadOwnerId}`) ?? "[]", + ); if (Array.isArray(stored) && !cancelled) { setRecentQueries( stored.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).slice(0, 5), @@ -1151,24 +1187,29 @@ export function ClinicalDashboard({ cancelled = true; window.cancelAnimationFrame(frame); }; - }, []); - - const rememberRecentQuery = useCallback((value: string) => { - const trimmedValue = value.trim(); - if (!trimmedValue) return; - setRecentQueries((current) => { - const next = [trimmedValue, ...current.filter((item) => item.toLowerCase() !== trimmedValue.toLowerCase())].slice( - 0, - 5, - ); - try { - window.localStorage.setItem(recentQueryStorageKey, JSON.stringify(next)); - } catch { - // Recent questions are a convenience only; ignore storage failures. - } - return next; - }); - }, []); + }, [answerThreadOwnerId]); + + const rememberRecentQuery = useCallback( + (value: string) => { + const trimmedValue = value.trim(); + if (!trimmedValue) return; + setRecentQueries((current) => { + const next = [ + trimmedValue, + ...current.filter((item) => item.toLowerCase() !== trimmedValue.toLowerCase()), + ].slice(0, 5); + try { + if (answerThreadOwnerId) { + window.sessionStorage.setItem(`${recentQueryStorageKey}:${answerThreadOwnerId}`, JSON.stringify(next)); + } + } catch { + // Recent questions are a convenience only; ignore storage failures. + } + return next; + }); + }, + [answerThreadOwnerId], + ); useEffect(() => { if (!answerThreadBootstrapped) return; @@ -1177,13 +1218,22 @@ export function ClinicalDashboard({ clearPersistedAnswerThread(); return; } - savePersistedAnswerThread({ + if (!answerThreadOwnerId) return; + savePersistedAnswerThread(answerThreadOwnerId, { version: 1, priorTurns: priorAnswerTurns, latestTurn: latestAnswerTurnRef.current, collapsedTurnIds: [...collapsedTurnIds], }); - }, [searchMode, answer, priorAnswerTurns, collapsedTurnIds, latestAnswerQuery, answerThreadBootstrapped]); + }, [ + searchMode, + answer, + priorAnswerTurns, + collapsedTurnIds, + latestAnswerQuery, + answerThreadBootstrapped, + answerThreadOwnerId, + ]); useEffect(() => { jobsRef.current = jobs; @@ -1237,6 +1287,7 @@ export function ClinicalDashboard({ if (includeSetup) { const setupResponse = await fetch("/api/setup-status", { cache: "no-store", + headers: authorizationHeader, signal: controller.signal, }).catch(() => null); if (!canCommit()) return; diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 9bcb1edd1..415c9d40f 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2140,7 +2140,7 @@ export function DocumentViewer({ return null; } setLocalProjectReady(true); - return fetch("/api/setup-status"); + return fetch("/api/setup-status", { headers: authorizationHeader }); }) .then((response) => (response?.ok ? response.json() : null)) .then((payload) => { @@ -2150,7 +2150,7 @@ export function DocumentViewer({ return () => { active = false; }; - }, [isConfigured]); + }, [authorizationHeader, isConfigured]); useEffect(() => { if (!canViewSourceDocuments && authStatus === "loading") { @@ -2451,7 +2451,7 @@ export function DocumentViewer({ : (effectiveViewerError ?? "Source unavailable"); const documentHomeHref = "/?mode=documents"; const scopedDocumentHref = readyDocument - ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}` + ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}&documentId=${encodeURIComponent(documentId)}` : documentHomeHref; const documentPageHref = (page: number) => { const params = new URLSearchParams({ page: String(Math.max(1, Math.trunc(page))) }); diff --git a/src/components/ServiceDetailPage.tsx b/src/components/ServiceDetailPage.tsx deleted file mode 100644 index c19277a98..000000000 --- a/src/components/ServiceDetailPage.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ServiceDetailPage } from "@/components/services/service-detail-page"; diff --git a/src/components/clinical-dashboard-client.tsx b/src/components/clinical-dashboard-client.tsx deleted file mode 100644 index d9a3bee82..000000000 --- a/src/components/clinical-dashboard-client.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import type { AppModeId } from "@/lib/app-modes"; - -const ClinicalDashboard = dynamic(() => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), { - ssr: false, -}); - -type ClinicalDashboardClientProps = { - initialSearchMode?: AppModeId; - initialQuery?: string; - focusSearch?: boolean; - autoRunSearch?: boolean; -}; - -export function ClinicalDashboardClient(props: ClinicalDashboardClientProps) { - return ; -} diff --git a/src/components/clinical-dashboard-lazy.tsx b/src/components/clinical-dashboard-lazy.tsx deleted file mode 100644 index 70bd7aafc..000000000 --- a/src/components/clinical-dashboard-lazy.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -// `ssr: false` requires a Client Component in the App Router; this wrapper -// keeps the heavy dashboard bundle browser-only for the server-rendered -// home page. -export const ClinicalDashboardLazy = dynamic( - () => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), - { ssr: false }, -); diff --git a/src/components/clinical-dashboard/favourites-home-page.tsx b/src/components/clinical-dashboard/favourites-home-page.tsx deleted file mode 100644 index fcbf7beb4..000000000 --- a/src/components/clinical-dashboard/favourites-home-page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; - -import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; -import { appModeHomeHref } from "@/lib/app-modes"; -import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; - -type FavouritesHomePageProps = { - query?: string; -}; - -export function FavouritesHomePage({ query = "" }: FavouritesHomePageProps) { - const router = useRouter(); - const [queryOverride, setQueryOverride] = useState<{ source: string; value: string } | null>(null); - const filterQuery = queryOverride?.source === query ? queryOverride.value : query; - - return ( -
-
- { - setQueryOverride({ source: query, value: "" }); - router.push(appModeHomeHref("favourites", { focus: true })); - }} - onAddFavourite={() => undefined} - desktopComposerSlotId={modeHomeDesktopComposerSlotId} - headingLevel={1} - /> -
-
- ); -} diff --git a/src/components/document-search-live-opener.tsx b/src/components/document-search-live-opener.tsx deleted file mode 100644 index 0ec70ce46..000000000 --- a/src/components/document-search-live-opener.tsx +++ /dev/null @@ -1,665 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { - CircleAlert, - ArrowLeft, - BadgeCheck, - BookOpen, - ExternalLink, - FileImage, - FileText, - Filter, - Loader2, - Search, - Sparkles, - Table2, - Target, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; - -import { cn } from "@/components/ui-primitives"; -import { useAuthSession } from "@/lib/supabase/client"; - -type DocumentListItem = { - id: string; - title?: string | null; - file_name?: string | null; - status?: string | null; -}; - -type DocumentsPayload = { - documents?: DocumentListItem[]; -}; - -type ChunkSearchResult = { - id: string; - page_number?: number | null; - chunk_index?: number | null; - section_heading?: string | null; - snippet?: string | null; - score?: number | null; -}; - -type ChunkSearchPayload = { - results?: ChunkSearchResult[]; -}; - -type DocumentDetailPayload = { - chunks?: ChunkSearchResult[]; -}; - -type ResolverState = - { status: "opening"; message: string; liveHref?: string } | { status: "mock"; message: string; liveHref?: string }; - -type MockSourceDocument = { - slug: string; - title: string; - fileName: string; - kind: string; - defaultPage: number; - pageCount: number; - status: string; - review: string; - section: string; - summary: string; - tags: string[]; - matchedTerms: string[]; - evidence: Array<{ label: string; value: string; icon: typeof Table2; tone: "success" | "info" | "warning" }>; - passage: string[]; - tableRows: Array<[string, string, string]>; -}; - -const focusRing = - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; - -const defaultQuery = "clozapine monitoring table"; - -const mockSources: MockSourceDocument[] = [ - { - slug: "clozapine-monitoring", - title: "Clozapine physical health monitoring protocol", - fileName: "clozapine-physical-health-monitoring.pdf", - kind: "Protocol", - defaultPage: 12, - pageCount: 18, - status: "Current", - review: "Review 2026", - section: "Blood test monitoring table", - summary: - "Mock source preview for the command-centre handoff. It keeps the exact page, table evidence, and actions visible without requiring private document authentication.", - tags: ["Medication", "Monitoring", "Shared care"], - matchedTerms: ["clozapine", "monitoring", "table"], - evidence: [ - { label: "Table evidence", value: "8 rows", icon: Table2, tone: "success" }, - { label: "PDF page", value: "p.12", icon: FileText, tone: "info" }, - { label: "Review note", value: "2026", icon: CircleAlert, tone: "warning" }, - ], - passage: [ - "Monitoring requirements are grouped by treatment stage and missed-dose interval.", - "Restart and escalation decisions should be checked against the local protocol table.", - "Shared-care transfer requires the monitoring schedule and review responsibility to be visible.", - ], - tableRows: [ - ["Stable treatment", "Continue scheduled FBC/ANC checks", "Routine review"], - ["Missed dose 48-72h", "Restart pathway and monitoring check", "Prescriber review"], - ["Review due", "Confirm local protocol currency", "Document source status"], - ], - }, - { - slug: "acute-agitation-pathway", - title: "Acute agitation clinical pathway", - fileName: "acute-agitation-clinical-pathway.pdf", - kind: "Guideline", - defaultPage: 4, - pageCount: 9, - status: "Current", - review: "Local pathway", - section: "Flowchart and escalation pathway", - summary: - "Mock source preview showing how image and flowchart evidence can stay attached to the selected search result.", - tags: ["Risk", "Escalation", "ED"], - matchedTerms: ["agitation", "pathway", "flowchart"], - evidence: [ - { label: "Image evidence", value: "flowchart", icon: FileImage, tone: "info" }, - { label: "PDF page", value: "p.4", icon: FileText, tone: "success" }, - { label: "Risk pathway", value: "visible", icon: CircleAlert, tone: "warning" }, - ], - passage: [ - "The pathway separates immediate safety steps from medication and senior review prompts.", - "Flowchart evidence remains visible before opening the full source file.", - "Escalation points are grouped so the result can be scoped or used for a follow-up answer.", - ], - tableRows: [ - ["Immediate risk", "Use local safety pathway", "Escalate"], - ["De-escalation", "Document response and triggers", "Review"], - ["Senior input", "Confirm local governance", "Open source"], - ], - }, - { - slug: "mental-health-act-forms", - title: "Mental Health Act forms quick reference", - fileName: "mental-health-act-forms-reference.pdf", - kind: "Quick reference", - defaultPage: 2, - pageCount: 6, - status: "Indexed", - review: "Form checklist", - section: "Forms and documentation", - summary: - "Mock source preview for form-heavy results, keeping the document type and target page obvious from the handoff.", - tags: ["Forms", "Workflow", "Legal"], - matchedTerms: ["forms", "workflow", "reference"], - evidence: [ - { label: "Checklist", value: "forms", icon: BadgeCheck, tone: "success" }, - { label: "PDF page", value: "p.2", icon: FileText, tone: "info" }, - { label: "Workflow", value: "legal", icon: CircleAlert, tone: "warning" }, - ], - passage: [ - "The quick reference groups forms by use case and required documentation step.", - "The handoff preserves the target page so users can inspect the original source quickly.", - "Scope and answer actions remain available from the selected source preview.", - ], - tableRows: [ - ["Assessment", "Open form checklist", "Confirm status"], - ["Transfer", "Check required document", "Open source"], - ["Review", "Record local governance", "Scope"], - ], - }, -]; - -async function fetchJson(url: string, signal: AbortSignal, authorizationHeader: Record): Promise { - const response = await fetch(url, { - cache: "no-store", - headers: { Accept: "application/json", ...authorizationHeader }, - signal, - }); - if (!response.ok) { - throw new Error(`Request failed with ${response.status}.`); - } - return (await response.json()) as T; -} - -function pageFor(result: ChunkSearchResult | undefined) { - return Math.max(1, Number(result?.page_number ?? 1)); -} - -function documentSearchTerm(query: string, documentHint: string) { - const lowered = `${documentHint} ${query}`.toLowerCase(); - if (lowered.includes("clozapine")) return "clozapine"; - if (lowered.includes("agitation")) return "agitation"; - if (lowered.includes("mental health act")) return "mental health act"; - return query.split(/\s+/).slice(0, 3).join(" ") || defaultQuery; -} - -function liveDocumentHref(documentId: string, result: ChunkSearchResult | undefined) { - const params = new URLSearchParams({ page: String(pageFor(result)) }); - if (result?.id) params.set("chunk", result.id); - return `/documents/${documentId}?${params.toString()}`; -} - -function numberParam(value: string | null, fallback: number) { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; -} - -function mockSourceFor(documentHint: string, query: string) { - const normalized = `${documentHint} ${query}`.toLowerCase(); - return ( - mockSources.find((source) => normalized.includes(source.slug) || normalized.includes(source.title.toLowerCase())) ?? - (normalized.includes("agitation") - ? mockSources.find((source) => source.slug === "acute-agitation-pathway") - : null) ?? - (normalized.includes("mental health act") || normalized.includes("forms") - ? mockSources.find((source) => source.slug === "mental-health-act-forms") - : null) ?? - mockSources[0] - ); -} - -function TonePill({ - children, - tone = "neutral", -}: { - children: React.ReactNode; - tone?: "accent" | "info" | "success" | "warning" | "neutral"; -}) { - const toneClass = - tone === "accent" - ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" - : tone === "info" - ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]" - : tone === "success" - ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" - : tone === "warning" - ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" - : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]"; - return ( - - {children} - - ); -} - -function EvidenceCard({ label, value, icon: Icon, tone }: MockSourceDocument["evidence"][number]) { - const toneClass = - tone === "success" - ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" - : tone === "warning" - ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" - : "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; - return ( -
- - -

{label}

-

{value}

-
- ); -} - -function MockDocumentPagePreview({ source, page, chunk }: { source: MockSourceDocument; page: number; chunk: string }) { - return ( -
-
-
-

- Mock source page -

-

{source.section}

-
-
- p.{page} - {chunk.replaceAll("-", " ")} -
-
-
-
-
- - - -
-
-

{source.passage[0]}

-
-
- {source.passage.slice(1).map((line) => ( -

- {line} -

- ))} -
-
- - - - - - - - - - {source.tableRows.map((row, index) => ( - - {row.map((cell) => ( - - ))} - - ))} - -
Source rowWhat to reviewAction
- {cell} -
-
-
- -
-
- ); -} - -function MockSourceWorkbench({ - source, - page, - chunk, - query, - message, - liveHref, -}: { - source: MockSourceDocument; - page: number; - chunk: string; - query: string; - message: string; - liveHref?: string; -}) { - return ( -
-
-
- - -
-

- Mock source preview -

-

- {source.title} -

-

{message}

-
- {liveHref ? ( - -
-
- -
-
- -
- {source.evidence.map((item) => ( - - ))} -
-
- - -
-
- ); -} - -export function DocumentSearchLiveOpener() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { authorizationHeader, status: authStatus } = useAuthSession(); - const query = searchParams.get("q")?.trim() || defaultQuery; - const documentHint = searchParams.get("document")?.trim() || "clozapine"; - const mockSource = useMemo(() => mockSourceFor(documentHint, query), [documentHint, query]); - const requestedPage = numberParam(searchParams.get("page"), mockSource.defaultPage); - const chunk = searchParams.get("chunk")?.trim() || "best-match"; - const [state, setState] = useState({ - status: "opening", - message: "Finding an indexed document and matching source chunk.", - }); - - const lookupTerm = useMemo(() => documentSearchTerm(query, documentHint), [documentHint, query]); - - useEffect(() => { - const controller = new AbortController(); - - async function openLiveDocument() { - if (authStatus === "loading") { - setState({ status: "opening", message: "Checking browser document access." }); - return; - } - - try { - setState({ status: "opening", message: "Finding a real indexed document." }); - const documentParams = new URLSearchParams({ - limit: "20", - includeMeta: "false", - status: "indexed", - q: lookupTerm, - }); - let payload = await fetchJson( - `/api/documents?${documentParams.toString()}`, - controller.signal, - authorizationHeader, - ); - let documents = (payload.documents ?? []).filter((document) => document.status === "indexed"); - - if (documents.length === 0) { - const fallbackParams = new URLSearchParams({ limit: "20", includeMeta: "false", status: "indexed" }); - payload = await fetchJson( - `/api/documents?${fallbackParams.toString()}`, - controller.signal, - authorizationHeader, - ); - documents = (payload.documents ?? []).filter((document) => document.status === "indexed"); - } - - if (documents.length === 0) { - setState({ - status: "mock", - message: - "No indexed live document was available for this lookup. This mock preview shows the intended source handoff.", - }); - return; - } - - setState({ status: "opening", message: "Selecting the best matching chunk." }); - let best: { document: DocumentListItem; result?: ChunkSearchResult; score: number } | null = null; - - for (const document of documents.slice(0, 8)) { - const chunkParams = new URLSearchParams({ q: query, limit: "1" }); - const searchPayload = await fetchJson( - `/api/documents/${document.id}/search?${chunkParams.toString()}`, - controller.signal, - authorizationHeader, - ); - const result = searchPayload.results?.[0]; - const score = Number(result?.score ?? 0); - if (result && (!best || score > best.score)) { - best = { document, result, score }; - } - } - - if (!best) { - const document = documents[0]; - const detailPayload = await fetchJson( - `/api/documents/${document.id}?page=1&pageLimit=1&chunkLimit=1`, - controller.signal, - authorizationHeader, - ); - best = { document, result: detailPayload.chunks?.[0], score: 0 }; - } - - const liveHref = liveDocumentHref(best.document.id, best.result); - setState({ - status: "opening", - message: `Opening ${best.document.title ?? best.document.file_name ?? "document"} in the live viewer.`, - liveHref, - }); - router.replace(liveHref); - } catch (error) { - if (controller.signal.aborted) return; - setState({ - status: "mock", - message: - error instanceof Error - ? `${error.message} Showing the mock source preview instead.` - : "The live document could not be opened. Showing the mock source preview instead.", - }); - } - } - - void openLiveDocument(); - return () => controller.abort(); - }, [authStatus, authorizationHeader, lookupTerm, query, router]); - - return ( -
-
- -
-
- ); -} diff --git a/src/components/document-viewer-client.tsx b/src/components/document-viewer-client.tsx deleted file mode 100644 index bfbbb86c2..000000000 --- a/src/components/document-viewer-client.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -const DocumentViewer = dynamic(() => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), { - ssr: false, -}); - -type DocumentViewerClientProps = { - documentId: string; - initialPage: number; - chunkId?: string; -}; - -export function DocumentViewerClient(props: DocumentViewerClientProps) { - return ; -} diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index 18bc90e34..7aaf670fb 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -326,9 +326,14 @@ function PathwayContextCard({ ) : null} - @@ -385,11 +390,11 @@ function ActionPanel({
{hrefForCall ? ( @@ -476,7 +481,10 @@ export function FormDetailPage({ form }: { form: FormRecord }) { try { const current = readSavedRegistrySlugs(savedFormsStorageKey); const next = current.includes(form.slug) ? current.filter((item) => item !== form.slug) : [form.slug, ...current]; - writeSavedRegistrySlugs(savedFormsStorageKey, next); + if (!writeSavedRegistrySlugs(savedFormsStorageKey, next)) { + setNotice("Save failed"); + return; + } const nowSaved = next.includes(form.slug); setSaved(nowSaved); setNotice(nowSaved ? "Form saved" : "Form removed from saved items"); diff --git a/src/components/mode-home-page-skeleton.tsx b/src/components/mode-home-page-skeleton.tsx index 3e04065a3..3280ca630 100644 --- a/src/components/mode-home-page-skeleton.tsx +++ b/src/components/mode-home-page-skeleton.tsx @@ -1,9 +1,7 @@ -import { cn } from "@/components/ui-primitives"; - function SkeletonBlock({ className }: { className?: string }) { return ( ); diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 78b55f86e..7af5835dc 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -490,7 +490,10 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const next = current.includes(service.slug) ? current.filter((item) => item !== service.slug) : [service.slug, ...current]; - writeSavedRegistrySlugs(savedServicesStorageKey, next); + if (!writeSavedRegistrySlugs(savedServicesStorageKey, next)) { + setNotice("Save failed"); + return; + } const nowSaved = next.includes(service.slug); setSaved(nowSaved); setNotice(nowSaved ? "Service saved" : "Service removed from saved items"); diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 6fe93282f..f2ea64c19 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -422,6 +422,8 @@ function RightRail({ @@ -565,6 +567,8 @@ export function ServicesNavigatorPage() { className="inline-flex min-h-10 w-10 items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-sm font-bold text-[color:var(--text-heading)] shadow-[var(--shadow-tight)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)] sm:min-h-11 sm:w-auto sm:px-4" type="button" aria-label="Open service filters" + disabled + title="Advanced filters are not available yet" > Filters @@ -572,6 +576,8 @@ export function ServicesNavigatorPage() { diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index f73e4dda7..04590ed45 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -77,10 +77,15 @@ export function Sheet({ }) { const panelRef = useRef(null); const closeRef = useRef(null); + const onCloseRef = useRef(onClose); const dragRef = useRef<{ startY: number; dragging: boolean }>({ startY: 0, dragging: false }); const titleId = useId(); const descId = useId(); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + // Swipe-to-dismiss for the mobile bottom sheet: dragging the grip down past a // threshold closes the sheet; a shorter drag snaps back. Grip-initiated only, // so it never competes with scrolling the sheet body. Keyboard/backdrop/close @@ -133,7 +138,7 @@ export function Sheet({ function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); - onClose(); + onCloseRef.current(); return; } if (event.key !== "Tab") return; @@ -179,7 +184,7 @@ export function Sheet({ }, 50); }); }; - }, [open, onClose, initialFocusRef, returnFocusRef]); + }, [open, initialFocusRef, returnFocusRef]); if (!open) return null; diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index 461ec358e..ca0c7d682 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -145,7 +145,7 @@ function deriveTrust(answer: RagAnswer): AnswerRenderTrust { function sourceStrengthFor(candidate: SourceCandidate) { if (candidate.sourceStrength) return candidate.sourceStrength; - return candidate.citation.source_metadata?.document_status === "current" ? "strong" : "none"; + return "none"; } function sourceLinkFromCandidate(candidate: SourceCandidate): SourceLink { @@ -230,12 +230,20 @@ function candidateFromCoreSourceLink(link: CoreSourceLink, triggerField: string) function collectSourceCandidates(answer: RagAnswer, sources: SearchResult[]) { const candidates: SourceCandidate[] = []; + const supportingChunkIds = new Set([ + ...(answer.citations ?? []).map((citation) => citation.chunk_id), + ...(answer.quoteCards ?? answer.smartPanel?.quotes ?? []).map((quote) => quote.chunk_id), + ...(answer.answerSections ?? []).flatMap((section) => section.citation_chunk_ids ?? []), + ...(answer.smartApiPlan?.coreSourceLinks ?? []).map((link) => link.chunk_id).filter(Boolean), + ]); for (const link of answer.smartApiPlan?.coreSourceLinks ?? []) { const candidate = candidateFromCoreSourceLink(link, "smartApiPlan.coreSourceLinks"); if (candidate) candidates.push(candidate); } const bestSource = answer.bestSource ?? answer.smartPanel?.bestSource ?? null; - if (bestSource) candidates.push(candidateFromBestSource(bestSource, "bestSource")); + if (bestSource && supportingChunkIds.has(bestSource.chunk_id)) { + candidates.push(candidateFromBestSource(bestSource, "bestSource")); + } for (const citation of answer.citations ?? []) candidates.push(candidateFromCitation(citation, "citations")); for (const quote of answer.quoteCards ?? answer.smartPanel?.quotes ?? []) { candidates.push({ @@ -245,7 +253,9 @@ function collectSourceCandidates(answer: RagAnswer, sources: SearchResult[]) { sourceStrength: quote.source_strength, }); } - for (const source of sources) candidates.push(candidateFromSearchResult(source, "sources")); + for (const source of sources) { + if (supportingChunkIds.has(source.id)) candidates.push(candidateFromSearchResult(source, "sources")); + } const sourceById = new Map(sources.map((source) => [source.id, source])); for (const section of answer.answerSections ?? []) { diff --git a/src/lib/answer-telemetry.ts b/src/lib/answer-telemetry.ts index 9df176fdc..49fc683fa 100644 --- a/src/lib/answer-telemetry.ts +++ b/src/lib/answer-telemetry.ts @@ -128,23 +128,22 @@ export function buildAnswerLogRow(args: { query: string; ownerId?: string | null // detached and its error swallowed (with a throttled warning). let answerLogFailureCount = 0; -export function logAnswerDiagnostics(args: { +export async function logAnswerDiagnostics(args: { supabase: ReturnType; query: string; ownerId?: string | null; answer: AnswerTelemetrySource; }) { - void (async () => { - try { - await args.supabase.from("rag_retrieval_logs").insert(buildAnswerLogRow(args)); - } catch (error) { - answerLogFailureCount += 1; - if (answerLogFailureCount <= 3 || answerLogFailureCount % 25 === 0) { - console.warn("rag_retrieval_logs answer insert failed", { - failures: answerLogFailureCount, - message: error instanceof Error ? error.message : "unknown answer logging error", - }); - } + try { + const { error } = await args.supabase.from("rag_retrieval_logs").insert(buildAnswerLogRow(args)); + if (error) throw error; + } catch (error) { + answerLogFailureCount += 1; + if (answerLogFailureCount <= 3 || answerLogFailureCount % 25 === 0) { + console.warn("rag_retrieval_logs answer insert failed", { + failures: answerLogFailureCount, + message: error instanceof Error ? error.message : "unknown answer logging error", + }); } - })(); + } } diff --git a/src/lib/answer-thread-storage.ts b/src/lib/answer-thread-storage.ts index d2aa683b4..2a0ac1d0e 100644 --- a/src/lib/answer-thread-storage.ts +++ b/src/lib/answer-thread-storage.ts @@ -66,10 +66,14 @@ function normalizePersistedAnswerThread(value: unknown): PersistedAnswerThread | }; } -export function loadPersistedAnswerThread(): PersistedAnswerThread | null { - if (typeof window === "undefined") return null; +function scopedStorageKey(ownerId: string) { + return `${answerThreadStorageKey}:${ownerId}`; +} + +export function loadPersistedAnswerThread(ownerId: string): PersistedAnswerThread | null { + if (typeof window === "undefined" || !ownerId) return null; try { - const raw = window.localStorage.getItem(answerThreadStorageKey); + const raw = window.sessionStorage.getItem(scopedStorageKey(ownerId)); if (!raw) return null; return normalizePersistedAnswerThread(JSON.parse(raw)); } catch { @@ -77,8 +81,8 @@ export function loadPersistedAnswerThread(): PersistedAnswerThread | null { } } -export function savePersistedAnswerThread(thread: PersistedAnswerThread): boolean { - if (typeof window === "undefined") return false; +export function savePersistedAnswerThread(ownerId: string, thread: PersistedAnswerThread): boolean { + if (typeof window === "undefined" || !ownerId) return false; try { const payload: PersistedAnswerThread = { version: 1, @@ -88,7 +92,7 @@ export function savePersistedAnswerThread(thread: PersistedAnswerThread): boolea }; const serialized = JSON.stringify(payload); if (serialized.length > maxStorageBytes) return false; - window.localStorage.setItem(answerThreadStorageKey, serialized); + window.sessionStorage.setItem(scopedStorageKey(ownerId), serialized); return true; } catch { return false; @@ -99,6 +103,12 @@ export function clearPersistedAnswerThread() { if (typeof window === "undefined") return; try { window.localStorage.removeItem(answerThreadStorageKey); + for (let index = window.sessionStorage.length - 1; index >= 0; index -= 1) { + const key = window.sessionStorage.key(index); + if (key === answerThreadStorageKey || key?.startsWith(`${answerThreadStorageKey}:`)) { + window.sessionStorage.removeItem(key); + } + } } catch { // Thread persistence is a convenience only. } diff --git a/src/lib/answer-verification.ts b/src/lib/answer-verification.ts index 77103c0e7..ff2808045 100644 --- a/src/lib/answer-verification.ts +++ b/src/lib/answer-verification.ts @@ -33,7 +33,7 @@ import type { // a trailing \b after it can never match (it would require a word char to its // right), which previously dropped every percentage token. const NUMERIC_TOKEN_PATTERN = - /\b\d+(?:[.,]\d+)?(?:\s*[-–—]\s*\d+(?:[.,]\d+)?)?\s*(?:×10\^?\d*\/?l?|x10\^?\d*\/?l?|mg\/(?:day|kg|m2|dose)?|mg|mcg|microgram(?:s)?|micrograms?|μg|g\b|kg|ml|mL|l\b|mmol\/l|mmol\/L|mmol|mol\/l|umol\/l|µmol\/l|ng\/ml|units?\/?\w*|iu\b|hours?|hrs?|h\b|days?|weeks?|wk\b|months?|minutes?|mins?|years?|°c|mmhg|bpm)\b|\b\d+(?:[.,]\d+)?\s*%/giu; + /\b\d+\s*:\s*\d+\b|\b\d+(?:[.,]\d+)?(?:\s*[-–—]\s*\d+(?:[.,]\d+)?)?\s*(?:×10\^?\d*\/?l?|x10\^?\d*\/?l?|mg\/(?:day|hour|hr|h|kg|m2|dose)|mg|mcg|ug|microgram(?:s)?|micrograms?|μg|g\b|kg|ml\/(?:day|hour|hr|h)|ml|mL|l\b|mmol\/l|mmol\/L|mmol|mol\/l|umol\/l|µmol\/l|ng\/ml|units?\/?\w*|iu\b|hours?|hrs?|h\b|days?|weeks?|wk\b|months?|minutes?|mins?|years?|°c|mmhg|bpm)\b|\b\d+(?:[.,]\d+)?\s*%/giu; // Decimal numbers and ranges that, while not unit-bearing, are very likely // clinical thresholds in context (e.g. "ANC 2.0", "INR 2-3"). We only treat a @@ -69,6 +69,7 @@ function normalizeNumericToken(raw: string): string { .replace(/[–—]/g, "-") .replace(/,/g, ".") .replace(/µ/g, "μ") + .replace(/ug\b/g, "mcg") .replace(/×10\^?(\d+)/g, "x10^$1") .replace(/x10(\d)/g, "x10^$1") .trim(); @@ -177,8 +178,11 @@ export function verifyAnswerNumbers( return { answerTokens, unverifiedTokens: answerTokens, hasUnverifiedNumbers: answerTokens.length > 0 }; } - const sourceTokens = sourceNumericTokenSet(citedResults); - const unverifiedTokens = answerTokens.filter((token) => !sourceTokens.has(token)); + // Top-level citations do not identify which sentence each citation supports. + // Require a numeric claim to be present in every cited chunk rather than + // allowing an unrelated citation to verify a number by union membership. + const sourceTokenSets = citedResults.map((result) => sourceNumericTokenSet([result])); + const unverifiedTokens = answerTokens.filter((token) => !sourceTokenSets.every((tokens) => tokens.has(token))); return { answerTokens, diff --git a/src/lib/env.ts b/src/lib/env.ts index d4e22baa7..5f31cf26b 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -129,7 +129,7 @@ const envSchema = z.object({ RAG_QUERY_HASH_SECRET: z.string().min(16).optional(), SUPABASE_DOCUMENT_BUCKET: z.string().default("clinical-documents"), SUPABASE_IMAGE_BUCKET: z.string().default("clinical-images"), - MAX_UPLOAD_MB: z.coerce.number().int().positive().default(150), + MAX_UPLOAD_MB: z.coerce.number().int().positive().max(150).default(150), MAX_IMPORT_JOBS_PER_RUN: z.coerce.number().int().positive().default(5), MAX_IMPORT_BYTES_PER_RUN: z.coerce.number().int().positive().default(157286400), CHUNK_SIZE: z.coerce.number().int().positive().default(2000), diff --git a/src/lib/evidence.ts b/src/lib/evidence.ts index 80d0317e3..d705eecc3 100644 --- a/src/lib/evidence.ts +++ b/src/lib/evidence.ts @@ -293,13 +293,17 @@ export function buildSmartPanel( export function selectBestSourceRecommendation( results: SearchResult[], - quoteCards: QuoteCard[] = [], + quoteCards?: QuoteCard[], ): BestSourceRecommendation | null { if (results.length === 0) return null; - const candidates = results.some((result) => result.relevance?.isSourceBacked) - ? results.filter((result) => result.relevance?.isSourceBacked) - : results; + const quotedChunkIds = new Set((quoteCards ?? []).map((quote) => quote.chunk_id)); + if (quoteCards && quotedChunkIds.size === 0) return null; + + const quoteSupportedResults = quoteCards ? results.filter((result) => quotedChunkIds.has(result.id)) : results; + const candidates = quoteSupportedResults.some((result) => result.relevance?.isSourceBacked) + ? quoteSupportedResults.filter((result) => result.relevance?.isSourceBacked) + : quoteSupportedResults; let best = candidates[0]; for (const result of candidates.slice(1)) { const bestScore = best.hybrid_score ?? best.similarity; @@ -309,8 +313,8 @@ export function selectBestSourceRecommendation( } } - const directQuote = quoteCards.find((quote) => quote.chunk_id === best.id); - const documentQuote = quoteCards.find((quote) => quote.document_id === best.document_id); + const directQuote = quoteCards?.find((quote) => quote.chunk_id === best.id); + const documentQuote = quoteCards?.find((quote) => quote.document_id === best.document_id); const quote = directQuote?.quote ?? documentQuote?.quote; // Track truncation by the pre-slice length rather than guessing from the // post-trim snippet length (=== 260 dropped the ellipsis when trim shortened a diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index ebdfe86ab..67b316f4a 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -244,6 +244,21 @@ async function extractXlsx(buffer: Buffer) { return { pages, images: [] } satisfies ExtractedDocument; } +export async function assertOoxmlArchiveBudget(buffer: Buffer) { + const zip = await JSZip.loadAsync(buffer); + const entries = Object.values(zip.files); + if (entries.length > 10_000) throw new Error("OOXML archive contains too many entries."); + const expandedBytes = entries.reduce((total, file) => { + const data = (file as unknown as { _data?: { uncompressedSize?: number } })._data; + return total + Math.max(0, Number(data?.uncompressedSize ?? 0)); + }, 0); + const maxExpandedBytes = 512 * 1024 * 1024; + const maxRatioBytes = Math.max(buffer.byteLength * 100, 16 * 1024 * 1024); + if (expandedBytes > maxExpandedBytes || expandedBytes > maxRatioBytes) { + throw new Error("OOXML archive exceeds the safe expanded-size budget."); + } +} + function extractTxt(buffer: Buffer) { return { pages: [{ pageNumber: 1, text: buffer.toString("utf8"), ocrUsed: false }], @@ -260,6 +275,7 @@ export async function extractDocument(args: { buffer: Buffer; fileName: string; args.mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || args.fileName.toLowerCase().endsWith(".docx") ) { + await assertOoxmlArchiveBudget(args.buffer); return extractDocx(args.buffer); } @@ -267,6 +283,7 @@ export async function extractDocument(args: { buffer: Buffer; fileName: string; args.mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || args.fileName.toLowerCase().endsWith(".xlsx") ) { + await assertOoxmlArchiveBudget(args.buffer); return extractXlsx(args.buffer); } diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts index 6b70349d2..fba67bd96 100644 --- a/src/lib/rag-cache.ts +++ b/src/lib/rag-cache.ts @@ -229,11 +229,13 @@ export async function setCachedSearch( results: SearchResult[], telemetry: SearchTelemetry, queryVariants: string[] = [], + options?: { indexingVersionAtRetrievalStart?: string | null }, ): Promise { if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return; const cacheTelemetry = normalizeCacheStorageTelemetry(telemetry); - const indexingVersion = await cacheIndexingVersion(args); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); + if (options?.indexingVersionAtRetrievalStart && indexingVersion !== options.indexingVersionAtRetrievalStart) return; const key = scopedSearchCacheKey(args, telemetry.query_class, queryVariants); searchCache.set(key, { expiresAt: Date.now() + env.RAG_SEARCH_CACHE_TTL_MS, @@ -283,8 +285,12 @@ function sharedCacheSelector( return query; } -export async function cacheIndexingVersion(args: Pick) { +export async function cacheIndexingVersion( + args: Pick, + options?: { forceRefresh?: boolean }, +) { const cacheKey = cacheIndexingVersionCacheKey(args); + if (options?.forceRefresh) cacheIndexingVersionCache.delete(cacheKey); const cached = readExpiringCacheEntry(cacheIndexingVersionCache, cacheKey); if (cached) return cached.value; diff --git a/src/lib/rag-quote-verification.ts b/src/lib/rag-quote-verification.ts index c8bd37546..6cb8e029c 100644 --- a/src/lib/rag-quote-verification.ts +++ b/src/lib/rag-quote-verification.ts @@ -100,8 +100,9 @@ export function enrichGroundedReviewCitations(answer: RagAnswer, results: Search if ((answer.unverifiedNumericTokens?.length ?? 0) > 0 || answer.faithfulnessWarning) return answer; const existing = new Set(answer.citations.map((citation) => citation.chunk_id)); + const verifiedQuoteIds = new Set((answer.quoteCards ?? []).map((quote) => quote.chunk_id)); const additional = compactCitations(results) - .filter((citation) => !existing.has(citation.chunk_id)) + .filter((citation) => verifiedQuoteIds.has(citation.chunk_id) && !existing.has(citation.chunk_id)) .slice(0, minCitations - answer.citations.length); if (additional.length === 0) return answer; diff --git a/src/lib/rag.ts b/src/lib/rag.ts index bc0cf5cb1..59f52022c 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -2925,6 +2925,7 @@ function shouldUseMemoryBeforeFastPath(queryClass: RagQueryClass) { export async function searchChunksWithTelemetry(args: SearchChunksArgs) { assertGlobalSearchAllowed(args); throwIfAborted(args.signal); + const indexingVersionAtRetrievalStart = await cacheIndexingVersion(args, { forceRefresh: true }); const supabase = createAdminClient(); // When the provider is source-only (offline mode, or auto mode without a usable key) we must // never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only. @@ -3028,7 +3029,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { if (cached) return cached; if (sharedCached?.kind === "hit") { - await setCachedSearch(args, sharedCached.results, sharedCached.telemetry, queryVariants); + await setCachedSearch(args, sharedCached.results, sharedCached.telemetry, queryVariants, { + indexingVersionAtRetrievalStart, + }); return { results: sharedCached.results, telemetry: sharedCached.telemetry }; } if (sharedCached?.kind === "miss") { @@ -3059,7 +3062,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.embedding_skip_reason = "unsupported_short_circuit"; telemetry.retrieval_strategy = "unsupported_short_circuit"; recordSearchScoreTelemetry(telemetry, []); - await setCachedSearch(args, [], telemetry, queryVariants); + await setCachedSearch(args, [], telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: [] as SearchResult[], telemetry }; } @@ -3125,7 +3128,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { markEmbeddingSkippedByTextFastPath(telemetry, baseTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); - await setCachedSearch(args, textFastResults, telemetry, queryVariants); + await setCachedSearch(args, textFastResults, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: textFastResults, telemetry }; } @@ -3168,7 +3171,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { markEmbeddingSkippedByTextFastPath(telemetry, boostedTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); - await setCachedSearch(args, textFastResults, telemetry, queryVariants); + await setCachedSearch(args, textFastResults, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: textFastResults, telemetry }; } } @@ -3279,7 +3282,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { ); telemetry.retrieval_strategy = "document_lookup_fast_path"; recordSearchScoreTelemetry(telemetry, documentLookupResults); - await setCachedSearch(args, documentLookupResults, telemetry, queryVariants); + await setCachedSearch(args, documentLookupResults, telemetry, queryVariants, { + indexingVersionAtRetrievalStart, + }); return { results: documentLookupResults, telemetry }; } textFastResults = mergeSearchResults(documentLookupResults, textFastResults); @@ -3303,7 +3308,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); - await setCachedSearch(args, coverageGateResults, telemetry, queryVariants); + await setCachedSearch(args, coverageGateResults, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: coverageGateResults, telemetry }; } textFastResults = mergeSearchResults(coverageGateResults, textFastResults); @@ -3480,7 +3485,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; telemetry.retrieval_strategy = "hybrid"; recordSearchScoreTelemetry(telemetry, results); - await setCachedSearch(args, results, telemetry, queryVariants); + await setCachedSearch(args, results, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results, telemetry }; } @@ -3562,7 +3567,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; telemetry.retrieval_strategy = "vector_fallback"; recordSearchScoreTelemetry(telemetry, results); - await setCachedSearch(args, results, telemetry, queryVariants); + await setCachedSearch(args, results, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results, telemetry }; } @@ -4828,6 +4833,7 @@ ${qualityRetryInstruction}` await setCachedAnswer(args, answer, { indexingVersionAtRetrievalStart }); return answer; } catch (error) { + if ((error instanceof DOMException && error.name === "AbortError") || args.signal?.aborted) throw error; const relatedDocuments = await relatedDocumentsPromise; await args.onProgress?.({ stage: "finalizing", diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index e8d2f8797..7612f8ef5 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2185,6 +2185,10 @@ export type Database = { }; invoke_indexing_v3_agent: { Args: { p_limit?: number }; Returns: number }; invoke_ingestion_worker: { Args: { p_limit?: number }; Returns: number }; + request_indexing_v3_enrichment: { + Args: { p_document_id: string; p_owner_id: string }; + Returns: { job_id?: string; ok?: boolean }; + }; is_committed_artifact_generation: { Args: { artifact_metadata: Json; document_metadata: Json }; Returns: boolean; diff --git a/src/lib/validation/body.ts b/src/lib/validation/body.ts index f7a3bad5a..0fd4f7dd8 100644 --- a/src/lib/validation/body.ts +++ b/src/lib/validation/body.ts @@ -1,12 +1,53 @@ import { z } from "zod"; +import { PublicApiError } from "@/lib/http"; import { parseSchema } from "@/lib/validation/http"; +export const defaultJsonBodyLimitBytes = 256 * 1024; + +async function readBoundedJson(request: Request, maxBytes = defaultJsonBodyLimitBytes): Promise { + const declaredLength = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new PublicApiError("Request body is too large.", 413, { code: "payload_too_large" }); + } + + if (!request.body) return null; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel("payload_too_large"); + throw new PublicApiError("Request body is too large.", 413, { code: "payload_too_large" }); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } +} + export async function parseJsonBody( request: Request, schema: TSchema, message = "Invalid request body.", ): Promise> { - const body = await request.json().catch(() => null); + const body = await readBoundedJson(request); return parseSchema(schema, body, message, "invalid_body"); } @@ -15,7 +56,10 @@ export async function parseJsonBodyOrDefault( schema: TSchema, fallback: z.infer, ): Promise> { - const body = await request.json().catch(() => undefined); + const body = await readBoundedJson(request).catch((error) => { + if (error instanceof PublicApiError) throw error; + return undefined; + }); const parsed = schema.safeParse(body); return parsed.success ? parsed.data : fallback; } diff --git a/src/proxy.ts b/src/proxy.ts index 0b80dff55..16fe19646 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -40,6 +40,20 @@ export async function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); const csp = buildContentSecurityPolicy({ isDevelopment, isLocalHttpRuntime, nonce }); + if (request.nextUrl.pathname === "/api/upload") { + const declaredLength = Number(request.headers.get("content-length")); + const uploadEnvelopeBytes = env.MAX_UPLOAD_MB * 1024 * 1024 + 1024 * 1024; + if (Number.isFinite(declaredLength) && declaredLength > uploadEnvelopeBytes) { + const response = NextResponse.json( + { error: "Upload request is too large.", code: "payload_too_large" }, + { status: 413 }, + ); + response.headers.set("content-security-policy", csp); + response.headers.set("cache-control", "private, no-store"); + return response; + } + } + // Request headers Next.js reads during SSR: `x-nonce` for our own inline //