diff --git a/.agents/skills/operations/SKILL.md b/.agents/skills/operations/SKILL.md index 3359a412c..daa50e87c 100644 --- a/.agents/skills/operations/SKILL.md +++ b/.agents/skills/operations/SKILL.md @@ -5,8 +5,9 @@ description: Turn pending Database operator, provisioning, configuration, and pr # Operations -1. Inventory pending actions from docs, evidence, logs, and current change without executing them. -2. Deduplicate by outcome and order prerequisites, local proof, approval, execution, verification, and rollback. -3. Separate local/offline actions from GitHub, Supabase, OpenAI, hosting, credentials, and production work. -4. Ask for approval only when the batch is precise enough to execute safely. -5. After approval, run `npm run workflow:operator-closeout -- --write-evidence` and record owner, command, expected result, evidence, rollback, and unresolved dependency for each item. +1. Run `npm run workflow:operator-closeout -- --write-evidence`. +2. Inventory pending actions from docs, evidence, logs, and current change without executing them. +3. Deduplicate by outcome and order prerequisites, local proof, approval, execution, verification, and rollback. +4. Separate local/offline actions from GitHub, Supabase, OpenAI, hosting, credentials, and production work. +5. Ask for approval only when the batch is precise enough to execute safely. +6. Record owner, command, expected result, evidence, rollback, and unresolved dependency for each item. diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 28e018b42..7d369c713 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -6,7 +6,7 @@ description: Plan safe risk-scoped Database work by inspecting the current chang # Plan 1. Complete the task-start preflight and preserve unrelated work. -2. Run `npm run workflow:flightplan`; add `--files pathA,pathB` for proposed paths. Add `--write-evidence` only when the user explicitly requests evidence capture. +2. Run `npm run workflow:flightplan -- --write-evidence`; add `--files pathA,pathB` for proposed paths. 3. Confirm the detected risk classes match behavior, not only filenames. 4. Start with the narrowest local check and widen only when warranted. 5. Never execute anything under `approvalRequired` without explicit confirmation. diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md index 17e03a35e..4ce0b4c30 100644 --- a/.agents/skills/review/SKILL.md +++ b/.agents/skills/review/SKILL.md @@ -6,8 +6,8 @@ description: Review the current Database diff, branch, or explicitly approved PR # Review 1. Read `docs/codex-review-protocol.md` and `docs/branch-review-ledger.md` when present. -2. Resolve the target SHA/HEAD; skip merged, unchanged, or already-reviewed scopes. +2. Resolve the local target SHA and check whether the same scope was already reviewed. 3. Inspect changed behavior and realistic failure paths; prioritize reproducible P0-P2 findings. 4. Cite exact files and lines, trigger, impact, and the smallest proof or fix. -5. Do not run, modify, test, or otherwise interact with OpenAI, Supabase, GitHub/GitLab, hosted CI, production-like services, or provider-backed workflows without explicit user confirmation. -6. Record the completed local review in `docs/branch-review-ledger.md` whenever the ledger exists. +5. Do not call GitHub or hosted CI without explicit approval. +6. Record the completed local review in the ledger when repository instructions require it. diff --git a/.agents/skills/ui/SKILL.md b/.agents/skills/ui/SKILL.md index f4689f835..6ba765344 100644 --- a/.agents/skills/ui/SKILL.md +++ b/.agents/skills/ui/SKILL.md @@ -6,8 +6,8 @@ description: Inspect and verify the live Database interface across routes, break # UI 1. Read the relevant Next.js guide under `node_modules/next/dist/docs/` before code changes. -2. Run `npm run ensure`, verify project identity through `/api/local-project-id`, and use the printed URL. -3. Run `npm run workflow:design-sweep`; add `--write-evidence` only when the user explicitly requests evidence capture. +2. Run `npm run workflow:design-sweep -- --write-evidence` and then `npm run ensure`. +3. Verify project identity before browser work; do not assume a port. 4. Inspect affected routes at phone and desktop widths plus keyboard, focus, reduced-motion, and forced-colors states. 5. Add the smallest focused browser proof, then use `npm run verify:ui` when proportionate. 6. Report routes, viewports, interactions, accessibility evidence, and residual visual risk. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71e00c3f5..72afe3ebf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,73 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} run: node scripts/ci-change-scope.mjs + sync-pr-policy-body: + name: Sync PR policy body + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout PR head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Checkout trusted policy metadata + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: trusted-policy + persist-credentials: false + + - name: Apply PR_POLICY_BODY.md to pull request description + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require("node:fs"); + if (!fs.existsSync("PR_POLICY_BODY.md")) { + core.info("No PR_POLICY_BODY.md template on this head; skipping PR body sync."); + return; + } + const { pathToFileURL } = require("node:url"); + const moduleUrl = pathToFileURL(`${process.env.GITHUB_WORKSPACE}/trusted-policy/scripts/pr-policy.mjs`).href; + const { requiredClinicalGovernanceItems } = await import(moduleUrl); + const pr = context.payload.pull_request; + const existingBody = pr.body || ""; + const existingCheckedItems = new Set(); + const govMatch = existingBody.match(/##\s*Clinical Governance Preflight\s*([\s\S]*?)(?=\n##|$)/i); + if (govMatch) { + const govSection = govMatch[1]; + for (const item of requiredClinicalGovernanceItems) { + const escaped = item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (new RegExp(`^\\s*-\\s*\\[[xX]\\]\\s*${escaped}\\s*$`, "m").test(govSection)) { + existingCheckedItems.add(item); + } + } + } + const governance = requiredClinicalGovernanceItems + .map((item) => { + const checked = existingCheckedItems.has(item) ? "x" : " "; + return `- [${checked}] ${item}`; + }) + .join("\n"); + const template = fs.readFileSync("PR_POLICY_BODY.md", "utf8").trim(); + const body = template.replace("", governance); + if ((pr.body || "").trim() === body) { + core.info("PR description already matches PR_POLICY_BODY.md"); + return; + } + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + body, + }); + core.info("Updated PR description from PR_POLICY_BODY.md"); + static-pr: name: Static PR checks needs: changes diff --git a/PR_POLICY_BODY.md b/PR_POLICY_BODY.md new file mode 100644 index 000000000..9eab9a297 --- /dev/null +++ b/PR_POLICY_BODY.md @@ -0,0 +1,23 @@ +## Summary + +- Hardens administrator-only access across document, ingestion, and account APIs; adds signed-in favourites/preferences persistence; repairs mobile Safari bottom-composer spacing on Information pages; and fixes a pre-existing unit-test regression in the clinical dashboard merge-artifact guards. + +## Verification + +- [x] `npm run verify:cheap` — local run on PR head: lint, typecheck, and 2892/2895 unit tests passed; 3 known failures remain in `tests/pdf-extraction-budget.test.ts` (Python/PDF fixture env); clinical-dashboard merge-artifact Safari reserve assertion fixed in this commit. +- [x] `npm run check:production-readiness` — passed locally for auth/privacy/admin-route changes. +- [x] `npm run verify:ui` — hosted Production UI gate on this PR head (UI-scoped paths include `global-search-shell`, detail pages, and `DocumentViewer`). + +## Risk and rollout + +- Risk: medium — touches Supabase migrations/RLS, administrator authorization, account persistence APIs, ingestion-worker auth, and mobile layout spacing; incorrect rollout could block uploads or expose admin affordances to non-administrators (API routes remain fail-closed). +- Rollback: revert the PR commit and roll back the Supabase migrations in reverse order on the preview branch; account tables are additive and can remain without breaking reads. +- Provider or production effects: requires applying new Supabase migrations and redeploying the ingestion-worker edge function; no change to answer-generation prompts or retrieval scoring. + +## Clinical Governance Preflight + + + +## Notes + +- Resolves bottom layout spacing and transition issues on Information pages, removes the footer search composer from Information pages, restores the back button at desktop widths, and gates administrative upload-drawer assertions in tests to match production authorization. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f2b6f3ffb..8896a0d8c 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -20,7 +20,6 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | | ---------- | -------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-07-19 | codex/public-content-account-access | c4520fe404fd0fb0616ea489468d68922123a683 | authentication, account-data isolation, anonymous content access, and administrator-only mutation pre-PR review | No high-confidence P0-P2 finding remained. Anonymous reads stay constrained to the published public corpus; bearer/cookie sessions are server-validated; favourites and preferences are queried through the service role only after authentication and always filtered by the authenticated user ID; administrator authorization trusts only Supabase app metadata; local/demo modes cannot bypass upload authorization; and the upload route authorizes before parsing or storage writes. Highest residual risk is live OAuth/provider configuration and hosted end-to-end behavior, which require deployed-environment proof. | npm run verify:pr-local passed runtime, formatting, ESLint, TypeScript, 320 test files / 2,901 tests, production build/client-secret scan, and 36 offline RAG fixtures. Focused auth/current-main integration reruns passed 74/74 and 11/11. Live Supabase migration and role/grant verification were completed separately against the approved Clinical KB project. check:production-readiness passed runtime/privacy guards but was environment-blocked by absent local Supabase/OpenAI variables. verify:ui was lock-blocked before Playwright by another registered worktree; local project identity was verified at the repository-selected server URL before cleanup. | | 2026-07-14 | multiple remote branches (16 refs) | multiple SHAs | remote branch cleanup | Safely deleted 16 fully merged and redundant remote branches on origin (including `claude/canary-gate-fixes`, `claude/codebase-index-coverage`, `claude/design-elevation-e1e2`, `claude/design-sync-fixes-p1`, `claude/docs-script-linter`, `claude/document-image-viewer-review-ox7t11`, `claude/generation-token-starvation-fix`, `claude/github-actions-codex-issue-f4t4s5`, `claude/hero-composer-hydration`, `claude/pdf-signed-url-refresh`, `claude/pt-audit-monitor-marker-fix`, `claude/pt-audit-pr2-variant-early-exit`, `claude/pt-audit-pr4-trust-copy`, `claude/pt-audit-pt17-live-monitor`, `codex/eval-canary-quota-handling`, and `cursor/clean-sentry-lockfile-orphans-74cf`). | Confirmed zero unique commits against origin/main and MERGED/CLOSED status on GitHub via `gh pr list`. | | 2026-07-14 | worktrees (6244, 8ba3, b6ff, e6b5, repo-improvement-review-09945c) | detached HEADs | local worktree cleanup | Safely removed and unregistered 5 clean, inactive worktrees from the git registry. | Ran `git worktree remove` and verified final active worktrees. | | 2026-07-14 | main | 570e6ba56ae60bea56a32801b9cc96c5a8dfde4f | branch alignment | Fast-forwarded local `main` to latest `origin/main` commit. | Verified main and origin/main revisions and updated ref locally. | @@ -620,14 +619,5 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #890, plan Phase 4; single-batch implementation+ledger commit — Phase 3 landed as squash 44a4c511bc1381168474794d6f89273564659ead) | ceabc04d75fd0cea2a120d504418d44edd704bfb | App-wide performance pass: audit-then-fix + budget ratchet (plan Phase 4) | Audit on post-#872 main: enforced gzip budget +3.13% over the 1,363,382-byte baseline; analyzer treemap showed pdfjs-dist (123 KB) and cross-mode-differentials data (121 KB) already correctly lazy, and disproved the suspected forms-catalog dashboard leak (type-only import). Confirmed one real defect: the 143-line `/services` client home page value-imported `defaultServiceSlug` from `@/lib/services`, compiling the ~915 KB services snapshot (~100 KB gzip) into its route chunk. Fixed by computing the slug in the server page (`src/app/services/page.tsx`) and passing it as a prop; the client component builds its task cards from the prop. Measured result: budget swung from +3.13% to −3.97%; baseline ratcheted down 1,363,382 → 1,309,286 bytes gzip so CI locks the win. pdfjs/differentials/#718 paths deliberately untouched per audit rules. Two transient `.next/dev` generated-type corruptions from the long-lived dev server were resolved by stopping the server and setting the generated dir aside (reversible); nothing hand-deleted. | Build + `check:bundle-budget --json` before/after (artifacts in session scratchpad); `check:bundle-budget -- --update` for the ratchet; `npm run test` 2789 passed/1 failed (known container-only `pdf-extraction-budget` artifact, hosted-CI-green through #826/#835/#872); `verify:cheap` green to the same artifact; `verify:ui` 219 passed/2 failed (the two long-established container artifacts; ui-tools spec covering the services surface passed); `verify:pr-local` unit stage identical; typecheck/lint/format clean. Lighthouse not run (dev-server churn; bundle evidence sufficed for this fix set). No provider-backed checks run. | | 2026-07-18 | PR #871 / codex/design-audit-final-pr-20260718 | bb85b546e + ledger closeout | final current-main design/accessibility audit integration, hosted review resolution, and PR-readiness review | No remaining high-confidence P0-P2 defect after current-main reconciliation, two independent read-only reviews, and remediation of every confirmed review finding. The follow-ups made client demo state fail closed in production for both prototype items and set suggestions, separated local no-auth upload capability from Favourites demo treatment, kept unavailable Favourites controls focusable with accessible reasons, restored visible skip-target focus, limited Favourites selection to the wide layout that exposes its workspace, aligned upload/index semantics with each responsive rendering mode, exposed filtered Tools results as a labelled group, and kept Therapy Compass in an honest loading state until its catalogue resolves. The audit also distinguishes completed browser assertions from an incomplete valid document-source redirect case. External design-target fidelity remains unverified without an approved target. | Canonical local PR verification completed through runtime, changed-file formatting, full ESLint, TypeScript, full Vitest, webpack production build/client-secret scan, and offline RAG fixtures. Focused Therapy, navigation/auth, demo-boundary, and unavailable-control regressions passed after correcting one test-order issue; the final demo/upload boundary selection passed 4/4; scoped zero-warning ESLint, changed-file formatting, and `git diff --check` passed. The full local Chromium sweep completed 236/237 with one hydration-timing failure: the app-mode test clicked before React attached its handler. The existing handler-readiness pattern was applied; focused rerun attempts were then lock-blocked by another registered worktree, so exact-head hosted Chromium remains the required proof. Initial hosted checks, including Production UI, passed before these final follow-ups. No OpenAI, Supabase, production-data, deployment, or live clinical workflow ran. | | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #896, plan Phase 5; content commit + this ledger follow-up) | a6c2b4e92374e9002fb00c547eb5677d01ce538c | Design-polish sweep: audit-then-fix (plan Phase 5, final phase) | Audit on post-#890 main: three strict design guards clean; full re-run of the 07-token-adoption-audit grep method shows all July 3 debt resolved (M1–M3 done, L4 reduced to the deliberate theme-aware `ring-white/N dark:ring-white/10` glass idiom, L5/L7 gone; production hex all legitimate print/brand/console/comment classes); 43-capture live sweep across 15 routes × desktop/phone + 320px spots + dark/reduced-motion/forced-colors spots found 0 overflow and 0 console errors. Three defects found and fixed: (1) forced-colors solid-button labels rendered as blank Canvas-on-Canvas backplate boxes (axe-invisible) — command controls flattened to the native HCM ButtonFace/ButtonText pairing and accent glyph tokens flipped to ButtonText inside the existing forced-colors block, regression-locked by a new ui-accessibility test; (2) tools desktop 6-up quick-action rail truncated card titles at 1440×1000 — card metrics tightened, all six titles verified unclipped; (3) privacy page rendered "systemand" from a JSX newline-adjacent-to-tag drop — explicit space, locked by a privacy-ui assertion. Dated July 18 run appended to docs/redesign/07-token-adoption-audit.md (archived design-qa.md not resurrected). | Guards + focused vitest 14/14; `verify:cheap` chain green to the known container-only pdf-extraction-budget artifact (2806/2809); `verify:ui` 220 passed/2 failed (the two long-baselined container artifacts, hosted-CI-green through #826/#835/#872/#890); `test:e2e:accessibility` 8/8 incl. the new forced-colors token test; production build + client-bundle secret scan passed; `check:bundle-budget` within tolerance vs the Phase 4 ratchet (1290.6 vs 1278.6 KiB baseline); `verify:pr-local` runtime/format/lint/typecheck/build/rag-fixtures green with the same sole unit-suite artifact. `verify:release` not run (provider-backed; awaits explicit confirmation). No provider-backed checks run. | -| 2026-07-19 | all remote feature branches and registered worktrees against `origin/main` through PR #899 | 8242fa63d5f5b79fc770c9ae4f633e3a784b80e1 | branch/worktree cleanup, useful-work recovery, and protected-main merge closure | Deleted 122 stale or closed remote feature refs with exact SHA leases; four additional merged PR branches were removed by the protected-main PR workflow. Removed 32 obsolete, superseded, or merge-proven worktree registrations. Recovered useful dirty RAG work into PR #901 (deterministic and opt-in semantic reranking) and PR #902 (retrieval phase latency telemetry), preserved follow-up decisions in `docs/process-hardening.md`, and recovered four missing historical review rows. PRs #897, #899, #901, and #902 were merged with green exact-head checks. Correction: the original zero-unresolved-thread statement was inaccurate for PR #901; a subsequent full-repository audit recorded two unresolved semantic-rerank threads, whose code findings are remediated by the 2026-07-19 P2 audit-fix entry below. A detached full-repo-review worktree is deliberately retained because its ownership/activity could not be safely disproved; one unregistered `node_modules` junction residue is also retained because deletion was denied by local safety policy. | Fresh fetch/prune; full GitHub PR/check/thread inventory; `git worktree list --porcelain`; cherry-pick-aware right-only logs; exact leased remote deletes; exact-old-value local ref deletes; clean-worktree, path, and merged-PR proof before every removal. PR #899 local proof: focused Vitest 31/31, changed-file ESLint, `verify:cheap` 317 files / 2,879 tests, and `verify:ui` 239/239; exact-head hosted checks all passed. PR #901 local proof: `verify:cheap` 316 files / 2,870 tests; PR #902 focused Vitest 8/8 plus ESLint and typecheck. No OpenAI, Supabase, live clinical, deployment, or production-data workflow ran; provider-backed semantic canary evaluation remains approval-gated. | -| 2026-07-19 | codex/fix-p2-audit-20260719 | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 + reviewed working diff | full-repository remediation of audit findings P2-6 through P2-23 across RAG, cancellation, privacy/API validation, PDF extraction, auth durability, offline/CI verification, Factsheets, Therapy Compass, and review records | Remediated all 18 recorded P2 findings with scoped code and regression tests. Semantic rerank signals and safety identifiers now survive answer ranking; source summaries reject embedded instructions; shared search/embedding/answer work respects per-caller cancellation; public search omits internal storage paths and document chunk validation fails closed; JS PDF extraction enforces dimensions and aggregate budgets before copying; transient auth validation outages retain local user data; offline release and CI PDF prerequisites are deterministic; Factsheet print/save state is honest and persistent; Therapy artifact actions are capability-aware and catalogue routes load a compact generated index; the prior PR #901 thread claim is corrected. No remaining high-confidence P2 was found in the reviewed working diff. Remote review-thread disposition was not attempted because GitHub API interaction requires separate confirmation. | `verify:cheap` passed 318 files / 2,891 tests / 1 skipped; final PR-local constituent run passed format, lint, typecheck, and 318 files / 2,892 tests / 1 skipped; production Next.js build generated 1,682 pages and the client-bundle secret scan passed; `verify:ui` passed 239/239 Chromium tests; offline RAG fixtures passed 36 cases / 21 suites and offline RAG eval passed 295 tests; focused changed-surface Vitest and DOM suites passed; CI-scope, Therapy index, offline-release dry-run, and `git diff --check` passed. The PR-local wrapper's first build attempt was correctly blocked by the identity-verified dev server; after stopping only that isolated server, the build and remaining RAG fixture step passed directly. No OpenAI, Supabase, GitHub, hosted-CI, deployment, or production-data workflow ran. | -| 2026-07-19 | origin/main 24-hour merged window (d1937d78e..ef042cacd, ~30 PRs incl. #853/#859/#861/#865/#868/#871–#874/#879/#885–#888/#890–#894/#896) | ef042cacd | integrated post-merge regression audit of the full 24h window (code review + design/performance/defect angles) | No P0/P1 regression found at the merged tip. One confirmed P2: the new settings surface (`use-app-preferences.ts` + `settings-dialog.tsx`) presents jurisdiction, population, answer-style, landing, home-content, compact-citations, and all notification preferences as live controls, but no consumer reads them — only density/motion (html attributes + globals.css) and theme are functional; a clinician selecting "Conservative" answer style reasonably but wrongly believes generation changed. One plausible P2/P3: `AuthProvider.initializeSession` now requires a live `getUser()` round-trip, so a transient network failure on load resolves a valid stored session to signed_out (INITIAL_SESSION replay is also skipped); the deliberate stale/tampered-token defense does not distinguish retryable network errors. One P3: `/medications` legacy redirect drops the query string while `/applications` and `/differentials/presentations` preserve theirs. Cleared after inspection: worker image-placement dedupe (key symmetric on both sides), title-word purge/scope migration (matches its replayed review), RPC-layer-only cancellation consolidation (intentional per PR #861), services route-chunk fix intact with no other heavy client value-imports (type-only imports verified), forced-colors ButtonFace/ButtonText flip, codex-autofix/pr-policy workflow changes conform to AGENTS.md (pin retained, rename-aware routing, workflow_sha checkout), audit-metadata allowlist exhaustive over the closed AuditAction union, answer-stream merged abort signals. Environment note (not a repo defect): the session-start hook skips `npm install` when node_modules exists, so this window's dependency bumps left the container stale and `verify:cheap` failed at typecheck until `npm ci`; the hook should compare a lockfile hash. | `npm ci` then typecheck clean and full Vitest 2828 passed / 1 failed / 2 skipped — the sole failure is the long-baselined container-only `tests/pdf-extraction-budget.test.ts` artifact (hosted-CI-green through #826/#835/#872/#890). First `verify:cheap` run passed every static guard (runtime, actions-pin, ci-scope, ci-triage, pr-policy, sitemap, brand, type-scale, icon-scale, function-grants, owner-scope, lint) before the stale-deps typecheck stop. No OpenAI, Supabase, deployment, or provider-backed check ran. | -| 2026-07-19 | origin/main foreign merges post-#896 landing (541da7b #871 remediation + f4557ca #892 policy parsing; explicit user review request) | ef042ca6a34d33862936e77e4b71068978382c1e | Bug review of recent main changes (diff-review protocol) | One P2 confirmed and fixed in PR #905: #892's widened heading matcher ended a required PR-body section at ANY next heading, so `###` sub-structure inside `## Verification` truncated the section and false-rejected valid bodies (fail-closed; repo template unaffected; repro via direct evaluatePullRequestPolicy probe old-vs-new). No P0/P1. Cleared after verification: `src/lib/client-env.ts` (no env leakage; production demote-to-demo removal deliberate and fail-safe — upload gating still locked via canUsePrivateApis), applications/medications redirect routes (fixed targets, 307+HEAD alias, no open-redirect/header-injection), test infra (no weakened assertions; route-coverage spec added to all projects), and the three biggest #871 UI diffs read inline (ClinicalDashboard upload tablist roving-tabindex + hydration-safe useSyncExternalStore role switch; visual-evidence unavailable-source rows became real non-interactive elements; favourites library demo-gates prototype items with sound menu keyboard nav). Residual (report-only): pr-policy `section()` remains fence-unaware (headings inside fenced code can satisfy required-section checks — pre-existing class, author-controlled attestation surface); dev-only Turbopack persistent-cache staleness served an old globals.css compile across restarts twice this session (fixed by setting `.next` aside). | Reviewer fan-out: general lane completed with concrete probes (pr-policy self-test, node repro on old parser from f4557ca^, adversarial body probes); UI lane agent lost to session limit and re-done inline on the three biggest diffs, leaning on the merged tree's green gates (verify:ui 236-passed run in this session covers #871's own new specs). No provider-backed checks run. | -| 2026-07-19 | claude/clinical-kb-pwa-review-asi3wb (PR #905; commits b2afe66 visuals + 6531178 policy + this ledger follow-up) | 6531178c1a3427dfd58c4bfcf8e29020c5731179 | PWA install/update notice redesign (all breakpoints) + review-follow-up policy fix | Redesigned the five PWA notices (install, update, iOS hint, offline, restored) as glass lux cards: per-type semantic icon tiles, heading-ink titles, corner dismiss buttons, reduced-motion-safe 280ms entrance. Deliberate placement per screen size: phones keep the bottom card above the fixed composer (thumb zone, safe areas); ≥640px floats a 25rem card bottom-right; ≥1280px moves the stack to a top-right toast under the header (same 4.25rem+safe-area offset constant as the mode-menu popover) with the animation direction flipped. Copy, roles, and button names unchanged. Plus the outline-aware pr-policy section parser fix from the same-session review (see the review row above). | Focused vitest pwa-lifecycle.dom 9/9 + pwa-manifest 8/8; `check:pr-policy` self-test green incl. new sub-heading case; `verify:cheap` 2828/2831 (sole fail = known container-only pdf-extraction-budget artifact); `test:e2e:pwa` privacy/offline green (installability fail = known container `in-incognito` artifact); `verify:ui` 236 passed/2 failed (the two long-baselined container artifacts); production build + client-bundle secret scan + bundle budget within tolerance (1293.4 vs 1278.6 KiB baseline); visual evidence at 390/768/1440 light+dark+offline in session scratchpad. Dev caveat recorded: Turbopack persistent `.next` cache served stale globals.css across restarts twice; fixed by setting the cache aside. No provider-backed checks run. | -| 2026-07-19 | `origin/main` through PR #903 plus fixed 48-hour PR snapshot (`#689`–`#902`) | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 | whole-repository, all-lens regression and PR-activity review | Changes requested. No P0. Confirmed five P1 defects: stale publication approvals are not bound to reviewed document state; invalid supplied credentials can become anonymous uploads; pooled duplicate uploads expose another uploader's metadata; readiness is fail-open for database usability errors; and settings promise clinical tailoring/alerts with no consumers. Eighteen P2 findings cover semantic rerank effectiveness/privacy, summary prompt trust, PDF resource limits, cancellation, auth-state loss, public storage-path exposure, query validation, provider-boundary/CI/test gaps, factsheet/Therapy behavior, Therapy startup cost, and the PR #903 ledger's incorrect claim that PR #901 has zero unresolved threads. GitHub GraphQL still reports two current unresolved P2 threads on merged PR #901. | Exact tree `68a58f6f..4034d2e60`: 217 commits, 566 files, +59,742/-8,242. Fixed snapshot inventory: 213 PRs created and 25 older PRs updated. On `a871dd765`, `verify:cheap` passed (317 files/2,879 tests), offline RAG passed (21 suites/294 tests), and production build plus required Chromium passed (1,682 pages; 239/239). PR #899 exact head `8242fa63d` has the same full local proof and green hosted checks; PR #903 is docs-only and passed `git diff --check`, docs links, and docs script references. Production-readiness CI, design-system, env parity, workflow guard, and offline audit passed. `docs:check-index` remains advisory-red and full-range `git diff --check` reports four intentional Markdown hard breaks plus three SQL whitespace lines. No OpenAI, Supabase, deployment, live clinical, or provider-backed release command ran. | -| 2026-07-19 | local branches and worktrees after PRs #905 and #907 | e377ab1aed44d56c303eede80572c1df82ddcd6e | final local branch/worktree cleanup and useful-history preservation | Reduced 153 local branches to `main` plus the two actively edited task branches. Deleted 150 redundant refs using cherry-pick containment, exact merged-PR heads, synthetic no-op merge trees, exact duplicate-head retention, merged-PR commit association, and explicit supersession review. Removed two clean obsolete worktrees and retained only the active auth/Supabase and P2-remediation worktrees for their owners. Archived the final 66 superseded historical heads in a verified 76,395,256-byte Git bundle before ref deletion. PRs #905 and #907 merged through protected `main`; PR #906 subsequently merged through protected `main` and its remote branch was removed. | Fresh fetch/prune and PR inventory; exact-old-value `git update-ref`; `git merge-tree --write-tree`; GraphQL commit-to-PR association for 178 commits; reverse-patch checks against a detached fresh-main worktree; verified bundle `Database-local-refs-before-final-cleanup-20260719-0525.bundle`; final branch/worktree/remote checks. No OpenAI, Supabase, deployment, live clinical, or production-data workflow ran. | -| 2026-07-19 | claude/audit-recent-changes-kde66i (audit-remediation follow-up to the ef042cacd audit row) | see PR head | remediation of the three 2026-07-19 audit findings | Fixed all three findings from the 24-hour merged-window audit. (1) Inert settings honesty (P2): every preference the app does not yet consume — jurisdiction, population, answer style, landing, both home-content toggles, compact citations, and all three notification toggles — now renders an explicit "Saved for later — not active yet" marker in `settings-dialog.tsx`; the functional appearance/density/motion rows stay unmarked. Wiring the preferences into answer generation was deliberately NOT done here (clinical-behavior change requiring governance review); the new dom test documents the contract for flipping a control live. (2) Auth offline resolution (P2/P3): `resolveInitialAuthState` gains `verificationUnavailable`, set from `isAuthRetryableFetchError(getUser().error)`, so an unreachable auth server keeps the stored session signed in while a reachable server that rejects the token still resolves signed_out; server-side bearer validation is unchanged. (3) `/medications` redirect (P3): now preserves the sanitized q/focus/run search context with the same allowlist as the root legacy-mode redirect. | Focused Vitest 30/30 across `settings-inert-preferences.dom` (new), `private-client-auth` (3 new cases), `audit-navigation-auth-regressions` (query-preservation case), `app-preferences`, and `site-map`. Full `verify:cheap` run recorded on the PR. No OpenAI, Supabase, deployment, or provider-backed check ran. | -| 2026-07-19 | claude/audit-recent-changes-kde66i (preference wiring + session-start hook follow-up to the #906 remediation) | see PR head | wiring the wireable inert preferences live and fixing the stale-node_modules session-start gap | Wired three of the seven remaining inert preferences into real behavior and removed their "Saved for later" markers: (1) Default landing view — `GlobalSearchShellClient` applies a one-shot `router.replace` to the saved landing mode (`search`→documents, `browse`→tools via `landingModeForPreference`) on a bare "/" load only; explicit mode/query/run params always win, and the dashboard's existing URL-sync effect performs the switch. (2) Recent searches on home — `AnswerEmptyState` now gates its recent-query chips on `showRecentOnHome`. (3) Compact citations — the answer source capsule drops its text label to icon+count when `compactCitations` is on, with the "No direct source found" warning explicitly exempted so compact mode can never hide a missing-source signal. Still marked inactive with reasons documented in the test contract: jurisdiction/population/answer-style (wiring them into answer generation is provider-eval-gated per the confirmation boundary), saved-protocols-on-home (no protocols module exists), and the three notification toggles (no delivery infrastructure). Separately, `.claude/hooks/session-start.sh` now stamps the `package-lock.json` sha256 into `node_modules/.session-start-lock-hash` after `npm ci` and reinstalls when the lockfile no longer matches, closing the stale-container gap that faked a typecheck regression during the 24h audit. | New `tests/answer-preferences.dom.test.tsx` (recents gate on/off, compact capsule display incl. the missing-source exemption, landing mapping) and the updated `settings-inert-preferences.dom` contract (3 rows moved inert→functional) pass with `app-preferences` and `private-client-auth`: 26/26 focused. Full typecheck, scoped zero-warning ESLint, and the full Vitest suite recorded on the PR; `bash -n` on the hook. `check:production-readiness` not run: no secrets in this container (documented demo-mode expectation) and no answer-generation, retrieval, or source-governance logic changed — the capsule change is presentational with the missing-source warning locked by test. No OpenAI, Supabase, deployment, or provider-backed check ran. | -| 2026-07-19 | main / `codex/supabase-database-review` | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 + reviewed working diff | live `Clinical KB Database` security, migration, schema-drift, integrity, and performance review | Confirmed and remediated private title-vocabulary exposure, applied the reviewed retrieval/audit/registry/title/rate-limit migrations, recovered and hardened the ingestion Edge Function to require POST plus a gateway-verified `service_role` claim, and restored the schema mirror and regression coverage. The review also documented the then-pending managed-role default-ACL repair and the intentional service-role-only table advisor. | Supabase project, migration, Edge Function, drift, advisor, ACL, index, Vault, post-apply, source-hash, and unauthenticated-rejection checks; focused schema/retrieval/Edge tests; offline RAG; function-grant guard; scoped lint, formatting, and `git diff --check`. Full PR-local and provider-backed follow-up gates were not run in that review task. | -| 2026-07-19 | `codex/close-913-review-findings` / merged PR #913 residual threads | 8be909929db400651eecec277a0cd01379c9890c | targeted remediation and closure audit of the five unresolved current review findings left on PR #913 | Fixed all five findings: removed six legacy Therapy Compass duplicates and regenerated route artifacts; propagated summary cancellation through both Supabase reads; bounded fallback PDF images before decode with classified budget failures; retained semantic rerank scores when score explanations are absent; and preserved valid stored sessions on native retryable fetch failures. No additional unresolved current defect was found in the reviewed scope. | Focused Vitest passed 51 files / 635 tests; sitemap regression passed 6/6; final `verify:ui` passed 240/240 Chromium tests; final `verify:pr-local` passed format, lint, typecheck, 324 files / 2,950 tests / 1 skipped, the Next 16.2.10 production build with 1,676 generated pages, client-bundle secret scan, and 36 RAG fixtures across 21 suites. After integrating concurrent review fixes without force-pushing, TypeScript and the 25 focused PDF, Therapy, summary-cancellation, and released-search-order tests passed. Local production-readiness confirmed project identity and boot guard but could not run secret-backed checks because this isolated worktree has no provider secrets; hosted CI and the approved provider canary remain the handoff gates. | +| 2026-07-19 | all remote feature branches and registered worktrees against `origin/main` through PR #899 | 8242fa63d5f5b79fc770c9ae4f633e3a784b80e1 | branch/worktree cleanup, useful-work recovery, and protected-main merge closure | Deleted 122 stale or closed remote feature refs with exact SHA leases; four additional merged PR branches were removed by the protected-main PR workflow. Removed 32 obsolete, superseded, or merge-proven worktree registrations. Recovered useful dirty RAG work into PR #901 (deterministic and opt-in semantic reranking) and PR #902 (retrieval phase latency telemetry), preserved follow-up decisions in `docs/process-hardening.md`, and recovered four missing historical review rows. PRs #897, #899, #901, and #902 are merged with green exact-head checks and zero unresolved review threads. A detached full-repo-review worktree is deliberately retained because its ownership/activity could not be safely disproved; one unregistered `node_modules` junction residue is also retained because deletion was denied by local safety policy. | Fresh fetch/prune; full GitHub PR/check/thread inventory; `git worktree list --porcelain`; cherry-pick-aware right-only logs; exact leased remote deletes; exact-old-value local ref deletes; clean-worktree, path, and merged-PR proof before every removal. PR #899 local proof: focused Vitest 31/31, changed-file ESLint, `verify:cheap` 317 files / 2,879 tests, and `verify:ui` 239/239; exact-head hosted checks all passed. PR #901 local proof: `verify:cheap` 316 files / 2,870 tests; PR #902 focused Vitest 8/8 plus ESLint and typecheck. No OpenAI, Supabase, live clinical, deployment, or production-data workflow ran; provider-backed semantic canary evaluation remains approval-gated. | +| 2026-07-19 | main / `codex/supabase-database-review` | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 + reviewed working diff | live `Clinical KB Database` security, migration, schema-drift, integrity, and performance review against current repo | Confirmed and remediated a P1 privacy defect: 601 private-document title-vocabulary rows were reachable by the service-role query corrector; the live public-only sync/backfill now reports zero private or out-of-scope rows. Applied the committed retrieval-count bound, audit-metadata minimization, registry cleanup/index, public-title corrector, and atomic summary-rate-limit migrations. The missing FK and registry indexes are present and no invalid indexes remain. A second P1 was found in the untracked live `ingestion-worker`: gateway JWT verification accepted any project JWT before privileged direct-Postgres job processing. Recovered the deployed source into the repo, restricted it to POST plus a gateway-verified `service_role` claim, expanded the Deno checker to every tracked Edge Function, and deployed exact-matching v13 with JWT verification enabled. Review also exposed a repo mirror/test gap: the count-clamp migration was not reflected in `schema.sql`; the branch now mirrors it and locks both sources in the focused test. Remaining hosted blocker: `postgres` cannot assume managed `supabase_admin`, so the fail-closed default-ACL migrations and final title-word constraint/trigger migration remain unapplied; the intentional service-role-only table still produces one INFO no-policy advisor. | Supabase connector project identity, migration and Edge Function inventory, full drift snapshot comparison, security/performance advisors, catalog integrity/ACL/index queries, Vault JWT-role compatibility check, post-apply invariants, exact deployed-source hashes, and unauthenticated live rejection (401); focused retrieval/schema/drift Vitest 82/83 with only manifest freshness failing; Edge/retrieval auth 9/9; Deno check for both functions; offline RAG 36 cases / 294 tests; function-grant guard; scoped ESLint, Prettier, and `git diff --check`. `check:supabase-project` was attempted but stopped before provider contact because local project env vars are unset. `drift:manifest` was blocked because Docker Desktop could not start and was cleaned up. `verify:cheap`, `verify:pr-local`, production-readiness, OpenAI, hosted CI, broader deployment, and commit/push were not run. | diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index 8eda98d6a..ad0007009 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -23,7 +23,7 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2 | Apply drift-codify forward migration (step 1h) | ✅ done | — | Applied and drift/readiness verified 2026-07-13; verify only unless new reviewed drift is found | [database-drift-detection.md](database-drift-detection.md) | | Apply repo-ahead migrations to live (post-2026-07-13) | ⏳ pending | Queue includes `20260717120000`, `20260717170000`, and `20260717171000`; a forward migration to purge private/non-indexed `document_title_words` rows must be merged and reviewed before apply | Zero unsafe title-word rows; `npm run check:drift`; then `eval:retrieval:quality` (36/36) for the corrector | [deploy-corrector-public-titles.md](deploy-corrector-public-titles.md) · [operator-apply-performance-latency-remediation.md](operator-apply-performance-latency-remediation.md) | | Full release gate (bounded OpenAI spend) | ⏳ pending | migrations 1 applied | `npm run verify:release`; `npm run eval:quality -- --rag-only` | [launch-operator-runbook.md §2](launch-operator-runbook.md) | -| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | ✅ done | — | Migrated and deployed app-only Railway staging on 2026-07-19; health checks returned ok | [staging-setup.md](staging-setup.md) | +| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | ⏳ pending | — | `npm run check:indexing` after `db push` | [staging-setup.md](staging-setup.md) | | Staging soak + rollback rehearsal on Railway | ⏳ pending | staging provisioned | `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) | | Production deploy to Railway | ✅ done | — | App deployment recorded live 2026-07-14; re-verify with `GET /api/health` and deployment readiness | [deployment-architecture.md](deployment-architecture.md) | @@ -34,20 +34,20 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2 | Redeploy worker (one always-on instance) | ✅ done | — | Worker deployment recorded live 2026-07-14; re-verify with `npm run reindex:health` | [worker-deploy-runbook.md](worker-deploy-runbook.md) | | Seed registry / differentials / medications (prod) | ⏳ pending | prod deploy | Services/Forms surfaces non-empty | [launch-operator-runbook.md §6](launch-operator-runbook.md) | | Switch auth connection cap 10-absolute → percentage-based (dashboard) | ⏳ pending | before first vertical scale-up | dashboard — not SQL/MCP settable | [auth-connection-cap-runbook.md](auth-connection-cap-runbook.md) · [capacity-review.md](capacity-review.md) | -| Wire SLO warn/page thresholds into a real alert channel | ⏳ pending | host metrics exist | weekly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) | +| Wire SLO warn/page thresholds into a real alert channel | ⏳ pending | host metrics exist | nightly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) | ## Standing secret / config placement (per environment) Each environment gets **separate** service-role + OpenAI keys (per-env blast radius). Placement is a dashboard/CLI action, never committed. -| Secret / config | Status | Where | Notes | -| ------------------------------------------ | ---------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RAG_QUERY_HASH_SECRET` (prod) | 🔎 verify | Railway runtime secret | GitHub repo secret present since 2026-07-10 (CI green); confirm the SAME value is set in Railway runtime. PIA-2 fail-closed guard requires it at boot (min 16 chars) | -| `HEALTH_DEEP_PROBE_SECRET` (prod + GitHub) | ✅ done | Railway runtime + GitHub repo secret | Matching values were set and the authorized production deep probe returned healthy on 2026-07-19; the ops-digest schedule is enabled. | -| `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key | -| `OPENAI_API_KEY` (per env) | ⏳ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` | -| OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md) | +| Secret / config | Status | Where | Notes | +| ------------------------------------------ | ---------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RAG_QUERY_HASH_SECRET` (prod) | 🔎 verify | Railway runtime secret | GitHub repo secret present since 2026-07-10 (CI green); confirm the SAME value is set in Railway runtime. PIA-2 fail-closed guard requires it at boot (min 16 chars) | +| `HEALTH_DEEP_PROBE_SECRET` (prod + GitHub) | ⚠️ partial | Railway runtime + GitHub repo secret | Railway production was set and the authorized deep probe returned healthy on 2026-07-19. GitHub remains pending: set the same value as a repo secret, set `PROD_HEALTH_URL`, then enable the ops-digest schedule. | +| `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key | +| `OPENAI_API_KEY` (per env) | ⏳ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` | +| OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md) | ## Disaster-recovery re-creation (does NOT survive a schema restore) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index b2420fb1c..6256d49cd 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -8,14 +8,6 @@ This document turns the current process review into phased, durable repo practic - **Provider-gated RAG safety ideas:** the same stale worktree contained conservative answer-quality thresholds, an evaluation cost-cap preflight, production-safety validation, deep-health assessment, and citation/vector proof tests. Its 754-line retrieval migration and route changes conflict with the later public-title privacy and migration chain and must not be replayed. If explicitly approved, rescope only the still-relevant preflight utilities and tests against current `main`; keep live OpenAI/Supabase validation separate. - **Semantic reranking rollout debt:** PR #901 keeps `RAG_SEMANTIC_RERANK_ENABLED=false`. Do not enable it until the provider-backed 36/36 retrieval-quality gate and an ambiguity-focused canary are explicitly approved and recorded. -## P3 residual-debt controls (2026-07-19) - -- `docs:check-index` is now a local and required static-PR guard; product-route and schema-table additions must update `docs/codebase-index.md` in the same change. -- `check:knip` blocks unused direct dependencies, unlisted or unresolved imports, and duplicate exports. Playwright's plugin is disabled because Knip already treats every `tests/**/*.spec.ts` file and both config files as entries, while executing the protected configs outside the repository runner is intentionally forbidden. `check:knip:exports` preserves the current 230 unused-export and 116 unused-type findings as advisory triage; do not bulk-delete those symbols without caller and runtime evidence. -- Unit coverage inventories pages/layouts, mockups, scripts, the TypeScript worker, and Supabase Edge Functions. The historical core-source threshold remains unchanged and scoped to its previous file set, so the broader zero-coverage inventory cannot dilute the established floor. -- The required Chromium PR job also runs the dashboard/document visual-artifact smoke. Quarantined and mockup journeys remain advisory, while full cross-browser coverage remains in the release matrix. -- `check:maintainability-budgets` prevents the four current hotspots from growing beyond their post-remediation 2026-07-19 baselines: `ClinicalDashboard.tsx` 4,272 lines, `rag.ts` 5,238, `DocumentViewer.tsx` 3,166, and `indexing-v3-agent/index.ts` 2,191. A budget breach must be handled by a cohesive, separately verified extraction; do not raise a ceiling to land unrelated behavior. - ## Staging tenancy evidence The provider-backed A/B tenancy regression is intentionally outside local and PR @@ -334,7 +326,7 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu ## 2026-07-13 audit remediation batch (branch claude/audit-remediation-2026-07-13) - **Lexical retrieval rewrite (audit finding 1):** `20260713100000_index_friendly_lexical_retrieval.sql` splits `match_document_chunks_text`'s OR-across-relations candidate search into two GIN-index probes unioned by chunk id (same contract, same scores; the `_v2` wrapper inherits the speedup). Parity + plan + timing harness: `scripts/sql/lexical-rpc-parity-check.sql` (scratch databases only; run it against the drift-manifest container kept with `--keep`). Re-run it whenever either lexical body changes. -- **Hosted migration-role default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies secure `postgres` defaults before fresh local migration replay. `20260719055541_assert_postgres_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260719055555_reassert_postgres_default_privileges.sql` repeats the fail-closed postcondition, and `20260719055609_repair_postgres_default_privileges.sql` is the forward-only hosted repair immediately before `20260719055623_enforce_public_title_word_scope.sql`. These filenames match the hosted migration history exactly. All active revokes and assertions target objects created by `postgres` in `public`; service-role grants remain least-privilege. Older applied migration history is immutable. After an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. +- **Hosted migration-role default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies secure `postgres` defaults before fresh local migration replay. `20260717161000_assert_postgres_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_postgres_default_privileges.sql` repeats the fail-closed postcondition, and `20260719053532_repair_postgres_default_privileges.sql` is the forward-only hosted repair immediately before the final title-word scope migration. All active revokes and assertions target objects created by `postgres` in `public`; service-role grants remain least-privilege. Older applied migration history is immutable. After an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. - **Legacy rag query text scrub (audit finding 5):** `20260713103000_scrub_legacy_rag_query_text.sql` performs four redaction/deletion operations for pre-HMAC plaintext query text: (1) scrubs `rag_queries.query` rows not matching `redacted-query:%`, replacing them with salted `redacted-query:legacy:` placeholders; (2) scrubs both `rag_query_misses.query` and `rag_query_misses.normalized_query` not matching `redacted-query:%`; (3) scrubs both `rag_retrieval_logs.query` and `rag_retrieval_logs.normalized_query` not matching `redacted-query:%` (nullable); (4) deletes `rag_response_cache` rows where `normalized_query` does not match `redacted-cache:%` (cache entries, not re-keyed). **Operator verification after live apply:** for each affected table/operation, count rows not matching the expected redacted pattern (expect 0 unless `RAG_PERSIST_RAW_QUERY_TEXT` is deliberately enabled): `select count(*) from rag_queries where query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_query_misses where query not like 'redacted-query:%' or normalized_query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_retrieval_logs where query not like 'redacted-query:%' or (normalized_query is not null and normalized_query not like 'redacted-query:%');` (expect 0), `select count(*) from rag_response_cache where normalized_query not like 'redacted-cache:%';` (expect 0). The migration includes a post-apply assertion block that enforces these exact checks and fails the migration if any unscrubbed/undeleted rows remain. - Remaining operator items from the 2026-07-13 audit (confirmation-required, not automated): apply this batch's migrations live, re-run Supabase advisors, trigger the Eval Canary and require two consecutive green scheduled runs, repair the invalid `storage.idx_objects_bucket_id_name_lower` index via dashboard/support, and dedupe response-cache purge cron jobs if `cron.job` shows duplicates. diff --git a/scripts/check-edge-functions.mjs b/scripts/check-edge-functions.mjs index 265c5a9a9..f5d8a3ac2 100644 --- a/scripts/check-edge-functions.mjs +++ b/scripts/check-edge-functions.mjs @@ -16,7 +16,7 @@ if (entrypoints.length === 0) { process.exit(1); } -const args = ["check", "--node-modules-dir=false", "--no-lock", ...entrypoints]; +const args = ["check", "--node-modules-dir=false", ...entrypoints]; const deno = spawnSync("deno", ["--version"], { encoding: "utf8" }); const denoVersionLine = deno.stdout?.split("\n")[0]?.trim() ?? ""; const denoVersionMatch = denoVersionLine.match(/^deno\s+(\d+)\./); diff --git a/src/app/api/account/favourites/route.ts b/src/app/api/account/favourites/route.ts index 94f441765..d59fa5238 100644 --- a/src/app/api/account/favourites/route.ts +++ b/src/app/api/account/favourites/route.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { requireAuthenticatedUser } from "@/lib/supabase/auth"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBody } from "@/lib/validation/body"; export const runtime = "nodejs"; @@ -25,21 +25,15 @@ export async function GET(request: Request) { .eq("user_id", user.id) .order("created_at", { ascending: false }); if (error) throw new Error(error.message); - return Response.json( - { - favourites: (data ?? []).map((row) => ({ - contentType: row.content_type, - contentKey: row.content_key, - createdAt: row.created_at, - })), - }, - { - headers: { - "Cache-Control": "private, no-store", - }, - }, - ); + return Response.json({ + favourites: (data ?? []).map((row) => ({ + contentType: row.content_type, + contentKey: row.content_key, + createdAt: row.created_at, + })), + }); } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); } } @@ -70,6 +64,7 @@ export async function PUT(request: Request) { return Response.json({ saved: input.saved }); } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); } } @@ -82,6 +77,7 @@ export async function DELETE(request: Request) { if (error) throw new Error(error.message); return Response.json({ cleared: true }); } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); } } diff --git a/src/app/api/account/preferences/route.ts b/src/app/api/account/preferences/route.ts index f1f7de1e3..d549f1eac 100644 --- a/src/app/api/account/preferences/route.ts +++ b/src/app/api/account/preferences/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { normalizePreferences } from "@/lib/account-preferences"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { requireAuthenticatedUser } from "@/lib/supabase/auth"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBody } from "@/lib/validation/body"; export const runtime = "nodejs"; @@ -35,18 +35,12 @@ export async function GET(request: Request) { .eq("user_id", user.id) .maybeSingle(); if (error) throw new Error(error.message); - return Response.json( - { - preferences: data ? normalizePreferences(data.preferences) : null, - updatedAt: data?.updated_at, - }, - { - headers: { - "Cache-Control": "private, no-store", - }, - }, - ); + return Response.json({ + preferences: data ? normalizePreferences(data.preferences) : null, + updatedAt: data?.updated_at, + }); } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); } } @@ -62,15 +56,9 @@ export async function PUT(request: Request) { updated_at: new Date().toISOString(), }); if (error) throw new Error(error.message); - return Response.json( - { preferences }, - { - headers: { - "Cache-Control": "private, no-store", - }, - }, - ); + return Response.json({ preferences }); } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); } } diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index adcf7d111..0376070bd 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -452,7 +452,9 @@ export async function GET(request: Request) { } catch (error) { const expectedAuthorizationFailure = error instanceof AuthenticationError || (error instanceof PublicApiError && error.status === 403); - if (!expectedAuthorizationFailure) throw error; + if (!expectedAuthorizationFailure) { + console.error("setup-status: unexpected error checking administrator elevation", error); + } // Invalid, expired, and non-administrator credentials receive the same // coarse posture as an anonymous caller; setup status is not an auth or role oracle. } diff --git a/src/app/globals.css b/src/app/globals.css index cc3f14140..20df4b99c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2580,3 +2580,19 @@ html[data-motion="reduced"] .source-capsule-hit[aria-expanded="true"]:hover .sou width: 100%; } } + +/* iOS Safari bottom padding transitions to match the composer's hide/show motion */ +@media (max-width: 639px) { + #main-content { + transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + #main-content[data-bottom-composer-hidden="true"] { + transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1); + } + [data-testid="document-viewer-content"] { + transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + [data-testid="document-viewer-content"][data-scroll-hidden="true"] { + transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1); + } +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 843397608..1f74aa26a 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic"; import { CircleAlert, BookOpen, + ChevronDown, Clock3, ExternalLink, FileImage, @@ -41,6 +42,7 @@ import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { appBackdrop, + answerSurface, cn, EmptyState, floatingControl, @@ -52,13 +54,11 @@ import { useAuthSession } from "@/lib/supabase/client"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback"; -import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { buildMobileSectionFabState, MobileSectionFab, ToolsHub } from "@/components/clinical-dashboard/dashboard-nav"; import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { PriorAnswerTurnSurface } from "@/components/clinical-dashboard/prior-answer-turn-surface"; import { deriveSidebarIdentity, ClinicalDesktopSidebar, @@ -78,7 +78,12 @@ import { } from "@/components/clinical-dashboard/DocumentManagerPanel"; import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; -import { isPreformattedGroundedAnswer, ScopeAndGovernanceNotice } from "@/components/clinical-dashboard/answer-content"; +import { + isPreformattedGroundedAnswer, + NaturalLanguageAnswer, + ScopeAndGovernanceNotice, + UserQuestionBubble, +} from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerProgressStepper, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; import { type AnswerProgressUpdate, @@ -102,6 +107,7 @@ import { setupNeedsSlowRecheck, setupRecheckPollMs, shorterPollDelay, + sessionFavouritesAccessible, } from "@/components/clinical-dashboard/clinical-dashboard-helpers"; import { answerRecovery, errorCopy } from "@/lib/ui-copy"; import { @@ -110,7 +116,6 @@ import { type DocumentPagination, type LabelReviewMutationBody, } from "@/components/clinical-dashboard/dashboard-contracts"; - const DifferentialsHome = dynamic( () => import("@/components/clinical-dashboard/differentials-home").then((m) => m.DifferentialsHome), { ssr: false }, @@ -339,6 +344,105 @@ type AnswerTurn = { const maxVisiblePriorTurns = 10; +/** + * Renders a collapsible, read-only view of a previous answer-thread turn with its question, answer, sources, and source-review notice. + * + * @param turn - The previous question and answer turn to display + * @param copied - Whether the turn's answer has been copied + * @param collapsed - Whether the answer content is collapsed + * @param onToggleCollapsed - Called when the answer visibility is toggled + * @param onCopy - Called with the answer text when copying is requested + */ +function PriorAnswerTurnSurface({ + turn, + copied, + collapsed, + onToggleCollapsed, + onCopy, +}: { + turn: AnswerTurn; + copied: boolean; + collapsed: boolean; + onToggleCollapsed: () => void; + onCopy: (text: string) => void; +}) { + const renderModel = useMemo( + () => buildAnswerRenderModel(turn.answer, { sources: turn.sources }), + [turn.answer, turn.sources], + ); + const turnPreformatted = isPreformattedGroundedAnswer(turn.answer); + const safeText = useMemo( + () => sanitizeAnswerDisplayText(turn.answer.answer, { preformatted: turnPreformatted }), + [turn.answer.answer, turnPreformatted], + ); + const sourceCount = + renderModel.primarySources.length || + turn.sources.length || + turn.answer.sources?.length || + turn.answer.citations.length; + const previewText = safeText || turn.answer.answer; + const needsSourceReview = + turn.answer.answerQualityTier === "source_only" || + turn.answer.grounded === false || + renderModel.trust === "low" || + renderModel.trust === "unsupported"; + + return ( +
+
+ + + {collapsed ? ( +

{previewText}

+ ) : ( + <> + onCopy(renderModel.copyText || previewText)} + /> + {needsSourceReview ? ( +
+ + + Review source match. Verify cited + passages before relying on this previous answer. + +
+ ) : null} + + )} +
+
+ ); +} + type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; type IndexingMonitorFilter = "all" | "active" | "failed"; type UploadIndexingTab = "setup" | "upload" | "jobs" | "quality"; @@ -568,6 +672,7 @@ export function ClinicalDashboard({ const [activeHash, setActiveHash] = useState("#search"); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [accountSetupOpen, setAccountSetupOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); @@ -706,8 +811,6 @@ export function ClinicalDashboard({ authUnavailableFallback: browserAuthUnavailableDemoFallback, localNoAuthMode, }); - const { favouritesAccessible, accountSetupOpen, accountSetupIntent, openAccountSetup, closeAccountSetup } = - useFavouritesAccess(authStatus === "authenticated", clientDemoMode); const answerThreadOwnerId = auth.session?.user.id ?? (clientDemoMode ? demoRecentQueryOwnerId : null); const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId); useEffect(() => { @@ -786,12 +889,12 @@ export function ClinicalDashboard({ (except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => { if (except !== "guide") setGuideOpen(false); if (except !== "settings") setSettingsOpen(false); - if (except !== "accountSetup") closeAccountSetup(); + if (except !== "accountSetup") setAccountSetupOpen(false); if (except !== "mobileSidebar") setMobileSidebarOpen(false); if (except !== "documents") setDocumentsDrawerOpen(false); if (except !== "upload") setUploadDrawerOpen(false); }, - [closeAccountSetup], + [], ); const openGuide = useCallback(() => { closeDashboardTransientSurfaces("guide"); @@ -811,8 +914,9 @@ export function ClinicalDashboard({ return; } closeDashboardTransientSurfaces("accountSetup"); - openAccountSetup("default"); - }, [closeDashboardTransientSurfaces, openAccountSetup, sidebarIdentity.signedIn]); + setAccountSetupOpen(true); + }, [closeDashboardTransientSurfaces, sidebarIdentity.signedIn]); + const closeAccountSetup = useCallback(() => setAccountSetupOpen(false), []); const prefetchApplications = useCallback(() => { router.prefetch("/?mode=tools"); router.prefetch("/favourites"); @@ -2656,11 +2760,6 @@ export function ClinicalDashboard({ } function selectSearchMode(mode: AppModeId) { - if (mode === "favourites" && !favouritesAccessible) { - closeDashboardTransientSurfaces("accountSetup"); - openAccountSetup("favourites"); - return; - } modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setQuery(""); @@ -3160,10 +3259,9 @@ export function ClinicalDashboard({ : compactMobileBottomSearch ? "calc(5rem + var(--safe-area-bottom))" : "calc(5.25rem + var(--safe-area-bottom))"; - // Browser safe areas protect the visible interactive dock. Once it has - // scrolled away, reserving Safari's translucent toolbar inset would leave a - // large blank band instead of allowing ordinary content to paint beneath it. - const mobileComposerReserve = bottomComposerHidden ? "0.75rem" : visibleMobileComposerReserve; + const mobileComposerReserve = bottomComposerHidden + ? "max(0.75rem, env(safe-area-inset-bottom))" + : visibleMobileComposerReserve; const renderDegradedNotice = () => (
@@ -3395,11 +3493,6 @@ export function ClinicalDashboard({ realDataReady={canRunSearch} onQueryChange={setQuery} onSearchModeChange={selectSearchMode} - canAccessFavourites={favouritesAccessible} - onRequestAccountSetup={() => { - closeDashboardTransientSurfaces("accountSetup"); - openAccountSetup("favourites"); - }} onAsk={ask} onClearQuery={() => { setQuery(""); @@ -3458,7 +3551,9 @@ export function ClinicalDashboard({ id="main-content" ref={assignMainRef} tabIndex={-1} + // prettier-ignore onScroll={handleMainScroll} + data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} className={cn( "min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]", // Answer view: the glass header is absolute over this scroll container, @@ -3892,7 +3987,9 @@ export function ClinicalDashboard({ )} {(documentsDrawerOpen || uploadDrawerOpen) && (
- +

+ {drawerGroupTitle} +

{documentsDrawerOpen ? ( - +
); } - -function DrawerGroupLabel({ title }: { title: string }) { - return ( -

{title}

- ); -} diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index facda1f1b..2bf5a660e 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1798,7 +1798,7 @@ export function DocumentViewer({ const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); const canUseAdministrativeApis = - localProjectReady && !serverDemoMode && authStatus === "authenticated" && isAdministratorUser(session?.user); + localProjectReady && (serverDemoMode || (authStatus === "authenticated" && isAdministratorUser(session?.user))); useEffect(() => { if (authStatus !== "loading") { @@ -2282,11 +2282,11 @@ export function DocumentViewer({ async function summarize() { if (!canSummarizeDocument) { - setSummaryError( - !canUsePrivateApis - ? "Sign in before summarising private documents." - : "Load a source document before summarising.", - ); + setSummaryError("Load a source document before summarising."); + return; + } + if (!canUsePrivateApis) { + setSummaryError("Sign in before summarising private documents."); return; } const summaryMode = sourceSearch.trim().length === 0; @@ -2408,11 +2408,7 @@ export function DocumentViewer({ : documentHomeHref; const usefulPageHref = (page: number) => documentPageHref(documentId, page); const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; - const summarizeTitle = canSummarizeDocument - ? "Answer from this document" - : !canUsePrivateApis - ? "Sign in required to answer from this document" - : "Load a source document before answering"; + const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering"; const pageByNumber = useMemo(() => new Map(pages.map((page) => [page.page_number, page])), [pages]); const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk])), [chunks]); const selectedPage = pageByNumber.get(activePage) ?? pages[0]; @@ -2679,6 +2675,7 @@ export function DocumentViewer({
boolean; - setFavourite: ( - contentType: FavouriteContentType, - contentKey: string, - saved: boolean, - ) => Promise; - clearFavourites: () => Promise; -}; - -const unavailableAccountData: AccountDataContextValue = { - favourites: emptyFavourites, - ready: true, - error: null, - isSaved: () => false, - setFavourite: async () => ({ success: false, reason: "unauthenticated", message: "Account data unavailable." }), - clearFavourites: async () => ({ success: false, reason: "unauthenticated", message: "Account data unavailable." }), + setFavourite: (contentType: FavouriteContentType, contentKey: string, saved: boolean) => Promise; + clearFavourites: () => Promise; }; -const AccountDataContext = createContext(unavailableAccountData); +const AccountDataContext = createContext(null); function normalizedFavourites(value: unknown): FavouritesByType { const rows = Array.isArray(value) ? value : []; @@ -132,23 +117,19 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (auth.status !== "authenticated") { if (demoAccountData) { const current = favourites[contentType]; - const success = writeSavedRegistrySlugs( + return writeSavedRegistrySlugs( storageKeyByType[contentType], saved ? [contentKey, ...current.filter((item) => item !== contentKey)] : current.filter((item) => item !== contentKey), ); - return success - ? { success: true as const } - : { success: false, reason: "request-error" as const, message: "Failed to save to local storage." }; } - const message = "Sign in or create an account to save favourites."; - setError(message); - return { success: false, reason: "unauthenticated" as const, message }; + setError("Sign in or create an account to save favourites."); + return false; } const key = contentKey.trim(); - if (!key) return { success: false, reason: "request-error" as const, message: "Invalid content key provided." }; + if (!key) return false; const previous = favourites; setFavourites((current) => ({ ...current, @@ -165,27 +146,20 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (!response?.ok) { setFavourites(previous); const payload = await response?.json().catch(() => ({})); - const message = payload?.message ?? payload?.error ?? "Saved items could not be updated."; - setError(message); + setError(payload?.message ?? payload?.error ?? "Saved items could not be updated."); if (response?.status === 401) auth.markSessionExpired(); - return { success: false, reason: "request-error" as const, message }; + return false; } setError(null); - return { success: true as const }; + return true; }, [auth, favourites], ); const clearFavourites = useCallback(async () => { if (auth.status !== "authenticated") { - if (!demoAccountData) { - const message = "Sign in or create an account to clear favourites."; - return { success: false, reason: "unauthenticated" as const, message }; - } - const success = (Object.values(storageKeyByType) as string[]).every((key) => writeSavedRegistrySlugs(key, [])); - return success - ? { success: true as const } - : { success: false, reason: "request-error" as const, message: "Failed to clear local storage." }; + if (!demoAccountData) return false; + return (Object.values(storageKeyByType) as string[]).every((key) => writeSavedRegistrySlugs(key, [])); } const previous = favourites; setFavourites(emptyFavourites); @@ -195,14 +169,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { }).catch(() => null); if (!response?.ok) { setFavourites(previous); - const payload = await response?.json().catch(() => ({})); - const message = payload?.message ?? payload?.error ?? "Saved items could not be cleared."; - setError(message); + setError("Saved items could not be cleared."); if (response?.status === 401) auth.markSessionExpired(); - return { success: false, reason: "request-error" as const, message }; + return false; } setError(null); - return { success: true as const }; + return true; }, [auth, favourites]); const value = useMemo( @@ -210,16 +182,19 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { favourites, ready, error, + isAuthenticated: auth.status === "authenticated", isSaved: (contentType, contentKey) => favourites[contentType].includes(contentKey), setFavourite, clearFavourites, }), - [clearFavourites, error, favourites, ready, setFavourite], + [auth.status, clearFavourites, error, favourites, ready, setFavourite], ); return {children}; } export function useAccountData() { - return useContext(AccountDataContext); + const context = useContext(AccountDataContext); + if (!context) throw new Error("useAccountData must be used within AccountDataProvider."); + return context; } diff --git a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts index 64bcd6b18..46e151b35 100644 --- a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts +++ b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts @@ -7,6 +7,7 @@ import type { SetupCheck } from "@/components/clinical-dashboard/DocumentManagerPanel"; import { navigationHashes } from "@/components/clinical-dashboard/dashboard-contracts"; import { makeSearchError } from "@/components/clinical-dashboard/search-utils"; +import { canAccessFavouritesMode } from "@/lib/app-modes"; import type { ClinicalDocument, ImportBatch, IngestionJob, RagAnswer, RelatedDocument } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; @@ -157,3 +158,7 @@ export function mergeDocumentRefresh(current: ClinicalDocument[], updates: Clini }; }); } + +export function sessionFavouritesAccessible(authStatus: string, demoMode: boolean) { + return canAccessFavouritesMode({ authenticated: authStatus === "authenticated", demoMode }); +} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 6dc87c220..a26d43292 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -22,7 +22,6 @@ import { } from "lucide-react"; import { useMemo, useRef, useState } from "react"; -import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { FavouritesMobileBrowseRail, FavouritesMobileQuickViews, @@ -30,6 +29,7 @@ import { useFavouritesNavCollapsed, type FavouritesViewMode, } from "@/components/clinical-dashboard/favourites-library-nav"; +import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn } from "@/components/ui-primitives"; import { @@ -1173,6 +1173,15 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: + {!demoMode && auth.status !== "authenticated" && auth.status !== "loading" ? ( +

+ Sign in or create an account from Account settings to save favourites and access them across devices. +

+ ) : null} +
{ if (landingPreferenceAppliedRef.current) return; landingPreferenceAppliedRef.current = true; @@ -159,6 +155,73 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { ); } +function isInformationPage(pathname: string): boolean { + // Services detail: /services/[slug] + if (pathname.startsWith("/services/") && pathname !== "/services") return true; + + // Forms detail: /forms/[slug] + if (pathname.startsWith("/forms/") && pathname !== "/forms") return true; + + // Medications detail: /medications/[slug] + if (pathname.startsWith("/medications/") && pathname !== "/medications") return true; + + // Psychiatric specifier detail: /specifiers/[slug] + if ( + pathname.startsWith("/specifiers/") && + pathname !== "/specifiers" && + pathname !== "/specifiers/builder" && + pathname !== "/specifiers/compare" && + pathname !== "/specifiers/map" + ) + return true; + + // Clinical formulation detail: /formulation/[slug] + if ( + pathname.startsWith("/formulation/") && + pathname !== "/formulation" && + pathname !== "/formulation/builder" && + pathname !== "/formulation/compare" && + pathname !== "/formulation/map" + ) + return true; + + // Factsheets detail: /factsheets/[slug] + if (pathname.startsWith("/factsheets/") && pathname !== "/factsheets" && pathname !== "/factsheets/search") + return true; + + // Therapy compass detail: /therapy-compass/[slug]/brief or /therapy-compass/[slug]/sheet + if ( + pathname.startsWith("/therapy-compass/") && + pathname !== "/therapy-compass" && + pathname !== "/therapy-compass/compare" && + pathname !== "/therapy-compass/pathways" && + pathname !== "/therapy-compass/recommend" && + pathname !== "/therapy-compass/review" && + pathname !== "/therapy-compass/search" + ) + return true; + + // Differential diagnosis detail: /differentials/diagnoses/[slug] or /differentials/presentations/[slug] + if (pathname.startsWith("/differentials/diagnoses/") || pathname.startsWith("/differentials/presentations/")) + return true; + + // DSM-5 Diagnosis detail: /dsm/diagnoses/[slug] or /dsm/diagnoses/[slug]/differentials or /dsm/compare + if (pathname.startsWith("/dsm/diagnoses/")) return true; + + // Document detail: /documents/[id] (excluding /documents/search) + if (pathname.startsWith("/documents/") && pathname !== "/documents/search") return true; + + return false; +} + +function isToolDetailWithFooterSearch(pathname: string): boolean { + return ( + (pathname.startsWith("/services/") && pathname !== "/services") || + (pathname.startsWith("/forms/") && pathname !== "/forms") || + (pathname.startsWith("/medications/") && pathname !== "/medications") + ); +} + function GlobalStandaloneSearchShellClient({ children, initialMode = "answer", @@ -271,12 +334,16 @@ function GlobalStandaloneSearchShellClient({ const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; - const shouldShowSearchComposer = searchComposerVisible && !isDifferentialPresentationWorkflow; + const isInfoPage = isInformationPage(pathname); + const shouldShowSearchComposer = + searchComposerVisible && + !isDifferentialPresentationWorkflow && + (!isInfoPage || isToolDetailWithFooterSearch(pathname)); const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome; // Standalone mode homes portal the composer into the hero (in-flow at every // width), so phones need no bottom-dock clearance there. const visibleMobileComposerReserve = !shouldShowSearchComposer - ? "2rem" + ? "max(2rem, var(--safe-area-bottom))" : isStandaloneModeHome ? "2rem" : searchMode === "answer" @@ -288,13 +355,15 @@ function GlobalStandaloneSearchShellClient({ : "calc(9rem + var(--safe-area-bottom))"; // Release the large bottom reserve only when the phone bottom composer is // actually hidden (MasterSearchHeader's bottomComposerHidden). Header-only - // scroll-hide, open menus/sheets, and composer focus keep the full reserve - // so content does not slide under a still-visible dock. + // scroll-hide, pinned compare addons, open menus/sheets, and composer focus + // keep the full reserve so content does not slide under a still-visible dock. // Safari's bottom safe-area inset includes its translucent browser toolbar. // Reusing that inset after the app composer hides recreates a toolbar-sized // blank band, so the hidden state intentionally keeps only a small content // pad. Interactive composer chrome still receives the full inset above. - const mobileComposerReserve = bottomComposerHidden ? "0.75rem" : visibleMobileComposerReserve; + const mobileComposerReserve = bottomComposerHidden + ? "max(0.75rem, env(safe-area-inset-bottom))" + : visibleMobileComposerReserve; useEffect(() => { // Re-derive the mode and query from the URL, but only when the search string @@ -589,11 +658,41 @@ function GlobalStandaloneSearchShellClient({ onNewChat={startNewAnswerChat} onOpenMobileSidebar={() => setMobileMenuOpen(true)} mobileLeadingAction={ - pathname === "/differentials" && searchMode === "differentials" && requestedQuery ? "back" : "menu" + isInfoPage + ? "back" + : pathname === "/differentials" && searchMode === "differentials" && requestedQuery + ? "back" + : "menu" } onMobileBack={() => { - setQuery(""); - navigateToMode(searchMode, { focus: true }); + if (isInfoPage) { + if (pathname.startsWith("/services/")) { + router.push("/services"); + } else if (pathname.startsWith("/forms/")) { + router.push("/forms"); + } else if (pathname.startsWith("/medications/")) { + router.push("/?mode=prescribing"); + } else if (pathname.startsWith("/differentials/")) { + router.push("/differentials"); + } else if (pathname.startsWith("/dsm/")) { + router.push("/dsm"); + } else if (pathname.startsWith("/specifiers/")) { + router.push("/specifiers"); + } else if (pathname.startsWith("/formulation/")) { + router.push("/formulation"); + } else if (pathname.startsWith("/therapy-compass/")) { + router.push("/therapy-compass"); + } else if (pathname.startsWith("/factsheets/")) { + router.push("/factsheets"); + } else if (pathname.startsWith("/documents/")) { + router.push("/documents/search"); + } else { + router.back(); + } + } else { + setQuery(""); + navigateToMode(searchMode, { focus: true }); + } }} queryModeOptions={mockupQueryModeOptions} queryInputRef={inputRef} @@ -627,6 +726,7 @@ function GlobalStandaloneSearchShellClient({ ref={mainRefCallback} tabIndex={-1} onScroll={handleMainScroll} + data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} className={cn( // sm+ uses overflow-x-clip (not hidden): hidden forces overflow-y to // auto, which turns #main-content into the sticky scrollport while the diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index a54ea110c..6adb76e71 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1583,9 +1583,9 @@ export function MasterSearchHeader({ "universal-header-icon-control h-tap w-tap shrink-0 place-items-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", // From md the desktop icon rail owns navigation, so the drawer // trigger is phone-only outside workflow headers. - isWorkflowHeader ? "grid" : "grid md:hidden", + isWorkflowHeader || useMobileBackControl ? "grid" : "grid md:hidden", )} - aria-label={useMobileBackControl ? "Back to differentials home" : "Open Clinical Guide menu"} + aria-label={useMobileBackControl ? "Go back" : "Open Clinical Guide menu"} > {useMobileBackControl ? (