diff --git a/.agents/skills/catalog.json b/.agents/skills/catalog.json index 21b4ce308..b544b2f6b 100644 --- a/.agents/skills/catalog.json +++ b/.agents/skills/catalog.json @@ -3,7 +3,20 @@ "categories": [ { "name": "Everyday", - "skills": ["skills", "plan", "run", "test", "fix", "review", "task", "handover", "health", "audit", "export"] + "skills": [ + "skills", + "plan", + "run", + "test", + "fix", + "review", + "task", + "handover", + "health", + "audit", + "export", + "prompt-perfector" + ] }, { "name": "Clinical and app", diff --git a/.agents/skills/prompt-perfector/SKILL.md b/.agents/skills/prompt-perfector/SKILL.md new file mode 100644 index 000000000..a69a09372 --- /dev/null +++ b/.agents/skills/prompt-perfector/SKILL.md @@ -0,0 +1,20 @@ +--- +name: prompt-perfector +description: Refine, structure, and optimize user prompts for LLMs while ensuring execution occurs in a isolated environment. Use when asked to polish, perfect, or evaluate prompts safely. +--- + +# Prompt Perfector + +Refines user prompts into structured, highly effective instructions and executes evaluation tasks in an isolated workspace (`Workspace: "branch"`). + +## Core Capabilities + +1. **Prompt Refinement**: Analyzes input prompts for clarity, context, constraints, output format specifications, and edge cases. +2. **Environment Isolation**: Ensures any code execution, prompt testing, or subagent tasks spawned for prompt validation run within an isolated workspace (`Workspace: "branch"` or `"share"`). + +## Workflow + +1. **Deconstruct Intent**: Identify the goal, target model, domain constraints, and missing specifications. +2. **Enhance Structure**: Apply structured formatting (System Instructions, Context, Input Schema, Output Constraints, Examples). +3. **Isolated Testing**: If prompt validation requires subagent execution or file testing, invoke subagents with `Workspace: "branch"`. +4. **Deliver Output**: Present the perfected prompt with a summary of structural enhancements and usage recommendations. diff --git a/.agents/skills/prompt-perfector/agents/openai.yaml b/.agents/skills/prompt-perfector/agents/openai.yaml new file mode 100644 index 000000000..de2e433d7 --- /dev/null +++ b/.agents/skills/prompt-perfector/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Prompt Perfector" + short_description: "Refine prompts and evaluate them in isolation" + default_prompt: "Use $prompt-perfector to refine this prompt for clarity, constraints, and safe isolated evaluation." diff --git a/.gitignore b/.gitignore index 34ad0f427..d0de27077 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ !/.agents/skills/performance/ !/.agents/skills/plan/ !/.agents/skills/privacy/ +!/.agents/skills/prompt-perfector/ !/.agents/skills/rag-change-lab/ !/.agents/skills/rag/ !/.agents/skills/recovery/ diff --git a/AGENTS.md b/AGENTS.md index 3025ebffe..57395af1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -475,7 +475,7 @@ Record one `docs/branch-review-ledger.md` row per PR touched, and end with the p ## Repository productivity skills -Automatically apply repo-local skills under `.agents/skills/` when their descriptions match the user's request. Run `npm run skills` for the validated catalog of 32 canonical single-word skills and `npm run check:skills` to verify catalog integrity. The older long names remain compatibility aliases and must not be counted as unique skills. +Automatically apply repo-local skills under `.agents/skills/` when their descriptions match the user's request. Run `npm run skills` for the validated catalog of 33 canonical single-word skills and `npm run check:skills` to verify catalog integrity. The older long names remain compatibility aliases and must not be counted as unique skills. The foundational orchestration skills are: diff --git a/docs/audit-handover-2026-07-14.md b/docs/audit-handover-2026-07-14.md index 42579a98a..f63d84255 100644 --- a/docs/audit-handover-2026-07-14.md +++ b/docs/audit-handover-2026-07-14.md @@ -117,19 +117,19 @@ evals, service-role tenancy regression class, upstream OCR quality labels drivin ### 4.1 Security / privacy / API -| ID | Finding | Evidence | Remediation wave | -| --- | ----------------------------------------------------------- | -------------------------------------------------------- | ---------------- | -| S1 | Authed public-doc DTOs leak `storage_path` / `content_hash` | `documents/route.ts`, `documents/[id]/route.ts` | Wave D1 | -| S2 | Auth UI trusts `getSession()`; APIs use `getUser` | `src/lib/supabase/client.tsx` | Wave D3 | -| S3 | Public-upload quarantine pool high blast radius if enabled | `upload/route.ts` + pool owner | Wave D4 | -| S4 | Auth 401 envelope `{ error }` only | `supabase/auth.ts` vs `http.ts` | Wave H1 | -| S5 | Many hand-rolled `{ error }` omit `code`/`message` | upload, doc 404s, feedback, demo | Wave H1 | -| S6 | Auth write/admin routes lack rate limits | bulk, labels, table-facts, ingestion/*, eval-cases, jobs | Wave H2 | -| S7 | Document list offset max `1_000_000` | `documents/route.ts` | Wave H3 | -| S8 | Upload reserves max body when Content-Length absent | `upload/route.ts` | Wave H4 | -| S9 | Authed stream summarize excludes public docs | `answer/stream` → `summarizeDocument` owner-only | Wave H5 | -| S10 | Bulk returns raw DB `error.message` | `documents/bulk/route.ts` | Wave D5 | -| S11 | `/api/jobs` can return demo jobs when env missing | `jobs/route.ts` | Wave H6 | +| ID | Finding | Evidence | Remediation wave | +| --- | ----------------------------------------------------------- | --------------------------------------------------------- | ---------------- | +| S1 | Authed public-doc DTOs leak `storage_path` / `content_hash` | `documents/route.ts`, `documents/[id]/route.ts` | Wave D1 | +| S2 | Auth UI trusts `getSession()`; APIs use `getUser` | `src/lib/supabase/client.tsx` | Wave D3 | +| S3 | Public-upload quarantine pool high blast radius if enabled | `upload/route.ts` + pool owner | Wave D4 | +| S4 | Auth 401 envelope `{ error }` only | `supabase/auth.ts` vs `http.ts` | Wave H1 | +| S5 | Many hand-rolled `{ error }` omit `code`/`message` | upload, doc 404s, feedback, demo | Wave H1 | +| S6 | Auth write/admin routes lack rate limits | bulk, labels, table-facts, ingestion/\*, eval-cases, jobs | Wave H2 | +| S7 | Document list offset max `1_000_000` | `documents/route.ts` | Wave H3 | +| S8 | Upload reserves max body when Content-Length absent | `upload/route.ts` | Wave H4 | +| S9 | Authed stream summarize excludes public docs | `answer/stream` → `summarizeDocument` owner-only | Wave H5 | +| S10 | Bulk returns raw DB `error.message` | `documents/bulk/route.ts` | Wave D5 | +| S11 | `/api/jobs` can return demo jobs when env missing | `jobs/route.ts` | Wave H6 | ### 4.2 Ingestion / indexing diff --git a/docs/audit/repo-audit-2026-07-01.md b/docs/audit/repo-audit-2026-07-01.md index 1b5175fe1..23a6ccfef 100644 --- a/docs/audit/repo-audit-2026-07-01.md +++ b/docs/audit/repo-audit-2026-07-01.md @@ -44,7 +44,7 @@ The dominant theme is **clinical-safety / data-integrity in the answer path**: s `src/lib/retrieval-selection.ts:473` · privacy/clinical · CONFIRMED · safe_cleanup=false -- **Defect:** `annotateResultWithSelection` writes back `Math.max(originalScore, candidate.score)` as `hybrid_score`, so any net-negative `resultBoost` (outdated −0.24, review_due −0.12, unverified −0.08, poor-extraction −0.12) never lowers the score consumers see — the floor is meant to let intent "rescue" _raise_ a score, but it also blocks all penalties. +- **Defect:** `annotateResultWithSelection` writes back `Math.max(originalScore, candidate.score)` as `hybrid_score`, so any net-negative `resultBoost` (outdated −0.24, review*due −0.12, unverified −0.08, poor-extraction −0.12) never lowers the score consumers see — the floor is meant to let intent "rescue" \_raise* a score, but it also blocks all penalties. - **Failure:** An `outdated` chunk with base 0.70 → ~0.46 after penalties gets floored back to 0.70. `evaluateEvidenceCoverageGate` (`rag.ts:3233`, gate ~0.6) then treats the stale guideline as strong and presents it with high confidence instead of demoting it. - **Fix:** Don't floor at the original when `resultBoost` is negative — persist the clamped boosted score so freshness/validation penalties reach coverage gating. @@ -204,21 +204,21 @@ working tree of `claude/cool-wiles-12aade` (uncommitted, nothing pushed). 42 fil Five parallel review agents then audited the fix diff itself. They confirmed the bulk of the changes sound and surfaced **12 follow-up findings — all fixed** in the same working tree: -| # | Finding (introduced/incomplete) | Resolution | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| R1 | **HIGH** — H3's un-floored penalties **compounded** across the pipeline's 2–3 selection passes (`baseScore` re-read the penalized `hybrid_score`) | resolved by discarding the H3 change and aligning `retrieval-selection.ts` verbatim with PR #118 (relevance-first, no metadata penalties — see H3 row). Residual note: POSITIVE intent boosts still compound mildly across passes on main's design (pre-existing, eval-validated); a `preSelectionScore` idempotency fix was prototyped and withdrawn — reintroduce only gated on the golden eval. | -| R2 | M2's whole-word `mg`/`mcg` no longer matched digit-attached doses ("10mg"), and could re-cancel dosing via M3 | `(?:\b | \d)` digit-adjacent allowance (regression test "clozapine 100mg…") | -| R3 | L3's aggregate `none` could contradict per-source "nearby" chips for purely-semantic matches and trip the danger governance banner | aggregate `nearby` also granted when relevance score ≥ 0.5 | -| R4 | H1 incomplete — rich-mode prompts show table-fact **metadata** snippets (`accessible_table_markdown`/`table_text_snippet`/`cells`) not covered by the corpus | included in both `tableFactText` (verification) and `tableFactQuoteText` (quotes) | -| R5 | H2's rescued noisy fragments inflated `provenanceScore` past 0.42, flipping `useful` false and blanking text that previously survived | provenance computed over baseline-kept fragments only; thresholds count toward `clinicalSignalScore` | -| R6 | `x10` threshold branch matched "Rx100"/"0x10" | lookbehind guard `(?`. +- **Expected:** Release-targeted PRs should receive the same trusted PR metadata/evidence policy as main-targeted PRs, unless an equivalent release gate exists. +- **Actual risk:** CI runs on PRs targeting `main` and `release/**`, but the PR policy workflow only runs for `main`. Release PRs can therefore appear CI-covered while skipping evidence, verification, rollback, and high-risk governance metadata checks. +- **Smallest proof/check:** Inspect workflow trigger branches: CI includes `release/**`; PR policy does not. +- **Suggested fix:** Add `release/**` to the PR policy workflow branch trigger, or document and enforce an equivalent release-specific policy. + +### P2 — Streaming `summaryMode` can validate one document scope but summarize a different `documentId` + +- **Files:** `src/lib/validation/answer-request.ts`, `src/app/api/answer/stream/route.ts`, `tests/private-access-routes.test.ts`. +- **Trigger:** Send `/api/answer/stream` with `summaryMode: true`, `documentId: A`, and `documentIds: [B]`. +- **Expected:** Summary-mode scope validation should be tied to exactly the document being summarized, or mismatched fields should be rejected. +- **Actual risk:** Scope resolution prefers `documentIds` when present, but the summary branch later summarizes `documentId` directly. This can pass selected-scope validation using one document set while generating a clinical summary for another document. +- **Smallest proof/check:** Add a route test asserting `summaryMode` rejects mismatched `documentId`/`documentIds`, or only summarizes when `documentIds` is absent or exactly `[documentId]`. +- **Suggested fix:** Tighten summary-mode validation to require exactly one `documentId` and no conflicting `documentIds` or filters unless explicitly supported. + +### P2 — GitHub Actions pin guard does not scan composite action files + +- **Files:** `scripts/check-github-action-pins.mjs`, `.github/actions/setup-node-cached/action.yml`, `.github/actions/setup-ui-e2e/action.yml`, `.github/workflows/ci.yml`. +- **Trigger:** A future change introduces a mutable external `uses:` reference inside `.github/actions/**/action.yml`. +- **Expected:** The action pin guard should scan both workflow files and reusable local composite action definitions. +- **Actual risk:** The current pin checker discovers only `.github/workflows` files, while CI executes local composite actions that can contain third-party `uses:` entries. Future unpinned composite-action dependencies could bypass the guard. +- **Smallest proof/check:** Add a fixture/self-test with an unpinned `uses:` inside a composite action and assert `npm run check:github-actions` fails. +- **Suggested fix:** Extend discovery to `.github/actions/**/action.yml` and `.github/actions/**/action.yaml`. + +### P2 — Current shell runtime is Node 20 while the repo requires Node 24 + +- **Files:** `package.json`, `.nvmrc`, `scripts/check-node-engine.cjs`, `Dockerfile`, `Dockerfile.worker`. +- **Trigger:** Run install, runtime, build, typecheck, lint, or Next.js checks in the current shell. +- **Expected:** Local verification should run under Node 24 and npm 11 as declared by the project. +- **Actual risk:** Verification under Node 20 is not representative and fails the repo's own runtime guard. CI/production Docker images are aligned to Node 24, so Node 20 results are environment-limited. +- **Smallest proof/check:** `node scripts/check-node-engine.cjs` fails in the current shell with Node 20.20.2. +- **Suggested fix:** Use Node 24 for local validation, or add a plain-Node preflight that reports the mismatch before deeper checks. + +### P2 — `node_modules` is absent, blocking local dependency/build/Next.js validation + +- **Files:** `package.json`, `scripts/deployment-boot-smoke.mjs`, `AGENTS.md`. +- **Trigger:** Run build, lint, typecheck, runtime checks, or attempt to read Next.js docs under `node_modules/next/dist/docs/` in this checkout. +- **Expected:** Installed dependencies should be present before relying on local dependency/build/Next.js conclusions. +- **Actual risk:** The required Next.js docs and local binaries are absent, causing checks to fail before application logic is reached. +- **Smallest proof/check:** `test -d node_modules` reports absent; `npm run typecheck` fails because `node_modules/typescript/bin/tsc` is missing. +- **Suggested fix:** Run `npm ci` under Node 24 before deep local validation, or add friendlier prerequisite diagnostics. + +### P2 — Formatting drift across 27 files + +- **Files:** 27 files reported by `npm run format:check`, including docs, scripts, app routes, components, libraries, and tests. +- **Trigger:** Run `npm run format:check`. +- **Expected:** Prettier should pass cleanly or formatting drift should be intentionally documented. +- **Actual risk:** Formatting drift creates noisy diffs and weakens review signal, especially during broad clinical/retrieval changes. +- **Smallest proof/check:** `npm run format:check` exits 1 and reports 27 files with style issues. +- **Suggested fix:** Run `npm run format` in a dedicated formatting-only change after confirming the drift is not intentional. + +### P3 — Href-less anchor in document-search mockup is styled as actionable but has no `href` + +- **File:** `src/components/master-document-flow-mockups.tsx`. +- **Trigger:** Open the document-search/source mockup and try to interact with the styled `Table 3` inline reference. +- **Expected:** If actionable, it should be a real link/button; if not, it should not be an anchor. +- **Actual risk:** Sighted users see link styling while keyboard and assistive-tech users do not receive a real link target. +- **Smallest proof/check:** Static search for `` tags without `href` identifies this inline anchor. +- **Suggested fix:** Replace with `href="#table-3"` or a non-interactive ``. + +### P3 — “Coming soon” favourites controls are keyboard-focusable despite `aria-disabled` + +- **File:** `src/components/clinical-dashboard/favourites-hub.tsx`. +- **Trigger:** Keyboard navigate through `/favourites` toolbar controls. +- **Expected:** Unavailable actions should use native `disabled` or provide a clear focusable explanatory pattern. +- **Actual risk:** Keyboard users encounter controls that look/feel operable but cannot perform an action. +- **Smallest proof/check:** Static inspection shows `aria-disabled="true"` without native `disabled` on the relevant buttons. +- **Suggested fix:** Use native `disabled`, a non-button status/pill, or a fully documented focusable disabled pattern with visible explanation and activation guards. + +### P3 — Differential density controls are disabled UI relying on `title="Soon"` + +- **File:** `src/components/differentials/differential-presentation-workflow-page.tsx`. +- **Trigger:** Open a differential presentation workflow and inspect the `Compact`/`Detailed` toolbar controls. +- **Expected:** Unimplemented toolbar controls should either be hidden, clearly marked as coming soon, or exposed with an accessible explanation. +- **Actual risk:** The only explanation is `title="Soon"`, which is unreliable for keyboard, touch, and assistive technology users. +- **Smallest proof/check:** Static inspection shows native disabled controls with `aria-disabled` and `title="Soon"`. +- **Suggested fix:** Add visible accessible explanation, remove until implemented, or present current density as static state. + +### P3 — `.npmrc` contains npm-unknown `allow-scripts=true` + +- **Files:** `.npmrc`, `package.json`. +- **Trigger:** Run npm scripts in this checkout. +- **Expected:** npm project config should be recognized or documented if intentionally consumed elsewhere. +- **Actual risk:** npm prints warnings that `allow-scripts` is unknown and may stop working in the next major npm version, creating noisy logs and possible future install friction. +- **Smallest proof/check:** npm commands print `Unknown project config "allow-scripts"`. +- **Suggested fix:** Confirm whether repo tooling consumes it; remove or document it accordingly. + +## Areas with no high-confidence defect found in this sweep + +- Database/RLS/Postgres functions: no high-confidence issue found in static review. Tables appear to have RLS enabled, broad privileges are revoked before service-role grants, sensitive functions use explicit `search_path`, and storage policy shape is owner-scoped. +- Security/auth/privacy API boundaries: no high-confidence issue found in static review. High-risk mutation routes generally require authenticated/admin context or use intentional public access with rate limiting and owner/public scope guards. +- Server actions: no server-action marker was found in the searched source set. + +## Improvement backlog + +1. Add a static service-role API boundary check for mutation routes. +2. Add focused anonymous signed-url privacy tests for document and image URLs. +3. Add a health/readiness disclosure matrix for anonymous, invalid-token, valid-token, and admin callers. +4. Maintain a checked-in allowlist of intentional public API surfaces. +5. Add a no-server-actions guard if server actions remain intentionally unused. +6. Add a static table/RLS parity check. +7. Add a SECURITY DEFINER `search_path` static check. +8. Add storage policy shape assertions for owner-scoped paths. +9. Generate or maintain an RLS/ACL privilege manifest for review diffs. +10. Make summary-mode answer validation a discriminated contract. +11. Extract shared answer request execution between stream and non-stream routes. +12. Add explicit route parity tests for summary mode. +13. Include summary-mode scope used for generation in telemetry/client payloads. +14. Mirror PR policy branch coverage with CI branch coverage. +15. Scan composite actions in the GitHub action pin guard. +16. Add CI-scope self-test coverage for release-branch governance. +17. Add composite-action pin guard fixtures. +18. Add a plain-Node environment prerequisite check for Node/npm/dependency presence. +19. Clarify how agents should locate Next.js version docs when `node_modules` is not installed. +20. Add container-level runtime boot smoke coverage for final Docker image layout. +21. Standardize unavailable UI action semantics. +22. Avoid `title` as the only explanation for disabled/coming-soon clinical workflow controls. +23. Prefer SPA navigation over `window.location.assign` for internal dashboard actions where practical. +24. Add a lightweight static accessibility guard for href-less anchors and disabled-control patterns. +25. Run a dedicated formatting-only pass after approval. + +## Checks run in the main audit session + +- `pwd && find .. -name AGENTS.md -print && git status --short --branch && git rev-parse --abbrev-ref HEAD && git log --oneline -5` +- `cat .agents/skills/workflows/SKILL.md && cat .agents/skills/database-flightplan/SKILL.md && cat .agents/skills/session-lifecycle/SKILL.md` +- `cat AGENTS.md | sed -n '1,220p' && sed -n '1,220p' docs/codex-review-protocol.md && cat package.json` +- `npm run workflow:flightplan -- --write-evidence` +- `git status --short && git diff --name-only && find . -maxdepth 3 -name AGENTS.md -print && rg -n "TODO|FIXME|HACK|XXX|SECURITY|BUG|throw new Error\(|console\.log|any\b|@ts-ignore|eslint-disable|dangerouslySetInnerHTML|innerHTML|eval\(|process\.env\.|SERVICE_ROLE|SUPABASE_SERVICE|OPENAI_API_KEY|TODO" src scripts worker supabase tests .github docs --glob '!node_modules' --glob '!package-lock.json'` +- `npm run check:knip` +- `npm run format:check` +- `npm run typecheck` +- `npm run lint` +- `npm run check:runtime` + +## Check results and limitations + +- `npm run workflow:flightplan -- --write-evidence` passed and wrote ignored local evidence under `.local/workflow-evidence/`. +- `npm run format:check` failed with formatting drift in 27 files. +- `npm run check:knip` failed because `knip` was not found; `node_modules` is absent. +- `npm run typecheck` failed because `node_modules/typescript/bin/tsc` is missing. +- `npm run lint` failed because the concurrent heavy-run lock was held by the typecheck command. This is an agent execution error caused by launching two heavy repo commands in parallel. +- `npm run check:runtime` failed because `tsx` could not be resolved from missing `node_modules`. +- Provider-backed/live checks were not run: `npm run check:supabase-project`, `npm run check:production-readiness`, live retrieval/answer evals, hosted CI, and GitHub API actions remain confirmation-required by repository policy. + +## Follow-up priority + +1. Fix summary-mode route contract issues first because they can affect clinical answer/source behavior. +2. Fix release PR policy coverage and composite action pin scanning because they affect governance and supply-chain protection. +3. Restore a correct local verification environment: Node 24, npm 11, installed dependencies. +4. Run a dedicated formatting pass or decide whether existing drift should remain. +5. Then run `npm run verify:cheap` and risk-selected PR-local checks under the correct environment. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 250735a6f..f0a4938e4 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -680,6 +680,85 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-20 | cursor/documents-search-header-3eab / PR #936 | bdc333a9fa7e420e2beb2f146c6f271196869cb5 (squash on main) | post-merge closeout + branch-cleanup | Squash-merged to main. Product proof on main: `DocumentResultsControls`, identity-first documents results chrome, governance notice under controls, Also-in-library strip removed from documents results path. Remote feature ref already deleted by protected-main workflow; local tip `fd559a07` retained only merge/review commits with no unique product patch vs main. | Hosted pre-merge and post-merge required checks green (Static/Unit/Build/Production UI/PR required). Squash content proof via main tree symbols; remote `ls-remote` empty after prune; local branch deleted after this ledger row. No OpenAI/Supabase provider calls. | | 2026-07-20 | cursor/fix-ui-overlap-duplicate-header-3eab / PR #944 | 08181b8b45cf23d53ff7fb8682fb5f83f4417fb0 (squash on main) | post-merge closeout + branch-cleanup | Squash-merged to main. Product proof: `gotoHome` pins `/?mode=answer` and waits for exactly one `header#search` before overlap measurement, closing the landing-preference dual-banner flake that failed Production UI then PR required on #936. Remote feature ref already deleted; local tip `418de1b9` is merge-only after squash. | Local before merge: verify:pr-local (2961 unit) + Chromium ui-overlap 12/12. Hosted exact-head and post-merge: Production UI, PR required, Static, Unit green (Build skipped for test-only). No OpenAI/Supabase provider calls. | | 2026-07-19 | cursor/mobile-header-new-chat-inset-66c0 (PR #940) | 84eb0b6c3782e27fbbd1ec79b87b10327802ec1e | final mobile header new-chat edge inset review + merge readiness | No high-confidence P0-P1. Root cause: unlayered `@media (max-width:639px)` zeroed `.edge-glass-header` padding and beat `@layer components`. Fixed with tokenized `--header-edge-pad: 1rem` shared by layered base + unlayered phone guard; Playwright symmetry checks at 360/390; source contract blocks a `max(0px, safe-area)` regression. Merged latest `origin/main` (including #933/#942/#943) while keeping the header-edge-pad token. Residual: headless Chromium cannot exercise asymmetric safe-area `max()`; DocumentViewer gains the same pad but is outside the symmetry test. | Local geometry probe 360/390/640 = 16px/16px symmetric; CSS contract Vitest 5/5; `ui-overlap` Chromium 14/14; prior `verify:ui` 242/242 on the functional head; `verify:cheap` unit suite hit only the known container-only `pdf-extraction-budget` python ENOENT artifact (also fails on clean main / hosted-CI-green elsewhere). PR marked ready; squash auto-merge enabled. No OpenAI/live Supabase/provider calls. | +| 2026-07-20 | origin/main PWA-surface review, window `ef042ca..e128384` (44+ commits; explicit user request) + phone install-sheet redesign (this branch, content commit d8d8c4a) | e1283846647b20cd49ca6ae6920a7c76f2b945d7 | PWA-version review of new progress + install-notification design elevation | Sweep verdict (agent-verified): only #905/#897 (this program's own work) touched PWA surfaces in the window — sw.js CACHE_VERSION `2026-07-18-v1` and its offline.html binding, manifest (7 icons incl. monochrome; screenshots deliberately absent), icons route, kill-switch, and next.config headers all unchanged, so the privacy contract holds by construction. Geometric risk from five composer/dock-space reworks (#933 mobile-composer-reserve, #932, #930, #922, #899) probed live at 390×844: the non-install notice stack keeps correct clearance at rest (offline card bottom 744/844 with the 5.5rem gutter) and the stack's offset is now a custom property (`--pwa-notice-bottom-gap`) for one-line re-anchoring if composer geometry moves again. Redesign: install prompt + iOS hint present as a native bottom sheet on phones (full-bleed, flush bottom, grip bar matching #935's sheet language, real app icon identity via /icons/icon-192, Free/no-store meta row, two accent step chips for Share→Add to Home Screen as aria-hidden reinforcement of the unchanged accessible sentence); ≥640px keeps the #905 card/toast placements; region names, button names, and test-locked sentences unchanged. | Focused vitest 56/56 across the four PWA suites; `verify:cheap` 2971/2975 (sole fail = known container-only pdf-extraction-budget artifact); `test:e2e:pwa` privacy/offline green (installability fail = known container `in-incognito` artifact); `verify:ui` 243 passed/2 failed (the two long-baselined container artifacts); production build + client-bundle secret scan + bundle budget within tolerance (1303.4 vs 1278.6 KiB baseline; sheet adds ~0 JS); visual evidence at 390 light/dark/iOS-hint + 768 + 1440 with bounding-box math (full-bleed flush-bottom on phone; card/toast intact above). No provider-backed checks run. | +| 2026-07-20 | Credentialed release-gate checkpoint closeout (workflow_dispatch on main `7ec25d9`; user-authorized ≤$10, single dispatch each) | 7ec25d9675dea13635fa4a895af88c93da694a42 | Credentialed half of the release gate: CI dispatch + live eval canary | CI dispatch: 9/10 jobs green (unit coverage, build, Chromium production journeys, migration replay on local Supabase emulator, production-readiness CI-safe, policy self-tests, static/safety/scope). `release-browser-matrix` (WebKit/Firefox) CANCELLED twice by main-churn: ci.yml `concurrency: CI-${ref}, cancel-in-progress: true` kills in-flight dispatch runs on every main push and this repo merges every few minutes — livelock confirmed at the 2-attempt cap; WebKit/iOS verification remains outstanding with three human options (quiet-window dispatch, the weekly scheduled run, or a one-line dedicated concurrency group for the matrix job — operational-risk change, not applied). Eval canary: golden retrieval eval FAILED 4/36 (document_recall@5 0.944, ndcg@10 0.923, force_embedding_failure_count 0, no 429s — vector layer healthy, NOT the documented vector-ptsd transient class); the July 17 dispatch PASSED this step pre-#901, so the regression window implicates #901's deterministic semantic reranking (lithium-therapy-monitoring shows three unrelated documents with byte-identical rerank scores burying the lithium guideline; two other failures add fixture-vs-corpus identity components; answer-quality subset — the July 17 failure — never ran). NO canary re-run per plan (deterministic, not transient); retrieval is clinical-path and deferred to a human decision. Provider spend ≈ $1–2 (one canary run's embeddings); matrix/CI runs $0. | actions_run_trigger dispatches + rerun_failed_jobs (attempt 2); job-level conclusions and log excerpts from runs 29675875530 (both attempts), 29675878737 (July 19 canary), and 29567502452 (July 17 baseline). No local provider calls; secrets never left GitHub Actions. | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: ci.yml concurrency) | 79aaacf04e64f753c480dd957a81ac9be6acbb43 | CI workflow concurrency: stop main churn cancelling dispatch/schedule runs | One-line group expression: workflow_dispatch/schedule events now get a per-run concurrency group (github.run_id) while push/PR keep the shared ref group with cancel-in-progress — fixes the release-browser-matrix livelock (cancelled twice on 2026-07-19/20 by main merges mid-run; the weekly Sunday 18:00 UTC scheduled run was subject to the same cancellation). release-browser-matrix is not a required branch-protection check; pin/scope checkers do not constrain the concurrency block. Accepted side effect: deliberate runs can overlap push runs. Rollback: plain revert. | check:github-actions PASS; check:ci-scope PASS; format:check PASS (repo files; local-only .claude/settings.local.json warning is gitignored) | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: #901 rerank regression closeout) | fd5dcd1582e1c7aa8bf92ff097575e0c68aa930d | #901 rerank regression: baseline re-eval + offline guard net + saturated-tie fix | Baseline eval-canary dispatch on remediated main b3ae061 (run 29731533081) PASSED — golden retrieval 36/36 + answer-quality green, confirming #913-#926 closed the live 4/36 regression (July 19 failing run tested the raw #901 state; fixes landed 1-8h after it, never re-evaled until now). Residual defect found by new offline repro and fixed: with byte-identical imputed fast-path primaries, selection's clamped keys tie exactly and the chunk-id fallback decides, which secondStageScore's position adjustment launders into releaseRankScore (buried the CIWA answer doc in repro); fix = contentRankScore carried on RetrievalCandidate as tie-break between clamped rerankScore and chunk id, never added to scores. Amended #901's own never-live-validated saturated-tie pin to content-rank-then-id. Guards: tests/rag-fast-path-ordering.test.ts (4 end-to-end eval-shape repros) + ranking-tuning gates (4 golden-mapped hard negatives at production weights + full-snapshot high-risk). Fixture aliases NOT changed (Step C not triggered — identity cases pass live). | Targeted 4-suite vitest 37/37; npm run test 2981 passed (sole fail = container-only pdf-extraction-budget artifact); verify:cheap green through all 18 pre-test checks incl. lint+typecheck; check:production-readiness PASS on substantive checks (2 FAILs = documented missing-secret class in this container); build + client-bundle scan + check:rag:fixtures PASS | +| 2026-07-20 | claude/phone-scroll-fix (PR #993) | f0f4c42e7 | Phone scroll "locks to bottom" regression from #964 + all-pages phone scroll audit + guardrails | Root cause: #964's phone mode-home dock made one hide-on-scroll event release ~180-260px of scroll geometry (header grid collapse + reserve-pad shrink) — more than a short mode home's remaining runway, so scrollTop clamps onto the new bottom and a 12px up-drag snaps it back (oscillation; measured 266→84px runway, 2 flips/gesture on /formulation @390×844). Invisible to all gates because the suite-wide reducedMotion:"reduce" disables the causal transitions. Fix: collapse-budget gate in computeScrollHideUpdate (hosts report the would-be geometry release via readChromeCollapseBudget; hide refused without runway to absorb it; budget-less consumers unchanged; bottom-clamp guard untouched). Bonus latent bug fixed: DocumentViewer's one-shot #main-content discovery could hold a detached node after shell remounts (viewer hide-on-scroll dead); observer now persists. ui-smoke bottom-hide scenario updated to the gate contract. New 18-test sweep tests/ui-phone-scroll.spec.ts (10 mode homes + 3 dashboard modes + 4 long routes, motion enabled, simulated PWA insets) verified to fail red pre-fix. Residual: real-device iOS momentum clamping is device-only — user PWA confirmation requested post-deploy. | vitest focused 24/24 + mobile-composer-reserve; verify:cheap 3020 green; ui-phone-scroll chromium 18/18 + webkit advisory 18/18; ui-formulation+ui-tools+ui-smoke 180/180; verify:ui 264/264; verify:pr-local green (format/build/bundle-scan/RAG fixtures). No provider calls. | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: coverage tie-break follow-up) | 57ec880b306a8e2b31c5f20dacc47256fc93b4e2 | Post-merge live-eval finding on #982 + corrective fix: saturated-tie key rankScore → query-term coverage | Post-#982 golden dispatch (eval-canary run #50, 29735004222, main b9057f0 + deps) came back 35/36: the three verifiable July-19 failures (lithium-therapy-monitoring, clozapine-anc-threshold, patient-safety-plan-include) all PASS live, but alcohol-ciwa-threshold flipped pass→FAIL vs the same-morning pre-#982 run #49 (29731533081, 36/36) — failing top-3 ordered by descending rankScore (1.85/1.75/1.53, all finalScore-saturated, releaseRankScore 1.09/1.086/1.07), i.e. #982's tie-break let generic clinicalSignalBoost stacking outvote the ciwa/score/threshold-bearing chunk; #982 is the only retrieval-path delta in the window. Fix: contentRankScore → contentCoverageScore sourced from lexicalCoverageScore (query-term coverage, immune to boost stacking; ties still fall to chunk id); saturated-tie contract test re-pinned so coverage beats a HIGHER rankScore (discriminating — old key fails it); fast-path CIWA guard gains the run-#50 screening-chunk shape + content-term assertion. Live validation: tonight's 18:00 UTC scheduled canary (dispatch cap 2/2 spent ≈$2-4). Separately: ci.yml dispatch 4012 survived 30+ min of main churn under #979's per-run concurrency group (fix working); duplicate dispatch 4017 cancelled. | Targeted vitest 38/38; npm run test 3012 passed / 1 known container-only pdf-budget artifact; verify:cheap green to the same artifact; build + client-bundle scan + check:rag:fixtures PASS; check:production-readiness expected missing-secret FAILs only (no secrets in container) | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: WebKit matrix diagnosis) | caa298972846ef52d472bfbff98c90fc26481ed8 | First completed release-browser-matrix in weeks (run 4012, main b9057f0, 38 min uncancelled under #979) — full triage of 28 failures + fix for the dominant cluster | Run 4012: 716 passed / 28 failed (25 webkit, 3 firefox) / 4 skipped. Dominant cluster (root-caused, FIXED here): all 8 ui-universal-search webkit failures share one signature — typeahead content never enters the DOM — because commandDropdownCanDisplay (added 42a3e3c 2026-07-17, AFTER the last completed matrix; never ran on WebKit until 4012) requires fine-pointer OR zero-touch; headless browsers fail the (hover:hover)+(pointer:fine) query (proven by the zero-touch escape existing for CI at all), Chromium/Firefox pass via maxTouchPoints===0, and Playwright's Linux WebKit build advertises phantom touch points — flunking both branches and disabling useUniversalSearch entirely. Fix: beforeEach addInitScript in ui-universal-search/ui-smoke/ui-tools specs stubbing Navigator.prototype.maxTouchPoints to the runner's true 0 (inert on Chromium/Firefox; product gate + tests/search-command-surface.test.ts pins untouched). Local chromium runs of ui-universal-search fail 16/20 IDENTICALLY on unmodified main (container artifact — hosted CI chromium green in 4012 is authoritative; verified by stash/run/pop baseline). Expected delta next matrix: ≥8 webkit failures clear; candidates ui-tools:1244 + several ui-smoke answer-flow cases (same surface). NOT yet root-caused (triaged remainder, hosted-matrix-only reproduction): webkit ui-stress overflow 330/409 (360px overflow at mobile), webkit ui-accessibility 195/252 (focus dismissal; forced-colors labels), webkit ui-formulation 132, webkit ui-smoke copy-table/retry/recovery/recent-searches/source-only/differential-context/viewer-hydration/document-questions, webkit ui-tools 2087 (goto interrupted by ?q= navigation), webkit ui-universal-search 183 strict-mode duplicate options (fallback-surface rendering; likely clears with the gate fix), firefox ui-smoke 946/2206/2932 (@critical document search fails on BOTH firefox and webkit — cross-browser, highest-priority remainder). | prettier PASS; eslint PASS; typecheck PASS; chromium stash-baseline no-delta (16 fail pre AND post — container artifact); webkit validation lands via tonight's 18:00 UTC scheduled matrix | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: matrix-remainder fixes) | ec070ccb20b9a067216e64e8399d9cd795027024 | Run-4012 remainder: 7 root-caused fixes (SW navigation hijack, WebKit focus/forced-colors/stale-style, Firefox tab order, CIWA alias, helper extraction) | Agent-diagnosed with probe evidence: (1) production-build PWA worker registers in EVERY matrix test (pwa-lifecycle.tsx:287 NODE_ENV gate), clients.claim()s the page and serves all navigations — Chromium probe proved SW-served reloads bypass page.route entirely (routeSawNav=[]); Playwright-Firefox wedges on its only two reloads (ui-smoke 2206/2932, both deterministic) → playwright.config.ts serviceWorkers:'block' + ui-pwa 'allow' opt-in (offline/CacheStorage journey verified passing locally under the opt-in). (2) Mode-menu dismissal relied solely on wrapper focusout; WebKit Tab navigation can move focus nowhere (links excluded) or wrap into the menu → keydown Tab-close in handleModeTriggerKeyDown (agent verified no test depends on old forward-Tab behavior). (3) WebKit has no forced-colors implementation → capability skip on the token-remap test. (4) WebKit stale :disabled computed style feeds axe a phantom 1.93 contrast for the re-enabled Previous button (blend arithmetic exact: 0.4×#475467+0.6×#fff=#b5bbc2) → toBeEnabled+opacity-1 pin before the scan, failure-at-pin = direct proof. (5) Firefox includes scrollable containers in tab order (sheet body scrollHeight 1125 vs 707 measured) → conditional step-over. (6) canary #50/#51 CIWA failure root cause: whitespace-delimited textContainsClinicalTerm can never match hyphenated 'CIWA-Ar' though the dosing-table region ranks top-5 → clinicalContentAliases ciwa:[ciwa, ciwa-ar] (plan-authorized fixture-alias route; content substance unchanged). (7) tests/helpers/zero-touch.ts shared helper replaces six #995 inline stubs (CodeRabbit follow-up). NOT claimed: ui-stress 409 overflow, ui-tools 2087 navigation race, webkit answer-flow subset — next matrix run (post-merge dispatch) measures these vs the 28-failure baseline; ui-smoke 2932 webkit-side is a distinct mock-data class deliberately deferred pending that run. | Targeted vitest 25/25 (eval-retrieval + search-command-surface); npm run test 3012 passed / 1 known container pdf-budget artifact; verify:cheap green to same artifact; build + client-bundle scan PASS; prettier/eslint/typecheck clean; chromium ui-pwa under new SW config: offline/CacheStorage privacy journey PASS, installability = known container in-incognito artifact; UI verification not run locally beyond that: chromium ui suites carry documented container artifacts — hosted ui-critical/ui-advisory + next matrix authoritative | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: eval measurement floor, ADDENDUM 4 A-PR-1) | 81ab9696da4b330ca0b2e5519891a9942f90421b | Measurement floor for evidence-gated ranking tuning: canary artifact emission, alias-aware snapshot builder, snapshot provenance/freshness — no ranking behavior change | Closes the three gaps blocking safe tuning (Phase B): (1) eval-canary's golden step now writes the per-case JSON artifact (--json-out decoupled from --json so the tee'd log keeps the human-readable lines the failure-issue analyzer parses) and uploads .local/eval-canary/ via pinned upload-artifact (30-day retention, include-hidden-files for the dot-dir, contents = same class as the already-public step logs: titles/telemetry/220-char previews of the all-public corpus) — snapshot regeneration stops costing a paid dispatch; (2) clinicalDocumentAliases/clinicalContentAliases moved verbatim to shared scripts/lib/clinical-aliases.ts and the snapshot builder grades documentMatch/contentMatch through them (discriminating tests: EMHS agitation title and spelled-out "absolute neutrophil count" grade as hits only via aliases — raw labelMatches pinned false), ending tuner ground truth disagreeing with the live gates; (3) snapshots carry generatedAt + optional sourceRunId, validator accepts them, exactly-36 relaxed to at-least-36 (floor still rejects truncated artifacts; sourceCaseCount + per-case candidate minimums unchanged), and a 30-day freshness test (activates on first regeneration) blocks silent corpus drift. Builder smoke-verified end-to-end on a synthetic 36-case artifact (alias grading + provenance stamped + validator green). Static hyphen audit of all 36 cases' terms: no currently-blocked term (canary #52 = 36/36); residual risk classes documented for the A-PR-2 artifact-grounded pass — punctuation-joined tokens (IM/PO, schizo-affective, post-natal) and inert stem entries (obsess/compuls/hyperactiv/impuls can never match whole-token) that currently ride on whole-word OR-alternates. | Targeted vitest 52/52 (ranking-tuning + eval-retrieval + eval-quality); npm run test 3019 passed / 1 known container-only pdf-budget artifact; lint + typecheck clean; check:github-actions + check:ci-scope PASS; prettier clean; check:production-readiness expected missing-secret FAILs only (demo-mode container); no provider calls — live validation = tonight's scheduled canary emits the first artifact at $0 | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR #1001: A-PR-2 measurement-floor completion) | 3d8f798 + 5b664f8 + e58c827 | First provenance-stamped snapshot regeneration from a live canary artifact + alias tiering docs + fixture-length pin + lithium doc-gate — Phase A of ADDENDUM 4 functionally complete | Artifact chain: user-authorized paid dispatch (canary #53, run 29763761133, 36/36 green, doc_recall 1.0 / content_recall 1.0 — first perfect content recall, ciwa alias confirmed live; mrr@10 0.8644, irrelevant@10 0.1083) emitted the first eval-canary-output artifact (51787 bytes, sha256 5af5b802… verified byte-identical after user transfer into the sandbox — this container cannot download run artifacts). Work: (1) two-tier alias documentation — investigation of the governance-review P3 showed src/lib/eval-document-matching.ts is a deliberately WIDER captured-case tier (e.g. "Clozapine GP Shared Care"); bulk-merge would loosen golden ground truth, so both files now carry cross-referencing do-not-merge headers instead; (2) snapshot case count pinned to live golden fixture length (regeneration instructions in failure message) — closes the coarse-floor P3; (3) snapshot regenerated via the alias-aware builder with --source-run-id provenance: agitation-im-po-options 0→5 graded positives (EMHS alias working on real data), flowchart-next-step confirmed sole zero-positive case; generatedAt promoted to validator-REQUIRED (closes the hand-edit P3); two stale data pins updated (missing-positives 2→1; broad_summary defaults-equality pin dropped — defaults' provenance was the retired snapshot, fresh recommendations are Phase B input); (4) artifact-grounded punctuation audit: 7 joined-token occurrences in top-5 previews — 3 ciwa-ar (alias-covered, incl. line-broken "ciwa- ar"), 4 ORDINARY-PROSE punctuation ("treatment," / "mood," / "(opioid" / "ptsd.[35]") — matcher word-boundary change proposed as its OWN reviewed follow-up per plan (systemic class, not bundled); (5) lithium-therapy-monitoring was the ONLY ungated case (rr@10 hardcoded 0.00 = measurement noise): expectedDocumentSubstrings ["Lithium"] added from live evidence (deliberately broad across the corpus's multiple legitimate lithium guidelines), snapshot rebuilt in lockstep from the same artifact, measured mrr@10 +~0.028 from de-noising. Deferred with reasons: NEW-query fixture cases (saturated-tie shapes, captured rag_query_misses) need live validation before they may gate — unlocked by Phase D-1 branch-eval dispatch or a dedicated validation dispatch; real ordering headroom for Phase B = flowchart 0.20, alcohol-ciwa 0.25, patient-safety 0.33, opioid 0.33, all text_fast_path. | Targeted vitest 76/76 ×3 (after each stage); npm run test 3019 passed / 1 known container-only pdf-budget artifact; prettier clean; freshness gate ACTIVE and green; no provider calls beyond the user-authorized dispatch (~$1-2, ADDENDUM 4 spend now ~$1-2 of ≤$10) | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: B-PR-1 canary dispatch inputs) | see PR head | eval-canary.yml workflow_dispatch gains rag_ranking_config (staged Phase B weight evals, loud malformed-JSON validation to prevent silent fallback corrupting pair comparisons) + ref (Phase D-1 pre-merge branch evals) — schedule runs unaffected (empty inputs = today's behavior) | Tuner (offline, $0) on the A-PR-2 regenerated snapshot recommends 3 constrained per-class improvements (document_lookup titleSectionRelevance→0.9; table_threshold clinicalEvidence→0.95; comparison hybridRelevance→0.95 with proxy mrr 0.833→1.0), all with recall non-regression + zero high-risk hard-negative failures; medication_dose_risk + broad_summary stay neutral. Staged config JSON banked for the live pair; baseline = canary #53 re-gated (mrr@10 ≈0.8922 after lithium de-noising). Targets per approved plan: mrr@10 ≥0.90, irrelevant@10 ≤0.08, zero case regressions. Budget: user raised cap to ≤$20 (spent ≈$1-2). Security note: ref input runs branch code with eval secrets — dispatch requires repo write access (sole trusted collaborator), documented in PR risk. | check:github-actions PASS; check:ci-scope PASS; prettier clean; no provider calls (workflow change only) | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (Phase B live pair — no code change; recorded for eval-history completeness) | canary runs 29763761133 (#53 baseline) + 29769050798 (#54 tuned) | ADDENDUM 4 Phase B verdict: staged 3-class tuner weights produced ZERO live movement — NOT adopted per the measured-gain rule | Offline tuner recommendations (document_lookup titleSection→0.9, table_threshold clinicalEvidence→0.95, comparison hybridRelevance→0.95; proxy comparison-mrr 0.833→1.0) staged via the B-PR-1 rag_ranking_config input; #54 log proves the override active (validation step echo). Result vs re-gated baseline: mrr@10 0.8921 vs 0.8922, irrelevant@10 0.1083 vs 0.1083, recalls 1.0/1.0 both, 36/36 both, all four headroom cases byte-identical rr (flowchart 0.20, ciwa-threshold 0.25, patient-safety 0.33, opioid 0.33). Learning: the 5-candidate linear proxy saturates; real headroom lives in fast-path saturated-tie structure (Phase C trigger condition mrr < 0.90 formally met; user authorized C). Nothing to roll back (per-run override). Spend ≈$2-4 of user-raised ≤$20 cap. | Both canary runs green (36/36 + answer-quality); override-active proof in #54 job log; no adoption = no code diff | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: Phase C saturation-tail primaries) | 7572c7f | ADDENDUM 4 Phase C (user-authorized): per-candidate discriminative primaries for saturated fast-path ties — design-agent planned (consumer map + dead-band envelope proof), red-proven, tie-conservation guarded | Mechanism: min(text_rank,1) collapses all tr≥1 candidates to byte-identical imputed primaries; ordering fell to chunk id at release. Fix: saturationTailUnit (pure, monotone, SET-INDEPENDENT — rejected per-query min-max + rank-tier designs for set-dependence/#118 authority risk; rejected full-range log rescale for moving sub-knee values across the 0.62-0.82 gate ladder) scales the excess into DEAD cap bands only: S2 table-fact similarity (0.92, 0.94) with hybrid byte-identical (gates/triggers/selection provably unchanged; similarity = the release tie-break key), S1 lexical-chunk hybrid (0.48, 0.5) behind the truthful-contract signature (sub-0.5 bars hold). Sub-knee byte-identical (fixtures now DERIVE from the helper; 0.45→0.755/0.795 pinned). Discriminating test verified RED on old formulas (2 fail: discriminating + envelope) → green with tail; equal-tr tie-conservation pins the #987 coverage comparator; second-stage-engaged pools documented out of scope (position-derived releaseRankScore sorts first there) — matches live evidence that non-engaged pools (patient-safety, opioid, flowchart) are where id-order decided. S3/S4 = C-PR-2 candidates, evidence-gated on the post-merge canary vs #54 baseline (doc/content recall MUST stay 1.0, zero per-case regressions; success signal = rr lift on the headroom cases). Rollback: single revert (helpers + 2 expression sites + 1 map call; no schema/config/cache surface). | Targeted vitest 121/121 (fast-path 11/11 incl. 6 new, retrieval-selection, rag-routing, rag-answer-fallback, ranking-tuning, second-stage); npm run test 3025 passed / 1 known container pdf-budget artifact; lint + typecheck + prettier clean; red-proof executed and recorded; live validation = post-merge canary dispatch (~$1-2) | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (PR: revert Phase C pending review) | revert of f96217c | User-requested revert of #1004 (Phase C saturation-tail primaries): auto-merge fired before the adversarial rag-retrieval-reviewer pass landed; retrieval code returns to the last reviewed state until that verdict is in | Clean single-commit revert (the documented rollback path — helpers + 2 expression sites + 1 map call; no schema/config/cache surface). Ledger history rows from f96217c retained (docs are append-only record, not behavior). The 19:44Z post-merge canary dispatched on f96217c completes regardless and stands as Phase C's live validation datapoint; re-land decision = reviewer verdict + that pair result together. Offline state of the reverted change remains fully proven (red-proof + 121/121 + envelope tests). | Revert verified by vitest fast-path suite returning to pre-C 5/5 shape expected in CI; ladder on the revert = hosted pr-required | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (Phase C live verdict — no code change; main stays reverted at 0a498e6) | canary run 29773198933 (#55, on f96217c) | ADDENDUM 4 Phase C LIVE-REFUTED: canary on the merged tail code FAILED 3/36 (doc_recall 1.0→0.9167, mrr@10 0.8921→0.8138) — the user-ordered revert (#1005) was correct and STANDS | Failures: patient-safety-plan-include (PtSafetyPlan out of top-5, rr 0.33→0.14), patient-property-visual-table (rr 1.00→0.11) and schizophrenia-overview (rr 1.00→0.14) — two previously rank-1 cases destroyed. Root cause (post-hoc): the S1 lexical-chunk lift spreads hybrid_score, which is the PRIMARY release sort key — inside the dead (0.48,0.5) band it still PREEMPTS every downstream key, so raw ts_rank order overrode the boost/title/subject-aware relevance order that previously decided all-tied-at-0.48 pools. Lexically-loud chunks leapfrogged title-boosted correct documents = the #118 mechanism, reproduced live. The offline S1 test used identical-content candidates (coverage tie) and could not see it. The S2 similarity tail (tie-break-position key) remains reviewer-verified safe in isolation — retrieval reviewer verdict on the full diff: APPROVE-WITH-NITS, P3 only (0.49-lowering proven unreachable via the SQL 0.48 cap; no gate crossings in (0.92,0.94); hardening nits recorded). DISPOSITION: no re-land as-is. Any retry = S2-only + S1 redesigned to a key BELOW relevance in the comparator chain, new design + fresh pair. The staged-rollback discipline (canary pair + instant revert) worked exactly as designed. Spend ≈$4-8 of ≤$20. | Canary #55 read from job log (3 FAIL lines + summary); reviewer verdict from subagent report; main verified reverted (0 saturationTailUnit refs at 0a498e6) | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (PR: RAG memory + safeguards) | see PR head | User-directed close-out: durable RAG behaviour memory (docs/rag-behaviour/, 4 files) + AGENTS.md standing protection rules + ENFORCED safeguards — pr-policy blocking `RAG impact:` gate on protected surfaces (self-tested: undeclared/vague blocked, no-change/canary declarations pass) + source-pin contract test on imputation formulas and release comparator key order (red-proven vs a mutated constant). Confirmation canary #56 (29774459706, reverted main 0a498e6): SUCCESS — 36/36 restored, closing the #55-regression→revert→restore arc live. Phase D complete (D-1 ref input #1003, D-2 eval:trend #1006, D-3 policy §3.1 #1006, D-4 latency in trend rows); remaining documented plans: word-boundary matcher (own PR), irrelevant@10 labeling audit, Phase E (separate approval). Spend ≈$5-10 of ≤$20. | pr-policy self-test + workflow guard PASS; contract test 4/4 + red-proof; npm run test 3025 passed / 1 known container pdf-budget artifact; lint+typecheck+prettier clean; docs:check-links 1030 refs PASS; check:github-actions PASS | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (PR: matcher + artifact follow-ups) | ab145f6 | Remaining documented improvements implemented: word-boundary textContainsClinicalTerm + top-10 canary artifact rows | Matcher: boundaries + internal separators widened to any non-alphanumeric run — PROVEN strict superset by artifact replay on canary #53 (1,126 term×alias×result comparisons, 0 lost matches, 7 gained = exactly the previously-documented punctuation-joined occurrences: treatment,/mood,/(opioid/ptsd.[35]/ciwa-ar ×3). More-tolerant measurement cannot fail a passing case → weekly scheduled canary = free live confirmation. Exported + 3 direct unit-test groups (superset preservation, audit classes incl. line-broken 'ciwa- ar' and 'full-blood-count', substring-inside-word rejections). Artifact: topResultSummary 5→10 rows so rr@10/irrelevant@10 metrics' actual inputs are captured — unblocks the offline irrelevant@10 labeling audit next artifact. docs/rag-behaviour updated to implemented state. Phase E remains gated on separate approval. | Targeted vitest 59/59; npm run test 3028 passed / 1 known container pdf-budget artifact; lint+typecheck+prettier clean; audit script run recorded above; no provider calls | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (PR: DB easy wins) | see PR head | User-requested database + process easy-wins pass: read-only Supabase advisor sweep (user-authorized) → canary-liveness probe + advisor-disposition docs; live mutations withheld for per-item confirmation | Advisors (live, read-only): security = 1 INFO (document_title_words RLS-no-policy = the deliberate fail-closed pattern — now comment-documented at the schema block so it is never 'fixed'); performance = ~33 unused-index INFOs + auth connection-strategy note → docs/db-maintenance.md TRIAGE list with retrieval-surface trgm indexes flagged RAG-protected (dropping = full canary protocol), owner-scoped indexes retained for multi-tenant design, operational candidates deferred (negligible benefit at corpus size). Implemented: ci.yml static-pr warn-only eval-canary staleness probe (actions:read, github-script pinned, >8 days → warning; never fails) — needed because #923 (2026-07-19) moved the canary cadence from daily to weekly Sunday 18:00 UTC, where a dropped fire would go unnoticed for a week (the failure-issue step only reacts to runs that happen). CORRECTION (CodeRabbit review on this PR): the initially recorded "2026-07-20 dropped Sunday fire" incident did not occur — 2026-07-20 is a Monday; the Sunday 2026-07-19 slot fired as scheduled run #48 (19:03 UTC, success), and under the weekly cron no 2026-07-20 slot existed. Probe stands as proactive hardening, staleness now measured from updated_at with a finite-timestamp guard. Presented for confirmation (NOT implemented): scheduled telemetry retention (purge:query-logs is owner-scoped + unscheduled; needs owner/window/policy decision), auth percentage connection strategy (dashboard config), any index drops. | check:github-actions PASS; check:ci-scope PASS; check:function-grants 28/28 PASS; docs:check-links 1034 PASS; supabase-schema vitest 66/66; lint+typecheck+prettier clean; Supabase access read-only only | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted from e6cd6cb; PR: telemetry-retention correction) | 01368ff (+0144e69 main merge-in) | User-directed "implement your recommendation for all decisions" close-out: the scheduled-retention recommendation is RETRACTED as founded on a false premise — telemetry retention is ALREADY ACTIVE inside the database via pg_cron; docs corrected, nothing built, no live mutations | Fresh read-only cron.job verification (2026-07-20) matches docs/privacy-impact-assessment.md §6 exactly: jobid 11 purge-expired-rag-queries daily 03:30 (30d), jobid 12 purge-rag-retrieval-logs daily 03:00 (90d), jobid 13 purge-rag-query-misses daily 03:45 (90d), jobid 16 purge-rag-response-cache hourly (bounded 1000); v3 worker jobs present-inactive under backlog auto-toggle (jobid 10); obsolete unbounded cache job absent; audit_logs indefinite by design. db-maintenance.md "open decision" section replaced with the resolved state; purge:query-logs clarified as the MANUAL owner-scoped tool (not the retention mechanism). A GitHub-side weekly deleter would have duplicated pg_cron with window drift (a 90d rag_queries sweep can never out-delete the live 30d job). Remaining decisions stand as documented no-action: auth percentage connection strategy deferred to next instance resize; operational index drops not recommended. | prettier + docs:check-links PASS; Supabase access read-only (single cron.job SELECT, user-authorized read-only envelope); no workflow/schema/config changes | +| 2026-07-21 | claude/x4-sast-gate (PR #1012) | 0d4985e63 | Maturity X4: blocking SAST gate on the untrusted-document parsing surface | Triage-first per workorder: CI-pinned semgrep/semgrep:1.168.0 over worker/**, src/lib/ingestion*.ts, src/lib/extractors, src/app/api/{ingestion,upload} = 0 ERROR findings (24 TS rules/17 files; 55 Python rules/3 files) — gate starts green with no suppressions. Shipped `semgrep-ingestion-gate` job (no continue-on-error; container digest-pinned to the triage-verified 1.168.0 image) with p/python added for the worker OCR stack; repo-wide advisory job untouched. check-github-action-pins.mjs now enforces both policy halves fail-closed (advisory repo-wide / blocking-and-scoped gate / digest-pinned gate container). Residuals: registry-pack mutability accepted for the narrow surface; making the workflow a branch-protection required check is an operator decision outside this PR. | Exact gate command exit 0 in pinned container; check:github-actions (new assertions verified fail-closed); check:ci-scope; yaml-contract vitest 1/1; verify:cheap 3031 tests green. No provider calls (local Docker only). | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted from f33890a; PR: Phase E instrument input) | see PR head + follow-up SHA pin | Phase E kickoff (user-approved with budget): eval-canary gains opt-in `answer_quality_eval` dispatch input running the 30-case eval:answer-quality fixture (5 quality metrics + per-intent targeting) — the instrument was previously never run in CI. Dispatch-only, default-off, informational (exit 0, no --targeting-floor): scheduled runs and existing dispatch shapes byte-identical; gates stay owned by eval:quality. Follows the #1003 input-only precedent. | E-1 recon (all $0/read-only) recorded here: canary answer gate samples only 8 of 44 eval:quality cases (ANSWER_CASE_LIMIT default); eval:quality --provider-mode offline is a real $0 harness (provider deleted, deterministic source-only path) usable as an E-3 regression guard; live 30-day answer telemetry (34 answer-path rows): 25 full answers, misses = evidence_gap ×4 (avg 26s spent before gap), provider_incomplete_max_output_tokens ×2 (avg 82s wasted then discarded), provider_generation_failed ×1, retrieval_gap_or_conflict ×1, source_only_no_api ×1; success latency avg 13.1s / p90 25.2s. Headroom classes for E-3: truncation waste (rag.ts:4265 self-heal insufficient live), late evidence-gap detection, p90 latency, thin CI answer coverage. Next: E-2 baseline dispatch (answer_case_limit=44 + answer_quality_eval=true, est $3-8 of user-authorized ≤$20 Phase E envelope) after this merges. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS; prettier clean; no provider calls this PR (workflow change only); RAG impact: none — retrieval steps untouched | +| 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted from 5b4098d; PR: E-2 baseline record + instrument fix) | see PR head | ADDENDUM Phase E-2 BASELINE BANKED (canary dispatch #57, run 29786560936, main 5b4098d, answer_case_limit=44): golden retrieval 36/36 green in-run (no-regression net held); eval:quality full-44 RED on exactly two gates — citation_failure_rate 0.0227 (1/44: neuroleptic-side-effect-escalation — expected doc never retrieved, generation quality-failed, extractive fallback with 1 citation) and route_ceiling_failure_count 2 (clozapine-anc-withhold-threshold: 13.3s pure retrieval vs 12s extractive budget, RPC 9.3s, zero generation; agitation-arousal-typo-dosing: 25054ms vs 25000ms after provider_timeout ate 22.6s pre-recovery). Green gates: grounded_supported 1.0, unsupported_correct 1.0 (all 14 refusals incl. both prompt-injection probes at 2ms), numeric grounding failures 0, governance danger 0, p95 17.4s. Non-blocking signals: expected_source_hit 0.6136 (both admission-discharge cases miss MHSP.AdmissionCommunityPts.pdf to sibling NMHS/RKPG policies — labeling-vs-ranking question, §3.1 class), source governance warning rate 0.8182 (metadata debt, waivable class). SYSTEMIC E-3 TARGET: ~9 of 19 generation attempts discarded (fast output fails quality gate → extractive fallback wins; ~7 cases carry fast_quality_retry_strong→extractive reasons) = ~half of generation latency+spend wasted. Instrument defect found+fixed this PR: targeting step was skipped after the red gate (GitHub failure-skip semantics) → if: gains !cancelled() so baselines observe red gates. Spend: est ~$1-2 actual (19 OpenAI-request cases; cost rates unset in CI so report shows n/a) of ≤$20 Phase E envelope. Next: cheap re-dispatch (default limit 8 + answer_quality_eval=true) to bank the skipped 30-case targeting baseline. | Gates this PR: check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS; prettier clean. Baseline evidence: job log run 29786560936 (5 failing-case diagnostics + Answer Metrics table read in full) | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (restarted from 87815f4; PR: E-3a cost self-reporting) | see PR head + follow-up pin | Phase E-3a wave (all $0 diagnostics complete, recorded here) + I8 fix: eval-canary env gains the three RAG_EVAL_*_USD_PER_MILLION rates (gpt-5.6-terra standard tier, verified from the live OpenAI pricing page 2026-07-21; documented as a LOWER BOUND since gpt-5.6-sol strong retries cost 2x and the estimator applies one rate set) so estimated_cost_usd stops reading n/a in CI. E-3a findings: (1) discarded-generation histogram from run #57 — fast_quality_retry_strong→extractive x6, generation_quality_failed x4, provider_timeout x2; gate sub-reasons fragment_like x2, bad_final_answer_quality x1, ungrounded_extractive_fallback x1 → E-3c targets the fast-attempt-fails-extractive-wins shape first. (2) I2 resolved: routeCeilingExceeded (eval-quality.ts:157/171) keys on the RUNTIME's own route_budget_ms + deadline flag, NOT the cross-region-widened eval gates — clozapine case = 13.3s retrieval vs the runtime's 12s extractive budget, geography-amplified; design → E-3b. (3) FIXTURE-CORPUS DRIFT (major): the live corpus contains ZERO MHSP*-named files; the 44-case fixture's expectedFiles are all MHSP.*/CG.MHSP.* and pass only via the eval-document-matching alias tier. All three expected-doc failures triaged: neuroleptic-side-effect-escalation = REAL retrieval-coverage gap (alias exists; Neuroleptic Side Effects (AKG).pdf indexed 11 chunks, 3 contain escalat%, yet answer retrieval returned a single off-point source) → protected-path item awaiting user go-ahead; admission-discharge x2 = top-5 preference for sibling NMHS/RKPG policies over the existing aliased AKG doc → labeling-vs-ranking decision presented to user. No fixture/alias edits made (ground truth requires sign-off). | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS; prettier clean; Supabase access read-only (documents/document_chunks SELECTs); OpenAI pricing page read via WebFetch | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (restarted from c308fdc; PR: labeling widen) | see PR head | USER-APPROVED ground-truth widening (AskUserQuestion 2026-07-21: "Widen to accept siblings") for the two admission-discharge cases: the WIDER alias tier's AdmissionCommunityPts entry gains the two NMHS "Admission to Discharge" titles (Community Mental Health + Mental Health Inpatients). Deliberate exclusions documented in-code: discharge-only docs must not satisfy the admission slot; the MHHITH programme policy is too narrow. STRICT golden tier untouched (bulk-merge prohibition respected). Verified by replaying run #57's actual top-5 lists through expectedFileCoverage: admission-discharge-coverage-paraphrase now passes; admission-discharge-comparison STILL FAILS honestly (its top-5 carries no admission-side doc at all — dup discharge-planning + Falls Prevention) and is retained as a genuine comparison-class retrieval-coverage signal, folded into the same investigation as the neuroleptic case. | typecheck PASS; prettier clean; probe script replay recorded (paraphrase allHit true / comparison allHit false, missing admission slot); no retrieval code touched; protected-file edit (eval-document-matching.ts) per explicit user authorization | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: E-3b budget-aware generation deadlines) | 314d03f | Clinical-governance review of E-3b diff `git diff origin/main...HEAD` (commits 1078264 + 314d03f): answer-side generation timing/gating + telemetry — reserve-aware generation timeout (`generationRequestTimeoutMs`), truncation self-heal budget gate (`deadlineAllowsGenerationRetry`), `route_budget_exhausted_by_retrieval` telemetry, and the cross-region eval carve-out in scripts/eval-quality.ts. | APPROVE-WITH-NITS. No P0/P1/P2. (a) Conservative failure preserved: reserve-aware timeout fires ~2s early producing the SAME error type — an internal SDK timeout is mapped by mapOpenAIError to PublicApiError(openai_timeout) (openai.ts:525-529), NOT a bare DOMException, so the rag.ts:4636 re-throw guard is not tripped and the existing source-backed fallback/extractive recovery is reached; truncation-skip falls through to the terminal throw (rag.ts:4309-4313) into the same catch. No new answer-producing path. (b) Safety gates intact: all recovery answers finalize through finalizeAnswer→finalizeRagAnswerQuality and isSafeExtractiveFallbackCandidate (grounded/confidence/quality/numeric) — none touched. (c) Eval carve-out env-gated: crossRegionRunner && budgetExhaustedByRetrieval && generationMs===0; EVAL_LATENCY_CONTEXT set only in eval-canary.yml:164, prod caller (eval-quality.ts:1095) passes no options → inert in release/local; generationMs===0 requirement means a generation-side failure (generationMs>0) is never suppressed. (d)/(e) Telemetry additions non-PHI (boolean + mechanical retry-reason strings); no privacy/query-privacy/cross-border/verification source files touched; no new provider call. Nits (P3, non-blocking): carve-out also excuses fast/strong routes when generationMs===0 (sound — provably no generation ran); `requestTimeoutMs` now prod-dead (test-only); report label "retrieval-exhausted" (routeDeadlineExceeded && flag) is broader than actual gate suppression (audit label only, gate stays strict). | 88 offline unit tests PASS (tests/rag-route-budget.test.ts, tests/eval-quality.test.ts, tests/rag-offline-answer.test.ts, tests/rag-answer-fallback.test.ts); no provider/Supabase/OpenAI calls; no files mutated except this ledger row | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: E-3b budget-aware generation deadlines) | 314d03f (+eb08ec5 review row) | ADDENDUM 5 wave E-3b implemented per the design-agent plan: generation attempts clamped to route budget minus a measured 2s recovery reserve (single call-site, all four attempt kinds); truncation self-heal gated on retry viability (reserve+5s floor) with observable truncation_retry_skipped_budget_reserve marker; additive route_budget_exhausted_by_retrieval runtime flag; eval route-ceiling gains the triple-condition cross-region carve-out (context + runtime flag + zero generation) with retrieval-exhausted audit cells — local/release gates provably strict. Fixes I3 (54ms budget overrun after 22.6s provider timeout), I5 (82s truncation waste class), resolves I2 (clozapine 13.3s retrieval vs 12s runtime budget = geography, now suppressed ONLY in the sanctioned cross-region context with full auditability). Reviewer verdicts: rag-retrieval-reviewer APPROVE-WITH-NITS (2 P3: prod-dead requestTimeoutMs retained for symmetry; report-cell coupling cosmetic; cached-replay invariant PROVEN — budget-exhausted answers never cached, carve-out unreachable via replay; marker isolation proven — SLO counters key on fallback_reason not answer_retry_reasons); clinical-governance-reviewer APPROVE-WITH-NITS (prior row) — internal-timeout→PublicApiError→existing-fallback path verified, all safety gates still applied to recovery answers. ALSO BANKED — E-2 targeting baseline (canary run #58, 29788404357, all-green incl. first execution of the !cancelled()-fixed instrument, ~$1-2): metric_rates relevance 0.6 / readability 1.0 / artifact_leaks 1.0 / intent_coverage 0.9333 / fail_closed 0.9; targeting_rate 0.5909 (13/22); by intent: document_lookup 5/5, red_result_action 3/3, contraindication 2/2, dose 1/5, monitoring_schedule 1/5, pathway_referral 1/2; all 9 misses = missing dose figure/schedule-interval (answer lengths 73-232 chars) → E-3c co-primary target alongside the wasted-generation class. Phase E spend ≈$3-6 of ≤$20. | Red-proofs: reserve pinned 3 independent ways (exact 23000ms grant, deadline flag clear, total under budget); self-heal skip pins exact marker + single provider call; offline flag pinned true/false. Focused: route-budget 9/9, eval-quality 27/27, fallback+offline 52/52, parser/abort regressions 15/15. Full suite 3043 passed / 1 known container pdf artifact. typecheck+lint+prettier clean. No provider calls; live proof = E-4 paired run | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (restarted from e02ba3d; PR: E-3c PR-A instrument) | see PR head | ADDENDUM 5 wave E-3c design accepted (Plan agent, full report in session record) and PR-A delivered: eval-answer-quality gains --dump-answers (per-case answer TEXT + sections + targeting verdict JSON for the canary artifact — answers are not retained at rest by privacy design, so miss diagnosis needs eval-time capture), parseArgs/buildAnswerDumpRecord exported behind an import.meta main-guard (eval-quality precedent), canary targeting step wired with the flag. Design highlights for the record: DEFECT 1 root confirmed as fast-attempt-doomed-then-discarded on strong_routine_retrieval procedural shapes — PR-B generalizes the EXISTING validated-extractive short-circuit pattern (LAI + blocked-recovery precedents, hasValidatedExtractiveCandidate) to the measured shape via a new rag-extractive-first.ts module (net ~-110 rag.ts lines against the 5030 budget); DEFECT 2 rank-1 root = extractive lead-slot selection prefers shortest sentence and admits figure-less leads (sort at rag-extractive-answer.ts:917, 1-slot monitoring leads) — PR-C adds intent-figure-aware lead promotion with a claim-support atom-corpus nuke-proofing guard + dose/threshold fallback candidate preference (find(safe && figure) ?? find(safe)); answer-verification CLEARED as direct cause (whole-answer gap or unbold only, never per-figure deletion). H2 (strong-route comparison/complex residual) explicitly deferred as the named E-3d candidate. E-4 metrics set: discarded-generation <20% from ~47%, dose ≥3/5, monitoring ≥3/5, no intent below #58, recalls pinned 1.0, relevance/fail_closed/readability/artifact_leaks ≥ #58. | PR-A gates: new tests/eval-answer-quality.test.ts 4/4; typecheck, check:github-actions, check:ci-scope, eval-canary-workflow test, prettier all clean; no provider calls | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: E-3c PR-B short-circuit) | f7e6cbb + hardening commit | E-3c PR-B: pre-generation validated-extractive short-circuit for gate-passed routine procedural "What...process/include/required" queries (marker validated_routine_extractive_first), generalizing the LAI + blocked-recovery precedents via new rag-extractive-first.ts (3 predicates moved byte-verbatim, machine-verified; rag.ts 5029→4908 vs 5030 budget). Kills the run-#57 6x wasted-generation class. REVIEWS (both pre-push): rag-retrieval-reviewer APPROVE-WITH-NITS — move fidelity brace-diff verified byte-identical; confidence-gate skip PROVEN safe (passed-gate is a no-op in applyConfidenceGate; markers pairwise mutually exclusive); comparison false-positives blocked by unchanged classifier precedence; offline/source-only idempotent; zero retrieval/ranking/selection/threshold change; P2 = eval-only assertions (intent_coverage/artifact_leaks/expected-file) unverifiable offline for flip candidates (quality-nocc-document-support, quality-form-required-documentation, quality-discharge-documentation, quality-duress-pathway + rag-set siblings) → pre-merge BRANCH canary recommended and ADOPTED (offline-green + review-approved proven insufficient for this surface, 2026-07-20). clinical-governance-reviewer APPROVE-WITH-NITS — full gate-stack trace: nothing bypassed (same finalizeRagAnswerQuality, same citation scoping, numeric verification not fail-open, ungrounded-finalize defense at rag.ts:3694); P2 = pre-existing bare-cross-reference-with-overlap gap, NOT materially widened (new trigger anti-correlates), hardening recommended → APPLIED this PR: !isBareCrossReferenceAnswer screen in hasValidatedExtractiveCandidate (closes all three short-circuit paths; discriminating test added; disclosed post-review delta, strictly narrows shipping). MERGE GATE: draft until the branch canary pair (baseline #57/#58 vs branch run with answer_case_limit=44 + answer_quality_eval=true, est $3-6 of authorized envelope) is green — zero per-case regressions, recalls 1.0, quality/targeting rates >= baseline. | Red-proof + 3 negative guards; focused 61/61 + fallback/offline/contract suites; full suite 3061 passed / 1 known container artifact (pre-hardening tree; hardening re-verified focused); typecheck+lint+prettier clean; maintainability budget passed (4908/5030) | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (pair verdict — no code change; main stays at 22b6a2e) | canary run 29794759627 (#59, branch head 7310cb3 = merged PR-B content) | E-3b + E-3c PR-B LIVE-VALIDATED (pair vs banked #57/#58): route_ceiling_failures 2→0 (E-3b proven: agitation timeout now fits inside budget; clozapine retrieval-exhausted ceiling honestly excused via the triple-condition cross-region carve-out — exactly one "retrieval-exhausted" audit cell in the report); p95 17.4s→15.47s (-11%); golden retrieval SUCCESS 36/36 (stop-ship criterion held); validated_routine_extractive_first fired on 4 of the 6 target cases (patient-safety-plan 3.3s, treatment-team-process 2.6s, ect-procedure 2.2s, illegal-substances 2.2s — all pure extractive, zero generation, was 6-9s each with a discarded attempt), the other 2 (community-home-visits, best-practice-prescribing) stayed on generation+fallback because their extractive candidates legitimately fail validation gates = the designed-conservative outcome; ZERO new failing cases (list 5→2, both known residuals: neuroleptic citation red = Option A territory, admission-comparison expected-doc = non-blocking labeling residual); grounded 1.0 + unsupported_correct 1.0 held; targeting 0.5909→0.619, fail_closed 0.9→0.9333, readability/artifact_leaks/intent_coverage unchanged; relevance 0.6→0.5667 = single-case wobble on n=30, WATCH in E-4, not a gate. Discarded-generation rate materially down (4 conversions; residual = the H2 strong-route slice named as E-3d candidate, per design's 20-33% expectation band). MERGE STANDS (user had armed auto-merge pre-verdict; revert drill not triggered). Spend +~$3-6 → Phase E total ~$6-12 of ≤$20. | Pair evidence: run #59 job log (Blocking failures = citation only; Answer Case Diagnostics markers; metric_rates + targeting blocks); dump artifact populated for PR-C diagnosis | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: E-3c PR-C figure-aware selection) | 043b030 + P2-fix commit | E-3c PR-C: dose/monitoring extractive answers now carry the asked-for figure/schedule when the cited chunk verbatim supports it — lead-slot promotion (dose swaps last of 2 slots, monitoring appends 2nd sentence; no-op when a lead already carries a figure) guarded by the claim-support atom corpus (sourceEvidenceText exported, promotionAtomKey byte-identical to claim-support's atomKey), plus the dose/threshold generation-fallback preferring the safe figure-carrying candidate (safety gate unchanged, filter-order-stable). Fallback helpers extracted to rag-extractive-answer (cycle-check verified); rag.ts 4908→4901. Six discriminating tests each verified red-on-prior-code incl. proving the nuke-guard load-bearing by disabling it. REVIEWS (both pre-push on 043b030): rag-retrieval-reviewer APPROVE-WITH-NITS — no-op path byte-identical verified, atom-key identity verified, filter-vs-find proven side-effect-free, 2-sentence append gate-safe, intent double-gated, zero retrieval/ordering change, imputation contract green; P2 = zero-atom monitoring figures ("every 6 weeks" yields no value atom) pass the guard trivially and can be nuked by claim support if sourced only from adjacent context (fails SAFE — evidence gap, never a wrong figure). clinical-governance-reviewer APPROVE-WITH-NITS — all six clinical concerns CLEARED end-to-end (verbatim-support guarantee, citation binding preserved, conservative failure test-proven, unsafe candidates impossible, wrong-drug risk controlled by pre-existing entity/multi-drug guards, no PHI); same zero-atom finding as P3 + one comment-precision nit. P2 FIXED post-review (disclosed): zero-atom figures now require the matched figure substring verbatim in sourceEvidenceText (intentFigureMatchText); proven both directions by 3 new tests (promotes from content, refuses from adjacent-context-only); comment-precision nit folded in. | Focused post-fix: extractive-formatting 32/32 + fallback/eval-cases/offline/contract/extractive-first 98/98 incl. imputation contract; typecheck+prettier+budgets clean; full-suite 3068-passed baseline pre-P2-fix (fix re-verified focused). Live proof = E-4 pair next | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (E-4 pair verdict — no code change; #1039 merged as 9b655fa) | canary run 29800029819 (#60, main 9b655fa = E-3b+PR-B+PR-C) | PHASE E-3 WAVE CLOSED — E-4 VERDICT: ADOPT (no revert). 44-case: the ONLY blocking red is the KNOWN persisting neuroleptic citation case (0.0227, identical #57 signature: generation quality-failed → extractive fallback 1 citation — the pre-declared Option A carve-out, retrieval-side, untouched by answer waves); route_ceiling_failures 0 CONFIRMED ON MAIN (E-3b: agitation 23.1s < 25s after 20.3s provider timeout; clozapine 14.5s with exactly one retrieval-exhausted audit cell); grounded 1.0 / unsupported_correct 1.0 / numeric 0 / governance-danger 0 all held; expected_source_hit 0.6136→0.6364 (#1020 widen); generation attempts 19→9 across 44 cases (10+ cases short-circuit via validated_routine_extractive_first at 2-6s, zero generation spend — the absolute wasted-generation seconds collapse; residual 6 discarded attempts are the named E-3d H2 strong/comparison slice). Targeting vs #58 baseline: rate 0.5909→0.6667, dose 1/5→2/4 (sertraline + quetiapine still miss), document_lookup 5/5→6/6, contraindication 2/2, red_result 2/2, pathway 1/2; readability/artifact_leaks 1.0. NOT met: monitoring_schedule flat 1/5 — per-miss lens shows answers of 73-232 chars with NO schedule token available to promote (olanzapine-lai 79ch, metabolic 73ch = single-fact extractive answers; the PR-C promotion is a no-op when no figure-bearing fact is extracted) → root is fact-extraction/retrieval depth on monitoring shapes, queued as the Option-A-wave companion diagnosis (dump artifact 8483731630, 30d retention). WATCH escalated: relevance 0.6 (#58) → 0.5667 (#59) → 0.5333 (#60) — two single-case steps coinciding with more terse extractive answers; fail_closed 0.9 = exactly the #58 main baseline (#59's 0.9333 was the outlier), safety texture flat. Adoption per plan criteria: targeting ≥ baseline ✓, grounded/refusal 1.0 ✓, golden 36/36 ✓ (in-run), ceilings 0 ✓, citation red = carved known case ✓. CodeRabbit post-review follow-up landed pre-merge (02b5c78): interval-regex full-match reorder (atom path proven to intercept the claimed exploit; reorder = drift hardening), clinicalValueAtomKey exported (mirror deleted), guard tests made honestly discriminating + genuine zero-atom "annually" coverage both directions. Instrument note: cost rates live on the targeting step env but eval:quality still reports cost n/a (estimator not consuming them in the 44-case path) — minor tooling residual. Spend +~$2-4 → Phase E total ~$8-16 of ≤$20. | Evidence: run #60 job log read in full (Threshold Status: citation-only; Answer Metrics + 44-row diagnostics; targeting metric_rates + 7-miss list); artifact eval-canary-output 8483731630 sha256 d5c7006e… (download blocked in-session — GitHub App scope; log tee carried the targeting output) | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A: title-supported escalation rescue) | 0abf3c9 (parent 1aebf02) | rag-retrieval-reviewer PROTECTED-surface review of the S3 document-lookup escalation rescue: shouldAttemptDocumentLookupFastPath exported + gains medication_dose_risk branch firing ONLY when analysis.intent==="escalation_risk" && documentTitleTerms.length>0; call site passes queryAnalysis; new tests/rag-document-lookup-escalation-rescue.test.ts. | APPROVE-WITH-NITS. No P0/P1/P2. Binding constraints held: released-search-order.ts + retrieval-selection.ts clamp + rag-candidate-sources.ts imputation all byte-identical to parent (git diff empty); imputation-contract test green; 0.66/0.055 floor (rag.ts:1765) unmodified and still gates rescued pools (S3 block sits after the 2513 fast-path return + re-runs decideTextFastPath at 2631). BLAST RADIUS empirically proven via 110-case offline probe (44 ragEvalCases + 30 answerQualityEvalCases + 36 golden): EXACTLY 1 fires (neuroleptic-side-effect-escalation, intent=escalation_risk tt=3); all 8 named dose cases non-firing (clozapine-monitoring general, paraphrase general, agitation-pharm general, im-po drug_dosing, typo-dosing drug_dosing, missed-dose-table drug_dosing, LAI general/tt0, prompt-injection-forge intent=protocol). intentFromSignals precedence (clinical-search.ts:575-585) returns drug_dosing before escalation_risk so pure-dose structurally cannot fire — confirmed by construction AND empirically. ADVERSARIAL: prompt-injection-forge intent=protocol => cannot fire (empirical); unsupported short-circuit (rag.ts:2371) precedes S3 block. DOWNSTREAM: buildRetrievalIntent for the escalation query yields EMPTY requiredTermSignals => demote/promote arms (retrieval-selection.ts:354-355/529/555) + wrong-medication cap (rag.ts:730-737, gated on clinical_subject) all inert; end-to-end test proves neuroleptic-doc rank#1 with >=2 citations, sibling retained, arrival-order invariant. golden vector-\* probes = broad_summary (already allowlisted) => predicate byte-identical => unaffected. TEST HONESTY: S3 fixture (0.92/0.34/0.94/synthetic_text) faithful to searchDocumentLookupFastPath (rag-candidate-sources.ts:570 caps alias documentScore at 0.34 => sim=min(0.92,0.58+0.34+bonus)=0.92, hybrid=min(0.94,0.94)); differently-relevant fixtures (not identical-content); red-proof structurally airtight (e2e gates its own fixture pool on the predicate). EFFORT: one S3 RPC (same call allowlisted classes issue), rescue-only in the non-forceEmbedding path (floor already rejected => query was headed to embedding anyway; successful rescue short-circuits at 2646 pre-embedding = net-neutral/positive). NITS (P3, non-blocking, no code change pre-canary): (1) redundant analysis?. on the documentTitleTerms clause (&& short-circuit already guarantees analysis defined there); (2) a queryMode forcing medication_dose_risk over an originally-escalation-shaped table_threshold could newly fire S3, but table_threshold already ran S3 so behavior-consistent, not a regression; (3) "forceEmbedding discards S3 merges" is imprecise — line 2649 merges S3 even under forceEmbedding; the accurate unaffected-reason for vector-\* probes is broad_summary allowlisting. TRUST GATE = the mandated live canary pair (correctly deferred by the commit). | Offline only, no provider calls: new test 10/10 + imputation-contract 2/2; protected-surface batch 31/31 (escalation-rescue + imputation-contract + fast-path-ordering + released-search-order + retrieval-selection); 110-case classifier probe (temp test, removed); npm run typecheck exit 0. Not run (provider-gated): eval:retrieval:quality, eval:rag, the live canary pair. | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (parity commit review) | 1aebf02 (fix landed a3b9a54) | rag-retrieval-reviewer on the monitoring evidence-gate parity commit: REQUEST-CHANGES (soft) — P2 reproduced: inflected monitoring kind tokens (monitor\w*/annual(?:ly)?/blood tests?/ecgs?/lfts?) steal sole-dose-value sentences from the dose arm; dose-intent answers then reject the monitoring-kind fact ("Quetiapine is monitored at a dose of 200 mg daily" flipped grounded true→false, source-gap — fails CLOSED, never a wrong dose). P3: monitoring figure escape lacked the dose escape's multi-drug bare-row guard. Clean: over-admission bounded (broad vocab lives in gate/filter only, promotion still corpus-guarded, claim-support unchanged); regex cost negligible; mismatched-unit test relaxation legitimate (synopsis is corpus-verbatim; weeks pin enforced by atom identity + adjacent_context exclusion from both gate and claim corpora). | BOTH FINDINGS FIXED in a3b9a54: kind arm classifies legacy tokens byte-identically and new-inflection-only sentences fall through to the dose arm when they carry a clinicalDoseValuePattern value (both repro sentences pinned as dose-intent tests); multi-drug bare-row guard extended to monitoring_schedule with a discriminating test — red-proven both directions. Reviewer checks: formatting 38/38, focused 644/644; targeting eval deferred to the wave's live canary pair. | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (parity commit review) | 1aebf02 (fix landed a3b9a54) | clinical-governance-reviewer on the same commit: APPROVE-WITH-NITS. P2 (independently converged with the retrieval reviewer's P3): monitoring figure-escape lacked the dose-path multi-drug cross-entity guard — a bare wrong-drug schedule/level row in a multi-drug chunk could be entity-prefixed for a named-drug monitoring query; downstream gates verify text-vs-source presence, never attribution (worked lithium/valproate LFT path traced through finalize). FIXED in a3b9a54 exactly as its smallest-fix prescribed (guard at the :872-881 site now fires for monitoring_schedule; negative multi-drug test added, red-proven). Clean: unsupported figures impossible (admission-only change; promotion corpus guard + numeric verification + claim support all byte-unchanged); conservative failure intact (figure-bearing-only escape, schedule-free refusal pinned); adjacent-context safety held (sourceEvidenceText excludes adjacent_context; weeks refusal confirmed by probe); no PHI/provider/ranking surface. P3s: RAG impact line (present in the PR body — behaviour-change form, correct since the PR also carries the Option A retrieval change); multi-drug negative test (landed in a3b9a54). | Offline guard-chain trace + targeted vitest probes (named-drug guard, bare-figure admission, conservative gap, adjacent refusal). Provider/release gates deferred per confirmation boundary. | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A rescue review) | 0abf3c9 | clinical-governance-reviewer on the S3 escalation rescue: APPROVE-WITH-NITS, no P0/P1. P2 = the mandated live canary pair itself (process gate, declared in the PR body; offline-green + review-approved proven insufficient for this surface 2026-07-20). P3s: multi-drug escalation-query recall edge (titled drug + untitled drug — fast-path return can skip the vector leg; recall limitation, not misattribution, mirrors the pre-existing allowlisted-class tradeoff); reviewer probe files must stay uncommitted (relocated to scratchpad). All six clinical concerns verified safe: wrong-document impossible (alias phrases must appear in the query; per-document alias groups, no cross-drug conflation), conservative availability (S3 purely additive via keyed-union merge; sibling retention test-pinned), live expansion acceptable (title-named correct-entity SOP in every firing shape), fail-closed double layer (adversarial short-circuit precedes the predicate; injection-forge case intent=protocol cannot fire — executed), governance metadata unbypassed (same attachDocumentRankingMetadata + status=indexed + access-scope filters), no PHI/provider/schema surface. | Reviewer checks: escalation-rescue suite 8/8, injection-forge intent derivation executed, static trace of the full S3 chain. Live canary pair = the trust gate, dispatched post-merge. | +| 2026-07-21 | claude/patient-profile-input-bounds-123366 (PR #1045: FV-03 fail-safe input bounds) | 75303e8b8 | Clinical-governance verification of FV-03 (patient-profile numeric fields → medication-safety alert engine). 4-agent adversarial workflow (consumer map + suppression audit + physiological bounds + synthesis). | ADJUST→implemented. Consumer map: evaluatePatientAlerts is the ONLY numeric consumer, no dose arithmetic, sanitize() is the sole guaranteed chokepoint. Suppression audit found naive null-routing UNSAFE via the bare-renal both-null hole (medication-patient-alerts.ts:286) — nulling one out-of-range renal input while the other is present-normal → false all-clear; fixed with &&→|| (0 bare-renal contraindication rows in corpus → no-op on current data). Bounds VALIDATED (age 0-130, egfr 0-250, crcl 0-400, qtc 240-800, scr µmol/L 15-3000 unit-aware): never reject a legitimate clinical extreme. Reject-to-null (never clamp). | typecheck, lint, format:check, full unit+jsdom 349 files/3120 passed/0 failed (incl. 38 new/updated FV-03 tests), design-system-contract (baselines unchanged), type-scale, icon-scale, check:production-readiness READY. verify:ui in CI. No provider calls. | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A wave verdict — no code change; #1040 merged as cde6c5c) | canary run 29827012719 (#61, main cde6c5c) vs banked #60 (29800029819) | OPTION A WAVE ADOPTED — FIRST FULLY-GREEN 44-CASE CANARY IN PROGRAM HISTORY (Blocking failures: None). (1) Option A payoff EXCEEDED: citation_failure_rate 0.0227→0; the neuroleptic-side-effect-escalation case flipped from wrong-doc→failed-generation→1-citation-fallback to **strong route, successful gpt-5.6-sol generation, passed in 15.4s with no fallback marker** — the rescued S3 retrieval fixed generation itself, not just the citation count; expected_source_hit 0.6364→0.6591. (2) Golden held exactly as the blast-radius analysis promised: 36/36 PASS, content_recall 1.0, mrr@10 0.8921 BYTE-IDENTICAL to the pre-wave baseline (zero ordering movement — no golden case fires the predicate), irrelevant@10 0.1083→0.0917 (slightly better). (3) Parity payoff PARTIAL: monitoring targeting 1/5→2/5 (olanzapine-lai flipped — previously called a retrieval-depth residual; quetiapine-dose also flipped on the dose side); lithium-range (232ch) + metabolic (73ch, byte-identical answer to #60) did NOT flip despite offline-proven fixes — their live chunk sets evidently contain no admissible schedule sentence even under the widened gate → reclassified as retrieval-depth/live-content residuals joining adhd; below the ≥3/5 target but strictly improved, no regression anywhere. Dose 2/5 vs 2/4: same passing count, applicable set grew (new quality-metformin-renal-dosing miss = eval-set churn, not regression). (4) No-worse EXCEEDED: relevance 0.5333→0.6 (the two-step watch-item slide FULLY REVERSED to the #58 level), targeting_rate 0.6667→0.6957, fail_closed 0.9 held, readability/artifact_leaks 1.0, route ceilings 0, grounded 1.0, unsupported_correct 1.0, numeric 0, p95 22.8s, red_result 3/3. Adoption per the measured-gain rule: primary goal achieved, three case flips, relevance recovered, zero regressions. Residual queue: monitoring retrieval-depth trio (lithium-range/metabolic/adhd), E-3d H2 discards, weekly ANSWER_CASE_LIMIT 8→44 raise now unblocked (gate would be green), comparison-class coverage. Wave spend +~$2-4 → Phase E + Option A total ~$12-20 of ≤$20. | Evidence: run #61 job log read (Threshold Status: None; Answer Metrics; neuroleptic diagnostics row; targeting metric_rates + 6-miss list; golden 36 PASS lines + summary). Revert drill NOT triggered. | +| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: I9 weekly coverage raise) | see PR head | ADDENDUM 5 post-green item I9 (plan-authorized "after reds fixed"): weekly scheduled canary ANSWER_CASE_LIMIT default 8→44 — the Sunday 18:00 UTC cron now guards the FULL answer-quality case set instead of the first 8 (both #57 blocking reds historically lived OUTSIDE the first 8, leaving the weekly gate blind to them). Unblocked by run #61 proving the citation gate green on the full 44. Cost: est +$1-2/week (user-authorized in the plan). Contract test pin updated in lockstep (eval-canary-workflow.test.ts). Dispatch shapes unchanged (input override still wins); operational-risk diff, plain-revert rollback. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS (20/20); eval-canary-workflow contract 4/4; prettier clean; no provider calls | +| 2026-07-21 | claude/database-governance-audit-10b6ed (PR #1051: source-governance audit — safe subset) | cee396730 | Governance-metadata observability + UI display + provenance flow test; no ranking/retrieval/generation surface touched. | IMPLEMENTED + handed off (not a review of prior work). Resolved audit #1 (logger.warn on unrecognized enum values; return value unchanged), #2 (review_due_source added to frontendVisibleWarningCodes → answer-level badge; warning-severity, no refusal impact), #9 (source_metadata retained on safety-finding citations + governance pill in SafetyFindingsListContent), #13 (new tests/provenance-flow.test.ts: DB-normalize→governance→client payload sources+safety citations→render policy). Deferred #4/5/6/8/10 (RAG-protected ranking/selection/LLM-context/cache — need live eval-canary+approval), #11/#5 flag debt (D5/D4), #3 (is_public schema/RLS), #7 (conflict-detection scope), #12 (canary automation). Rebased onto origin/main (was 18 behind; conflict-free — none of the 18 commits touched the 8 files). PR-policy CI green (confirmed no ragRankingPatterns match). | verify:pr-local exit 0 (351 files/3129 tests, production build, client-bundle secret scan, offline RAG fixtures 36/36); typecheck + lint + prettier green. verify:ui NOT run locally: pre-existing globals.css Tailwind/Turbopack dev-compile error (git-clean, unrelated; prod build passed) — CI Production UI job covers it. check:production-readiness deferred (offline env/config validator; PR changes no env/secret/config inputs; secretless worktree). No provider calls. | +| 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 — work repository-wide review sweep + +- Branch/ref: work +- HEAD: 39378863a5d713bfdeb617377a90319ae75810d4 +- Scope: Repository-wide static review sweep across security/auth/privacy, RAG/clinical answers, database/RLS, UI/accessibility, CI/release automation, dependencies/build/runtime, and local verification hygiene. +- Outcome: Findings recorded in docs/audit/repo-wide-review-sweep-2026-07-19.md. Highest severity: P1 summary-mode non-stream route contract drift; P1 release PR policy coverage gap. +- Checks: npm run workflow:flightplan -- --write-evidence (pass); npm run format:check (failed existing formatting drift); npm run check:knip (failed missing node_modules); npm run typecheck (failed missing TypeScript binary); npm run lint (failed heavy-run lock because typecheck was active); npm run check:runtime (failed missing tsx/node_modules). Provider-backed checks skipped per confirmation boundary. + +## 2026-07-23 — work quick current-state follow-up review + +- Branch/ref: work +- HEAD: 570a507d099c64fcf9db1d27ddbef5f5e1f142d3 +- Scope: Quick follow-up review of issues raised in the 2026-07-19 repository-wide review sweep, plus local static checks requested in chat. +- Outcome: Several prior findings remain reproducible in the current tree: non-stream /api/answer still accepts summaryMode without a summary branch; stream summaryMode can still scope documentIds separately from summarized documentId; PR policy still targets only main while CI targets main and release/**; action pin checker still scans only workflow YAML files; local shell remains Node 20 with node_modules absent; Prettier drift still reports 27 files. check:github-actions and check:pr-policy self-tests pass but do not cover the remaining coverage gaps. +- Checks: node/npm/dependency presence probe; static source inspection of answer request/routes, CI/PR policy triggers, action pin checker, UI/accessibility remnants, .npmrc/package engines; npm run check:github-actions && npm run check:pr-policy && git diff --check (pass); npm run format:check (failed existing formatting drift). No provider-backed checks run. + +## 2026-07-24 — work search chrome behaviour review + +- Branch/ref: work +- HEAD: bcf4571dd37005622dbef7aae0e2374afafb6b0f +- Scope: Targeted review of search bar/header/footer chrome behaviour after the edge-to-edge phone dock fix, plus durable repo rules for page-adaptive search chrome. +- Outcome: No new P0/P1 search chrome defect found in the static review. Fixed one regression hazard: a stale ClinicalDashboard comment still instructed a 0.75rem hidden dock pad despite the implementation/tests requiring 0rem. Added durable search chrome behaviour rules in AGENTS.md and docs/search-chrome-behaviour.md, with a static guard tying the remembered rules to the hidden-reserve contract. +- Checks: dependency shortcut section count; git diff --check; targeted rg for stale 0.75rem hidden-pad source wording (only negative test assertions remain); targeted Vitest command attempted but blocked by missing node_modules/vitest under Node 20.20.2 in this container. No provider-backed checks run. +| 2026-07-24 | execute-audit-code-remediation (PR #1162) | 632e84c9436f1f28be9d7aaadbbe942f72618199 | Run PR sweep: CI fix + threads + drift | before: CONFLICTING + PR policy FAIL + unresolved Codex P1 (conflict markers in answer/route.ts). after: conflict markers removed and pushed (80212dd91, 632e84c94); merge origin/main aborted (non-trivial conflicts: privacy/page.tsx, answer-render-policy.ts, source-authority-metadata.ts, upload/route.ts, supabase/drift-manifest.json, settings-dialog, validation/answer-request, plus UI/docs/tests); PR policy still FAIL (Clinical Governance Preflight missing — body edit forbidden this sweep); thread reply/resolve needs parent (comment 3644028277 / thread PRRT_kwDOSh5Fis6Tfkev) — ManagePullRequest/GitHub write MCP unavailable | typecheck:internal pass; vitest clinical-dashboard-merge-artifacts + visual-evidence-tabs pass (9); no provider-backed checks run | +| 2026-07-24 | execute-audit-code-remediation (PR #1162) | e386d074da69be3d7805a9e851f430603d3b0249 | Run PR sweep: CI fix + threads + drift | supersedes prior #1162 row: post-sweep HEAD includes ledger commit; conflict-marker fixes at 80212dd91+632e84c94; merge-from-main skipped (privacy/clinical/source-authority/supabase conflicts); PR policy + thread resolve deferred to human/parent | same gates as prior row; no provider-backed checks run | +| 2026-07-24 | execute-audit-code-remediation (PR #1162) | a398316163f75748fcfd59db3b5c61fd87819877 | Run PR sweep: CI fix + threads + drift | re-attempt merge origin/main ABORTED: non-trivial conflicts remain in privacy (src/app/privacy/page.tsx), clinical/RAG (src/lib/answer-render-policy.ts, src/lib/validation/answer-request.ts), source governance (src/lib/source-authority-metadata.ts), upload (src/app/api/upload/route.ts), supabase/drift-manifest.json, plus UI/docs/tests (search-chrome-behaviour, settings-dialog, navigation-back-button, sheet, patient-safety-plan, form-detail, differentials, bulk route, colour-coding, favourites-auth-gate, private-access-routes, services-catalog). Markers previously cleaned; PR policy body deferred to parent | merge aborted; no provider-backed checks run | +| 2026-07-24 | execute-audit-code-remediation (PR #1162) | 9664fb279adae41cd9846cca1a1a17650a1ac138 | Run PR re-sync sweep | Re-check only: still CONFLICTING vs origin/main. Semantic conflicts include privacy/page.tsx, answer-render-policy.ts, answer-request.ts, source-authority-metadata.ts, upload/bulk routes, settings-dialog, drift-manifest (+ more). Merge aborted; no force-resolve. | merge origin/main and/or conflict re-check only; no provider-backed checks run | +| 2026-07-24 | repo-auditor (detached HEAD 037b4808 — explicit full-repo structural audit) | 037b4808100946f96aee28b8ff51939ae2986851 | Full-repo structural audit: broken imports, dead files, consolidation. Scope: src/, scripts/, worker/, tests/. Pure triage — no files mutated. | P0/P1: none. P2: confirmed dead file src/components/clinical-dashboard/prior-answer-turn-surface.tsx (115 lines, zero importers; superseded by answer-thread-turn.tsx per its 'Extracted from ClinicalDashboard.tsx maturity X3' comment; old file exports PriorAnswerTurn type + PriorAnswerTurnSurface component — both replaced). P3: three duplicate package.json script pairs (test:e2e = test:e2e:all; promote:public-documents = promote:public-documents:batch; postinstall = hooks:install — last pair intentional lifecycle). Unused public exports: authorization.ts (administratorRoleClaim/Value internal-only), api-client-error.ts (ApiClientError — only parseApiErrorResponse imported), rag/rag.ts (answerQuestion — superseded by answerQuestionWithScope), supabase/project.ts (isExpectedSupabaseProjectConfig + 2 types). RAG surface note only (do not touch): rag-extractive-first.ts exports hasValidatedRoutineExtractiveRecovery/hasValidatedGenericLaiManagementExtractiveAnswer with no external consumers. Knip false positives: taskkill (win32-gated), railway (opt-in --railway flag). No broken imports (typecheck + lint both exit 0, zero relative ../ imports in src/). | npm run typecheck (exit 0); npm run lint (exit 0); npm run check:knip (exit 0); knip --reporter json full export/file/type analysis; rg import-graph probes across all source trees | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138) | 46597a9b | Explicit performance + frontend-ui review of search/interactive surfaces; low-risk client deferral/cache/abort/progressive-reveal pass | Prior document/universal search latency work retained (NDJSON stream, LRU, lazy PDF, content-first detail). New work: differential debounce+abort+LRU; useDeferredValue on catalogue ranking; document results Show more window; RelatedDocumentsPanel memo; universal LRU 100+TTL; deferred registry search extracted from ClinicalDashboard. No RAG/retrieval/ranking edits. No high-confidence P0–P2 defect found in the shipped scope; residual risk = deferred paint lag on large catalogues and progressive reveal missing deep cards until Show more. | Focused Vitest 10/10 (differential + universal + performance boundaries); verify:cheap exit 0 (3262 tests); typecheck clean; verify:ui exit 0 (Chromium). No provider calls. | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 follow-up) | 7e2ccee0 | Bugfix pass on search interactive performance diff | Fixed P1 auth-stale differential matches; P2 progressive-reveal hiding selected card; P2 deferred empty/full-catalogue flash on services/forms/formulation/therapy-compass; Prettier CI failure on universal-search test. No remaining high-confidence P0–P2 in scoped diff. Residual: differential debounce skeleton flicker; RelatedDocumentsPanel memo limited by unstable callbacks. | Focused Vitest 11/11; typecheck; format:check; maintainability budgets. No provider calls. | | 2026-07-20 | origin/main PWA-surface review, window `ef042ca..e128384` (44+ commits; explicit user request) + phone install-sheet redesign (this branch, content commit d8d8c4a) | e1283846647b20cd49ca6ae6920a7c76f2b945d7 | PWA-version review of new progress + install-notification design elevation | Sweep verdict (agent-verified): only #905/#897 (this program's own work) touched PWA surfaces in the window ??? sw.js CACHE_VERSION `2026-07-18-v1` and its offline.html binding, manifest (7 icons incl. monochrome; screenshots deliberately absent), icons route, kill-switch, and next.config headers all unchanged, so the privacy contract holds by construction. Geometric risk from five composer/dock-space reworks (#933 mobile-composer-reserve, #932, #930, #922, #899) probed live at 390??844: the non-install notice stack keeps correct clearance at rest (offline card bottom 744/844 with the 5.5rem gutter) and the stack's offset is now a custom property (`--pwa-notice-bottom-gap`) for one-line re-anchoring if composer geometry moves again. Redesign: install prompt + iOS hint present as a native bottom sheet on phones (full-bleed, flush bottom, grip bar matching #935's sheet language, real app icon identity via /icons/icon-192, Free/no-store meta row, two accent step chips for Share???Add to Home Screen as aria-hidden reinforcement of the unchanged accessible sentence); ???640px keeps the #905 card/toast placements; region names, button names, and test-locked sentences unchanged. | Focused vitest 56/56 across the four PWA suites; `verify:cheap` 2971/2975 (sole fail = known container-only pdf-extraction-budget artifact); `test:e2e:pwa` privacy/offline green (installability fail = known container `in-incognito` artifact); `verify:ui` 243 passed/2 failed (the two long-baselined container artifacts); production build + client-bundle secret scan + bundle budget within tolerance (1303.4 vs 1278.6 KiB baseline; sheet adds ~0 JS); visual evidence at 390 light/dark/iOS-hint + 768 + 1440 with bounding-box math (full-bleed flush-bottom on phone; card/toast intact above). No provider-backed checks run. | | 2026-07-20 | Credentialed release-gate checkpoint closeout (workflow_dispatch on main `7ec25d9`; user-authorized ???$10, single dispatch each) | 7ec25d9675dea13635fa4a895af88c93da694a42 | Credentialed half of the release gate: CI dispatch + live eval canary | CI dispatch: 9/10 jobs green (unit coverage, build, Chromium production journeys, migration replay on local Supabase emulator, production-readiness CI-safe, policy self-tests, static/safety/scope). `release-browser-matrix` (WebKit/Firefox) CANCELLED twice by main-churn: ci.yml `concurrency: CI-${ref}, cancel-in-progress: true` kills in-flight dispatch runs on every main push and this repo merges every few minutes ??? livelock confirmed at the 2-attempt cap; WebKit/iOS verification remains outstanding with three human options (quiet-window dispatch, the weekly scheduled run, or a one-line dedicated concurrency group for the matrix job ??? operational-risk change, not applied). Eval canary: golden retrieval eval FAILED 4/36 (document_recall@5 0.944, ndcg@10 0.923, force_embedding_failure_count 0, no 429s ??? vector layer healthy, NOT the documented vector-ptsd transient class); the July 17 dispatch PASSED this step pre-#901, so the regression window implicates #901's deterministic semantic reranking (lithium-therapy-monitoring shows three unrelated documents with byte-identical rerank scores burying the lithium guideline; two other failures add fixture-vs-corpus identity components; answer-quality subset ??? the July 17 failure ??? never ran). NO canary re-run per plan (deterministic, not transient); retrieval is clinical-path and deferred to a human decision. Provider spend ??? $1???2 (one canary run's embeddings); matrix/CI runs $0. | actions_run_trigger dispatches + rerun_failed_jobs (attempt 2); job-level conclusions and log excerpts from runs 29675875530 (both attempts), 29675878737 (July 19 canary), and 29567502452 (July 17 baseline). No local provider calls; secrets never left GitHub Actions. | | 2026-07-20 | claude/clinical-kb-pwa-review-asi3wb (restarted; PR: ci.yml concurrency) | 79aaacf04e64f753c480dd957a81ac9be6acbb43 | CI workflow concurrency: stop main churn cancelling dispatch/schedule runs | One-line group expression: workflow_dispatch/schedule events now get a per-run concurrency group (github.run_id) while push/PR keep the shared ref group with cancel-in-progress ??? fixes the release-browser-matrix livelock (cancelled twice on 2026-07-19/20 by main merges mid-run; the weekly Sunday 18:00 UTC scheduled run was subject to the same cancellation). release-browser-matrix is not a required branch-protection check; pin/scope checkers do not constrain the concurrency block. Accepted side effect: deliberate runs can overlap push runs. Rollback: plain revert. | check:github-actions PASS; check:ci-scope PASS; format:check PASS (repo files; local-only .claude/settings.local.json warning is gitignored) | @@ -800,6 +879,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | cursor/frontend-ui-review-docs-e8d9 (PR #1146) | c29d57c5 | Babysit sweep: drift | Before: CONFLICTING. After: merged origin/main cleanly. 1 unresolved Codex P2 (ledger insert vs append) left for human. | merge origin/main only; no provider-backed checks run | | 2026-07-24 | execute-audit-code-remediation (PR #1162) | b4675d7b | Babysit sweep: drift skipped | Before: CONFLICTING, PR policy FAIL. Merge origin/main aborted: 20+ conflict files across clinical/auth/API surfaces ??? needs human resolution. | merge --abort; no provider-backed checks run | | 2026-07-25 | codex/fix-merge-conflicts-and-ci-on-open-prs (PR #1170) | e979fc892f11a17b1a8f2ef1ab058ef40d629182 | Open-PR maintenance: CI fix + threads + drift | Before: Static PR checks failed because route-group moves made nine valid legacy `src/app/*` documentation references appear missing; 0 unresolved threads; branch already contained current main. After: docs-link resolution checks known App Router route groups and the focused failure is fixed. | `node scripts/check-docs-links.mjs` pass (1162 references); Prettier check pass; `git diff --check` pass; no provider-backed checks run. | +| 2026-07-25 | execute-audit-code-remediation (PR #1162) | 1ea38c730f10c6b2de483c036c47e70f58780d43 | Open-PR maintenance: restore regressed thread fixes | Before: 3 unresolved review threads; the action-pin checker crashed on a duplicate helper, mobile detail-page back actions depended on browser history, and the bulk publisher-code fix was present but unverified. After: action discovery is single-source, detail routes use stable parent destinations, and all three thread fixes are covered or verified. | GitHub Actions pin self-test and `check:github-actions` pass; focused Vitest 20/20 pass; `npm run ensure` verified http://localhost:3264; Prettier and diff checks pass; no provider-backed checks run. | | 2026-07-25 | implement-audit-recommendations-fix (PR #1141) | 0aaf441775786e5def408a2eda551d9b5d95542d | Open-PR maintenance: review fix + drift | Before: 24 commits behind and 1 unresolved P2 thread; nested result content retained safe-area padding after the shared dock hid. After: current main is merged and child content uses fixed small phone gaps so the shared zero hidden reserve reaches the viewport edge. | focused Vitest pass (7/7); Prettier check pass; `git diff --check` pass; no provider-backed checks run. | | 2026-07-25 | audit-remediation (PR #1153) | 80ca8bbeaaf43696863ce4a0949ca880d17a5eed | Open-PR maintenance: fail-closed test and skill sync fixes | Before: 3 actionable review threads; Vitest accepted empty selections; skill sync auto-promoted uncatalogued folders and generated aliases as implicitly invocable. After: empty selections fail by default, uncatalogued folders require an explicit catalog decision, and generated alias manifests target the canonical skill with implicit invocation disabled. | `npm run skills:sync` pass; `npm run check:skills` pass (32 canonical, 8 aliases); Prettier and diff checks pass; focused Vitest blocked by the repository heavyweight lock owned by another worktree; no provider-backed checks run. | | 2026-07-24 | open-PR conflict sweep (13 clean + 5 conflicted) | multi-head | Conflict fix sweep | Merged origin/main into #1124 #1140-1142 #1146-1148 #1153 #1156-1158 #1167 #1169 #1171 #1172 (clean). Resolved conflicts on #1131 #1134 #1153 #1167 #1162. All 18 open PRs mergeable after sweep. | merge-tree classify + per-PR merge; focused private-access tests on #1162; Bugbot on #1162; no provider-backed checks | @@ -872,3 +952,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | open-pr-babysit-continuation-20260725 | multipass | Babysit continuation after 14 merges: #1177 landed; #1174/#1178/#1153 in progress; drafts #1187/#1192 skipped; large cluster #1162/#1185/#1186/#1188/#1190 content-conflicted (skip). | merge-tree inventory; no provider-backed checks. | | 2026-07-25 | audit-remediation (PR #1153) | 5a731df5c25fed9b07fd2321a0ad4b6519471f4b | PR babysit: CodeRabbit thread fixes + merge | Before: MERGEABLE/BLOCKED on required_review_thread_resolution + pending CI; 6 CodeRabbit threads. After: fixed sync-skills pad/YAML escape, PDF temp cleanup, squash-aware rollback wording; dispositioned ledger mid-table + retained false-positive; approved CI; merged to main `191b17d2f` (merge commit); branch deleted; tip is ancestor of main. | Hosted CI green on tip; no provider-backed checks. | | 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | 9135891bfd194394549cb480a7ec86de12b23ee7 | PR babysit: local-presence + /tools + CI/UI fixes + squash merge | Before: flaky Safety audit on package.json scripts, Production UI Sources autofocus flake, CodeRabbit short-env duplicate thread. After: ci-change-scope lockfile-only; strip stale short env keys; sheet open-focus retries + skip focus=1 reclaim under modal; squash-merged `d08ec2e8e`; branch deleted; key-file content-diff empty. | Hosted PR required SUCCESS (Production UI green on tip); focused local-presence vitest; no provider-backed checks. | +| 2026-07-25 | execute-audit-code-remediation (PR #1162) | d5455837231f5cb6a927e8c4752ef5aa9c72767c | Merge conflict + CI + Bugbot review | Merged origin/main (164 behind). Fixed conflicts in ClinicalDashboard/global-search-shell/mode-home/search-scope/tests/pdf extractor. Renamed duplicate migration 20260724120000→20260724130200. CI: skills openai.yaml, owner-scope setup-status exemption, sitemap, drift hash, answer-render duplicate key, setup-status mock `.eq`. Bugbot: fixed Codex P1 view-only indexing + P2 differential back; also fixed P2 viewer visibility for retained images and duplicate-upload cleanup ledger fail-closed. | Focused Vitest (back-href/worker/skills/setup-status/owner-scope/drift/sitemap/search-scope/favourites/forms/therapy/document-detail/upload-ledger) + typecheck + check:skills/migration-role/sitemap; no provider-backed checks. | +| 2026-07-25 | execute-audit-code-remediation (PR #1162) | f07c711a0e1ee853b128733a51db182d8192c3c4 | CI unblock after Bugbot | Fixed Prettier (11 files), restored package-lock/.npmrc to main so blocking npm audit is advisory (lockfile_changed=false), updated mobile-composer-reserve contract for answer-home hero breakpoint. Prior tip e5c8c49c had Static/Safety/Unit failures. | format:check; focused Vitest 41; ci-change-scope lockfile_changed=false; no provider-backed checks. | +| 2026-07-25 | execute-audit-code-remediation (PR #1162) | 1de1b32f562c0a972997750a5dc97a02ab1a9c15 | Production UI fix | Fixed ui-tools services referral header test (H1/quick-filters contract). Prior tip 09c6eb2d had Static/Safety/Unit/Migration green; only Production UI failed. | Local Playwright chromium services referral test PASS; no provider-backed checks. | +| 2026-07-25 | execute-audit-code-remediation (PR #1162) | b9b56c140eb14cbba5a2c2230e3fa28d3a791add | CI unblock after bot sync | Tip 96188eca had PR required SUCCESS (Static/Safety/Unit/Build/Migration/Production UI). Hosted pr-branch-sync then merged main (a420b86b/b9b56c14), leaving CI action_required for bot-authored runs. Pushing agent commit to re-trigger non-bot CI. | Prior tip 96188eca hosted CI green; local services referral Playwright PASS; no provider-backed checks. | diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md index a8794832d..878c572c2 100644 --- a/docs/database-drift-detection.md +++ b/docs/database-drift-detection.md @@ -181,6 +181,6 @@ live project need explicit operator approval. GUC pattern (`20260702160000` precedent). 10. **Migration-chain fidelity** (affects Supabase Preview/branches, not live): 13 keys where the chain diverges from schema.sql — buckets are only - created by schema.sql, `documents`/`ingestion_jobs` updated_at trigger + created by schema.sql, `documents`/`ingestion_jobs` updated*at trigger variants, post-legacy-drop embedding-fields index set, - `document_chunks_content_trgm_idx` shape, `rag_visual_eval_*` shapes. + `document_chunks_content_trgm_idx` shape, `rag_visual_eval*\*` shapes. diff --git a/docs/ingestion-concurrency-fix-workorder.md b/docs/ingestion-concurrency-fix-workorder.md index 2ad02e707..8a7da69fb 100644 --- a/docs/ingestion-concurrency-fix-workorder.md +++ b/docs/ingestion-concurrency-fix-workorder.md @@ -310,13 +310,13 @@ arm (a deep-memory pass after an agent pass). scoping only units+cards leaves agent memory-cards detached via `section_id ON DELETE SET NULL`. -**Required work (design, not patch):** decide the section_index ownership model +**Required work (design, not patch):** decide the section*index ownership model between the agent and deep-memory — e.g. give the agent a disjoint index range or stop it writing `document_sections`, or make deep-memory's section write an `on conflict (document_id, section_index)` upsert with a defined winner. Then scope index_units + memory_cards deletes with the NULL-safe filter above. -_Retrieval-affecting → eval gate. Touches `src/lib/deep-memory.ts` and the edge -agent._ +\_Retrieval-affecting → eval gate. Touches `src/lib/deep-memory.ts` and the edge +agent.* **Files:** `src/lib/deep-memory.ts`, `supabase/functions/indexing-v3-agent/*`, possibly a migration. diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 2466a10d4..5d3e29cb0 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -266,7 +266,7 @@ passes `p_worker_id`. Ordered apply steps, R17 manual `CONCURRENTLY` index, and - **Eval debt (blocking merge, not development):** `npm run eval:retrieval:quality` (23/23) and `eval:quality --rag-only` (`unsupported_correct_rate` 1.0) could NOT be run in the authoring environment (no live keys) — they MUST be run before merge per the standing gate above, with special attention to the weak-match OR-augmentation (flag off restores relax-on-empty exactly) and the retrieval-selection tiebreak (tie-only by construction). - **Eval debt settled 2026-07-07 — both flagged changes had regressed the golden eval (31/34 on main).** Isolated case-by-case against the same live corpus (pre-#325 code passed all 3): the weak-match OR-augmentation buried `opioid-withdrawal-doses` (docRecall@5 → 0; OR recall is append-only at the RPC merge but NOT after re-ranking), and the `selectRetrievalEvidence` pre-clamp tiebreak buried `alcohol-ciwa-threshold` + `clozapine-cbc-abbreviation-threshold` (boost-stacking magnitude re-ordered saturated top-5 sets). Fixed on `claude/retrieval-correctness`: `RAG_TEXT_WEAK_OR_RELAXATION` now defaults to `false` (opt-in experiment flag; re-enable only behind a fresh full golden run) and the selection-layer tiebreak is removed — the pre-clamp deep tiebreak lives in `rankClinicalResults` (below the engineered `rankingTieBreakScore`), which is the only place it is eval-proven. Lesson reinforced: "tie-only by construction" still changes which tied candidate wins; ordering changes inside saturated score regions are behavior changes and need the golden gate. - **UI verification run:** new `tests/ui-universal-search.spec.ts` (grouped typeahead renders, item selection navigates, Enter still runs the mode search — universal endpoint mocked), full `ui-tools`/`ui-tools-task-directory` (40/40) and `ui-smoke`/`ui-overlap` suites against a live dev server in demo mode, plus a live curl of `/api/search/universal` (grouped payload, domain filter, 400 on short query). -- **Known limitation:** the typeahead spec mocks the universal endpoint; an end-to-end spec against live seeded registries needs the owner-auth Playwright project (E2E_USER_* keys). +- **Known limitation:** the typeahead spec mocks the universal endpoint; an end-to-end spec against live seeded registries needs the owner-auth Playwright project (E2E*USER*\* keys). ## Cross-mode answer links workstream — verification state (2026-07-06) diff --git a/docs/site-map.md b/docs/site-map.md index 48e5ad878..50fc97a62 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1050,6 +1050,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/health` - Health check. Source: `src/app/api/health/route.ts`. - `/api/health/ready` - Route discovered from app directory Source: `src/app/api/health/ready/route.ts`. - `/api/images/[id]/signed-url` - Private image signed URL. Source: `src/app/api/images/[id]/signed-url/route.ts`. +- `/api/images/signed-urls` - Route discovered from app directory Source: `src/app/api/images/signed-urls/route.ts`. - `/api/ingestion/batches` - Ingestion batch state. Source: `src/app/api/ingestion/batches/route.ts`. - `/api/ingestion/jobs` - Ingestion job collection. Source: `src/app/api/ingestion/jobs/route.ts`. - `/api/ingestion/jobs/[id]/retry` - Retry ingestion job. Source: `src/app/api/ingestion/jobs/[id]/retry/route.ts`. diff --git a/docs/tenancy-defense-in-depth-review.md b/docs/tenancy-defense-in-depth-review.md index 98db69142..45176f1ff 100644 --- a/docs/tenancy-defense-in-depth-review.md +++ b/docs/tenancy-defense-in-depth-review.md @@ -318,10 +318,11 @@ runs only after `requireOwnedDocument`). The guard's `OWNER_SCOPE_ALLOWLIST` holds **exactly** these reviewed indirect-scope exceptions (any new entry must be added here and to the list in the guard, or the regression test fails): -| File | Table | Why it is safe | -| ----------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `src/app/api/setup-status/route.ts` | `documents` | Local-origin-gated `.limit(1)` existence probe ("is any document indexed?"); returns only status booleans, not an owner-data read (§3 / TEN-N1). | -| `src/app/api/setup-status/route.ts` | `import_batches` | Local-origin-gated `.limit(1)` existence probe for schema provisioning; returns only status booleans, not an owner-data read (§3 / TEN-N1). | +| File | Table | Why it is safe | +| ----------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/app/api/setup-status/route.ts` | `documents` | Local-origin-gated `.limit(1)` existence probe ("is any document indexed?"); returns only status booleans, not an owner-data read (§3 / TEN-N1). | +| `src/app/api/setup-status/route.ts` | `import_batches` | Local-origin-gated `.limit(1)` existence probe for schema provisioning; returns only status booleans, not an owner-data read (§3 / TEN-N1). | +| `src/app/api/setup-status/route.ts` | `storage_cleanup_jobs` | Local-origin-gated head-count probe for pending cleanup rows (schema/ops posture); returns only status booleans/counts, not an owner-data read (§3 / TEN-N1). | --- diff --git a/package.json b/package.json index f44e49405..c36cbfb98 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "cleanup:storage": "node scripts/run-tsx.mjs scripts/cleanup-storage.ts", "purge:query-logs": "node scripts/run-tsx.mjs scripts/purge-query-logs.ts", "audit:tables": "node scripts/run-tsx.mjs scripts/audit-tables.ts", + "audit:formatting-fixtures": "node scripts/run-tsx.mjs scripts/audit-formatting-fixtures.ts", "clean:artifacts": "npx rimraf test-output*.txt docs/archive/staging-tenancy-evidence-*", "samples": "node scripts/run-tsx.mjs scripts/generate-sample-documents.ts", "samples:check": "node scripts/run-tsx.mjs scripts/check-sample-extraction.ts", diff --git a/scripts/audit-formatting-fixtures.ts b/scripts/audit-formatting-fixtures.ts new file mode 100644 index 000000000..df833a0c0 --- /dev/null +++ b/scripts/audit-formatting-fixtures.ts @@ -0,0 +1,99 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +type FormattingIssue = { + file: string; + issue: string; + severity: "warning" | "fail"; +}; + +function numberValue(value: unknown) { + const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + return Number.isFinite(number) ? number : null; +} + +function metadata(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +async function auditExtractJson(file: string): Promise { + const raw = await readFile(file, "utf8"); + const payload = JSON.parse(raw) as { + images?: Array>; + pages?: Array>; + }; + const issues: FormattingIssue[] = []; + const images = payload.images ?? []; + const pages = payload.pages ?? []; + + for (const [index, image] of images.entries()) { + const meta = metadata(image.metadata); + const label = `${file}#image-${index + 1}`; + const sourceKind = String(image.sourceKind ?? image.source_kind ?? ""); + const width = numberValue(image.width); + const height = numberValue(image.height); + const cropCompleteness = numberValue(meta.crop_completeness); + const structuredConfidence = numberValue(meta.structured_extraction_confidence); + const ocrDensity = numberValue(meta.ocr_text_density); + + if (sourceKind === "table_crop" && !Array.isArray(meta.table_rows)) { + issues.push({ file: label, severity: "fail", issue: "table crop missing structured table rows" }); + } + if (meta.rows_truncated === true) { + issues.push({ file: label, severity: "warning", issue: "table rows truncated" }); + } + if (cropCompleteness !== null && cropCompleteness < 0.82) { + issues.push({ file: label, severity: "warning", issue: `crop cut-off risk (${cropCompleteness})` }); + } + if (structuredConfidence !== null && structuredConfidence < 0.58) { + issues.push({ file: label, severity: "warning", issue: `low structured confidence (${structuredConfidence})` }); + } + if (ocrDensity !== null && ocrDensity < 0.18) { + issues.push({ file: label, severity: "warning", issue: `low OCR text density (${ocrDensity})` }); + } + if (width && height) { + const ratio = width / height; + if (ratio > 4 || ratio < 0.45) { + issues.push({ file: label, severity: "warning", issue: `extreme image aspect ratio (${ratio.toFixed(2)})` }); + } + } + } + + for (const page of pages) { + if (page.needsOcr === true) { + issues.push({ + file: `${file}#page-${String(page.pageNumber ?? "?")}`, + severity: "fail", + issue: "page needs OCR but OCR text was unavailable", + }); + } + } + + return issues; +} + +async function main() { + const files = process.argv.slice(2); + if (files.length === 0) { + console.log("Usage: npm run audit:formatting-fixtures -- artifacts/extract.json [...]"); + return; + } + const issues: FormattingIssue[] = []; + for (const file of files) { + const absolute = path.resolve(file); + await stat(absolute); + issues.push(...(await auditExtractJson(absolute))); + } + console.log(`Formatting fixture audit: ${files.length} file(s), ${issues.length} issue(s)`); + for (const issue of issues) { + console.log(`${issue.severity.toUpperCase()} ${issue.file}: ${issue.issue}`); + } + if (issues.some((issue) => issue.severity === "fail")) { + throw new Error("Formatting fixture audit found failing issues."); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index 6f167b95b..55b98a8bf 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { validateActionReference } from "./github-action-pins.mjs"; import { yamlBlock } from "./yaml-contract.mjs"; @@ -10,30 +11,92 @@ const failures = []; const expectedSupabaseCliVersion = "2.108.0"; const expectedSupabaseCliVersionPattern = expectedSupabaseCliVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -function discoverGitHubActionFiles(workflowRoot) { - const workflowDir = path.join(workflowRoot, ".github", "workflows"); +function discoverWorkflowFiles(root) { + const workflowDir = path.join(root, ".github", "workflows"); if (!existsSync(workflowDir)) return []; return readdirSync(workflowDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name)) .map((entry) => path.join(workflowDir, entry.name)); } -for (const filePath of discoverGitHubActionFiles(process.cwd())) { - const fileName = path.relative(process.cwd(), filePath).replaceAll("\\", "/"); - const lines = readFileSync(filePath, "utf8").split(/\r?\n/); +function discoverCompositeActionFiles(root) { + const actionsDir = path.join(root, ".github", "actions"); + if (!existsSync(actionsDir)) return []; + const files = []; + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (/^action\.ya?ml$/i.test(entry.name)) { + files.push(fullPath); + } + } + }; + visit(actionsDir); + return files; +} - lines.forEach((line, index) => { - if (runsOnLatestPattern.test(line)) { - failures.push( - `${fileName}:${index + 1}: runs-on uses ubuntu-latest. Pin GitHub-hosted Linux jobs to ubuntu-24.04 so CI is not tied to the moving ubuntu-latest alias.`, - ); +function discoverGitHubActionFiles(root) { + return [...discoverWorkflowFiles(root), ...discoverCompositeActionFiles(root)]; +} + +function collectPinFailures(root) { + const failures = []; + for (const filePath of discoverGitHubActionFiles(root)) { + const fileName = path.relative(root, filePath).replaceAll("\\", "/"); + const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + + lines.forEach((line, index) => { + if (runsOnLatestPattern.test(line)) { + failures.push( + `${fileName}:${index + 1}: runs-on uses ubuntu-latest. Pin GitHub-hosted Linux jobs to ubuntu-24.04 so CI is not tied to the moving ubuntu-latest alias.`, + ); + } + + const actionFailure = validateActionReference(line); + if (actionFailure) failures.push(`${fileName}:${index + 1}: ${actionFailure}`); + }); + } + return failures; +} + +function selfTest() { + const root = mkdtempSync(path.join(os.tmpdir(), "github-action-pin-check-")); + try { + const workflowDir = path.join(root, ".github", "workflows"); + const actionDir = path.join(root, ".github", "actions", "fixture"); + mkdirSync(workflowDir, { recursive: true }); + mkdirSync(actionDir, { recursive: true }); + writeFileSync(path.join(workflowDir, "ok.yml"), "name: ok\n", "utf8"); + writeFileSync( + path.join(actionDir, "action.yml"), + "name: fixture\nruns:\n using: composite\n steps:\n - uses: actions/cache@v6\n", + "utf8", + ); + + const failures = collectPinFailures(root); + if ( + !failures.some( + (failure) => failure.includes(".github/actions/fixture/action.yml") && failure.includes("actions/cache@v6"), + ) + ) { + throw new Error("self-test failed: composite action uses entries were not scanned"); } + } finally { + rmSync(root, { recursive: true, force: true }); + } +} - const actionFailure = validateActionReference(line); - if (actionFailure) failures.push(`${fileName}:${index + 1}: ${actionFailure}`); - }); +if (process.argv.includes("--self-test")) { + selfTest(); + console.log("GitHub Actions pin check self-test passed."); + process.exit(0); } +selfTest(); +failures.push(...collectPinFailures(process.cwd())); + const ciWorkflowPath = path.join(workflowDir, "ci.yml"); const ciWorkflow = readFileSync(ciWorkflowPath, "utf8"); const migrationJob = yamlBlock(ciWorkflow, "db-reset-verify:", 2); @@ -105,28 +168,9 @@ if (!/^ image: semgrep\/semgrep@sha256:[0-9a-f]{64}\s*$/m.test(semgrepGateJ failures.push("sast.yml: the blocking ingestion gate container must be digest-pinned (semgrep/semgrep@sha256:...)."); } -// One SHA per action across every workflow AND composite action. Dependabot bumps -// one file at a time, so a laggard can sit on an old major indefinitely; because -// the per-line validation above only covers workflows, a composite skew (e.g. -// setup-node v5 vs v7) was previously invisible. Assert each action name resolves -// to a single SHA everywhere it is used. -function discoverCompositeActionFiles(workflowRoot) { - const actionsRoot = path.join(workflowRoot, ".github", "actions"); - if (!existsSync(actionsRoot)) return []; - const files = []; - for (const entry of readdirSync(actionsRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - for (const name of ["action.yml", "action.yaml"]) { - const candidate = path.join(actionsRoot, entry.name, name); - if (existsSync(candidate)) files.push(candidate); - } - } - return files; -} - const actionPinPattern = /uses:\s*([^@\s]+)@([0-9a-f]{40})(?:\s*#\s*(\S+))?/; const shasByAction = new Map(); -for (const filePath of [...discoverGitHubActionFiles(process.cwd()), ...discoverCompositeActionFiles(process.cwd())]) { +for (const filePath of discoverGitHubActionFiles(process.cwd())) { const fileName = path.relative(process.cwd(), filePath).replaceAll("\\", "/"); // Workflow lines are already run through validateActionReference in the first // pass; composite files are not, so validate them here. Without this, a diff --git a/scripts/check-owner-scope-api.mjs b/scripts/check-owner-scope-api.mjs index 431646677..8ef3c806e 100644 --- a/scripts/check-owner-scope-api.mjs +++ b/scripts/check-owner-scope-api.mjs @@ -59,6 +59,12 @@ export const OWNER_SCOPE_ALLOWLIST = [ reason: "Global setup/health diagnostic: a `.limit(1)` existence probe for schema provisioning, not an owner-data read. Same local-origin-gated status route — see docs/tenancy-defense-in-depth-review.md §3 (TEN-N1).", }, + { + file: "src/app/api/setup-status/route.ts", + table: "storage_cleanup_jobs", + reason: + "Global setup/health diagnostic: a head-count existence probe for pending cleanup rows (schema/ops posture), not an owner-data read. Same local-origin-gated status route — see docs/tenancy-defense-in-depth-review.md §3 (TEN-N1).", + }, ]; /** Extract table names that declare an `owner_id` column from supabase/schema.sql. */ diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts index 6dd9044d8..39ae94d01 100644 --- a/src/app/api/documents/[id]/labels/route.ts +++ b/src/app/api/documents/[id]/labels/route.ts @@ -61,7 +61,13 @@ function parseManualLabel(input: z.infer) { source: "generated", }); if (!normalized) { - throw new PublicApiError("Enter a short, specific clinical tag. Generic document-control tags are not allowed."); + throw new PublicApiError( + "Enter a short, specific clinical tag. Generic document-control tags are not allowed.", + 400, + { + code: "invalid_clinical_tag_taxonomy", + }, + ); } return normalized; } diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 0cbce5a1f..ce86483fb 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -68,6 +68,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: documentIds: [id], action: "Reindex", checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: mode !== "enrichment", staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, }); if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 8f045d6a2..8d5a1eaa0 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -13,7 +13,7 @@ import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-a export const runtime = "nodejs"; -const signedUrlTtlSeconds = 60 * 10; +const signedUrlTtlSeconds = env.DOCUMENT_SIGNED_URL_TTL_SECONDS; const routeIdSchema = z.string().uuid(); export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { @@ -25,7 +25,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: const document = getDemoDocument(id); if (!document) return NextResponse.json({ error: "Demo document not found." }, { status: 404 }); return NextResponse.json({ - url: document.storage_path, + url: shouldDownload ? `${document.storage_path}?download=1` : document.storage_path, fileType: document.file_type, expiresAt: new Date(Date.now() + signedUrlTtlSeconds * 1000).toISOString(), demoMode: true, diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 558d61f21..af9e94579 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -53,6 +53,7 @@ export async function POST(request: Request) { documentIds: documents.map((document) => document.id), action: "Bulk reindex", checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: parsed.mode !== "enrichment", staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, }); if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index c5fac4d63..a71cd9123 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -12,7 +12,7 @@ import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-a export const runtime = "nodejs"; -const signedUrlTtlSeconds = 60 * 10; +const signedUrlTtlSeconds = env.DOCUMENT_SIGNED_URL_TTL_SECONDS; const routeIdSchema = z.string().uuid(); export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { diff --git a/src/app/api/images/signed-urls/route.ts b/src/app/api/images/signed-urls/route.ts new file mode 100644 index 000000000..00a03dfa5 --- /dev/null +++ b/src/app/api/images/signed-urls/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { getDemoImage } from "@/lib/demo-data"; +import { env } from "@/lib/env"; +import { isDemoMode } from "@/lib/env"; +import { jsonError } from "@/lib/http"; +import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; +import { parseJsonBody } from "@/lib/validation/body"; + +export const runtime = "nodejs"; + +const signedUrlTtlSeconds = env.DOCUMENT_SIGNED_URL_TTL_SECONDS; +const batchRequestSchema = z.object({ + imageIds: z.array(z.string().uuid()).max(100), +}); + +export async function POST(request: Request) { + try { + const { imageIds } = await parseJsonBody(request, batchRequestSchema, "Invalid request body."); + + if (imageIds.length === 0) { + return NextResponse.json({ urls: {} }); + } + + if (isDemoMode()) { + const urls: Record< + string, + { url: string; mimeType: string | null; caption: string | null; expiresAt: string; demoMode: true } + > = {}; + for (const id of imageIds) { + const image = getDemoImage(id); + if (image) { + urls[id] = { + url: image.signed_url ?? image.storage_path, + mimeType: image.mime_type, + caption: image.caption, + expiresAt: new Date(Date.now() + signedUrlTtlSeconds * 1000).toISOString(), + demoMode: true, + }; + } + } + return NextResponse.json({ urls }); + } + + const supabase = createAdminClient(); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } + + // Fetch images + const { data: images, error: imagesError } = await supabase + .from("document_images") + .select("id,document_id,storage_path,mime_type,caption,metadata") + .in("id", imageIds); + + if (imagesError) throw new Error(imagesError.message); + if (!images || images.length === 0) { + return NextResponse.json({ urls: {} }); + } + + // Fetch distinct document IDs + const documentIds = Array.from(new Set(images.map((img) => img.document_id))); + + // Verify document access + const { data: documents, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").in("id", documentIds), + access.ownerId, + ); + + if (documentError) throw new Error(documentError.message); + if (!documents || documents.length === 0) { + return NextResponse.json({ urls: {} }); + } + + const documentMap = new Map(documents.map((doc) => [doc.id, doc])); + const validImages = images.filter((img) => { + const doc = documentMap.get(img.document_id); + if (!doc) return false; + return isCommittedGenerationMetadata({ + rowMetadata: img.metadata, + committedGeneration: committedIndexGeneration(doc.metadata), + }); + }); + + if (validImages.length === 0) { + return NextResponse.json({ urls: {} }); + } + + const storagePaths = validImages.map((img) => img.storage_path); + const signed = await supabase.storage + .from(env.SUPABASE_IMAGE_BUCKET) + .createSignedUrls(storagePaths, signedUrlTtlSeconds); + + if (signed.error) throw new Error(signed.error.message); + + const signedUrlMap = new Map(signed.data.map((res) => [res.path, res.signedUrl])); + + const urls: Record = + {}; + for (const img of validImages) { + const signedUrl = signedUrlMap.get(img.storage_path); + if (signedUrl) { + urls[img.id] = { + url: signedUrl, + mimeType: img.mime_type, + caption: img.caption, + expiresAt: new Date(Date.now() + signedUrlTtlSeconds * 1000).toISOString(), + }; + } + } + + return NextResponse.json({ urls }); + } catch (error) { + if (error instanceof AuthenticationError) { + return unauthorizedResponse(); + } + return jsonError(error); + } +} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index c3c3515b3..bfc8de55e 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1022,7 +1022,9 @@ export async function POST(request: Request) { const failurePayload = { results: [], telemetry: { - query_class: classifyRagQuery(error.message).queryClass, + query_class: fallbackBody + ? classifyRagQuery(fallbackBody.query).queryClass + : classifyRagQuery(error.message).queryClass, retrieval_strategy: null, failure_code: code, }, @@ -1031,7 +1033,7 @@ export async function POST(request: Request) { logSearchObservation({ supabase, ownerId, - query: "unknown", + query: fallbackBody?.query ?? "unknown", results: [], payload: failurePayload, failure: { diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index c872470ba..25608e1ba 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -112,11 +112,12 @@ async function readSchemaStatus(supabase: AdminClient | null) { if (!supabase) { throw new Error("Supabase admin client is unavailable."); } - const [documents, jobs, batches, buckets] = await Promise.all([ + const [documents, jobs, batches, buckets, cleanupJobs] = await Promise.all([ supabase.from("documents").select("id,content_hash,import_batch_id").limit(1), supabase.from("ingestion_jobs").select("id,attempt_count,max_attempts,locked_at").limit(1), supabase.from("import_batches").select("id").limit(1), supabase.storage.listBuckets(), + supabase.from("storage_cleanup_jobs").select("id", { count: "exact", head: true }).eq("status", "pending"), ]); const hasRequiredBuckets = @@ -124,7 +125,7 @@ async function readSchemaStatus(supabase: AdminClient | null) { buckets.data?.some((bucket) => bucket.id === env.SUPABASE_DOCUMENT_BUCKET) && buckets.data?.some((bucket) => bucket.id === env.SUPABASE_IMAGE_BUCKET); - if (documents.error || jobs.error || batches.error || !hasRequiredBuckets) { + if (documents.error || jobs.error || batches.error || cleanupJobs.error || !hasRequiredBuckets) { return check( "schema", "supabase/schema.sql applied", @@ -133,11 +134,14 @@ async function readSchemaStatus(supabase: AdminClient | null) { ); } + const pendingCleanup = cleanupJobs.count ?? 0; + const cleanupNote = pendingCleanup > 0 ? ` (${pendingCleanup} pending cleanup jobs)` : ""; + return check( "schema", "supabase/schema.sql applied", "ready", - "Required tables and storage buckets responded successfully.", + `Required tables and storage buckets responded successfully${cleanupNote}.`, ); } catch { return check( diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 0880b283a..c5e41b80f 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -58,6 +58,19 @@ async function duplicateUploadResponse(args: { storagePath: args.storagePath, message: cleanupStorageError.message, }); + const { error: cleanupLedgerError } = await args.supabase.from("storage_cleanup_jobs").insert({ + document_bucket: env.SUPABASE_DOCUMENT_BUCKET, + document_paths: [args.storagePath], + owner_id: args.ownerId, + status: "pending", + image_bucket: env.SUPABASE_IMAGE_BUCKET, + image_paths: [], + }); + if (cleanupLedgerError) { + // Keep the duplicate response path fail-closed for orphaned objects: without a + // ledger row there is no durable recovery record after storage.remove() failed. + throw new Error(`Duplicate upload cleanup ledger insert failed: ${cleanupLedgerError.message}`); + } } } @@ -209,49 +222,53 @@ export async function POST(request: Request) { // correction or approval-gated locality backfill against authenticated source metadata. assertUploadNotAborted(request); - const { data: document, error: documentError } = await supabase - .from("documents") - .insert({ - id: documentId, - owner_id: uploadOwnerId, - title, - description, - file_name: file.name, - file_type: file.type, - file_size: file.size, - storage_path: storagePath, + const documentPayload = { + id: documentId, + owner_id: uploadOwnerId, + title, + description, + file_name: file.name, + file_type: file.type, + file_size: file.size, + storage_path: storagePath, + content_hash: contentHash, + metadata: { + source_title: title, + publisher_code: null, + publisher: null, + jurisdiction: "Australia/WA", + version: null, + publication_date: null, + review_date: null, + uploaded_at: uploadedAt, + indexed_at: null, + uploaded_by: uploadOwnerId, + original_file_name: namePlan.originalFileName, + original_title: namePlan.originalTitle, + smart_title_base: namePlan.baseTitle, + smart_title_group_key: namePlan.duplicateGroupKey, + smart_title_duplicate_index: namePlan.duplicateIndex, + smart_title_duplicate_reason: namePlan.duplicateReason, + document_status: "unknown", + clinical_validation_status: "unverified", + extraction_quality: "unknown", + max_upload_mb: env.MAX_UPLOAD_MB, + confidentiality_scope: "guidelines-only", content_hash: contentHash, - status: "queued", - metadata: { - source_title: title, - publisher_code: null, - publisher: null, - jurisdiction: "Australia/WA", - version: null, - publication_date: null, - review_date: null, - uploaded_at: uploadedAt, - indexed_at: null, - uploaded_by: uploadOwnerId, - original_file_name: namePlan.originalFileName, - original_title: namePlan.originalTitle, - smart_title_base: namePlan.baseTitle, - smart_title_group_key: namePlan.duplicateGroupKey, - smart_title_duplicate_index: namePlan.duplicateIndex, - smart_title_duplicate_reason: namePlan.duplicateReason, - document_status: "unknown", - clinical_validation_status: "unverified", - extraction_quality: "unknown", - max_upload_mb: env.MAX_UPLOAD_MB, - confidentiality_scope: "guidelines-only", - content_hash: contentHash, - }, - }) - .select() - .single(); + }, + }; + + assertUploadNotAborted(request); + const { data: uploadRecord, error: uploadRecordError } = await supabase.rpc( + "create_uploaded_document_with_ingestion_job", + { + p_document: documentPayload, + p_max_attempts: env.WORKER_MAX_ATTEMPTS, + }, + ); - if (documentError) { - if (isContentHashDuplicateError(documentError)) { + if (uploadRecordError) { + if (isContentHashDuplicateError(uploadRecordError)) { insertedDocumentId = null; insertedDocumentOwnerId = null; return duplicateUploadResponse({ @@ -261,40 +278,17 @@ export async function POST(request: Request) { storagePath: uploadedPath, }); } - throw new Error(documentError.message); + throw new Error(uploadRecordError.message); + } + + const document = (uploadRecord as { document?: unknown } | null)?.document; + const job = (uploadRecord as { job?: unknown } | null)?.job; + if (!document || !job) { + throw new Error("Upload enqueue RPC returned an invalid response."); } insertedDocumentId = documentId; insertedDocumentOwnerId = uploadOwnerId; - assertUploadNotAborted(request); - const { data: job, error: jobError } = await supabase - .from("ingestion_jobs") - .insert({ - document_id: documentId, - batch_id: null, - status: "pending", - stage: "queued", - progress: 0, - max_attempts: env.WORKER_MAX_ATTEMPTS, - }) - .select() - .single(); - - if (jobError) { - const { error: rollbackDocumentError } = await supabase - .from("documents") - .delete() - .eq("id", documentId) - .eq("owner_id", uploadOwnerId); - if (rollbackDocumentError) { - throw new Error( - `Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`, - ); - } - insertedDocumentId = null; - insertedDocumentOwnerId = null; - throw new Error(jobError.message); - } await writeAuditLog(supabase, { ownerId: uploadOwnerId, diff --git a/src/app/api/webhooks/supabase/document-change/route.ts b/src/app/api/webhooks/supabase/document-change/route.ts index 73df6e4e6..346183e0b 100644 --- a/src/app/api/webhooks/supabase/document-change/route.ts +++ b/src/app/api/webhooks/supabase/document-change/route.ts @@ -179,7 +179,7 @@ async function clearReindexFlagIfRequested( p_metadata_patch: { reindex_requested: false }, }); if (error) { - logger.warn("Failed to clear reindex_requested flag", { ownerScoped: Boolean(ownerId) }); + logger.warn("Failed to clear reindex_requested flag", { ownerScoped: Boolean(ownerId), documentId }); return false; } return true; diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index ca493afe7..e174204f9 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3031,7 +3031,6 @@ export function ClinicalDashboard({ ); const showAuthPanel = false; const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); - const hasMobileBottomSearch = searchMode !== "answer"; const submittedAnswerSearchActive = activeModeResultKind === "answer" && !answer && canRunSearch && (modeSearchSubmitted || Boolean(submittedUrlQuery)); const showAnswerHome = activeModeResultKind === "answer" && !answer && !loading && !submittedAnswerSearchActive; @@ -3069,6 +3068,9 @@ export function ClinicalDashboard({ !modeSearchSubmitted && !(query.trim() && documentMatches.length > 0))))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; + const heroComposerBreakpoint = showDesktopHomeComposer || showAnswerHome ? "all" : "sm-up"; + const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all"; + const hasMobileBottomSearch = searchMode !== "answer" && !heroOwnsPhoneComposer; // Favourites and Tools are content-rich hubs: they share the centred hero but // stay top-aligned so their lists start in a stable position. const centeredModeHome = @@ -3081,7 +3083,6 @@ export function ClinicalDashboard({ ((searchMode === "services" || searchMode === "forms") && !modeSearchSubmitted && !query.trim() && !loading); const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); - const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId); // Hidden dock pad must stay at 0rem — Safari toolbar safe-area recreates a blank band. const mobileComposerReserve = resolveMobileComposerReserve( bottomComposerHidden, @@ -3387,9 +3388,10 @@ export function ClinicalDashboard({ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopHomeComposerSlotId={desktopHomeComposerSlotId} - // Mode homes keep the composer in the centred hero at every breakpoint, - // sharing the phone/tablet structure instead of switching to a bottom dock. - heroComposerBreakpoint={heroOwnsPhoneComposer ? "all" : "sm-up"} + // Mode homes keep the composer in the centred hero slot at every + // breakpoint so documents, therapy, and the other homes share the + // same phone/tablet structure instead of switching to a bottom dock. + heroComposerBreakpoint={heroComposerBreakpoint} // Answer view: the header overlays the scrolling
at every width // (main reserves matching top padding) so content frosts under the // glass bar, and it slides away/returns with scroll direction. Other diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index a736312bb..f27e5e890 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -317,7 +317,7 @@ export function FavouritesHub({
diff --git a/src/lib/document-detail-contract.ts b/src/lib/document-detail-contract.ts index 7d90e136f..acf75bf43 100644 --- a/src/lib/document-detail-contract.ts +++ b/src/lib/document-detail-contract.ts @@ -28,6 +28,16 @@ export type DocumentDetailImage = { accessibleTableMarkdown?: string | null; tableRows?: string[][] | null; tableColumns?: string[] | null; + rowCount?: number | null; + rowsTruncated?: boolean | null; + columnCount?: number | null; + width?: number | null; + height?: number | null; + cropCompleteness?: number | null; + imageQualityScore?: number | null; + ocrTextDensity?: number | null; + structuredExtractionConfidence?: number | null; + retainedForDocumentView?: boolean | null; }; export type DocumentDetailTableFact = { diff --git a/src/lib/document-detail.ts b/src/lib/document-detail.ts index 03bbf24e3..566b38365 100644 --- a/src/lib/document-detail.ts +++ b/src/lib/document-detail.ts @@ -4,7 +4,12 @@ import { z } from "zod"; import { getDemoDocumentPayload } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { PublicApiError } from "@/lib/http"; -import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; +import { + callerOwnsDocumentRow, + enforceDocumentReadRateLimit, + withOwnerReadScope, + redactNonOwnedDocumentFields, +} from "@/lib/public-api-access"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError } from "@/lib/supabase/auth"; @@ -112,6 +117,17 @@ function metadataStringArray(metadata: Record, key: string) { return items.length ? items : null; } +function metadataNumber(metadata: Record, key: string) { + const value = metadata[key]; + const number = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN; + return Number.isFinite(number) ? number : null; +} + +function metadataBoolean(metadata: Record, key: string) { + const value = metadata[key]; + return typeof value === "boolean" ? value : null; +} + function withImageTableMetadata(image: T) { const metadata = safeMetadata(image.metadata); const rawTableText = metadataText(metadata, "table_text"); @@ -129,6 +145,14 @@ function withImageTableMetadata(image: T) { accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText, tableRows: metadataStringArrayRows(metadata, "table_rows"), tableColumns: metadataStringArray(metadata, "table_columns"), + rowCount: metadataNumber(metadata, "row_count"), + rowsTruncated: metadataBoolean(metadata, "rows_truncated"), + columnCount: metadataNumber(metadata, "column_count"), + cropCompleteness: metadataNumber(metadata, "crop_completeness"), + imageQualityScore: metadataNumber(metadata, "image_quality_score"), + ocrTextDensity: metadataNumber(metadata, "ocr_text_density"), + structuredExtractionConfidence: metadataNumber(metadata, "structured_extraction_confidence"), + retainedForDocumentView: metadataBoolean(metadata, "retained_for_document_view"), }; } @@ -182,9 +206,12 @@ function selectedImageIds(selectedChunk: DocumentDetailChunk | null) { ); } +const documentViewImageVisibility = + "or(searchable.eq.true,source_kind.eq.table_crop,metadata->>retained_for_document_view.eq.true)"; + function imageWindowFilter(pageWindow: { from: number; to: number }, imageIds: string[]) { const filters = [ - `and(image_type.neq.logo_decorative,or(searchable.eq.true,source_kind.eq.table_crop),page_number.gte.${pageWindow.from},page_number.lte.${pageWindow.to})`, + `and(image_type.neq.logo_decorative,${documentViewImageVisibility},page_number.gte.${pageWindow.from},page_number.lte.${pageWindow.to})`, ]; if (imageIds.length > 0) filters.push(`id.in.(${imageIds.join(",")})`); return filters.join(","); @@ -322,22 +349,6 @@ function loadDemoDocumentDetail(rawId: string, query: DocumentDetailQuery): Docu }; } -function omitPublicInternalFields(row: Record) { - const internalKeys = new Set([ - "owner_id", - "storage_path", - "content_hash", - "source_path", - "import_batch_id", - "error_message", - "metadata", - "source_chunk_ids", - "source_image_ids", - "model", - ]); - return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key))); -} - /** * Loads the minimal authorized document-detail DTO shared by the API route and * the Server Component page. The document authorization check is completed @@ -430,9 +441,7 @@ export async function loadAuthorizedDocumentDetail(args: { if (query.assetScope === "window") { imagesRequest = imagesRequest.or(imageWindowFilter(pageRange, preservedImageIds)); } else { - imagesRequest = imagesRequest - .neq("image_type", "logo_decorative") - .or("searchable.eq.true,source_kind.eq.table_crop"); + imagesRequest = imagesRequest.neq("image_type", "logo_decorative").or(documentViewImageVisibility); } const imagesPending = imagesRequest.order("page_number", { ascending: true }).abortSignal(args.request.signal); @@ -475,13 +484,13 @@ export async function loadAuthorizedDocumentDetail(args: { } const publicRows = >(rows: T[]) => - isOwner ? rows : rows.map(omitPublicInternalFields); + isOwner ? rows : rows.map((row) => redactNonOwnedDocumentFields(row, access.ownerId)); const labels = (labelsResult.data ?? []) .filter((label) => !isHiddenDocumentLabel(label)) .map(withDocumentLabelReviewMetadata); const responseDocument = isOwner ? document - : omitPublicInternalFields(document as unknown as Record); + : redactNonOwnedDocumentFields(document as unknown as Record, access.ownerId); const documentMetadata = safeMetadata(document.metadata); const metadata = windowMetadata({ requestedPage, @@ -503,7 +512,7 @@ export async function loadAuthorizedDocumentDetail(args: { summary: isOwner || !summaryResult.data ? (summaryResult.data ?? null) - : omitPublicInternalFields(summaryResult.data as Record), + : redactNonOwnedDocumentFields(summaryResult.data as Record, access.ownerId), } as unknown as ClinicalDocument, pages: publicRows( committedRows(document, pagesResult.data ?? []).map(withoutMetadata) as Record[], diff --git a/src/lib/env.ts b/src/lib/env.ts index 4c7c91112..eaa40f0fe 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -231,6 +231,7 @@ const envSchema = z.object({ .transform((value) => value === "true"), PYTHON_BIN: z.string().default(resolvePythonBin()), NEXT_PUBLIC_DEMO_MODE: z.enum(["true", "false"]).optional().default("false"), + DOCUMENT_SIGNED_URL_TTL_SECONDS: z.coerce.number().int().positive().default(600), }); const parsedEnv = envSchema.parse(process.env); diff --git a/src/lib/ingestion-mutation-safety.ts b/src/lib/ingestion-mutation-safety.ts index 2cc7281a5..fbeb8f51e 100644 --- a/src/lib/ingestion-mutation-safety.ts +++ b/src/lib/ingestion-mutation-safety.ts @@ -25,7 +25,8 @@ export type IngestionJobRow = { max_attempts: number | null; }; -export type IngestionMutationSafetyReason = "ready" | "supabase_unavailable" | "active_jobs" | "stale_processing_jobs"; +export type IngestionMutationSafetyReason = + "ready" | "supabase_unavailable" | "active_jobs" | "active_agent_enrichment" | "stale_processing_jobs"; export type IngestionMutationSafetyResult = | { @@ -64,7 +65,12 @@ function isStaleProcessingJob(job: IngestionJobRow, staleAfterMinutes: number, n export const activeIngestionJobColumns = "id,document_id,status,stage,locked_at,updated_at,error_message,attempt_count,max_attempts"; -function activeJobMessage(documentCount: number, staleCount: number) { +function activeJobMessage(documentCount: number, staleCount: number, activeAgentEnrichmentCount = 0) { + if (activeAgentEnrichmentCount > 0) { + return documentCount === 1 + ? "Document has an active agent-enrichment pass. Wait for it to finish before reindexing." + : "One or more selected documents have an active agent-enrichment pass. Wait for it to finish before reindexing."; + } if (staleCount > 0) { return staleCount === 1 ? "A selected document has a stale processing ingestion job. Run queue recovery before reindexing." @@ -85,14 +91,25 @@ export function buildActiveJobsSafetyResult( staleAfterMinutes: number, checkedAt: string, now: Date = new Date(), + reason: "active_jobs" | "active_agent_enrichment" = "active_jobs", ): Extract { const staleProcessingJobs = activeJobs.filter((job) => isStaleProcessingJob(job, staleAfterMinutes, now.getTime())); + const resolvedReason = + reason === "active_agent_enrichment" + ? "active_agent_enrichment" + : staleProcessingJobs.length > 0 + ? "stale_processing_jobs" + : "active_jobs"; return { ok: false, status: 409, checkedAt, - reason: staleProcessingJobs.length > 0 ? "stale_processing_jobs" : "active_jobs", - message: activeJobMessage(activeJobs.length, staleProcessingJobs.length), + reason: resolvedReason, + message: activeJobMessage( + activeJobs.length, + staleProcessingJobs.length, + resolvedReason === "active_agent_enrichment" ? activeJobs.length : 0, + ), activeJobs, staleProcessingJobs, }; @@ -189,6 +206,7 @@ export async function checkIngestionMutationSafety(args: { documentIds: string[]; action: string; checkActiveJobs?: boolean; + checkActiveAgentEnrichmentJobs?: boolean; staleAfterMinutes: number; now?: Date; }): Promise { @@ -233,6 +251,41 @@ export async function checkIngestionMutationSafety(args: { return buildActiveJobsSafetyResult(activeJobs, args.staleAfterMinutes, health.checkedAt, args.now); } + if (args.checkActiveAgentEnrichmentJobs) { + const nowMs = (args.now ?? new Date()).getTime(); + const client = args.supabase as unknown as SupabaseClient; + const { data: agentJobs, error: agentError } = await client + .from("indexing_v3_agent_jobs") + .select("document_id,status,locked_at,updated_at") + .in("document_id", uniqueDocumentIds) + .eq("status", "processing"); + if (agentError) throw new Error(agentError.message); + + const activeAgentJobs = ((agentJobs ?? []) as AgentEnrichmentJobRow[]) + .filter((job) => Boolean(job.document_id) && isActiveAgentEnrichmentJob(job, args.staleAfterMinutes, nowMs)) + .map((job, index): IngestionJobRow => ({ + id: `agent-enrichment:${job.document_id ?? index}`, + document_id: job.document_id, + status: job.status, + stage: "agent_enrichment", + locked_at: job.locked_at, + updated_at: job.updated_at, + error_message: null, + attempt_count: null, + max_attempts: null, + })); + + if (activeAgentJobs.length > 0) { + return buildActiveJobsSafetyResult( + activeAgentJobs, + args.staleAfterMinutes, + health.checkedAt, + args.now, + "active_agent_enrichment", + ); + } + } + return { ok: true, checkedAt: health.checkedAt, diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 9e85756e9..64a56771a 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -96,12 +96,16 @@ export function withOwnerReadScope>(query: T, owne // titles, indexing internals), so — matching the anonymous list projection and the `[id]` detail // route — it is stripped for non-owners rather than surfaced as governance data. const NON_OWNER_INTERNAL_DOCUMENT_FIELDS = [ + "owner_id", "storage_path", "content_hash", "source_path", "import_batch_id", "error_message", "metadata", + "source_chunk_ids", + "source_image_ids", + "model", ] as const; /** True when `viewerOwnerId` is set and owns the row (i.e. the caller's own document). */ diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 0b7e1f553..cfb2ef5d6 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2306,6 +2306,13 @@ export type Database = { }; Returns: Json; }; + create_uploaded_document_with_ingestion_job: { + Args: { + p_document: Json; + p_max_attempts: number; + }; + Returns: Json; + }; complete_strict_enrichment_job: { Args: { p_agent_version?: string; diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 544324da2..f731ca7db 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,7 +2,7 @@ "generated_at": "2026-07-24T06:18:38.047Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "7c22a34be4b56894e19136e97897589a000b0502f7b666fffc9060c4e6c907a0", + "schema_sha256": "87e910024d52ea024f4b19c34f6e078ecc48160dc50a483fa785677740673db1", "replay_seconds": 18, "snapshot": { "views": [ @@ -6774,6 +6774,13 @@ "def_hash": "cb65883a561cb1f5cd2247213f417a41", "signature": "public.correct_clinical_query_terms(text,real)" }, + { + "schema": "public", + "name": "create_uploaded_document_with_ingestion_job", + "signature": "public.create_uploaded_document_with_ingestion_job(jsonb,integer)", + "language": "plpgsql", + "security_definer": true + }, { "acl": [ "postgres=X/postgres", diff --git a/supabase/migrations/20260724130200_create_uploaded_document_with_ingestion_job.sql b/supabase/migrations/20260724130200_create_uploaded_document_with_ingestion_job.sql new file mode 100644 index 000000000..7fccc8ad8 --- /dev/null +++ b/supabase/migrations/20260724130200_create_uploaded_document_with_ingestion_job.sql @@ -0,0 +1,70 @@ +-- Atomically create an uploaded document row and its initial ingestion job. +-- The upload route has already stored the object and validated owner/file metadata; +-- this RPC closes the process-crash window between `documents.insert` and +-- `ingestion_jobs.insert` by doing both database writes in one transaction. +create or replace function public.create_uploaded_document_with_ingestion_job( + p_document jsonb, + p_max_attempts integer +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_document public.documents%rowtype; + v_job public.ingestion_jobs%rowtype; +begin + insert into public.documents ( + id, + owner_id, + title, + description, + file_name, + file_type, + file_size, + storage_path, + content_hash, + status, + metadata + ) values ( + (p_document->>'id')::uuid, + (p_document->>'owner_id')::uuid, + p_document->>'title', + nullif(p_document->>'description', ''), + p_document->>'file_name', + p_document->>'file_type', + coalesce((p_document->>'file_size')::bigint, 0), + p_document->>'storage_path', + nullif(p_document->>'content_hash', ''), + 'queued', + coalesce(p_document->'metadata', '{}'::jsonb) + ) + returning * into v_document; + + insert into public.ingestion_jobs ( + document_id, + batch_id, + status, + stage, + progress, + max_attempts + ) values ( + v_document.id, + null, + 'pending', + 'queued', + 0, + p_max_attempts + ) + returning * into v_job; + + return jsonb_build_object( + 'document', to_jsonb(v_document), + 'job', to_jsonb(v_job) + ); +end; +$$; + +revoke execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) from public, anon, authenticated; +grant execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 36c8009a9..62e84520d 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -5818,6 +5818,78 @@ revoke execute on function public.request_indexing_v3_enrichment(uuid, uuid) fro grant execute on function public.request_indexing_v3_enrichment(uuid, uuid) to service_role; +-- Atomically create an uploaded document row and its initial ingestion job. +-- The upload route has already stored the object and validated owner/file metadata; +-- this RPC closes the process-crash window between `documents.insert` and +-- `ingestion_jobs.insert` by doing both database writes in one transaction. +create or replace function public.create_uploaded_document_with_ingestion_job( + p_document jsonb, + p_max_attempts integer +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_document public.documents%rowtype; + v_job public.ingestion_jobs%rowtype; +begin + insert into public.documents ( + id, + owner_id, + title, + description, + file_name, + file_type, + file_size, + storage_path, + content_hash, + status, + metadata + ) values ( + (p_document->>'id')::uuid, + (p_document->>'owner_id')::uuid, + p_document->>'title', + nullif(p_document->>'description', ''), + p_document->>'file_name', + p_document->>'file_type', + coalesce((p_document->>'file_size')::bigint, 0), + p_document->>'storage_path', + nullif(p_document->>'content_hash', ''), + 'queued', + coalesce(p_document->'metadata', '{}'::jsonb) + ) + returning * into v_document; + + insert into public.ingestion_jobs ( + document_id, + batch_id, + status, + stage, + progress, + max_attempts + ) values ( + v_document.id, + null, + 'pending', + 'queued', + 0, + p_max_attempts + ) + returning * into v_job; + + return jsonb_build_object( + 'document', to_jsonb(v_document), + 'job', to_jsonb(v_job) + ); +end; +$$; + +revoke execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) from public, anon, authenticated; +grant execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) to service_role; + + create table if not exists public.rag_visual_eval_cases ( id uuid primary key default gen_random_uuid(), owner_id uuid references auth.users(id) on delete set null, diff --git a/tests/accessible-table.dom.test.tsx b/tests/accessible-table.dom.test.tsx index e39ffc369..be4a609b9 100644 --- a/tests/accessible-table.dom.test.tsx +++ b/tests/accessible-table.dom.test.tsx @@ -48,4 +48,19 @@ describe("AccessibleTable (jsdom)", () => { expect(expandButton).toHaveAttribute("aria-expanded", "true"); expect(screen.getByTestId("table-fullscreen-dialog")).toBeInTheDocument(); }); + + it("shows the provided source-image fallback for low-confidence clinical tables", () => { + render( + Original table image
} + />, + ); + + expect(screen.getByTestId("table-low-confidence-note")).toHaveTextContent("showing the source document image"); + expect(screen.getByTestId("source-table-image")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); }); diff --git a/tests/answer-render-policy.test.ts b/tests/answer-render-policy.test.ts index d7c73345d..514ffe9c5 100644 --- a/tests/answer-render-policy.test.ts +++ b/tests/answer-render-policy.test.ts @@ -97,17 +97,6 @@ function answer(overrides: Partial = {}): RagAnswer { answer: "For red-range blood results, withhold clozapine and contact the monitoring service.", grounded: true, confidence: "high", - relevance: { - verdict: "direct", - label: "Direct source support", - matchedTerms: ["clozapine", "monitoring"], - missingTerms: [], - directSourceCount: 1, - weakSourceCount: 0, - score: 0.95, - supportReason: "A direct source supports the answer.", - isSourceBacked: true, - }, citations: [citation()], sources: [baseSource], answerSections: [ @@ -131,6 +120,17 @@ function answer(overrides: Partial = {}): RagAnswer { image_count: 1, viewer_href: "/documents/doc-1?page=4&chunk=chunk-1", } satisfies BestSourceRecommendation, + relevance: { + verdict: "direct", + label: "Direct source support", + matchedTerms: ["clozapine", "withhold"], + missingTerms: [], + directSourceCount: 1, + weakSourceCount: 0, + score: 1, + supportReason: "Direct source support.", + isSourceBacked: true, + }, ...overrides, }; } @@ -482,6 +482,21 @@ describe("answer render policy", () => { expect(model.copyText).toContain("within 24 hours"); }); + it("caps missing relevance metadata below source-backed trust", () => { + const model = buildAnswerRenderModel( + answer({ + relevance: undefined, + smartPanel: undefined, + }), + ); + + expect(model.trust).toBe("low"); + expect(model.allowedBlocks).toEqual(expect.arrayContaining(["sourceStatus", "reviewSources"])); + expect(model.allowedBlocks).not.toContain("evidenceMap"); + expect(model.allowedBlocks).not.toContain("quoteCards"); + expect(model.allowedBlocks).not.toContain("relatedDocuments"); + }); + it("limits unsupported answers to source review and warnings even when raw extras are present", () => { const model = buildAnswerRenderModel( answer({ diff --git a/tests/database-skills.test.ts b/tests/database-skills.test.ts index 4f69834c3..3c2dfdf29 100644 --- a/tests/database-skills.test.ts +++ b/tests/database-skills.test.ts @@ -16,8 +16,8 @@ describe("Database skill catalog", () => { const result = validateSkillCatalog(); expect(result.errors).toEqual([]); - expect(result.canonical).toHaveLength(32); - expect(new Set(result.canonical.map((skill: { name: string }) => skill.name))).toHaveProperty("size", 32); + expect(result.canonical).toHaveLength(33); + expect(new Set(result.canonical.map((skill: { name: string }) => skill.name))).toHaveProperty("size", 33); expect(result.aliases).toHaveLength(8); for (const category of catalog.categories) { expect(category.skills.every((skill: unknown) => typeof skill === "string")).toBe(true); @@ -27,7 +27,7 @@ describe("Database skill catalog", () => { it("discovers each declared skill from its folder metadata", () => { const discovered = discoverSkillDefinitions(); - expect(discovered).toHaveLength(40); + expect(discovered).toHaveLength(41); for (const skill of discovered) { if (!skill) continue; const metadataPath = path.join(skillsRoot, skill.name, "agents", "openai.yaml"); diff --git a/tests/document-detail-performance.test.ts b/tests/document-detail-performance.test.ts index 265893fc7..ac27717de 100644 --- a/tests/document-detail-performance.test.ts +++ b/tests/document-detail-performance.test.ts @@ -34,7 +34,11 @@ describe("document detail loading contract", () => { expect(loader).toContain("summaryRequest"); expect(loader).toContain("selectedImageIds(selectedChunk)"); expect(loader).toContain("imagesRequest.or(imageWindowFilter"); - expect(loader).toContain("and(image_type.neq.logo_decorative,or(searchable.eq.true,source_kind.eq.table_crop)"); + expect(loader).toContain("documentViewImageVisibility"); + expect(loader).toContain( + "or(searchable.eq.true,source_kind.eq.table_crop,metadata->>retained_for_document_view.eq.true)", + ); + expect(loader).toContain("and(image_type.neq.logo_decorative,${documentViewImageVisibility},page_number.gte."); expect(loader).toContain("id.in.(${imageIds.join"); expect(loader).toContain("tableFactsRequest.or(tableFactWindowFilter"); expect(loader).toContain("page_number.is.null"); @@ -62,6 +66,10 @@ describe("document detail loading contract", () => { expect(loader).toContain("map(withTableFactReviewMetadata)"); expect(loader).toContain("map(withDocumentLabelReviewMetadata)"); expect(loader).toContain("isHiddenDocumentLabel"); + expect(loader).toContain('metadataNumber(metadata, "row_count")'); + expect(loader).toContain('metadataBoolean(metadata, "rows_truncated")'); + expect(loader).toContain('metadataNumber(metadata, "crop_completeness")'); + expect(loader).toContain('metadataNumber(metadata, "structured_extraction_confidence")'); }); it("returns explicit demo, scope, and request-window metadata", () => { diff --git a/tests/favourites-hub-unavailable-controls.dom.test.tsx b/tests/favourites-hub-unavailable-controls.dom.test.tsx index 0bb48ecf4..9ee196e58 100644 --- a/tests/favourites-hub-unavailable-controls.dom.test.tsx +++ b/tests/favourites-hub-unavailable-controls.dom.test.tsx @@ -8,26 +8,21 @@ vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ( })); describe("FavouritesHub unavailable controls", () => { - it("keeps unavailable actions focusable and exposes their reasons", () => { + it("keeps unavailable actions natively disabled and exposes their reasons", () => { render( undefined} demoMode={false} />); const recent = screen.getByRole("button", { name: "Recent" }); const add = screen.getByRole("button", { name: /Add favourite/ }); const newSet = screen.getByRole("button", { name: "New set" }); - expect(recent).toBeEnabled(); - expect(recent).toHaveAttribute("aria-disabled", "true"); + expect(recent).toBeDisabled(); + expect(recent).not.toHaveAttribute("aria-disabled"); expect(recent).toHaveAccessibleDescription("Additional sort options are coming soon."); - expect(add).toBeEnabled(); - expect(add).toHaveAttribute("aria-disabled", "true"); + expect(add).toBeDisabled(); + expect(add).not.toHaveAttribute("aria-disabled"); expect(add).toHaveAccessibleDescription("Adding favourites from this screen is coming soon."); - expect(newSet).toBeEnabled(); - expect(newSet).toHaveAttribute("aria-disabled", "true"); + expect(newSet).toBeDisabled(); + expect(newSet).not.toHaveAttribute("aria-disabled"); expect(newSet).toHaveAccessibleDescription("Creating favourite sets is coming soon."); - - for (const control of [recent, add, newSet]) { - control.focus(); - expect(document.activeElement).toBe(control); - } }); }); diff --git a/tests/formatting-fixture-audit.test.ts b/tests/formatting-fixture-audit.test.ts new file mode 100644 index 000000000..d1072dc18 --- /dev/null +++ b/tests/formatting-fixture-audit.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +describe("formatting fixture audit", () => { + it("reports page-level formatting risks from extractor JSON without provider access", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "formatting-audit-")); + const fixture = path.join(dir, "extract.json"); + await writeFile( + fixture, + JSON.stringify({ + pages: [{ pageNumber: 2, needsOcr: true }], + images: [ + { + sourceKind: "table_crop", + width: 1600, + height: 260, + metadata: { + crop_completeness: 0.72, + rows_truncated: true, + structured_extraction_confidence: 0.42, + ocr_text_density: 0.1, + }, + }, + ], + }), + "utf8", + ); + + const result = spawnSync("node", ["scripts/run-tsx.mjs", "scripts/audit-formatting-fixtures.ts", fixture], { + cwd: process.cwd(), + encoding: "utf8", + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("table rows truncated"); + expect(result.stdout).toContain("crop cut-off risk"); + expect(result.stdout).toContain("extreme image aspect ratio"); + expect(result.stdout).toContain("page needs OCR"); + }); +}); diff --git a/tests/global-search-shell-back-href.test.ts b/tests/global-search-shell-back-href.test.ts new file mode 100644 index 000000000..cfe16fa62 --- /dev/null +++ b/tests/global-search-shell-back-href.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { infoPageBackHref, mobileBackHref } from "@/components/clinical-dashboard/global-search-shell"; + +describe("infoPageBackHref", () => { + it.each([ + ["/services/community-team", "/services"], + ["/forms/12a", "/forms"], + ["/medications/lithium", "/?mode=prescribing"], + ["/differentials/diagnoses/delirium", "/differentials"], + ["/dsm/diagnoses/delirium", "/dsm"], + ["/specifiers/anxious-distress", "/specifiers"], + ["/formulation/example", "/formulation"], + ["/therapy-compass/cbt/brief", "/therapy-compass"], + ["/factsheets/lithium", "/factsheets"], + ["/documents/example", "/documents/search?mode=documents"], + ])("maps %s to its stable in-app parent", (pathname, expected) => { + expect(infoPageBackHref(pathname)).toBe(expected); + }); + + it("leaves non-detail routes on browser-history behaviour", () => { + expect(infoPageBackHref("/services")).toBeNull(); + expect(infoPageBackHref("/privacy")).toBeNull(); + }); +}); + +describe("mobileBackHref", () => { + it("routes submitted differential search back to the differentials home with focus", () => { + expect(mobileBackHref("/differentials", "differentials", true)).toBe("/differentials?focus=1"); + }); + + it("does not invent a target for non-submitted differential home visits", () => { + expect(mobileBackHref("/differentials", "differentials", false)).toBeNull(); + }); + + it("still prefers info-page parents over mode-home fallbacks", () => { + expect(mobileBackHref("/differentials/diagnoses/delirium", "differentials", true)).toBe("/differentials"); + }); +}); diff --git a/tests/ingestion-mutation-safety.test.ts b/tests/ingestion-mutation-safety.test.ts index f5c3ca185..4e652cf2c 100644 --- a/tests/ingestion-mutation-safety.test.ts +++ b/tests/ingestion-mutation-safety.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildActiveJobsSafetyResult, isActiveAgentEnrichmentJob } from "../src/lib/ingestion-mutation-safety"; +import { + buildActiveJobsSafetyResult, + checkIngestionMutationSafety, + isActiveAgentEnrichmentJob, +} from "../src/lib/ingestion-mutation-safety"; describe("active enrichment-agent detection (R24d)", () => { const now = Date.parse("2026-07-07T00:00:00.000Z"); @@ -61,6 +65,87 @@ describe("active enrichment-agent detection (R24d)", () => { }); }); +describe("checkIngestionMutationSafety agent-enrichment guard", () => { + function supabaseMock(agentJobs: unknown[]) { + return { + from(table: string) { + if (table === "import_batches") { + return { + select: () => ({ + limit: async () => ({ error: null }), + }), + }; + } + if (table === "ingestion_jobs") { + return { + select: () => ({ + in: () => ({ + in: async () => ({ data: [], error: null }), + }), + }), + }; + } + if (table === "indexing_v3_agent_jobs") { + return { + select: () => ({ + in: () => ({ + eq: async () => ({ data: agentJobs, error: null }), + }), + }), + }; + } + throw new Error(`Unexpected table ${table}`); + }, + }; + } + + it("blocks full/retry mutations while a fresh agent-enrichment pass owns the document", async () => { + const result = await checkIngestionMutationSafety({ + supabase: supabaseMock([ + { + document_id: "doc-1", + status: "processing", + locked_at: "2026-07-07T23:50:00.000Z", + updated_at: "2026-07-07T23:50:00.000Z", + }, + ]) as never, + documentIds: ["doc-1"], + action: "Reindex", + checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: true, + staleAfterMinutes: 45, + now: new Date("2026-07-08T00:00:00.000Z"), + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected active agent-enrichment guard to block"); + expect(result.reason).toBe("active_agent_enrichment"); + expect(result.status).toBe(409); + expect(result.activeJobs[0]?.stage).toBe("agent_enrichment"); + }); + + it("does not query agent-enrichment jobs when the caller opts out", async () => { + const result = await checkIngestionMutationSafety({ + supabase: supabaseMock([ + { + document_id: "doc-1", + status: "processing", + locked_at: "2026-07-07T23:50:00.000Z", + updated_at: "2026-07-07T23:50:00.000Z", + }, + ]) as never, + documentIds: ["doc-1"], + action: "Enrichment", + checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: false, + staleAfterMinutes: 45, + now: new Date("2026-07-08T00:00:00.000Z"), + }); + + expect(result.ok).toBe(true); + }); +}); + describe("buildActiveJobsSafetyResult (R17 — reindex route 23505 race handling)", () => { const checkedAt = "2026-07-08T00:00:00.000Z"; const staleAfterMinutes = 45; diff --git a/tests/mobile-composer-reserve.test.ts b/tests/mobile-composer-reserve.test.ts index 79e5b33ff..2fda80f8f 100644 --- a/tests/mobile-composer-reserve.test.ts +++ b/tests/mobile-composer-reserve.test.ts @@ -108,7 +108,12 @@ describe("mobile composer reserve contract", () => { const dashboard = source("src/components/ClinicalDashboard.tsx"); const header = source("src/components/clinical-dashboard/master-search-header.tsx"); expect(dashboard).toContain('(activeModeResultKind === "favourites" && favouritesAccessible)'); - expect(dashboard).toContain("const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId);"); + expect(dashboard).toContain( + 'const heroComposerBreakpoint = showDesktopHomeComposer || showAnswerHome ? "all" : "sm-up";', + ); + expect(dashboard).toContain( + 'const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all";', + ); expect(dashboard).not.toContain("const heroOwnsPhoneComposer = showDesktopHomeComposer || showAnswerHome;"); // Prescribing leaves MedicationHome as soon as the draft query is non-empty; // keep the hero slot (and idle phone reserve) only while that home mounts. diff --git a/tests/pdf-extraction-budget.test.ts b/tests/pdf-extraction-budget.test.ts index f802a8f79..ff7f978cd 100644 --- a/tests/pdf-extraction-budget.test.ts +++ b/tests/pdf-extraction-budget.test.ts @@ -121,7 +121,7 @@ describe("PDF extraction budgets", () => { ); await expect( - runPythonPdfExtractor(inputPath, outputDir, { ...PDF_EXTRACTION_BUDGET, totalTimeoutMs: 1_000 }, fakeExtractor), + runPythonPdfExtractor(inputPath, outputDir, { ...PDF_EXTRACTION_BUDGET, totalTimeoutMs: 3_000 }, fakeExtractor), ).rejects.toMatchObject({ code: "PDF_EXTRACTION_DEADLINE_EXCEEDED" }); const childPid = Number(await readFile(childPidPath, "utf8")); @@ -131,6 +131,13 @@ describe("PDF extraction budgets", () => { while (Date.now() < deadline) { try { process.kill(childPid, 0); + if (process.platform === "linux") { + const stat = await readFile(`/proc/${childPid}/stat`, "utf8").catch(() => ""); + if (stat.split(" ")[2] === "Z") { + childIsAlive = false; + break; + } + } await new Promise((resolve) => setTimeout(resolve, 25)); } catch { childIsAlive = false; diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 2eb9dafc7..e800b883c 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -269,6 +269,64 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { error: null, }; } + if (name === "create_uploaded_document_with_ingestion_job") { + const document = (args?.p_document ?? {}) as Record; + const documentCall: QueryCall = { + table: "documents", + operation: "insert", + orFilters: [], + filters: [], + inFilters: [], + overlapsFilters: [], + insertPayload: document, + maybeSingle: false, + single: true, + }; + calls.push(documentCall); + const documentResult = resolveWithDefaultScope(documentCall); + if (documentResult.error) return documentResult; + + const insertedDocument = { + ...document, + ...(typeof documentResult.data === "object" && documentResult.data ? documentResult.data : {}), + status: "queued", + page_count: 0, + chunk_count: 0, + image_count: 0, + created_at: "2026-07-24T00:00:00.000Z", + }; + const jobPayload = { + document_id: document.id, + batch_id: null, + status: "pending", + stage: "queued", + progress: 0, + max_attempts: args?.p_max_attempts, + }; + const jobCall: QueryCall = { + table: "ingestion_jobs", + operation: "insert", + orFilters: [], + filters: [], + inFilters: [], + overlapsFilters: [], + insertPayload: jobPayload, + maybeSingle: false, + single: true, + }; + calls.push(jobCall); + const jobResult = resolveWithDefaultScope(jobCall); + if (jobResult.error) return jobResult; + + return ok({ + document: insertedDocument, + job: { + id: "job-1", + ...jobPayload, + ...(typeof jobResult.data === "object" && jobResult.data ? jobResult.data : {}), + }, + }); + } if (name === "request_ingestion_reindex_if_agent_idle") { return ok({ outcome: "queued", job: { id: "reindex-job" } }); } @@ -337,6 +395,7 @@ function mockRuntime( MAX_IN_FLIGHT_UPLOAD_MB: options.maxInFlightUploadMb ?? 151, SUPABASE_DOCUMENT_BUCKET: "clinical-documents", SUPABASE_IMAGE_BUCKET: "clinical-images", + DOCUMENT_SIGNED_URL_TTL_SECONDS: 600, RAG_SEARCH_CACHE_TTL_MS: 0, RAG_SEARCH_CACHE_SIZE: 0, RAG_ANSWER_CACHE_TTL_MS: 0, @@ -455,7 +514,7 @@ describe("private document API access", () => { const body = await payload(response); expect(response.status).toBe(200); - expect(body.documents).toEqual(documents); + expect(body.documents).toEqual([{ id: documentId, title: "Public guideline" }]); expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); expect(client.auth.getUser).not.toHaveBeenCalled(); }); @@ -524,7 +583,7 @@ describe("private document API access", () => { const body = await payload(response); expect(response.status).toBe(200); - expect(body.documents).toEqual(documents); + expect(body.documents).toEqual([{ id: documentId, title: "Public guideline", status: "indexed" }]); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); @@ -879,7 +938,7 @@ describe("private document API access", () => { }); it("omits internal document list fields for anonymous callers", async () => { - const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }]; + const documents = [{ id: documentId, title: "Public guideline", status: "indexed" }]; const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); @@ -1452,7 +1511,7 @@ describe("private document API access", () => { expect(client.storageMocks.upload).toHaveBeenCalledTimes(1); }); - it("cleans up document and storage when aborted after document insert", async () => { + it("cleans up document and storage when aborted after the atomic upload enqueue", async () => { const controller = new AbortController(); const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null); @@ -1473,7 +1532,7 @@ describe("private document API access", () => { expect(response.status).toBe(499); expect(client.calls.filter((call) => call.table === "documents" && call.operation === "insert")).toHaveLength(1); - expect(client.calls.some((call) => call.table === "ingestion_jobs" && call.operation === "insert")).toBe(false); + expect(client.calls.some((call) => call.table === "ingestion_jobs" && call.operation === "insert")).toBe(true); expect(client.calls.filter((call) => call.table === "documents" && call.operation === "delete")).toHaveLength(1); expect(client.storageMocks.remove).toHaveBeenCalledTimes(1); }); @@ -2254,7 +2313,7 @@ describe("private document API access", () => { expect(response.status).toBe(409); expect(await payload(response)).toMatchObject({ - error: "Reindex is paused while enrichment is active.", + error: "Document has an active agent-enrichment pass. Wait for it to finish before reindexing.", }); expect(client.calls.some((call) => call.table === "documents" && call.operation === "update")).toBe(false); expect(client.calls.some((call) => call.table === "ingestion_jobs" && call.operation === "insert")).toBe(false); @@ -2686,7 +2745,7 @@ describe("private document API access", () => { expect(response.status).toBe(409); expect(await payload(response)).toMatchObject({ - error: "Bulk reindex is paused while enrichment is active for one or more selected documents.", + error: "Document has an active agent-enrichment pass. Wait for it to finish before reindexing.", }); expect(client.calls.some((call) => call.table === "documents" && call.operation === "update")).toBe(false); expect(client.calls.some((call) => call.table === "ingestion_jobs" && call.operation === "insert")).toBe(false); @@ -2868,14 +2927,29 @@ describe("private document API access", () => { it("cleans up uploaded storage when job insert fails", async () => { const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "insert") { - return ok({ id: documentId }); - } - if (call.table === "ingestion_jobs" && call.operation === "insert") { return fail("job insert failed"); } return ok([]); }); mockRuntime(client); + client.rpc.mockImplementation(async (name: string, args?: Record) => { + if (name === "create_uploaded_document_with_ingestion_job") { + return fail("job insert failed"); + } + if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") { + return { + data: [ + rateLimitRow({ + limited: false, + limit_value: Number(args?.p_limit ?? 60), + remaining: Number(args?.p_limit ?? 60) - 1, + }), + ], + error: null, + }; + } + return ok([]); + }); const { POST } = await import("../src/app/api/upload/route"); const formData = new FormData(); formData.set("file", new File(["%PDF-1.7\n%%EOF"], "guideline.pdf", { type: "application/pdf" })); @@ -2890,24 +2964,18 @@ describe("private document API access", () => { expect(response.status).toBe(500); expect(client.storageMocks.remove).toHaveBeenCalledWith([uploadPath]); - expect( - client.calls.some( - (call) => - call.table === "documents" && - call.operation === "delete" && - call.filters.some((filter) => filter.column === "id" && typeof filter.value === "string") && - call.filters.some((filter) => filter.column === "owner_id" && filter.value === userId), - ), - ).toBe(true); + expect(client.calls.some((call) => call.table === "documents" && call.operation === "delete")).toBe(false); }); - it("still runs catch cleanup when upload cleanup calls return non-throwing errors", async () => { + it("still runs catch cleanup when post-enqueue cleanup calls return non-throwing errors", async () => { + const controller = new AbortController(); const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "insert") { + controller.abort(); return ok({ id: documentId }); } if (call.table === "ingestion_jobs" && call.operation === "insert") { - return fail("job insert failed"); + return ok({ id: "job-1", document_id: documentId }); } if (call.table === "documents" && call.operation === "delete") { return fail("document cleanup returned error"); @@ -2927,13 +2995,14 @@ describe("private document API access", () => { authenticatedRequest("/api/upload", { method: "POST", body: formData, + signal: controller.signal, }), ); const uploadPath = client.storageMocks.upload.mock.calls[0]?.[0] as string; const documentDeletes = client.calls.filter((call) => call.table === "documents" && call.operation === "delete"); - expect(response.status).toBe(500); - expect(documentDeletes.length).toBeGreaterThanOrEqual(2); + expect(response.status).toBe(499); + expect(documentDeletes).toHaveLength(1); expect(client.storageMocks.remove).toHaveBeenCalledWith([uploadPath]); }); @@ -4071,6 +4140,45 @@ describe("private document API access", () => { expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); }); + it("logs failed search telemetry against the parsed query instead of an unknown placeholder", async () => { + vi.stubEnv("NODE_ENV", "production"); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { retrieval_strategy: "text_fast_path" }, + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { searchChunksWithTelemetry }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + authenticatedRequest("/api/search", { + method: "POST", + body: JSON.stringify({ + query: "Clozapine monitoring", + includeRelatedDocuments: false, + filters: { sourceStatuses: ["current"] }, + }), + }), + ); + + expect(response.status).toBe(500); + await vi.waitFor(() => { + const insert = client.calls.find((call) => call.table === "rag_queries" && call.operation === "insert"); + expect(insert).toBeTruthy(); + const payload = insert?.insertPayload as Record; + const expectedHash = createHmac("sha256", "test-query-hash-secret-at-least-16-chars") + .update("clozapine monitoring") + .digest("hex"); + const unknownHash = createHmac("sha256", "test-query-hash-secret-at-least-16-chars") + .update("unknown") + .digest("hex"); + expect(payload.query).toBe(`redacted-query:${expectedHash}`); + expect(payload.query).not.toBe(`redacted-query:${unknownHash}`); + }); + }); + it("falls back to visible demo answers only outside production when Supabase rejects the API key", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Live answer", @@ -4569,8 +4677,9 @@ describe("private document API access", () => { it("rejects document summary mode when documentIds selects a different document", async () => { const summarizeDocument = vi.fn(); + const answerQuestionWithScope = vi.fn(); const client = createSupabaseMock(); - mockRuntime(client, { summarizeDocument }); + mockRuntime(client, { summarizeDocument, answerQuestionWithScope }); const { POST } = await import("../src/app/api/answer/stream/route"); const response = await POST( @@ -4588,6 +4697,7 @@ describe("private document API access", () => { expect(response.status).toBe(400); expect(await payload(response)).toMatchObject({ code: "invalid_body" }); expect(summarizeDocument).not.toHaveBeenCalled(); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); expect(client.rpc).not.toHaveBeenCalled(); }); @@ -5003,6 +5113,34 @@ describe("private document API access", () => { expectFeedbackTokenBoundToAnswer(body); }); + it("rejects non-stream document summaries before RAG/provider work", async () => { + const answerQuestionWithScope = vi.fn(); + const summarizeDocument = vi.fn(); + const client = createSupabaseMock(); + mockRuntime(client, { answerQuestionWithScope, summarizeDocument }); + const { POST } = await import("../src/app/api/answer/route"); + + const response = await POST( + authenticatedRequest("/api/answer", { + method: "POST", + body: JSON.stringify({ + query: "Summarize this document for practical clinical use.", + documentId, + summaryMode: true, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(payload(response)).resolves.toMatchObject({ + error: "Document summaries require the streaming answer endpoint.", + code: "summary_mode_stream_required", + }); + expect(client.rpc).not.toHaveBeenCalled(); + expect(summarizeDocument).not.toHaveBeenCalled(); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); + }); + it("rate limits document summarization before OpenAI work", async () => { const summarizeDocument = vi.fn(async () => ({ summary: "Expensive summary" })); const client = createSupabaseMock(); diff --git a/tests/retrieval-owner-filter-guard.test.ts b/tests/retrieval-owner-filter-guard.test.ts index 3ef8d6289..21faf4e51 100644 --- a/tests/retrieval-owner-filter-guard.test.ts +++ b/tests/retrieval-owner-filter-guard.test.ts @@ -52,7 +52,11 @@ const SANCTIONED_API_OWNER_SCOPE = [ // setup-status performs bounded schema/existence probes and never returns table // rows. It is intentionally owner-agnostic so a fresh deployment can diagnose // missing setup before any user corpus exists. -const OWNER_SCOPE_EXEMPTIONS = new Set(["setup-status/route.ts:documents", "setup-status/route.ts:import_batches"]); +const OWNER_SCOPE_EXEMPTIONS = new Set([ + "setup-status/route.ts:documents", + "setup-status/route.ts:import_batches", + "setup-status/route.ts:storage_cleanup_jobs", +]); // These internal helpers consume owner-authorized capability IDs created by the // surrounding route; they are not request-entry reads. Keep the names explicit so diff --git a/tests/services-catalog.test.ts b/tests/services-catalog.test.ts index 99e32ce18..9f6937c24 100644 --- a/tests/services-catalog.test.ts +++ b/tests/services-catalog.test.ts @@ -42,6 +42,7 @@ describe("services catalogue", () => { const bestUseCard = record.summaryCards?.find((card) => card.id === "best-use"); expect(bestUseCard?.title).toBe("After-hours crisis, homelessness, FDV, child-safety concerns"); expect(bestUseCard?.title?.length).toBeLessThanOrEqual(140); + expect(bestUseCard?.title?.includes("|")).toBe(false); expect(record.criteria?.some((criterion) => criterion.label === crisisCare!.best_use_indication)).toBe(true); }); diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index 0aa1f7cb1..f0cc76970 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -10,11 +10,17 @@ describe("/api/setup-status", () => { it("keeps schema diagnostics when the readiness probe reports a query failure", async () => { vi.stubEnv("NODE_ENV", "production"); const failedResult = async () => ({ error: { message: "relation is missing" }, data: [], count: 0 }); - const select = vi.fn(() => ({ - limit: vi.fn(failedResult), - order: vi.fn(() => ({ limit: vi.fn(failedResult) })), - in: vi.fn(failedResult), - })); + const select = vi.fn(() => { + const chain = { + limit: vi.fn(failedResult), + order: vi.fn(() => ({ limit: vi.fn(failedResult) })), + in: vi.fn(failedResult), + eq: vi.fn(failedResult), + then: undefined as undefined, + }; + // Head-count probes (`select(..., { head: true }).eq(...)`) await the eq() result. + return chain; + }); const from = vi.fn(() => ({ select })); const createAdminClient = vi.fn(() => ({ from, diff --git a/tests/signed-image.dom.test.tsx b/tests/signed-image.dom.test.tsx index 77291283b..154ce195b 100644 --- a/tests/signed-image.dom.test.tsx +++ b/tests/signed-image.dom.test.tsx @@ -83,4 +83,18 @@ describe("SignedImage failure/retry (jsdom)", () => { await user.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument()); }); + + it("uses a provided source aspect ratio instead of forcing every document crop into 4:3", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ url: "/demo/wide-table.png" }) }), + ); + + render(); + + const img = await screen.findByRole("img", { name: "Wide table" }); + const frame = img.closest("div"); + expect(frame).toHaveStyle({ aspectRatio: "4.2" }); + expect(frame?.className).not.toContain("aspect-[4/3]"); + }); }); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 9a9f1b752..35e9454ff 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -967,6 +967,33 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); + test("services referral header and best-use guidance stay actionable", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); + + // Eyebrow copy is "Referral matches"; the H1 is "{n} referral match(es)". + await expect(page.getByText("Referral matches", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: /\d+\s+referral match(?:es)?/i })).toBeVisible(); + await expect( + page.getByText("Prioritised for crisis support, culturally safe access, and phone referral."), + ).toBeVisible(); + await expect(page.getByRole("group", { name: "Quick service filters" })).toBeVisible(); + await expect(page.getByText("Quick filters")).toBeVisible(); + + const culturallySafe = page.getByRole("button", { name: "Culturally safe" }); + await expect(culturallySafe).toBeVisible(); + await waitForReactEventHandler(culturallySafe); + await culturallySafe.click(); + await expect(page).toHaveURL(/q=Aboriginal\+Torres\+Strait\+Islander/); + await expect(page.getByText("Aboriginal and Torres Strait Islander-specific").first()).toBeVisible(); + + await page.getByTestId("service-search-result-13yarn").getByRole("link", { name: "Open 13YARN" }).click(); + await expect(page).toHaveURL(/\/services\/13yarn$/); + await expect(page.getByText("Best use").first()).toBeVisible(); + await expect(page.getByText(/crisis support/i).first()).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + }); + test("forms mode shows registry-backed form records without unsupported pathway claims", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockAnswerDashboardApi(page); diff --git a/tests/upload-duplicate-cleanup-ledger.test.ts b/tests/upload-duplicate-cleanup-ledger.test.ts new file mode 100644 index 000000000..e4771bf24 --- /dev/null +++ b/tests/upload-duplicate-cleanup-ledger.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const uploadRoute = readFileSync(new URL("../src/app/api/upload/route.ts", import.meta.url), "utf8"); + +describe("duplicate upload cleanup ledger", () => { + it("fails closed when storage.remove fails and the cleanup ledger insert also fails", () => { + expect(uploadRoute).toContain('from("storage_cleanup_jobs").insert({'); + expect(uploadRoute).toContain("const { error: cleanupLedgerError }"); + expect(uploadRoute).toContain("Duplicate upload cleanup ledger insert failed"); + expect(uploadRoute).toMatch(/if \(cleanupLedgerError\) \{[\s\S]*throw new Error\(/); + }); +}); diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts index d74793a19..75aaa3978 100644 --- a/tests/worker-visual-capture.test.ts +++ b/tests/worker-visual-capture.test.ts @@ -36,6 +36,16 @@ describe("worker visual capture hardening", () => { expect(workerSource).toContain('indexing_v3_agent_status: "completed"'); }); + it("keeps view-only retained images out of retrieval index inputs", () => { + expect(workerSource).toContain( + "const persistedSearchable = !retainedWithoutCaptioning && policyAssessment.searchable", + ); + expect(workerSource).toContain("if (data.searchable !== false) {"); + expect(workerSource).toContain("insertedImages.push({"); + // The prior broadening pulled document-view-only rows into chunk/index/embedding inputs. + expect(workerSource).not.toContain("if (data.searchable !== false || retainForDocumentView)"); + }); + it("fails closed when the lease-fenced commit RPC is unavailable and keeps image uploads generation-scoped", () => { expect(workerSource).toContain("p_job_id: args.jobId"); expect(workerSource).toContain("p_worker_id: workerId"); diff --git a/worker/main.ts b/worker/main.ts index db79759dd..743709339 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -941,7 +941,11 @@ async function uploadAndCaptionImages( width: image.width ?? null, height: image.height ?? null, }); - if (lowSignalSkipReason && !["administrative table without clinical facts"].includes(lowSignalSkipReason)) { + if ( + lowSignalSkipReason && + image.sourceKind !== "table_crop" && + !["administrative table without clinical facts"].includes(lowSignalSkipReason) + ) { skippedImages += 1; noteSkippedImage(skipReasons, lowSignalSkipReason); continue; @@ -950,11 +954,49 @@ async function uploadAndCaptionImages( image.sourceKind === "table_crop" ? nonClinicalTableClassification({ tableMetadata, sourceKind: image.sourceKind }) : null; - if (!presetClassification && !selectedCaptionCandidateIndexes.has(index)) { + const retainUncaptionedForDocumentView = + ["table_crop", "diagram_crop", "page_region"].includes(image.sourceKind ?? "") && + !selectedCaptionCandidateIndexes.has(index); + if (!presetClassification && !selectedCaptionCandidateIndexes.has(index) && !retainUncaptionedForDocumentView) { skippedImages += 1; noteSkippedImage(skipReasons, "visual intelligence candidate below caption budget"); continue; } + const fallbackClassification: ImageClassification | null = retainUncaptionedForDocumentView + ? { + image_type: image.sourceKind === "table_crop" ? "clinical_table" : "unclear", + caption: + tableMetadata.tableTitle || + tableMetadata.tableLabel || + (image.sourceKind === "page_region" + ? "Document page region retained for review." + : "Document image retained for review."), + searchable: false, + clinical_relevance_score: 0, + labels: ["needs-review"], + skip_reason: "retained for document view without captioning", + clinical_use_class: "ambiguous", + clinical_use_reason: "Retained for document viewing without model captioning.", + clinical_signal_score: 0, + admin_signal_score: 0, + structured_visual_profile: deterministicStructuredVisualProfile({ + imageType: image.sourceKind === "table_crop" ? "clinical_table" : "unclear", + caption: + tableMetadata.tableTitle || + tableMetadata.tableLabel || + (image.sourceKind === "page_region" + ? "Document page region retained for review." + : "Document image retained for review."), + tableTitle: tableMetadata.tableTitle, + tableLabel: tableMetadata.tableLabel, + tableTextSnippet: tableMetadata.tableTextSnippet, + tableRows: tableMetadata.tableRows as string[][] | null, + tableColumns: tableMetadata.tableColumns as string[] | null, + metadata: {}, + }), + structured_extraction_confidence: 0.25, + } + : null; captionTasks.push({ candidate, index, @@ -964,7 +1006,7 @@ async function uploadAndCaptionImages( nearbyText, tableMetadata, contextHash, - presetClassification, + presetClassification: presetClassification ?? fallbackClassification, }); } @@ -1019,6 +1061,8 @@ async function uploadAndCaptionImages( const { task, classificationCacheHit } = resolved; const { candidate, index, image, perceptualHash, imageHash, nearbyText, tableMetadata, contextHash } = task; let classification = redactImageClassification(resolved.classification); + const retainedWithoutCaptioning = + task.presetClassification?.skip_reason === "retained for document view without captioning"; const policyAssessment = assessClinicalImageUse({ imageType: classification.image_type, searchable: classification.searchable, @@ -1068,12 +1112,15 @@ async function uploadAndCaptionImages( image.sourceKind === "table_crop" && ["administrative", "reference"].includes(policyAssessment.clinical_use_class) && classification.image_type !== "logo_decorative"; - if (classifiedSkipReason && !retainAsAuditTable) { + const retainForDocumentView = + retainAsAuditTable || + (["table_crop", "diagram_crop", "page_region"].includes(image.sourceKind ?? "") && retainedWithoutCaptioning); + if (classifiedSkipReason && !retainAsAuditTable && !retainForDocumentView) { skippedImages += 1; noteSkippedImage(skipReasons, classifiedSkipReason); continue; } - const persistedSearchable = policyAssessment.searchable; + const persistedSearchable = !retainedWithoutCaptioning && policyAssessment.searchable; if (persistedSearchable) { imageTypeCounts.set(classification.image_type, (imageTypeCounts.get(classification.image_type) ?? 0) + 1); } @@ -1147,7 +1194,7 @@ async function uploadAndCaptionImages( visual_budget_class: candidate.captionBudgetClass, visual_priority_reasons: candidate.reasons, retained_for_audit: retainAsAuditTable || undefined, - retained_for_document_view: retainAsAuditTable || undefined, + retained_for_document_view: retainForDocumentView || undefined, skip_reason: retainAsAuditTable ? classifiedSkipReason : classification.skip_reason, }), }) @@ -1163,6 +1210,8 @@ async function uploadAndCaptionImages( }); } if (!data) throw new Error("Document image insert returned no row."); + // View-only retained images stay out of retrieval indexes: insertedImages + // feeds document_index_units / embedding fields. searchable=false must not enter. if (data.searchable !== false) { insertedImages.push({ id: data.id, diff --git a/worker/python/extract_pdf_assets.py b/worker/python/extract_pdf_assets.py index cf6198a75..a2d204cf3 100644 --- a/worker/python/extract_pdf_assets.py +++ b/worker/python/extract_pdf_assets.py @@ -255,6 +255,7 @@ def save_page_crop(page, rect, output_dir, file_name, source_kind, metadata, bud image_path = os.path.join(output_dir, file_name) dimensions = write_pixmap(pix, image_path, budget) + crop_completeness = crop_completeness_score(rect, clipped, page.rect) crop_metadata = { **metadata, "bbox": rect_payload(rect), @@ -264,6 +265,8 @@ def save_page_crop(page, rect, output_dir, file_name, source_kind, metadata, bud "page_rotation": int(page.rotation or 0), "render_scale": round(render_scale, 3), "render_dpi": round(render_scale * 72), + "crop_completeness": crop_completeness, + "crop_touches_page_edge": crop_completeness < 0.99, "source_regions": [ { "source_kind": source_kind, @@ -293,6 +296,22 @@ def expanded_rect(rect, page_rect, x_padding=4, y_padding=4): ) +def table_expanded_rect(rect, page_rect): + return expanded_rect(rect, page_rect, x_padding=10, y_padding=18) + + +def crop_completeness_score(requested_rect, clipped_rect, page_rect): + requested_area = max(float(requested_rect.width) * float(requested_rect.height), 1.0) + clipped_area = max(float(clipped_rect.width) * float(clipped_rect.height), 0.0) + score = min(1.0, clipped_area / requested_area) + edge_penalty = 0.0 + tolerance = 1.0 + if abs(clipped_rect.x0 - page_rect.x0) <= tolerance or abs(clipped_rect.x1 - page_rect.x1) <= tolerance: + edge_penalty += 0.04 + if abs(clipped_rect.y0 - page_rect.y0) <= tolerance or abs(clipped_rect.y1 - page_rect.y1) <= tolerance: + edge_penalty += 0.04 + return round(max(0.0, score - edge_penalty), 3) + # Max gap between a find_tables bbox edge and continuing cell drawings that # still belong to the same table. Keeps footers/unrelated blocks from merging in. TABLE_EDGE_CONTENT_GAP = 24.0 diff --git a/worker/python/test_extract_pdf_assets_budget.py b/worker/python/test_extract_pdf_assets_budget.py index b51b5826f..139e2af8a 100644 --- a/worker/python/test_extract_pdf_assets_budget.py +++ b/worker/python/test_extract_pdf_assets_budget.py @@ -87,6 +87,15 @@ def get_pixmap(self, **_kwargs): self.assertIsNone(warning) self.assertEqual(calls, [{"timeout": 60}]) + def test_crop_completeness_penalizes_page_edge_clipping(self): + page = type("PageRect", (), {"x0": 0, "y0": 0, "x1": 200, "y1": 200, "width": 200, "height": 200})() + requested = type("Rect", (), {"width": 120, "height": 120})() + complete = type("Clip", (), {"width": 120, "height": 120, "x0": 20, "y0": 20, "x1": 140, "y1": 140})() + clipped = type("Clip", (), {"width": 90, "height": 120, "x0": 0, "y0": 20, "x1": 90, "y1": 140})() + + self.assertEqual(extractor.crop_completeness_score(requested, complete, page), 1.0) + self.assertLess(extractor.crop_completeness_score(requested, clipped, page), 0.82) + if __name__ == "__main__": unittest.main()